Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
b2ab7a4
feat: init tutor notes and mentorship
b0ink Jan 7, 2026
036e342
feat: init moderation inbox endpoint
b0ink Jan 8, 2026
4df0d2c
feat: moderated tasks
b0ink Jan 9, 2026
a4b2cc8
feat: add unit role trust factor
b0ink Jan 12, 2026
7ea9df2
fix: use time.zone
b0ink Jan 12, 2026
48142a9
refactor: expose tutor notes count
b0ink Jan 12, 2026
9ff948a
Merge branch '10.0.x' into feat/tutor-notes
b0ink Jan 12, 2026
e7839b4
refactor: add tutor notes permissions
b0ink Jan 13, 2026
8c0d581
refactor: rebase schema and mark tutor notes as read
b0ink Jan 13, 2026
d4fef97
refactor: set correct permissions for unit role entity
b0ink Jan 13, 2026
48dfdbb
refactor: sort tasks by showing oldest feedback first
b0ink Jan 13, 2026
1f99bc5
chore: check for mentor_id key
b0ink Jan 13, 2026
227f31a
test: check for tutor_note_count key
b0ink Jan 13, 2026
462f554
test: ensure tutor note count is only exposed to current user
b0ink Feb 2, 2026
93bb8b1
refactor: move moderation task logic to unit model
b0ink Feb 2, 2026
fa86d00
refactor: add type to moderated task schema
b0ink Feb 2, 2026
1c179b0
refactor: rename field to moderation_type
b0ink Feb 2, 2026
c3e16c2
feat: add env var for score factor
b0ink Feb 3, 2026
c003b37
chore: add moderation log
b0ink Feb 3, 2026
81c2437
chore: fix wording
b0ink Feb 3, 2026
9f9ea0e
refactor: track feedback score per task definition
b0ink Feb 9, 2026
051b51e
feat: moderate first three tasks tutor gives feedback to
b0ink Feb 9, 2026
6577ed1
fix: rename to random sample to avoid ruby conflicts
b0ink Feb 9, 2026
1a4919f
feat: init escalation request
b0ink Feb 9, 2026
21b93b9
test: ensure escalation attempts field is present
b0ink Feb 10, 2026
dff2793
refactor: ensure all escalated tasks are present in queue
b0ink Feb 10, 2026
709302c
chore: init action keyword
b0ink Feb 11, 2026
bf63539
Merge branch '10.0.x' into feat/tutor-notes
b0ink Feb 15, 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
2 changes: 2 additions & 0 deletions app/api/api_root.rb
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ class ApiRoot < Grape::API
mount TutorialEnrolmentsApi
mount UnitRolesApi
mount UnitsApi
mount TutorNotesApi

mount D2lIntegrationApi::D2lApi
mount D2lIntegrationApi::OauthPublicApi
Expand Down Expand Up @@ -154,6 +155,7 @@ class ApiRoot < Grape::API
AuthenticationHelpers.add_auth_to MarkingSessionsApi
AuthenticationHelpers.add_auth_to DiscussionPromptsApi
AuthenticationHelpers.add_auth_to OverseerStepsApi
AuthenticationHelpers.add_auth_to TutorNotesApi

add_swagger_documentation \
base_path: nil,
Expand Down
2 changes: 2 additions & 0 deletions app/api/entities/project_entity.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,7 @@ class ProjectEntity < Grape::Entity

expose :grade, if: :for_staff
expose :grade_rationale, if: :for_staff

expose :escalation_attempts_remaining
end
end
21 changes: 21 additions & 0 deletions app/api/entities/tutor_note_entity.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
module Entities
class TutorNoteEntity < Grape::Entity
expose :id

expose :note
expose :unit_role_id
expose :user_id

expose :created_at
expose :updated_at

expose :reply_to_id

expose :task_id
expose :task_definition_id
expose :project_id

expose :read_by_unit_role

end
end
10 changes: 10 additions & 0 deletions app/api/entities/unit_role_entity.rb
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
module Entities
class UnitRoleEntity < Grape::Entity

def staff?(my_role)
[Role.tutor_id, Role.convenor_id, Role.admin_id, Role.auditor_id].include?(my_role.id) unless my_role.nil?
end

expose :id
expose :role do |unit_role, options| unit_role.role.name end
expose :user, using: Entities::Minimal::MinimalUserEntity
expose :unit, using: Entities::Minimal::MinimalUnitEntity, unless: :in_unit
expose :observer_only

expose :mentor_id, if: ->(unit_role, options) { staff?(options[:my_role]) }
expose :tutor_note_count, if: ->(unit_role, options) { unit_role.unit.unit_role_for(options[:user])&.id == unit_role.id } do |unit_role, options|
unit_role.tutor_notes.where(read_by_unit_role: false).count
end
end
end
38 changes: 38 additions & 0 deletions app/api/tasks_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -424,4 +424,42 @@ class TasksApi < Grape::API
end
end

