diff --git a/app/api/units_api.rb b/app/api/units_api.rb index 3cc96fbe5..e8437894b 100644 --- a/app/api/units_api.rb +++ b/app/api/units_api.rb @@ -343,6 +343,37 @@ class UnitsApi < Grape::API present unit.tasks_as_hash(tasks), with: Grape::Presenters::Presenter end + desc 'Get operational workload data for a tutor dashboard' + params do + requires :id, type: Integer, desc: 'The unit id' + requires :unit_role_id, type: Integer, desc: 'The tutor or convenor unit role to view' + end + get '/units/:id/tutor_dashboard/:unit_role_id' do + unit = Unit.find(params[:id]) + + unless authorise? current_user, unit, :get_unit + error!({ error: 'You do not have permission to access this unit' }, 403) + end + + viewer = unit.unit_role_for(current_user) + if viewer.nil? || ![Role.tutor, Role.convenor].include?(viewer.role) + error!({ error: 'You do not have permission to access tutor dashboards' }, 403) + end + + target = unit.staff.find_by(id: params[:unit_role_id]) + error!({ error: 'Tutor dashboard not found' }, 404) if target.nil? + + unless [Role.tutor, Role.convenor].include?(target.role) + error!({ error: 'Tutor dashboard not found' }, 404) + end + + unless viewer == target || viewer.role == Role.convenor + error!({ error: 'You do not have permission to access this tutor dashboard' }, 403) + end + + present target.tutor_dashboard_stats(viewer: viewer), with: Grape::Presenters::Presenter + end + desc 'Get tasks ready for moderation' get '/units/:id/tasks/moderation' do unit = Unit.find(params[:id]) diff --git a/app/models/unit_role.rb b/app/models/unit_role.rb index 68fc0f5e0..d52455b28 100644 --- a/app/models/unit_role.rb +++ b/app/models/unit_role.rb @@ -43,6 +43,134 @@ def oldest_task_awaiting_feedback .max_by(&:days_awaiting_feedback) end + # Operational workload data for the tutor dashboard. This intentionally uses + # the same task source as the tutor inbox so that dashboard figures follow the + # inbox's assignment and visibility rules. + def tutor_dashboard_stats(viewer:) + now = Time.zone.now + inbox_tasks = unit.tasks_for_task_inbox(user, true).to_a.uniq(&:task_id) + ready_status_id = TaskStatus.ready_for_feedback.id + need_help_status_id = TaskStatus.need_help.id + ready_tasks = inbox_tasks.select { |task| task.status_id.to_i == ready_status_id } + + warning_cutoff = now - unit.feedback_warning_threshold_days.days + overflow_cutoff = now - unit.feedback_overflow_threshold_days.days + dated_ready_tasks = ready_tasks.select(&:submission_date) + overdue_tasks = dated_ready_tasks.select { |task| task.submission_date <= overflow_cutoff } + warning_tasks = dated_ready_tasks.select do |task| + task.submission_date <= warning_cutoff && task.submission_date > overflow_cutoff + end + within_threshold_tasks = dated_ready_tasks.select { |task| task.submission_date > warning_cutoff } + + boolean_type = ActiveModel::Type::Boolean.new + viewer_can_moderate_target = viewer.role == Role.convenor || mentor_id == viewer.id + moderation_count = + if viewer_can_moderate_target + unit.tasks_for_moderation(viewer.user).count do |task| + task_tutor = task.project.tutor_for(task.task_definition) + task_tutor.present? && task_tutor.id == user_id + end + end + + { + generated_at: now, + unit_role: { + id: id, + role: role.name, + user: { + id: user.id, + name: user.name, + first_name: user.first_name, + last_name: user.last_name, + nickname: user.nickname + } + }, + thresholds: { + warning_days: unit.feedback_warning_threshold_days, + overflow_days: unit.feedback_overflow_threshold_days + }, + inbox: { + total_count: inbox_tasks.length, + ready_for_feedback_count: ready_tasks.length, + overdue_count: overdue_tasks.length, + needs_help_count: inbox_tasks.count { |task| task.status_id.to_i == need_help_status_id }, + unread_activity_count: inbox_tasks.count { |task| task.number_unread.to_i.positive? }, + pinned_count: inbox_tasks.count { |task| boolean_type.cast(task.pinned) }, + age_buckets: { + within_threshold_count: within_threshold_tasks.length, + warning_count: warning_tasks.length, + overdue_count: overdue_tasks.length, + missing_submission_date_count: ready_tasks.count { |task| task.submission_date.blank? } + }, + oldest_tasks: dashboard_oldest_tasks(ready_tasks, now), + by_task_definition: dashboard_task_definition_breakdown(ready_tasks, overflow_cutoff) + }, + tutor_notes: { + total_count: tutor_notes.count, + unread_by_tutor_count: tutor_notes + .where(read_by_unit_role: false) + .where.not(user_id: user_id) + .count + }, + moderation: { + pending_count: moderation_count + }, + permissions: { + can_switch_tutor: viewer.role == Role.convenor, + can_view_moderation: viewer.role == Role.convenor || unit.staff.where(mentor_id: viewer.id).exists?, + can_view_overflow: viewer.can_mark_overflow_tasks?, + can_access_tutor_notes: viewer == self || viewer.role == Role.convenor + } + } + end + + def dashboard_oldest_tasks(ready_tasks, now) + task_records = Task + .where(id: ready_tasks.map(&:task_id)) + .includes(:task_definition, project: [:user, { unit: { teaching_period: :breaks } }]) + .index_by(&:id) + + ready_tasks + .filter_map { |task| task_records[task.task_id] } + .select(&:submission_date) + .sort_by(&:submission_date) + .first(5) + .map do |task| + { + id: task.id, + project_id: task.project_id, + student_id: task.project.student.id, + student_name: task.project.student.name, + task_definition_id: task.task_definition_id, + task_definition_abbreviation: task.task_definition.abbreviation, + task_definition_name: task.task_definition.name, + submission_date: task.submission_date, + days_awaiting_feedback: task.days_awaiting_feedback(now) + } + end + end + + def dashboard_task_definition_breakdown(ready_tasks, overflow_cutoff) + result = ready_tasks.group_by(&:task_definition_id).filter_map do |task_definition_id, tasks| + task_definition = unit.task_definitions.find { |definition| definition.id == task_definition_id } + next if task_definition.nil? + + { + task_definition_id: task_definition.id, + abbreviation: task_definition.abbreviation, + name: task_definition.name, + ready_for_feedback_count: tasks.length, + overdue_count: tasks.count do |task| + task.submission_date.present? && task.submission_date <= overflow_cutoff + end + } + end + + result.sort_by { |row| [-row[:overdue_count], -row[:ready_for_feedback_count], row[:abbreviation]] } + end + + private :dashboard_oldest_tasks, :dashboard_task_definition_breakdown + # # Permissions around unit role data # diff --git a/test/api/units/tutor_dashboard_test.rb b/test/api/units/tutor_dashboard_test.rb new file mode 100644 index 000000000..87d014aa6 --- /dev/null +++ b/test/api/units/tutor_dashboard_test.rb @@ -0,0 +1,103 @@ +require 'test_helper' + +class TutorDashboardTest < ActiveSupport::TestCase + include Rack::Test::Methods + include TestHelpers::AuthHelper + include TestHelpers::JsonHelper + + def app + Rails.application + end + + setup do + @unit = FactoryBot.create( + :unit, + student_count: 4, + unenrolled_student_count: 0, + part_enrolled_student_count: 0, + inactive_student_count: 0, + task_count: 1, + tutorials: 2, + staff_count: 0 + ) + @unit.update!( + feedback_warning_threshold_days: 4, + feedback_overflow_threshold_days: 7 + ) + + @convenor_role = @unit.main_convenor + @tutor_user = FactoryBot.create(:user, :tutor) + @other_tutor_user = FactoryBot.create(:user, :tutor) + @tutor_role = @unit.employ_staff(@tutor_user, Role.tutor) + @other_tutor_role = @unit.employ_staff(@other_tutor_user, Role.tutor) + @unit.tutorials.first.assign_tutor(@tutor_user) + @unit.tutorials.second.assign_tutor(@other_tutor_user) + + @task_definition = @unit.task_definitions.first + @task_definition.update!(target_grade: 0) + + tutor_projects = @unit.tutorials.first.projects.order(:id).to_a + @overdue_task = tutor_projects.first.task_for_task_definition(@task_definition) + @warning_task = tutor_projects.second.task_for_task_definition(@task_definition) + @overdue_task.update!( + task_status: TaskStatus.ready_for_feedback, + submission_date: 8.days.ago + ) + @warning_task.update!( + task_status: TaskStatus.ready_for_feedback, + submission_date: 5.days.ago + ) + + @tutor_role.add_tutor_note(@convenor_role.user, 'Please review this feedback.') + end + + test 'tutor dashboard reports assigned operational workload' do + add_auth_header_for(user: @tutor_user) + + get "/api/units/#{@unit.id}/tutor_dashboard/#{@tutor_role.id}" + + assert_equal 200, last_response.status, last_response_body + assert_equal @tutor_role.id, last_response_body.dig('unit_role', 'id') + assert_equal 2, last_response_body.dig('inbox', 'ready_for_feedback_count') + assert_equal 1, last_response_body.dig('inbox', 'overdue_count') + assert_equal 1, last_response_body.dig('inbox', 'age_buckets', 'warning_count') + assert_equal @overdue_task.id, last_response_body.dig('inbox', 'oldest_tasks', 0, 'id') + assert_equal 1, last_response_body.dig('tutor_notes', 'unread_by_tutor_count') + assert_equal false, last_response_body.dig('permissions', 'can_switch_tutor') + assert_nil last_response_body.dig('moderation', 'pending_count') + end + + test 'tutor cannot view another tutors dashboard' do + add_auth_header_for(user: @tutor_user) + + get "/api/units/#{@unit.id}/tutor_dashboard/#{@other_tutor_role.id}" + + assert_equal 403, last_response.status + end + + test 'convenor can view a tutor dashboard' do + add_auth_header_for(user: @convenor_role.user) + + get "/api/units/#{@unit.id}/tutor_dashboard/#{@tutor_role.id}" + + assert_equal 200, last_response.status, last_response_body + assert_equal true, last_response_body.dig('permissions', 'can_switch_tutor') + assert_equal true, last_response_body.dig('permissions', 'can_view_moderation') + assert_not_nil last_response_body.dig('moderation', 'pending_count') + end + + test 'dashboard rejects a unit role from another unit' do + other_unit = FactoryBot.create( + :unit, + with_students: false, + task_count: 0, + tutorials: 0, + staff_count: 0 + ) + add_auth_header_for(user: @convenor_role.user) + + get "/api/units/#{@unit.id}/tutor_dashboard/#{other_unit.main_convenor_id}" + + assert_equal 404, last_response.status + end +end