Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
61f7e93
Revise pull request template for clarity and completeness
maplefoxgit Aug 3, 2026
78fc954
Merge pull request #2 from ontrack-features-t2-2026/maintain/pull-req…
Clupai8o0 Aug 4, 2026
5af61f9
Merge pull request #14 from ontrack-features-t2-2026/chore/sync-ppi-w…
Clupai8o0 Aug 9, 2026
0b97760
feat(ppi): add peer progress snapshot persistence
maplefoxgit Aug 9, 2026
4a98704
feat(ppi): calculate peer progress snapshots
maplefoxgit Aug 9, 2026
8d7065b
test(ppi): cover peer progress aggregation
maplefoxgit Aug 9, 2026
36c03f3
feat(ppi): schedule peer progress aggregation
maplefoxgit Aug 10, 2026
004ddf7
test(ppi): cover peer progress aggregation job
maplefoxgit Aug 10, 2026
a1632f9
feat(ppi): add authorised student progress endpoint
maplefoxgit Aug 10, 2026
e224f2b
test(ppi): cover endpoint privacy and authorisation
maplefoxgit Aug 10, 2026
f0a3d34
docs(ppi): document peer progress API
maplefoxgit Aug 10, 2026
75b9989
fix(ppi): finalise the student response contract
maplefoxgit Aug 10, 2026
e80b471
docs(ppi): document peer progress response states
maplefoxgit Aug 10, 2026
8dd48cf
test(ppi): complete coverage and lint cleanup
maplefoxgit Aug 10, 2026
a1548d8
test(ppi): complete coverage and lint cleanup (remaining)
maplefoxgit Aug 10, 2026
9991c99
fix(ppi): count verified student uploads
maplefoxgit Aug 11, 2026
58be631
fix(ppi): enforce effective student release dates
maplefoxgit Aug 11, 2026
bc2d24d
fix(ppi): enforce the cohort privacy floor
maplefoxgit Aug 11, 2026
1578834
fix(ppi): isolate aggregation work by unit
maplefoxgit Aug 11, 2026
e7950be
feat(ppi): add authorised unit feature management
maplefoxgit Aug 11, 2026
814ceeb
fix(ppi): quantise student progress percentages
maplefoxgit Aug 18, 2026
f99c2c3
fix(ppi): invalidate snapshots after grade changes
maplefoxgit Aug 18, 2026
a8c0b18
fix(db): sync peer progress schema and collation
maplefoxgit Aug 18, 2026
457ea76
style(ppi): satisfy rubocop
maplefoxgit Aug 18, 2026
62ee298
fix(ppi): finalise privacy state safeguards
maplefoxgit Aug 18, 2026
7c7b16a
feat: add rake task to seed PPI sample dashboard test data
gauravmyana2002 Aug 19, 2026
a03e17b
docs: publish PPI backend data-source and field-ownership map
gauravmyana2002 Aug 19, 2026
91d4db9
fix(ppi): stop the quantised percentage revealing the submitted count
Clupai8o0 Aug 20, 2026
1e011b1
Merge pull request #16 from ontrack-features-t2-2026/ppi/student-prog…
Clupai8o0 Aug 20, 2026
50cd1e7
Merge pull request #39 from ontrack-features-t2-2026/feature/ppi-samp…
David-Tenni Aug 21, 2026
b1e3860
docs(ppi): sync data-source map with merged implementation
maplefoxgit Aug 23, 2026
871e66e
test(ppi): prevent peer details leaking to logs
maplefoxgit Aug 23, 2026
40d676c
Merge pull request #40 from ontrack-features-t2-2026/docs/ppi-data-so…
maplefoxgit Aug 23, 2026
357e741
chore(docs): remove committed evidence
maplefoxgit Aug 23, 2026
9092ae4
style(ppi): fix sample data lint
maplefoxgit Aug 23, 2026
87bbcb0
style(ppi): fix sample data lint
maplefoxgit Aug 23, 2026
be30dff
fix(ppi): prevent singleton progress buckets
maplefoxgit Aug 23, 2026
1fbbd75
Merge pull request #55 from ontrack-features-t2-2026/fix/ppi-quantisa…
maplefoxgit Aug 23, 2026
0725797
Merge pull request #62 from ontrack-features-t2-2026/style/ppi-sample…
Clupai8o0 Aug 26, 2026
b0ac35f
Merge pull request #46 from ontrack-features-t2-2026/security/ppi-aut…
Clupai8o0 Aug 26, 2026
710d6b0
feat(api): add pagination for large collection endpoints; optimize co…
Sep 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion app/api/activity_types_public_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ class ActivityTypesPublicApi < Grape::API