desc 'Request a feedback review (creates an escalation ModeratedTask)'
params do
requires :id, type: Integer, desc: 'The project id'
requires :task_definition_id, type: Integer, desc: 'The id of the task definition for the task to review'
end
post '/projects/:id/task_def_id/:task_definition_id/feedback_review' do
project = Project.find(params[:id])

unless authorise?(current_user, project, :make_submission)
error!({ error: 'You do not have permission to request a feedback review for this project.' }, 403)
end

if project.escalation_attempts_remaining <= 0
error!({ error: 'You can not escalate any more tasks.' }, 403)
end
# TODO: ensure that feedback has actually been left by a tutor (task comments)

task_definition = project.unit.task_definitions.find(params[:task_definition_id])
task = project.task_for_task_definition(task_definition)

existing = ModeratedTask.find_by(task: task)

if existing&.moderation_type == "escalation"
error!({ error: "A feedback review has already been requested for this task." }, 409)
end

existing&.destroy!

moderated_task = ModeratedTask.create!(
task: task,
task_definition: task_definition,
moderation_type: :escalation,
state: "open"
)

moderated_task.valid?
end

end
178 changes: 178 additions & 0 deletions app/api/tutor_notes_api.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
require 'grape'

class TutorNotesApi < Grape::API
helpers AuthenticationHelpers
helpers AuthorisationHelpers

before do
authenticated?
end

helpers do
def can_access_tutor_notes?(unit, current_user, unit_role)
current_user_role = unit.unit_role_for(current_user)

current_user_role.role == Role.convenor ||
unit_role.mentor_id == current_user_role.id ||
unit_role == current_user_role
end
end

desc "Get all the tutor notes for a unit role"
params do
requires :unit_role_id, type: Integer, desc: 'Unit role to fetch the notes for'
end
get '/unit_roles/:unit_role_id/tutor_notes' do
unit_role = UnitRole.find(params[:unit_role_id])
unit = unit_role.unit
unless authorise? current_user, unit, :get_unit
error!({ error: 'You do not have permission to access this unit' }, 403)
end

unless authorise? current_user, unit_role, :create_tutor_note
error!({ error: 'You do not have permission to access this.' }, 403)
end

unless can_access_tutor_notes?(unit, current_user, unit_role)
error!({ error: 'You do not have permission to access this.' }, 403)
end

result = unit_role.tutor_notes

present result, with: Entities::TutorNoteEntity, user: current_user
end

desc "Mark a tutor note as read"
params do
requires :unit_role_id, type: Integer, desc: 'Unit role to fetch the notes for'
end
put '/unit_roles/:unit_role_id/tutor_notes/:id/mark_as_read' do
unit_role = UnitRole.find(params[:unit_role_id])

unit = unit_role.unit
unless authorise? current_user, unit, :get_unit
error!({ error: 'You do not have permission to access this unit' }, 403)
end

unless can_access_tutor_notes?(unit, current_user, unit_role)
error!({ error: 'You do not have permission to access this.' }, 403)
end

tutor_note = unit_role.tutor_notes.find(params[:id])

current_unit_role = unit.unit_role_for(current_user)

unless current_unit_role == unit_role && unit_role == tutor_note.unit_role
error!({ error: 'You do not have permission to update this note.' }, 403)
end

tutor_note.update!(read_by_unit_role: true)

true
end

desc "Create a new note for a tutor"
params do
requires :note, type: String, desc: 'The text to add to the tutor note'
optional :reply_to_id, type: Integer, desc: 'ID of the tutor note this is being replied to'
optional :task_id, type: Integer, desc: 'ID of the task this note is related to'
end
post '/unit_roles/:unit_role_id/tutor_notes' do
unit_role = UnitRole.find(params[:unit_role_id])
unit = unit_role.unit
unless authorise? current_user, unit, :get_unit
error!({ error: 'You do not have permission to access this unit.' }, 403)
end

unless authorise? current_user, unit_role, :create_tutor_note
error!({ error: 'You do not have permission to create note.' }, 403)
end

unless can_access_tutor_notes?(unit, current_user, unit_role)
error!({ error: 'You do not have permission to create note.' }, 403)
end

text_note = params[:note]

reply_to_id = params[:reply_to_id]
if reply_to_id.present?
original_staff_note = TutorNote.find(reply_to_id)
error!(error: 'You do not have permission to read the replied tutor note') unless authorise?(current_user, original_staff_note.unit_role, :get)
error!(error: 'Original tutor note is not in this project.') if unit_role.tutor_notes.find(reply_to_id).blank?
end

task_id = params[:task_id]
if task_id.present?
task = Task.find(task_id)
error!(error: 'You do not have permission to add a note related to this task') unless authorise?(unit_role.user, task.project, :assess)
end

result = unit_role.add_tutor_note(current_user, text_note, task_id, reply_to_id)

if result.nil?
error!({ error: 'Duplicate note.' }, 403)
else
present result, with: Entities::TutorNoteEntity, user: current_user
end
end

desc "Delete a tutor note for a unit role"
delete '/unit_roles/:unit_role_id/tutor_notes/:id' do
unit_role = UnitRole.find(params[:unit_role_id])
unit = unit_role.unit
unless authorise? current_user, unit, :get_unit
error!({ error: 'You do not have permission to access this unit' }, 403)
end

