Skip to content

Commit 4c7489b

Browse files
ClupaimaplefoxgitTyler2811JOSHUA ERICKSONgauravmyana2002
committed
feat(ppi): peer progress and cross-unit dashboard
Brings the T2 2026 ppi-cpd work from ontrack-features-t2-2026 11.0.x (reviewed and merged work) onto thoth-tech 11.0.x. Co-authored-by: maplefoxgit <s223932052@deakin.edu.au> Co-authored-by: Tan Tai <s224621011@deakin.edu.au> Co-authored-by: JOSHUA ERICKSON <jerickson@deakin.edu.au> Co-authored-by: Gaurav Myana <s225108954@deakin.edu.au>
1 parent dc76a5a commit 4c7489b

34 files changed

Lines changed: 5539 additions & 11 deletions

‎app/api/entities/minimal/minimal_unit_entity.rb‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ class MinimalUnitEntity < Grape::Entity
2020
end
2121

2222
expose :active
23+
expose :allow_flexible_dates
24+
expose :ordered_task_definitions,
25+
as: :task_definitions,
26+
using: Entities::TaskDefinitionEntity,
27+
if: :include_task_definitions
2328
expose :grade_values
2429
expose :grade_definitions
2530
end

‎app/api/entities/unit_entity.rb‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ def can_read_unit_config?(my_role)
5555
expose :allow_student_change_tutorial, unless: :summary_only
5656
expose :allow_flexible_dates, unless: :summary_only
5757
expose :mark_late_submissions_as_assess_in_portfolio, unless: :summary_only
58+
expose :peer_progress_enabled,
59+
unless: :summary_only,
60+
if: lambda { |_unit, options|
61+
can_read_unit_config?(options[:my_role])
62+
}
5863

5964
expose :learning_outcomes, using: LearningOutcomeEntity, as: :ilos, unless: :summary_only
6065
expose :tutorial_streams, using: TutorialStreamEntity, unless: :summary_only