desc 'Get all the activity types'
get '/activity_types' do
present ActivityType.all, with: Entities::ActivityTypeEntity
if params.key?(:per_page) || params.key?(:page)
per_page = params[:per_page].to_i > 0 ? [params[:per_page].to_i, 500].min : 50
page = params[:page].to_i > 0 ? params[:page].to_i : 1
result = ActivityType.limit(per_page).offset((page - 1) * per_page)
else
result = ActivityType.all
end

present result, with: Entities::ActivityTypeEntity
end
end
2 changes: 2 additions & 0 deletions app/api/api_root.rb
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ class ApiRoot < Grape::API
mount GroupSetsApi
mount LearningOutcomesApi
mount ProjectsApi
mount PeerProgressApi
mount SettingsApi
mount StudentsApi
mount Submission::PortfolioApi
Expand Down Expand Up @@ -125,6 +126,7 @@ class ApiRoot < Grape::API
AuthenticationHelpers.add_auth_to GroupSetsApi
AuthenticationHelpers.add_auth_to LearningOutcomesApi
AuthenticationHelpers.add_auth_to ProjectsApi
AuthenticationHelpers.add_auth_to PeerProgressApi
AuthenticationHelpers.add_auth_to StudentsApi
AuthenticationHelpers.add_auth_to Submission::PortfolioApi
AuthenticationHelpers.add_auth_to Submission::PortfolioEvidenceApi
Expand Down
10 changes: 9 additions & 1 deletion app/api/campuses_public_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ class CampusesPublicApi < Grape::API

desc 'Get all the Campuses'
get '/campuses' do
present Campus.all, with: Entities::CampusEntity
if params.key?(:per_page) || params.key?(:page)
per_page = params[:per_page].to_i > 0 ? [params[:per_page].to_i, 500].min : 50
page = params[:page].to_i > 0 ? params[:page].to_i : 1
result = Campus.limit(per_page).offset((page - 1) * per_page)
else
result = Campus.all
end

present result, with: Entities::CampusEntity
end
end
5 changes: 5 additions & 0 deletions app/api/entities/unit_entity.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ def can_read_unit_config?(my_role)
expose :allow_student_change_tutorial, unless: :summary_only
expose :allow_flexible_dates, unless: :summary_only
expose :mark_late_submissions_as_assess_in_portfolio, unless: :summary_only
expose :peer_progress_enabled,
unless: :summary_only,
if: lambda { |_unit, options|
can_read_unit_config?(options[:my_role])
}

expose :learning_outcomes, using: LearningOutcomeEntity, as: :ilos, unless: :summary_only
expose :tutorial_streams, using: TutorialStreamEntity, unless: :summary_only
Expand Down
10 changes: 9 additions & 1 deletion app/api/group_sets_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,15 @@ class GroupSetsApi < Grape::API
error!({ error: 'Not authorised to get groups for this unit' }, 403)
end

present grp.projects, with: Entities::ProjectEntity, only: [:student, :id, :target_grade], user: current_user
if params.key?(:per_page) || params.key?(:page)
per_page = params[:per_page].to_i > 0 ? [params[:per_page].to_i, 500].min : 50
page = params[:page].to_i > 0 ? params[:page].to_i : 1
result = grp.projects.limit(per_page).offset((page - 1) * per_page)
else
result = grp.projects
end

present result, with: Entities::ProjectEntity, only: [:student, :id, :target_grade], user: current_user
end

desc 'Add a group member'
Expand Down
249 changes: 249 additions & 0 deletions app/api/peer_progress_api.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
# frozen_string_literal: true

require 'grape'

class PeerProgressApi < Grape::API
helpers AuthenticationHelpers