unless can_access_tutor_notes?(unit, current_user, unit_role)
error!({ error: 'You do not have permission to access create note.' }, 403)
end

tutor_note = unit_role.tutor_notes.find(params[:id])

error!({ error: 'Note does not belong to this tutor' }, 404) if tutor_note.unit_role != unit_role

unless authorise?(current_user, unit_role, :delete_tutor_note) || tutor_note.user.id == current_user.id
error!({ error: 'You do not have permission to delete this note.' }, 403)
end

tutor_note.destroy
error!({ error: tutor_note.errors.full_messages.last }, 403) unless tutor_note.destroyed?

present tutor_note.destroyed?, with: Grape::Presenters::Presenter
end

desc "Update a tutor note for a project"
params do
requires :unit_role_id, type: Integer, desc: 'The ID of the unit role'
requires :id, type: Integer, desc: 'The tutor note id to update'
requires :note, type: String, desc: 'The text to update the tutor note with'
end
put '/unit_roles/:unit_role_id/tutor_notes/:id' do
unit_role = UnitRole.find(params[:unit_role_id])
unit = unit_role.unit
unless authorise? current_user, unit, :get_unit
error!({ error: 'You do not have permission to access this unit' }, 403)
end

unless authorise? current_user, unit_role, :create_tutor_note
error!({ error: 'You do not have permission to access this.' }, 403)
end

tutor_note = unit_role.tutor_notes.find(params[:id])

unless can_access_tutor_notes?(unit, current_user, unit_role)
error!({ error: 'You do not have permission to access create note.' }, 403)
end

error!({ error: 'Note does not belong to this tutor' }, 404) if tutor_note.unit_role != unit_role

unless tutor_note.user.id == current_user.id
error!({ error: 'You do not have permission to delete this note.' }, 403)
end

tutor_note.update!(note: params[:note])
present tutor_note, with: Entities::TutorNoteEntity, user: current_user
end

end
73 changes: 72 additions & 1 deletion app/api/unit_roles_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ class UnitRolesApi < Grape::API
requires :unit_role, type: Hash do
requires :role_id, type: Integer, desc: 'The role to create with'
optional :observer_only, type: Boolean, desc: 'If the staff has read-only permissions'
optional :mentor_id, type: Integer, desc: 'Assign a mentor to this unit role'
end
end
put '/unit_roles/:id' do
Expand Down Expand Up @@ -99,7 +100,8 @@ class UnitRolesApi < Grape::API
.require(:unit_role)
.permit(
:role_id,
:observer_only
:observer_only,
:mentor_id
)

if unit_role_parameters[:role_id] == Role.tutor.id && unit_role.role == Role.convenor && unit_role.unit.convenors.count == 1
Expand All @@ -109,4 +111,73 @@ class UnitRolesApi < Grape::API
unit_role.update!(unit_role_parameters)
present unit_role, with: Entities::UnitRoleEntity, in_unit: true
end

desc 'Moderate tutor feedback'
params do
requires :id, type: Integer, desc: 'The id of the unit role to moderate'
requires :task_id, type: Integer, desc: 'The id of the task'
requires :action, type: String, desc: 'Action to apply to this moderated task'
# requires :score, type: Integer, desc: 'Moderation for the task'
# TODO: accept an "outcome" enum/string?
end
post '/unit_roles/:id/moderation/:task_id' do
unit_role = UnitRole.find(params[:id])
unit = unit_role.unit

task = Task.find(params[:task_id])
tutor_user = task.project.tutor_for(task.task_definition)
tutor = unit.unit_role_for(tutor_user)
unless tutor.id == unit_role.id
error!("Invalid unit role", 400)
end

current_unit_role = unit.unit_role_for(current_user)
unless tutor.mentor == current_unit_role
error!({ error: 'You do not have permission to moderate this feedback' }, 400)
end

action = params[:action].lower
# unless [-1, 0, 1].include?(score)
unless %w[show_more show_less dismiss_ok upheld overturn].include?(action)
error!({ error: 'Invalid moderation action' }, 400)
end

moderated_task = ModeratedTask.find_by(task: task)

recent_threshold = 0.minutes.ago
if moderated_task.last_moderated_date && moderated_task.last_moderated_date > recent_threshold
error!({ error: 'Feedback is too new to moderate' }, 400)
end

factor = Doubtfire::Application.config.moderation_score_factor

delta =
case action
when 'show_more', 'overturn'
-1
when 'show_less'
1
when 'dismiss_ok', 'upheld'
0
end

score = score.to_i + (delta * factor)

td_score = TutorFeedbackScore.find_by(unit_role: unit_role, task_definition: task.task_definition)

td_score.update!(
score: (td_score.score + delta).clamp(0, 99)
)

moderated_task.update!(last_moderated_date: Time.zone.now)

unless score == -1
moderated_task.update!({
state: :resolved,
user: current_user
})
end

true
end
end
Loading