‎app/api/peer_progress_api.rb‎

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
# frozen_string_literal: true
2+
3+
require 'grape'
4+
5+
class PeerProgressApi < Grape::API
6+
helpers AuthenticationHelpers
7+
8+
UNAVAILABLE_MESSAGE = 'Peer progress is currently unavailable.'
9+
NOT_FOUND_MESSAGE = 'Peer progress is unavailable for this project or task.'
10+
CONFIG_ERROR_MESSAGE = 'Peer progress is not configured.'
11+
# These two constants are a pair and must not be changed independently.
12+
#
13+
# The zero and hundred edge buckets only hide the underlying submitted count
14+
# while half a bucket is wider than one student's share of the peer-only
15+
# cohort. At 20 remaining peers, one peer is exactly five percentage points
16+
# and zero becomes a singleton bucket. A floor of 21 remaining peers makes
17+
# one peer's share smaller than the boundary, so every returned bucket
18+
# represents at least two possible peer counts.
19+
#
20+
# 21 and 10.0 leave no cohort size at or above the floor from which the count
21+
# can be recovered. peer_progress_api_test.rb asserts the relationship holds.
22+
MINIMUM_SAFE_COHORT_SIZE = 21
23+
PERCENTAGE_BUCKET_SIZE =
24+
PeerProgressDistributionPolicy::PERCENTAGE_BUCKET_SIZE
25+
26+
before do
27+
header 'Cache-Control', 'private, no-store'
28+
authenticated?
29+
end
30+
31+
helpers do
32+
def peer_progress_not_found!
33+
error!({ error: PeerProgressApi::NOT_FOUND_MESSAGE }, 404)
34+
end
35+
36+
def effective_task(project:, task_definition:)
37+
project.tasks.find_by(
38+
task_definition_id: task_definition.id
39+
) || Task.new(
40+
project: project,
41+
task_definition: task_definition,
42+
task_status: TaskStatus.not_started,
43+
extensions: 0
44+
)
45+
end
46+
47+
def released_for_project?(project:, task_definition:)
48+
start_date = effective_task(
49+
project: project,
50+
task_definition: task_definition
51+
).local_start_date
52+
53+
start_date.present? && start_date <= Time.zone.now
54+
end
55+
56+
def safe_target_grade(project)
57+
target_grade = project.target_grade
58+
59+
target_grade if target_grade.present? &&
60+
project.unit.grade_value?(target_grade)
61+
end
62+
63+
def positive_integer_env!(name)
64+
value = Integer(ENV.fetch(name), 10)
65+
raise ArgumentError unless value.positive?
66+
67+
value
68+
rescue KeyError, ArgumentError
69+
error!({ error: PeerProgressApi::CONFIG_ERROR_MESSAGE }, 503)
70+
end
71+
72+
def minimum_cohort_size!
73+
value = positive_integer_env!(
74+
'DF_PPI_MINIMUM_COHORT_SIZE'
75+
)
76+
77+
return value if value >= PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE
78+
79+
error!(
80+
{ error: PeerProgressApi::CONFIG_ERROR_MESSAGE },
81+
503
82+
)
83+
end
84+
85+
def peer_progress_payload(project:, task_definition:, **overrides)
86+
state = {
87+
snapshot: nil,
88+
submitted_percentage: nil,
89+
completed_percentage: nil,
90+
status_distribution: nil,
91+
distribution_unavailable_reason: nil,
92+
is_suppressed: false,
93+
is_stale: false,
94+
is_feature_enabled: true,
95+
is_user_enabled: current_user.display_peer_progress?,
96+
unavailable_reason: nil,
97+
unavailable_message: ''
98+
}
99+
overrides.assert_valid_keys(*state.keys)
100+
state.merge!(overrides)
101+
102+
distribution_available = state[:status_distribution].present?
103+
if !distribution_available &&
104+
state[:distribution_unavailable_reason].nil?
105+
state[:distribution_unavailable_reason] = state[:unavailable_reason]
106+
end
107+
108+
{
109+
task_definition_id: task_definition.id,
110+
unit_id: project.unit_id,
111+
target_grade: safe_target_grade(project),
112+
submitted_percentage: state[:submitted_percentage],
113+
completed_percentage: state[:completed_percentage],
114+
status_distribution: state[:status_distribution],
115+
distribution_available: distribution_available,
116+
distribution_unavailable_reason:
117+
state[:distribution_unavailable_reason],
118+
is_suppressed: state[:is_suppressed],
119+
is_stale: state[:is_stale],
120+
is_feature_enabled: state[:is_feature_enabled],
121+
is_user_enabled: state[:is_user_enabled],
122+
last_updated_at: state[:snapshot]&.calculated_at&.utc&.iso8601,
123+
unavailable_reason: state[:unavailable_reason],
124+
unavailable_message: state[:unavailable_message]
125+
}
126+
end
127+
128+
def peer_progress_result(project:, task_definition:)
129+
unit = project.unit
130+
131+
unless current_user.display_peer_progress?
132+
return peer_progress_payload(
133+
project: project,
134+
task_definition: task_definition,
135+
is_user_enabled: false,
136+
unavailable_reason: 'user_disabled',
137+
unavailable_message: PeerProgressApi::UNAVAILABLE_MESSAGE
138+
)
139+
end
140+
141+
unless unit.peer_progress_enabled?
142+
return peer_progress_payload(
143+
project: project,
144+
task_definition: task_definition,
145+
is_feature_enabled: false,
146+
unavailable_reason: 'feature_disabled',
147+
unavailable_message: PeerProgressApi::UNAVAILABLE_MESSAGE
148+
)
149+
end
150+
151+
target_grade = safe_target_grade(project)
152+
unless target_grade
153+
return peer_progress_payload(
154+
project: project,
155+
task_definition: task_definition,
156+
unavailable_reason: 'target_grade_unavailable',
157+
unavailable_message: PeerProgressApi::UNAVAILABLE_MESSAGE
158+
)
159+
end
160+
161+
snapshot = unit.peer_progress_snapshots.find_by(
162+
task_definition_id: task_definition.id,
163+
target_grade: target_grade
164+
)
165+
166+
if snapshot.nil? ||
167+
(project.target_grade_changed_at.present? &&
168+
snapshot.calculated_at < project.target_grade_changed_at)
169+
return peer_progress_payload(
170+
project: project,
171+
task_definition: task_definition,
172+
unavailable_reason: 'snapshot_unavailable',
173+
unavailable_message: PeerProgressApi::UNAVAILABLE_MESSAGE
174+
)
175+
end
176+
177+
viewer_task = effective_task(
178+
project: project,
179+
task_definition: task_definition
180+
)
181+
unless PeerProgressViewerPolicy.viewer_context_current?(
182+
snapshot: snapshot,
183+
viewer_project: project,
184+
viewer_task: viewer_task
185+
)
186+
return peer_progress_payload(
187+
project: project,
188+
task_definition: task_definition,
189+
snapshot: snapshot,
190+
unavailable_reason: 'snapshot_unavailable',
191+
unavailable_message: PeerProgressApi::UNAVAILABLE_MESSAGE
192+
)
193+
end
194+
195+
minimum_cohort_size = minimum_cohort_size!
196+
stale_after_hours = positive_integer_env!(
197+
'DF_PPI_STALE_AFTER_HOURS'
198+
)
199+
200+
is_stale = snapshot.calculated_at < stale_after_hours.hours.ago
201+
202+
# Treat an empty cohort exactly like every other cohort below the
203+
# privacy threshold. This prevents the response from revealing
204+
# whether a target-grade group is empty or merely small.
205+
peer_cohort_size = [snapshot.cohort_size - 1, 0].max
206+
if peer_cohort_size < minimum_cohort_size
207+
return peer_progress_payload(
208+
project: project,
209+
task_definition: task_definition,
210+
snapshot: snapshot,
211+
is_suppressed: true,
212+
is_stale: is_stale,
213+
unavailable_reason: 'insufficient_cohort',
214+
unavailable_message: PeerProgressApi::UNAVAILABLE_MESSAGE
215+
)
216+
end
217+
218+
peer_progress = PeerProgressViewerPolicy.build(
219+
snapshot: snapshot,
220+
viewer_project: project,
221+
viewer_task: viewer_task
222+
)
223+
if peer_progress.nil?
224+
return peer_progress_payload(
225+
project: project,
226+
task_definition: task_definition,
227+
snapshot: snapshot,
228+
is_stale: is_stale,
229+
unavailable_reason: 'aggregation_incomplete',
230+
unavailable_message: PeerProgressApi::UNAVAILABLE_MESSAGE
231+
)
232+
end
233+
234+
if is_stale
235+
return peer_progress_payload(
236+
project: project,
237+
task_definition: task_definition,
238+
snapshot: snapshot,
239+
is_stale: true,
240+
unavailable_reason: 'stale',
241+
unavailable_message: PeerProgressApi::UNAVAILABLE_MESSAGE
242+
)
243+
end
244+
245+
peer_progress_payload(
246+
project: project,
247+
task_definition: task_definition,
248+
snapshot: snapshot,
249+
**PeerProgressViewerPolicy.public_metrics(peer_progress)
250+
)
251+
end
252+
end
253+
254+
desc 'Get anonymous task-level peer progress for the authenticated student',
255+
tags: ['peer_progress'],
256+
summary: 'Get anonymous task-level peer progress'
257+
params do
258+
requires :id,
259+
type: Integer,
260+
desc: 'The authenticated student project ID'
261+
requires :task_definition_id,
262+
type: Integer,
263+
desc: 'The task definition ID'
264+
end
265+
get '/projects/:id/task_def_id/:task_definition_id/peer_progress' do
266+
peer_progress_not_found! if current_user.role.id != Role.student_id
267+
268+
project = Project.for_user(current_user, false)
269+
.includes(:unit)
270+
.find_by(id: params[:id])
271+
peer_progress_not_found! if project.nil?
272+
273+
unit = project.unit
274+
task_definition = unit.task_definitions.find_by(
275+
id: params[:task_definition_id]
276+
)
277+
peer_progress_not_found! if task_definition.nil?
278+
279+
peer_progress_not_found! unless released_for_project?(
280+
project: project,
281+
task_definition: task_definition
282+
)
283+
284+
target_grade = project.target_grade
285+
if target_grade.present? && unit.grade_value?(target_grade) &&
286+
task_definition.target_grade > target_grade
287+
peer_progress_not_found!
288+
end
289+
290+
present peer_progress_result(
291+
project: project,
292+
task_definition: task_definition
293+
), with: Grape::Presenters::Presenter
294+
end
295+
end

0 commit comments

Comments
 (0)