UNAVAILABLE_MESSAGE = 'Peer progress is currently unavailable.'
NOT_FOUND_MESSAGE = 'Peer progress is unavailable for this project or task.'
CONFIG_ERROR_MESSAGE = 'Peer progress is not configured.'
# These two constants are a pair and must not be changed independently.
#
# The zero and hundred edge buckets only hide the underlying submitted count
# while half a bucket is wider than one student's share of the cohort. At a
# cohort of 20, one student is exactly five percentage points and zero becomes
# a singleton bucket, revealing that nobody has submitted. A floor of 21 makes
# one student's share smaller than the five-point bucket boundary, so every
# returned bucket represents at least two possible submitted counts.
#
# 21 and 10.0 leave no cohort size at or above the floor from which the count
# can be recovered. peer_progress_api_test.rb asserts the relationship holds.
MINIMUM_SAFE_COHORT_SIZE = 21
PERCENTAGE_BUCKET_SIZE = 10.0

before do
header 'Cache-Control', 'private, no-store'
authenticated?
end

helpers do
def peer_progress_not_found!
error!({ error: PeerProgressApi::NOT_FOUND_MESSAGE }, 404)
end

def effective_task(project:, task_definition:)
project.tasks.find_by(
task_definition_id: task_definition.id
) || Task.new(
project: project,
task_definition: task_definition,
task_status: TaskStatus.not_started,
extensions: 0
)
end

def released_for_project?(project:, task_definition:)
start_date = effective_task(
project: project,
task_definition: task_definition
).local_start_date

start_date.present? && start_date <= Time.zone.now
end

def snapshot_predates_target_grade?(project, snapshot)
changed_at = project.target_grade_changed_at

changed_at.present? && snapshot.calculated_at < changed_at
end

def quantised_percentage(value)
bucket_size = PeerProgressApi::PERCENTAGE_BUCKET_SIZE

((value.to_f / bucket_size).round * bucket_size).to_f
end

def safe_target_grade(project)
target_grade = project.target_grade

return nil if target_grade.nil?
return nil unless project.unit.grade_value?(target_grade)

target_grade
end

def positive_integer_env!(name)
value = Integer(ENV.fetch(name), 10)
raise ArgumentError unless value.positive?

value
rescue KeyError, ArgumentError
error!({ error: PeerProgressApi::CONFIG_ERROR_MESSAGE }, 503)
end

def minimum_cohort_size!
value = positive_integer_env!(
'DF_PPI_MINIMUM_COHORT_SIZE'
)

return value if value >= PeerProgressApi::MINIMUM_SAFE_COHORT_SIZE

error!(
{ error: PeerProgressApi::CONFIG_ERROR_MESSAGE },
503
)
end

def peer_progress_payload(
project:,
task_definition:,
snapshot: nil,
submitted_percentage: nil,
is_suppressed: false,
is_stale: false,
is_feature_enabled: true,
unavailable_message: ''
)
{
task_definition_id: task_definition.id,
unit_id: project.unit_id,
target_grade: safe_target_grade(project),
submitted_percentage: submitted_percentage,
is_suppressed: is_suppressed,
is_stale: is_stale,
is_feature_enabled: is_feature_enabled,
last_updated_at: snapshot&.calculated_at&.utc&.iso8601,
unavailable_message: unavailable_message
}
end

def peer_progress_result(project:, task_definition:)
unit = project.unit

unless unit.peer_progress_enabled?
return peer_progress_payload(
project: project,
task_definition: task_definition,
is_feature_enabled: false,
unavailable_message: PeerProgressApi::UNAVAILABLE_MESSAGE
)
end

target_grade = safe_target_grade(project)
unless target_grade
return peer_progress_payload(
project: project,
task_definition: task_definition,
unavailable_message: PeerProgressApi::UNAVAILABLE_MESSAGE
)
end

snapshot = unit.peer_progress_snapshots.find_by(
task_definition_id: task_definition.id,
target_grade: target_grade
)

if snapshot.nil? ||
snapshot_predates_target_grade?(project, snapshot)
return peer_progress_payload(
project: project,
task_definition: task_definition,
unavailable_message: PeerProgressApi::UNAVAILABLE_MESSAGE
)
end

