Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
31 changes: 31 additions & 0 deletions app/api/units_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
128 changes: 128 additions & 0 deletions app/models/unit_role.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand Down
103 changes: 103 additions & 0 deletions test/api/units/tutor_dashboard_test.rb
Original file line number Diff line number Diff line change
@@ -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
Loading