minimum_cohort_size = minimum_cohort_size!
stale_after_hours = positive_integer_env!(
'DF_PPI_STALE_AFTER_HOURS'
)

is_stale = snapshot.calculated_at < stale_after_hours.hours.ago

# Treat an empty cohort exactly like every other cohort below the
# privacy threshold. This prevents the response from revealing
# whether a target-grade group is empty or merely small.
if snapshot.cohort_size < minimum_cohort_size
return peer_progress_payload(
project: project,
task_definition: task_definition,
snapshot: snapshot,
is_suppressed: true,
is_stale: is_stale,
unavailable_message: PeerProgressApi::UNAVAILABLE_MESSAGE
)
end

if snapshot.submitted_percentage.nil?
return peer_progress_payload(
project: project,
task_definition: task_definition,
snapshot: snapshot,
is_stale: is_stale,
unavailable_message: PeerProgressApi::UNAVAILABLE_MESSAGE
)
end

if is_stale
return peer_progress_payload(
project: project,
task_definition: task_definition,
snapshot: snapshot,
is_stale: true,
unavailable_message: PeerProgressApi::UNAVAILABLE_MESSAGE
)
end

peer_progress_payload(
project: project,
task_definition: task_definition,
snapshot: snapshot,
submitted_percentage: quantised_percentage(
snapshot.submitted_percentage
)
)
end
end

desc 'Get anonymous task-level peer progress for the authenticated student',
tags: ['peer_progress'],
summary: 'Get anonymous task-level peer progress'
params do
requires :id,
type: Integer,
desc: 'The authenticated student project ID'
requires :task_definition_id,
type: Integer,
desc: 'The task definition ID'
end
get '/projects/:id/task_def_id/:task_definition_id/peer_progress' do
peer_progress_not_found! if current_user.role.id != Role.student_id

project = Project.for_user(current_user, false)
.includes(:unit)
.find_by(id: params[:id])
peer_progress_not_found! if project.nil?

unit = project.unit
task_definition = unit.task_definitions.find_by(
id: params[:task_definition_id]
)
peer_progress_not_found! if task_definition.nil?

peer_progress_not_found! unless released_for_project?(
project: project,
task_definition: task_definition
)

target_grade = project.target_grade
if target_grade.present? && unit.grade_value?(target_grade) &&
task_definition.target_grade > target_grade
peer_progress_not_found!
end

present peer_progress_result(
project: project,
task_definition: task_definition
), with: Grape::Presenters::Presenter
end
end
5 changes: 5 additions & 0 deletions app/api/projects_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ class ProjectsApi < Grape::API
include_inactive = params[:include_inactive] || false

projects = Project.eager_load(:unit, :user).for_user current_user, include_inactive

per_page = params[:per_page].to_i > 0 ? [params[:per_page].to_i, 500].min : 50
page = params[:page].to_i > 0 ? params[:page].to_i : 1
projects = projects.limit(per_page).offset((page - 1) * per_page)

present projects, with: Entities::ProjectEntity, for_student: true, summary_only: true, user: current_user
end

Expand Down
6 changes: 6 additions & 0 deletions app/api/units_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ class UnitsApi < Grape::API

units = units.where('active = true') unless params[:include_in_active]

per_page = params[:per_page].to_i > 0 ? [params[:per_page].to_i, 500].min : 50
page = params[:page].to_i > 0 ? params[:page].to_i : 1
units = units.limit(per_page).offset((page - 1) * per_page)

present units, with: Entities::UnitEntity, user: current_user, summary_only: true, in_unit: true
end

Expand Down Expand Up @@ -73,6 +77,7 @@ class UnitsApi < Grape::API
optional :code, type: String
optional :description, type: String
optional :active, type: Boolean
optional :peer_progress_enabled, type: Boolean, desc: 'Enable anonymous peer progress for students in this unit'
optional :teaching_period_id, type: Integer
optional :start_date, type: Date
optional :end_date, type: Date
Expand Down Expand Up @@ -116,6 +121,7 @@ class UnitsApi < Grape::API
:description,
:start_date,
:end_date,
:peer_progress_enabled,
:teaching_period_id,
:active,
:main_convenor_id,
Expand Down
Loading