From ac589b10bcc8502dc5ba260d155c9f1dcaf817fa Mon Sep 17 00:00:00 2001 From: Audrey Phommasone Date: Mon, 23 Mar 2026 14:18:23 +1100 Subject: [PATCH 01/17] feat: create scheduled task-completion snapshots --- app/api/units_api.rb | 46 +++++++++++++++++++ app/models/task_completion_snapshot.rb | 10 ++++ app/models/unit.rb | 37 +++++++++++++++ .../aggregate_task_completion_stats_job.rb | 14 ++++++ config/schedule.yml | 4 ++ ...143502_create_task_completion_snapshots.rb | 17 +++++++ db/schema.rb | 16 ++++++- test/sidekiq/scheduled_job_test.rb | 3 +- 8 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 app/models/task_completion_snapshot.rb create mode 100644 app/sidekiq/aggregate_task_completion_stats_job.rb create mode 100644 db/migrate/20260322143502_create_task_completion_snapshots.rb diff --git a/app/api/units_api.rb b/app/api/units_api.rb index a20c8a3d7..536cedf3e 100644 --- a/app/api/units_api.rb +++ b/app/api/units_api.rb @@ -574,6 +574,52 @@ class UnitsApi < Grape::API present unit.student_task_completion_stats, with: Grape::Presenters::Presenter end + desc 'Get historical task completion snapshots' + params do + optional :start_date, type: Date, desc: 'Include snapshots captured on or after this date' + optional :end_date, type: Date, desc: 'Include snapshots captured on or before this date' + optional :limit, type: Integer, desc: 'Maximum number of snapshots to return', default: 365 + end + get '/units/:id/stats/task_completion_snapshots' do + unit = Unit.find(params[:id]) + unless authorise? current_user, unit, :download_stats + error!({ error: "Not authorised to download stats of student tasks in #{unit.code}" }, 403) + end + + snapshots = unit.task_completion_snapshots.order(captured_at: :desc) + snapshots = snapshots.where('snapshot_date >= ?', params[:start_date]) if params[:start_date].present? + snapshots = snapshots.where('snapshot_date <= ?', params[:end_date]) if params[:end_date].present? + snapshots = snapshots.limit([params[:limit].to_i, 365].min) + + present snapshots.map { |snapshot| + { + snapshot_date: snapshot.snapshot_date, + captured_at: snapshot.captured_at, + stats: snapshot.stats + } + }, with: Grape::Presenters::Presenter + end + + desc 'Capture task completion snapshot immediately for this unit' + post '/units/:id/stats/task_completion_snapshots/capture' do + unit = Unit.find(params[:id]) + unless authorise? current_user, unit, :download_stats + error!({ error: "Not authorised to capture stats of student tasks in #{unit.code}" }, 403) + end + + begin + snapshot = unit.capture_task_complete_stats_snapshot! + present( + { + snapshot_date: snapshot.snapshot_date, + captured_at: snapshot.captured_at, + stats: snapshot.stats + }, + with: Grape::Presenters::Presenter + ) + end + end + desc 'Download stats related to the number of tasks assessed by each tutor' get '/csv/units/:id/tutor_assessments' do unit = Unit.find(params[:id]) diff --git a/app/models/task_completion_snapshot.rb b/app/models/task_completion_snapshot.rb new file mode 100644 index 000000000..5260ac982 --- /dev/null +++ b/app/models/task_completion_snapshot.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class TaskCompletionSnapshot < ApplicationRecord + belongs_to :unit + + attribute :stats, :json + + validates :snapshot_date, :captured_at, :stats, presence: true + validates :snapshot_date, uniqueness: { scope: :unit_id } +end diff --git a/app/models/unit.rb b/app/models/unit.rb index 6d9580781..d5af9818b 100644 --- a/app/models/unit.rb +++ b/app/models/unit.rb @@ -155,6 +155,7 @@ def role_for(user) has_many :unit_roles, dependent: :destroy, inverse_of: :unit has_many :learning_outcomes, as: :context, dependent: :destroy # inverse_of: :unit has_many :marking_sessions, dependent: :destroy + has_many :task_completion_snapshots, dependent: :destroy, inverse_of: :unit has_many :comments, through: :projects has_many :tasks, through: :projects @@ -3128,6 +3129,42 @@ def get_tutor_times_csv(start_date: nil, end_date: nil, timezone: nil, ignore_se end end + def aggregate_task_complete_stats + result = {} + + task_definitions.each do |td| + result[td.abbreviation] = {} + end + + active_projects.each do |project| + campus_name = project.campus.name + result[campus_name] ||= {} + + task_definitions.each do |td| + result[campus_name][td.abbreviation] ||= {} + + task = project.task_for_task_definition(td) + next unless task + + status = task.task_status.id.to_s + result[campus_name][td.abbreviation][status] ||= 0 + result[campus_name][td.abbreviation][status] += 1 + end + end + + result + end + + def capture_task_complete_stats_snapshot!(captured_at: Time.zone.now) + task_completion_snapshots + .find_or_initialize_by(snapshot_date: captured_at.to_date) + .tap do |snapshot| + snapshot.captured_at = captured_at + snapshot.stats = aggregate_task_complete_stats + snapshot.save! + end + end + private def delete_associated_files diff --git a/app/sidekiq/aggregate_task_completion_stats_job.rb b/app/sidekiq/aggregate_task_completion_stats_job.rb new file mode 100644 index 000000000..33ca2f447 --- /dev/null +++ b/app/sidekiq/aggregate_task_completion_stats_job.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class AggregateTaskCompletionStatsJob + include Sidekiq::Job + + def perform(unit_id = nil) + if unit_id.present? + Unit.find(unit_id).capture_task_complete_stats_snapshot! + return + end + + Unit.active_units.find_each(&:capture_task_complete_stats_snapshot!) + end +end diff --git a/config/schedule.yml b/config/schedule.yml index b0bd370f0..7378cc245 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -16,6 +16,10 @@ refresh_moderation_feedback_timestamps: cron: "every 60 minutes" class: "RefreshModerationFeedbackTimestampsJob" +aggregate_task_completion_stats: + cron: "every day at 1" + class: "AggregateTaskCompletionStatsJob" + # archive_old_units: # cron: "every 6 months" # class: "ArchiveOldUnitsJob" diff --git a/db/migrate/20260322143502_create_task_completion_snapshots.rb b/db/migrate/20260322143502_create_task_completion_snapshots.rb new file mode 100644 index 000000000..6a63c6e66 --- /dev/null +++ b/db/migrate/20260322143502_create_task_completion_snapshots.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class CreateTaskCompletionSnapshots < ActiveRecord::Migration[8.0] + def change + create_table :task_completion_snapshots do |t| + t.references :unit, null: false, foreign_key: true + t.date :snapshot_date, null: false + t.datetime :captured_at, null: false + t.json :stats, null: false + + t.timestamps + end + + add_index :task_completion_snapshots, [:unit_id, :snapshot_date], unique: true + add_index :task_completion_snapshots, :captured_at + end +end \ No newline at end of file diff --git a/db/schema.rb b/db/schema.rb index cf1a8adcc..255631d7a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_03_09_065302) do +ActiveRecord::Schema[8.0].define(version: 2026_03_22_143502) do create_table "activity_types", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.string "name", null: false t.string "abbreviation", null: false @@ -404,6 +404,19 @@ t.index ["user_id"], name: "index_task_comments_on_user_id" end + create_table "task_completion_snapshots", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.bigint "unit_id", null: false + t.date "snapshot_date", null: false + t.datetime "captured_at", null: false + t.text "stats", size: :long, null: false, collation: "utf8mb4_bin" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["captured_at"], name: "index_task_completion_snapshots_on_captured_at" + t.index ["unit_id", "snapshot_date"], name: "index_task_completion_snapshots_on_unit_id_and_snapshot_date", unique: true + t.index ["unit_id"], name: "index_task_completion_snapshots_on_unit_id" + t.check_constraint "json_valid(`stats`)", name: "stats" + end + create_table "task_definition_grade_due_dates", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.bigint "task_definition_id", null: false t.integer "target_grade", null: false @@ -831,6 +844,7 @@ add_foreign_key "feedback_chips", "learning_outcomes" add_foreign_key "learning_outcome_links", "learning_outcomes", column: "source_id" add_foreign_key "learning_outcome_links", "learning_outcomes", column: "target_id" + add_foreign_key "task_completion_snapshots", "units" add_foreign_key "user_oauth_states", "users" add_foreign_key "user_oauth_tokens", "users" end diff --git a/test/sidekiq/scheduled_job_test.rb b/test/sidekiq/scheduled_job_test.rb index ba1b1b7e0..2100284ba 100644 --- a/test/sidekiq/scheduled_job_test.rb +++ b/test/sidekiq/scheduled_job_test.rb @@ -6,13 +6,14 @@ class TiiCheckProgressJobTest < ActiveSupport::TestCase def test_jobs_are_scheduled Sidekiq::Cron::Job.destroy_all! Sidekiq::Cron::Job.load_from_hash!(YAML.load_file(Rails.root.join('config/schedule.yml'))) - assert_equal 4, Sidekiq::Cron::Job.all.count, Sidekiq::Cron::Job.all.map(&:name) + assert_equal 5, Sidekiq::Cron::Job.all.count, Sidekiq::Cron::Job.all.map(&:name) Sidekiq::Cron::Job.all.each(&:enqueue!) assert_equal 1, TiiRegisterWebHookJob.jobs.count assert_equal 1, TiiCheckProgressJob.jobs.count assert_equal 1, ClearAccessTokensJob.jobs.count assert_equal 1, RefreshModerationFeedbackTimestampsJob.jobs.count + assert_equal 1, AggregateTaskCompletionStatsJob.jobs.count # assert_equal 1, ArchiveOldUnitsJob.jobs.count end From 2cee127e5c4b06e8eb22a733522030ec67b44433 Mon Sep 17 00:00:00 2001 From: Audrey Phommasone Date: Fri, 3 Apr 2026 10:14:44 +1100 Subject: [PATCH 02/17] feat: task completion stats aggregate by tutorial and job scheduled to 11:55pm --- app/api/units_api.rb | 13 ++++++++++++- app/models/unit.rb | 14 +++++++------- config/schedule.yml | 2 +- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/app/api/units_api.rb b/app/api/units_api.rb index 536cedf3e..3f73bfb53 100644 --- a/app/api/units_api.rb +++ b/app/api/units_api.rb @@ -592,10 +592,21 @@ class UnitsApi < Grape::API snapshots = snapshots.limit([params[:limit].to_i, 365].min) present snapshots.map { |snapshot| + converted_stats = snapshot.stats.transform_values do |tutorials| + tutorials.transform_values do |task_defs| + task_defs.transform_values do |status_counts| + status_counts.each_with_object({}) do |(status_id, count), status_acc| + status_key = TaskStatus.id_to_key(status_id.to_i).to_s + status_acc[status_key] = status_acc[status_key].to_i + count + end + end + end + end + { snapshot_date: snapshot.snapshot_date, captured_at: snapshot.captured_at, - stats: snapshot.stats + stats: converted_stats } }, with: Grape::Presenters::Presenter end diff --git a/app/models/unit.rb b/app/models/unit.rb index d5af9818b..a9100d119 100644 --- a/app/models/unit.rb +++ b/app/models/unit.rb @@ -3132,23 +3132,23 @@ def get_tutor_times_csv(start_date: nil, end_date: nil, timezone: nil, ignore_se def aggregate_task_complete_stats result = {} - task_definitions.each do |td| - result[td.abbreviation] = {} - end - active_projects.each do |project| campus_name = project.campus.name result[campus_name] ||= {} task_definitions.each do |td| - result[campus_name][td.abbreviation] ||= {} + tutorial = project.tutorial_for(td) + tutorial_name = tutorial&.abbreviation || 'Unassigned' + + result[campus_name][tutorial_name] ||= {} + result[campus_name][tutorial_name][td.abbreviation] ||= {} task = project.task_for_task_definition(td) next unless task status = task.task_status.id.to_s - result[campus_name][td.abbreviation][status] ||= 0 - result[campus_name][td.abbreviation][status] += 1 + result[campus_name][tutorial_name][td.abbreviation][status] ||= 0 + result[campus_name][tutorial_name][td.abbreviation][status] += 1 end end diff --git a/config/schedule.yml b/config/schedule.yml index 7378cc245..860c1d8a0 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -17,7 +17,7 @@ refresh_moderation_feedback_timestamps: class: "RefreshModerationFeedbackTimestampsJob" aggregate_task_completion_stats: - cron: "every day at 1" + cron: "every day at 11:55pm" class: "AggregateTaskCompletionStatsJob" # archive_old_units: From 8064aa376d20ceac2da52edb74ea3388e9cff44c Mon Sep 17 00:00:00 2001 From: Audrey Phommasone Date: Wed, 8 Apr 2026 10:24:42 +1000 Subject: [PATCH 03/17] feat: add tests for task completion snapshots retrieval and capture --- test/api/units_api_test.rb | 150 +++++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) diff --git a/test/api/units_api_test.rb b/test/api/units_api_test.rb index 9ff3ed5d0..e9307c2a2 100644 --- a/test/api/units_api_test.rb +++ b/test/api/units_api_test.rb @@ -622,4 +622,154 @@ def test_draft_learning_summary_upload_requirements unit.reload assert_equal task_def_doc.id, unit.draft_task_definition_id end + + def test_get_task_completion_snapshots + unit = FactoryBot.create :unit, with_students: false, task_count: 0 + + status_id = TaskStatus.complete.id + status_key = TaskStatus.id_to_key(status_id).to_s + + older_snapshot = TaskCompletionSnapshot.create!( + unit: unit, + snapshot_date: Date.new(2026, 4, 1), + captured_at: Time.zone.parse('2026-04-01 10:00:00'), + stats: { + 'Melbourne' => { + 'LA011' => { + 'Task 1' => { + status_id.to_s => 1 + } + } + } + } + ) + + mid_snapshot = TaskCompletionSnapshot.create!( + unit: unit, + snapshot_date: Date.new(2026, 4, 2), + captured_at: Time.zone.parse('2026-04-02 10:00:00'), + stats: { + 'Melbourne' => { + 'LA011' => { + 'Task 1' => { + status_id.to_s => 2 + } + } + } + } + ) + + latest_snapshot = TaskCompletionSnapshot.create!( + unit: unit, + snapshot_date: Date.new(2026, 4, 3), + captured_at: Time.zone.parse('2026-04-03 10:00:00'), + stats: { + 'Melbourne' => { + 'LA011' => { + 'Task 1' => { + status_id.to_s => 2, + status_id => 3 + } + } + } + } + ) + + add_auth_header_for(user: unit.main_convenor_user) + header 'Host', 'localhost' + get "/api/units/#{unit.id}/stats/task_completion_snapshots", { limit: 2 } + + assert_equal 200, last_response.status, last_response_body + assert_equal 2, last_response_body.length + + assert_equal latest_snapshot.snapshot_date.to_s, last_response_body[0]['snapshot_date'].to_date.to_s + assert_equal mid_snapshot.snapshot_date.to_s, last_response_body[1]['snapshot_date'].to_date.to_s + + latest_stats = last_response_body[0]['stats'] + assert_equal 3, latest_stats['Melbourne']['LA011']['Task 1'][status_key] + + assert_not_equal older_snapshot.snapshot_date.to_s, last_response_body[1]['snapshot_date'].to_date.to_s + end + + def test_get_task_completion_snapshots_filters_by_date + unit = FactoryBot.create :unit, with_students: false, task_count: 0 + + TaskCompletionSnapshot.create!( + unit: unit, + snapshot_date: Date.new(2026, 3, 30), + captured_at: Time.zone.parse('2026-03-30 10:00:00'), + stats: { 'snapshot' => {} } + ) + + included_snapshot = TaskCompletionSnapshot.create!( + unit: unit, + snapshot_date: Date.new(2026, 4, 2), + captured_at: Time.zone.parse('2026-04-02 10:00:00'), + stats: { 'snapshot' => {} } + ) + + TaskCompletionSnapshot.create!( + unit: unit, + snapshot_date: Date.new(2026, 4, 5), + captured_at: Time.zone.parse('2026-04-05 10:00:00'), + stats: { 'snapshot' => {} } + ) + + add_auth_header_for(user: unit.main_convenor_user) + header 'Host', 'localhost' + get "/api/units/#{unit.id}/stats/task_completion_snapshots", { + start_date: Date.new(2026, 4, 1), + end_date: Date.new(2026, 4, 3) + } + + assert_equal 200, last_response.status, last_response_body + assert_equal 1, last_response_body.length + assert_equal included_snapshot.snapshot_date.to_s, last_response_body[0]['snapshot_date'].to_date.to_s + end + + def test_get_task_completion_snapshots_not_authorised + unit = FactoryBot.create :unit, with_students: false, task_count: 0 + TaskCompletionSnapshot.create!( + unit: unit, + snapshot_date: Date.current, + captured_at: Time.zone.now, + stats: { 'snapshot' => {} } + ) + + add_auth_header_for(user: User.where(role: Role.student).first) + header 'Host', 'localhost' + get "/api/units/#{unit.id}/stats/task_completion_snapshots" + + assert_equal 403, last_response.status + end + + def test_post_capture_task_completion_snapshot + unit = FactoryBot.create :unit + + count_before = TaskCompletionSnapshot.where(unit: unit).count + + add_auth_header_for(user: unit.main_convenor_user) + header 'Host', 'localhost' + post "/api/units/#{unit.id}/stats/task_completion_snapshots/capture" + + assert_equal 201, last_response.status, last_response_body + + snapshot = TaskCompletionSnapshot.find_by(unit: unit, snapshot_date: Date.current) + assert_not_nil snapshot + assert_equal count_before + 1, TaskCompletionSnapshot.where(unit: unit).count + + assert_equal snapshot.snapshot_date.to_s, Date.current.to_s + assert_equal snapshot.stats, last_response_body['stats'] + assert_not_nil last_response_body['captured_at'] + end + + def test_post_capture_task_completion_snapshot_not_authorised + unit = FactoryBot.create :unit, with_students: false, task_count: 0 + + add_auth_header_for(user: User.where(role: Role.student).first) + header 'Host', 'localhost' + post "/api/units/#{unit.id}/stats/task_completion_snapshots/capture" + + assert_equal 403, last_response.status + end end From 63afeffd18d0d9ad46ab1e5a376f341eb309170e Mon Sep 17 00:00:00 2001 From: Audrey Phommasone Date: Wed, 8 Apr 2026 15:19:44 +1000 Subject: [PATCH 04/17] feat: add factory and tests for task_completion_snapshot model --- .../task_completion_snapshot_factory.rb | 8 ++ test/models/task_completion_snapshot_test.rb | 120 ++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 test/factories/task_completion_snapshot_factory.rb create mode 100644 test/models/task_completion_snapshot_test.rb diff --git a/test/factories/task_completion_snapshot_factory.rb b/test/factories/task_completion_snapshot_factory.rb new file mode 100644 index 000000000..fb3fd06b0 --- /dev/null +++ b/test/factories/task_completion_snapshot_factory.rb @@ -0,0 +1,8 @@ +FactoryBot.define do + factory :task_completion_snapshot do + unit + snapshot_date { Date.current } + captured_at { Time.current } + stats { { 'completed' => 5, 'in_progress' => 3, 'not_started' => 2 } } + end +end diff --git a/test/models/task_completion_snapshot_test.rb b/test/models/task_completion_snapshot_test.rb new file mode 100644 index 000000000..b497786b5 --- /dev/null +++ b/test/models/task_completion_snapshot_test.rb @@ -0,0 +1,120 @@ +require 'test_helper' + +class TaskCompletionSnapshotTest < ActiveSupport::TestCase + setup do + @unit = FactoryBot.create(:unit) + @snapshot = FactoryBot.create(:task_completion_snapshot, unit: @unit) + end + + # Association tests + test 'task_completion_snapshot belongs to unit' do + assert @snapshot.unit.is_a?(Unit) + assert_equal @unit, @snapshot.unit + end + + # Presence validation tests + test 'task_completion_snapshot is valid with all attributes' do + assert @snapshot.valid? + end + + test 'task_completion_snapshot is invalid without snapshot_date' do + snapshot = FactoryBot.build(:task_completion_snapshot, snapshot_date: nil) + assert_not snapshot.valid? + assert snapshot.errors[:snapshot_date].include?("can't be blank") + end + + test 'task_completion_snapshot is invalid without captured_at' do + snapshot = FactoryBot.build(:task_completion_snapshot, captured_at: nil) + assert_not snapshot.valid? + assert snapshot.errors[:captured_at].include?("can't be blank") + end + + test 'task_completion_snapshot is invalid without stats' do + snapshot = FactoryBot.build(:task_completion_snapshot, stats: nil) + assert_not snapshot.valid? + assert snapshot.errors[:stats].include?("can't be blank") + end + + test 'task_completion_snapshot is invalid with empty stats hash' do + snapshot = FactoryBot.build(:task_completion_snapshot, stats: {}) + assert_not snapshot.valid? + assert snapshot.errors[:stats].include?("can't be blank") + end + + # Uniqueness validation tests + test 'task_completion_snapshot enforces unique snapshot_date per unit' do + duplicate = FactoryBot.build(:task_completion_snapshot, + unit: @unit, + snapshot_date: @snapshot.snapshot_date) + assert_not duplicate.valid? + assert duplicate.errors[:snapshot_date].include?('has already been taken') + end + + test 'task_completion_snapshot allows same snapshot_date for different units' do + other_unit = FactoryBot.create(:unit) + snapshot = FactoryBot.build(:task_completion_snapshot, + unit: other_unit, + snapshot_date: @snapshot.snapshot_date) + assert snapshot.valid? + end + + # Creation tests + test 'can create task_completion_snapshot with valid attributes' do + snapshot = FactoryBot.create(:task_completion_snapshot) + assert snapshot.persisted? + assert_not_nil snapshot.id + end + + test 'snapshot_date is stored correctly' do + date = Date.current - 5.days + snapshot = FactoryBot.create(:task_completion_snapshot, snapshot_date: date) + assert_equal date, snapshot.snapshot_date + end + + test 'captured_at is stored correctly' do + time = Time.zone.now - 2.hours + snapshot = FactoryBot.create(:task_completion_snapshot, captured_at: time) + assert_equal time.to_i, snapshot.captured_at.to_i + end + + test 'stats json is stored and retrieved correctly' do + stats_data = { + 'completed' => 10, + 'in_progress' => 7, + 'not_started' => 3, + 'not_required' => 5 + } + snapshot = FactoryBot.create(:task_completion_snapshot, stats: stats_data) + assert_equal stats_data, snapshot.stats + end + + # Querying tests + test 'can find snapshot by unit and snapshot_date' do + found = TaskCompletionSnapshot.find_by(unit: @unit, snapshot_date: @snapshot.snapshot_date) + assert_equal @snapshot, found + end + + test 'can query all snapshots for a unit' do + other_snapshot = FactoryBot.create(:task_completion_snapshot, + unit: @unit, + snapshot_date: Time.zone.today + 1.day) + snapshots = @unit.task_completion_snapshots + assert_includes snapshots, @snapshot + assert_includes snapshots, other_snapshot + assert_equal 2, snapshots.count + end + + # Deletion tests + test 'can delete task_completion_snapshot' do + snapshot = FactoryBot.create(:task_completion_snapshot, snapshot_date: Date.current + 10.days) + id = snapshot.id + snapshot.destroy + assert_nil TaskCompletionSnapshot.find_by(id: id) + end + + test 'deleting snapshot does not affect unit' do + snapshot = FactoryBot.create(:task_completion_snapshot, unit: @unit, snapshot_date: Date.current + 5.days) + snapshot.destroy + assert_not_nil Unit.find(@unit.id) + end +end From e2674362cd65ae9a0f74f5ff306d383cef5ac595 Mon Sep 17 00:00:00 2001 From: Audrey Phommasone Date: Wed, 8 Apr 2026 16:19:24 +1000 Subject: [PATCH 05/17] feat: add tests for aggregating and capturing task-completion-stats snapshots --- test/models/unit_model_test.rb | 103 +++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/test/models/unit_model_test.rb b/test/models/unit_model_test.rb index 6a54c9b28..4f29bf0ce 100644 --- a/test/models/unit_model_test.rb +++ b/test/models/unit_model_test.rb @@ -1138,4 +1138,107 @@ def test_cant_disable_aip_only_while_aip_tasks_exist assert_includes unit.errors[:mark_late_submissions_as_assess_in_portfolio], 'cannot be disabled while tasks are in the Assess in Portfolio state' end + test 'aggregate-task-complete-stats groups by campus tutorial task and status' do + data = build_unit_with_controlled_task_statuses + unit = data[:unit] + tutorial = data[:tutorial] + task_definitions = data[:task_definitions] + + stats = unit.aggregate_task_complete_stats + + expected = { + tutorial.campus.name => { + tutorial.abbreviation => { + task_definitions[0].abbreviation => { + TaskStatus.complete.id.to_s => 2 + }, + task_definitions[1].abbreviation => { + TaskStatus.fail.id.to_s => 1, + TaskStatus.not_started.id.to_s => 1 + } + } + } + } + + assert_equal expected, stats + ensure + unit&.destroy + end + + test 'capture-task-complete-stats-snapshot creates snapshot for date' do + data = build_unit_with_controlled_task_statuses + unit = data[:unit] + captured_at = Time.zone.local(2026, 4, 8, 23, 55, 0) + expected_stats = unit.aggregate_task_complete_stats + + count_before = unit.task_completion_snapshots.count + snapshot = unit.capture_task_complete_stats_snapshot!(captured_at: captured_at) + + assert_equal count_before + 1, unit.task_completion_snapshots.count + assert_equal captured_at.to_date, snapshot.snapshot_date + assert_equal captured_at.to_i, snapshot.captured_at.to_i + assert_equal expected_stats, snapshot.stats + + persisted_snapshot = unit.task_completion_snapshots.find_by(snapshot_date: captured_at.to_date) + assert_not_nil persisted_snapshot + assert_equal snapshot.id, persisted_snapshot.id + ensure + unit&.destroy + end + + test 'capture-task-complete-stats-snapshot updates existing snapshot for same date' do + data = build_unit_with_controlled_task_statuses + unit = data[:unit] + task_definitions = data[:task_definitions] + student2 = data[:student2] + + first_time = Time.zone.local(2026, 4, 8, 9, 0, 0) + second_time = Time.zone.local(2026, 4, 8, 20, 0, 0) + + first_snapshot = unit.capture_task_complete_stats_snapshot!(captured_at: first_time) + first_stats = first_snapshot.stats.deep_dup + count_before = unit.task_completion_snapshots.count + + # Change one task status so the new capture has different stats. + student2.task_for_task_definition(task_definitions[0]).update!(task_status: TaskStatus.fail) + expected_updated_stats = unit.aggregate_task_complete_stats + + updated_snapshot = unit.capture_task_complete_stats_snapshot!(captured_at: second_time) + + assert_equal count_before, unit.task_completion_snapshots.count + assert_equal first_snapshot.id, updated_snapshot.id + assert_equal second_time.to_i, updated_snapshot.captured_at.to_i + assert_not_equal first_stats, updated_snapshot.stats + assert_equal expected_updated_stats, updated_snapshot.stats + ensure + unit&.destroy + end + + private + + def build_unit_with_controlled_task_statuses + unit = FactoryBot.create(:unit, with_students: false, task_count: 2, stream_count: 0, tutorials: 1, campus_count: 1) + tutorial = unit.tutorials.first + campus = tutorial.campus + task_definitions = unit.task_definitions.order(:id).to_a + + student1 = unit.enrol_student(FactoryBot.create(:user, :student), campus) + student2 = unit.enrol_student(FactoryBot.create(:user, :student), campus) + student1.enrol_in(tutorial) + student2.enrol_in(tutorial) + + student1.task_for_task_definition(task_definitions[0]).update!(task_status: TaskStatus.complete) + student2.task_for_task_definition(task_definitions[0]).update!(task_status: TaskStatus.complete) + student1.task_for_task_definition(task_definitions[1]).update!(task_status: TaskStatus.fail) + student2.task_for_task_definition(task_definitions[1]).update!(task_status: TaskStatus.not_started) + + { + unit: unit, + tutorial: tutorial, + task_definitions: task_definitions, + student1: student1, + student2: student2 + } + end + end From 3265a83a79eb7d3a67be0c0d816845cd9343ab3f Mon Sep 17 00:00:00 2001 From: Audrey Phommasone Date: Sun, 19 Apr 2026 13:47:57 +1000 Subject: [PATCH 06/17] refactor: remove foreign key constraint from task_completion_snapshots, rerun migration --- db/migrate/20260322143502_create_task_completion_snapshots.rb | 4 ++-- db/schema.rb | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/db/migrate/20260322143502_create_task_completion_snapshots.rb b/db/migrate/20260322143502_create_task_completion_snapshots.rb index 6a63c6e66..8f77d5ac5 100644 --- a/db/migrate/20260322143502_create_task_completion_snapshots.rb +++ b/db/migrate/20260322143502_create_task_completion_snapshots.rb @@ -3,7 +3,7 @@ class CreateTaskCompletionSnapshots < ActiveRecord::Migration[8.0] def change create_table :task_completion_snapshots do |t| - t.references :unit, null: false, foreign_key: true + t.references :unit, null: false t.date :snapshot_date, null: false t.datetime :captured_at, null: false t.json :stats, null: false @@ -14,4 +14,4 @@ def change add_index :task_completion_snapshots, [:unit_id, :snapshot_date], unique: true add_index :task_completion_snapshots, :captured_at end -end \ No newline at end of file +end diff --git a/db/schema.rb b/db/schema.rb index ed013e860..2e23eb753 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -846,7 +846,6 @@ add_foreign_key "feedback_chips", "learning_outcomes" add_foreign_key "learning_outcome_links", "learning_outcomes", column: "source_id" add_foreign_key "learning_outcome_links", "learning_outcomes", column: "target_id" - add_foreign_key "task_completion_snapshots", "units" add_foreign_key "user_oauth_states", "users" add_foreign_key "user_oauth_tokens", "users" end From 7c1fc5d2020755d1c11e406288124061900e9ea9 Mon Sep 17 00:00:00 2001 From: Audrey Phommasone Date: Sun, 19 Apr 2026 15:30:52 +1000 Subject: [PATCH 07/17] fix: `aggregate_task_complete_stats` uses existing status_for_task_definition --- app/models/unit.rb | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/models/unit.rb b/app/models/unit.rb index 66538b434..0866c867a 100644 --- a/app/models/unit.rb +++ b/app/models/unit.rb @@ -3465,10 +3465,9 @@ def aggregate_task_complete_stats result[campus_name][tutorial_name] ||= {} result[campus_name][tutorial_name][td.abbreviation] ||= {} - task = project.task_for_task_definition(td) - next unless task + status_key = project.status_for_task_definition(td) + status = TaskStatus.status_for_name(status_key.to_s)&.id.to_s || TaskStatus.not_started.id.to_s - status = task.task_status.id.to_s result[campus_name][tutorial_name][td.abbreviation][status] ||= 0 result[campus_name][tutorial_name][td.abbreviation][status] += 1 end From d9228f41d2ff55d31407335f31370194077020f4 Mon Sep 17 00:00:00 2001 From: Audrey Phommasone Date: Sun, 19 Apr 2026 16:05:23 +1000 Subject: [PATCH 08/17] refactor: task completion stats uses async sidekiq job for snapshots --- app/api/units_api.rb | 14 +++--------- .../aggregate_task_completion_stats_job.rb | 22 +++++++++++++++++-- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/app/api/units_api.rb b/app/api/units_api.rb index af809608c..948c31fc8 100644 --- a/app/api/units_api.rb +++ b/app/api/units_api.rb @@ -622,17 +622,9 @@ class UnitsApi < Grape::API error!({ error: "Not authorised to capture stats of student tasks in #{unit.code}" }, 403) end - begin - snapshot = unit.capture_task_complete_stats_snapshot! - present( - { - snapshot_date: snapshot.snapshot_date, - captured_at: snapshot.captured_at, - stats: snapshot.stats - }, - with: Grape::Presenters::Presenter - ) - end + job_id = AggregateTaskCompletionStatsJob.perform_async(unit.id) + job = setup_job(job_id) + present job, with: Entities::SidekiqJobEntity end desc 'Download stats related to the number of tasks assessed by each tutor' diff --git a/app/sidekiq/aggregate_task_completion_stats_job.rb b/app/sidekiq/aggregate_task_completion_stats_job.rb index 33ca2f447..4bb375ebe 100644 --- a/app/sidekiq/aggregate_task_completion_stats_job.rb +++ b/app/sidekiq/aggregate_task_completion_stats_job.rb @@ -2,13 +2,31 @@ class AggregateTaskCompletionStatsJob include Sidekiq::Job + include Sidekiq::Status::Worker + include LogHelper + include ApplicationHelper + + sidekiq_options lock: :until_executed, + lock_args_method: ->(args) { [args.first] }, + on_conflict: :reject, + retry: false def perform(unit_id = nil) + logger.info 'Starting task completion stats aggregation...' + + at(0) + total(1) + if unit_id.present? Unit.find(unit_id).capture_task_complete_stats_snapshot! - return + else + Unit.active_units.find_each(&:capture_task_complete_stats_snapshot!) end - Unit.active_units.find_each(&:capture_task_complete_stats_snapshot!) + at(1) + logger.info 'Completed task completion stats aggregation!' + rescue StandardError => e + logger.error e + raise e end end From 98f29d55a4e49909dd647e107c15bbe58946963a Mon Sep 17 00:00:00 2001 From: Audrey Phommasone Date: Sun, 19 Apr 2026 16:09:25 +1000 Subject: [PATCH 09/17] feat: add convenor permission for capturing task completion snapshots --- app/api/units_api.rb | 2 +- app/models/unit.rb | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/api/units_api.rb b/app/api/units_api.rb index 948c31fc8..e44d60480 100644 --- a/app/api/units_api.rb +++ b/app/api/units_api.rb @@ -618,7 +618,7 @@ class UnitsApi < Grape::API desc 'Capture task completion snapshot immediately for this unit' post '/units/:id/stats/task_completion_snapshots/capture' do unit = Unit.find(params[:id]) - unless authorise? current_user, unit, :download_stats + unless authorise? current_user, unit, :capture_task_completion_snapshot error!({ error: "Not authorised to capture stats of student tasks in #{unit.code}" }, 403) end diff --git a/app/models/unit.rb b/app/models/unit.rb index 0866c867a..b104c09fc 100644 --- a/app/models/unit.rb +++ b/app/models/unit.rb @@ -67,7 +67,8 @@ def self.permissions :get_tutor_times_summary, :get_marking_sessions, :upload_grades_csv, - :get_staff_notes + :get_staff_notes, + :capture_task_completion_snapshot ] # What can admin do with units? From f876fc5f2ca66da0e532a22d53489c9cb494d22e Mon Sep 17 00:00:00 2001 From: Audrey Phommasone Date: Sun, 19 Apr 2026 18:34:47 +1000 Subject: [PATCH 10/17] feat: add rate limit to task completion snapshot (30mins) --- app/api/units_api.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/api/units_api.rb b/app/api/units_api.rb index e44d60480..659bc1805 100644 --- a/app/api/units_api.rb +++ b/app/api/units_api.rb @@ -622,6 +622,14 @@ class UnitsApi < Grape::API error!({ error: "Not authorised to capture stats of student tasks in #{unit.code}" }, 403) end + # Check if a snapshot was captured within the past 30 minutes + recent_snapshot = unit.task_completion_snapshots.where('captured_at > ?', 30.minutes.ago).order(captured_at: :desc).first + if recent_snapshot.present? + remaining_seconds = [(recent_snapshot.captured_at + 30.minutes - Time.zone.now).ceil, 0].max + remaining_minutes = [(remaining_seconds / 60.0).ceil, 1].max + error!({ error: "A snapshot was captured at #{recent_snapshot.captured_at.strftime('%H:%M')}. Please wait #{remaining_minutes} more minute(s) before capturing another snapshot." }, 429) + end + job_id = AggregateTaskCompletionStatsJob.perform_async(unit.id) job = setup_job(job_id) present job, with: Entities::SidekiqJobEntity From a589be07d1a89feba6e6ae44572b8b5bcbe5c09c Mon Sep 17 00:00:00 2001 From: Audrey Phommasone Date: Mon, 20 Apr 2026 19:24:52 +1000 Subject: [PATCH 11/17] refactor: change snapshots to be stored as JSON files --- app/api/units_api.rb | 24 ++-- app/helpers/file_helper.rb | 24 ++++ app/models/task_completion_snapshot.rb | 52 ++++++- app/models/unit.rb | 10 +- ...143502_create_task_completion_snapshots.rb | 7 +- db/schema.rb | 8 +- test/api/units_api_test.rb | 107 +++++++------- .../task_completion_snapshot_factory.rb | 4 +- test/models/task_completion_snapshot_test.rb | 131 +++++++----------- test/models/unit_model_test.rb | 30 ++-- 10 files changed, 220 insertions(+), 177 deletions(-) diff --git a/app/api/units_api.rb b/app/api/units_api.rb index 659bc1805..3d69c2287 100644 --- a/app/api/units_api.rb +++ b/app/api/units_api.rb @@ -590,13 +590,20 @@ class UnitsApi < Grape::API error!({ error: "Not authorised to download stats of student tasks in #{unit.code}" }, 403) end - snapshots = unit.task_completion_snapshots.order(captured_at: :desc) - snapshots = snapshots.where('snapshot_date >= ?', params[:start_date]) if params[:start_date].present? - snapshots = snapshots.where('snapshot_date <= ?', params[:end_date]) if params[:end_date].present? + snapshots = unit.task_completion_snapshots.order(snapshot_timestamp: :desc) + if params[:start_date].present? + start_timestamp = params[:start_date].in_time_zone.beginning_of_day.to_i + snapshots = snapshots.where('CAST(snapshot_timestamp AS UNSIGNED) >= ?', start_timestamp) + end + if params[:end_date].present? + end_timestamp = params[:end_date].in_time_zone.end_of_day.to_i + snapshots = snapshots.where('CAST(snapshot_timestamp AS UNSIGNED) <= ?', end_timestamp) + end snapshots = snapshots.limit([params[:limit].to_i, 365].min) present snapshots.map { |snapshot| - converted_stats = snapshot.stats.transform_values do |tutorials| + raw_stats = snapshot.load_stats + converted_stats = raw_stats.transform_values do |tutorials| tutorials.transform_values do |task_defs| task_defs.transform_values do |status_counts| status_counts.each_with_object({}) do |(status_id, count), status_acc| @@ -609,7 +616,7 @@ class UnitsApi < Grape::API { snapshot_date: snapshot.snapshot_date, - captured_at: snapshot.captured_at, + snapshot_timestamp: snapshot.snapshot_timestamp, stats: converted_stats } }, with: Grape::Presenters::Presenter @@ -623,11 +630,12 @@ class UnitsApi < Grape::API end # Check if a snapshot was captured within the past 30 minutes - recent_snapshot = unit.task_completion_snapshots.where('captured_at > ?', 30.minutes.ago).order(captured_at: :desc).first + recent_snapshot = unit.task_completion_snapshots.where('CAST(snapshot_timestamp AS UNSIGNED) > ?', 30.minutes.ago.to_i).order(snapshot_timestamp: :desc).first if recent_snapshot.present? - remaining_seconds = [(recent_snapshot.captured_at + 30.minutes - Time.zone.now).ceil, 0].max + recent_snapshot_time = recent_snapshot.snapshot_time + remaining_seconds = [(recent_snapshot_time + 30.minutes - Time.zone.now).ceil, 0].max remaining_minutes = [(remaining_seconds / 60.0).ceil, 1].max - error!({ error: "A snapshot was captured at #{recent_snapshot.captured_at.strftime('%H:%M')}. Please wait #{remaining_minutes} more minute(s) before capturing another snapshot." }, 429) + error!({ error: "A snapshot was captured at #{recent_snapshot_time.strftime('%H:%M')}. Please wait #{remaining_minutes} more minute(s) before capturing another snapshot." }, 429) end job_id = AggregateTaskCompletionStatsJob.perform_async(unit.id) diff --git a/app/helpers/file_helper.rb b/app/helpers/file_helper.rb index 1f589f08a..3f859bfb6 100644 --- a/app/helpers/file_helper.rb +++ b/app/helpers/file_helper.rb @@ -276,6 +276,27 @@ def unit_portfolio_dir(unit, create: true, archived: true) dst end + def unit_analytics_dir(unit, create: true, archived: true) + dst = unit_work_root(unit, archived: archived) + dst << 'analytics/' + + FileUtils.mkdir_p(dst) if create + dst + end + + def unit_task_status_snapshots_dir(unit, create: true, archived: true) + dst = unit_analytics_dir(unit, create: create, archived: archived) + dst << 'task-statuses/' + + FileUtils.mkdir_p(dst) if create + dst + end + + def unit_task_status_snapshot_path(unit, snapshot_timestamp, create: true, archived: true) + snapshot_filename = "#{sanitized_filename(snapshot_timestamp.to_s)}.json" + File.join(unit_task_status_snapshots_dir(unit, create: create, archived: archived), snapshot_filename) + end + # # Generates a path for storing student portfolios # @@ -778,6 +799,9 @@ def line_wrap(path, width: 160) module_function :unit_dir module_function :root_portfolio_dir module_function :unit_portfolio_dir + module_function :unit_analytics_dir + module_function :unit_task_status_snapshots_dir + module_function :unit_task_status_snapshot_path module_function :unit_work_root module_function :project_work_root module_function :student_portfolio_dir diff --git a/app/models/task_completion_snapshot.rb b/app/models/task_completion_snapshot.rb index 5260ac982..81e8fb42b 100644 --- a/app/models/task_completion_snapshot.rb +++ b/app/models/task_completion_snapshot.rb @@ -1,10 +1,54 @@ # frozen_string_literal: true class TaskCompletionSnapshot < ApplicationRecord + include FileHelper + belongs_to :unit - - attribute :stats, :json - validates :snapshot_date, :captured_at, :stats, presence: true - validates :snapshot_date, uniqueness: { scope: :unit_id } + validates :snapshot_timestamp, presence: true + validates :snapshot_timestamp, uniqueness: { scope: :unit_id } + + after_destroy :delete_snapshot_file + + def snapshot_file_path + FileHelper.unit_task_status_snapshot_path(unit, snapshot_timestamp, create: true) + end + + def snapshot_date + return nil if snapshot_timestamp.blank? + + snapshot_time.to_date + end + + def snapshot_time + return nil if snapshot_timestamp.blank? + + Time.zone.at(snapshot_timestamp.to_i) + end + + def load_stats + if File.exist?(snapshot_file_path) + JSON.parse(File.read(snapshot_file_path)) + else + {} + end + rescue JSON::ParserError + {} + end + + def store_stats!(payload) + FileUtils.mkdir_p(File.dirname(snapshot_file_path)) + + tmp_path = "#{snapshot_file_path}.tmp" + File.write(tmp_path, JSON.pretty_generate(payload)) + FileUtils.mv(tmp_path, snapshot_file_path) + ensure + FileUtils.rm_f(tmp_path) if defined?(tmp_path) + end + + private + + def delete_snapshot_file + FileUtils.rm_f(snapshot_file_path) + end end diff --git a/app/models/unit.rb b/app/models/unit.rb index b104c09fc..46cfa7432 100644 --- a/app/models/unit.rb +++ b/app/models/unit.rb @@ -3477,13 +3477,15 @@ def aggregate_task_complete_stats result end - def capture_task_complete_stats_snapshot!(captured_at: Time.zone.now) + def capture_task_complete_stats_snapshot!(snapshot_time: Time.zone.now) + snapshot_payload = aggregate_task_complete_stats + timestamp = snapshot_time.to_i.to_s + task_completion_snapshots - .find_or_initialize_by(snapshot_date: captured_at.to_date) + .find_or_initialize_by(snapshot_timestamp: timestamp) .tap do |snapshot| - snapshot.captured_at = captured_at - snapshot.stats = aggregate_task_complete_stats snapshot.save! + snapshot.store_stats!(snapshot_payload) end end diff --git a/db/migrate/20260322143502_create_task_completion_snapshots.rb b/db/migrate/20260322143502_create_task_completion_snapshots.rb index 8f77d5ac5..3cdf51c54 100644 --- a/db/migrate/20260322143502_create_task_completion_snapshots.rb +++ b/db/migrate/20260322143502_create_task_completion_snapshots.rb @@ -4,14 +4,11 @@ class CreateTaskCompletionSnapshots < ActiveRecord::Migration[8.0] def change create_table :task_completion_snapshots do |t| t.references :unit, null: false - t.date :snapshot_date, null: false - t.datetime :captured_at, null: false - t.json :stats, null: false + t.string :snapshot_timestamp, null: false t.timestamps end - add_index :task_completion_snapshots, [:unit_id, :snapshot_date], unique: true - add_index :task_completion_snapshots, :captured_at + add_index :task_completion_snapshots, [:unit_id, :snapshot_timestamp], unique: true end end diff --git a/db/schema.rb b/db/schema.rb index 2e23eb753..c6db56c98 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -407,15 +407,11 @@ create_table "task_completion_snapshots", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| t.bigint "unit_id", null: false - t.date "snapshot_date", null: false - t.datetime "captured_at", null: false - t.text "stats", size: :long, null: false, collation: "utf8mb4_bin" + t.string "snapshot_timestamp", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["captured_at"], name: "index_task_completion_snapshots_on_captured_at" - t.index ["unit_id", "snapshot_date"], name: "index_task_completion_snapshots_on_unit_id_and_snapshot_date", unique: true + t.index ["unit_id", "snapshot_timestamp"], name: "idx_on_unit_id_snapshot_timestamp_e923c3ae10", unique: true t.index ["unit_id"], name: "index_task_completion_snapshots_on_unit_id" - t.check_constraint "json_valid(`stats`)", name: "stats" end create_table "task_definition_grade_due_dates", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| diff --git a/test/api/units_api_test.rb b/test/api/units_api_test.rb index e9307c2a2..7713efd05 100644 --- a/test/api/units_api_test.rb +++ b/test/api/units_api_test.rb @@ -631,49 +631,53 @@ def test_get_task_completion_snapshots older_snapshot = TaskCompletionSnapshot.create!( unit: unit, - snapshot_date: Date.new(2026, 4, 1), - captured_at: Time.zone.parse('2026-04-01 10:00:00'), - stats: { - 'Melbourne' => { - 'LA011' => { - 'Task 1' => { - status_id.to_s => 1 - } + snapshot_timestamp: Time.zone.parse('2026-04-01 10:00:00').to_i.to_s + ) + + older_payload = { + 'Melbourne' => { + 'LA011' => { + 'Task 1' => { + status_id.to_s => 1 } } } - ) + } mid_snapshot = TaskCompletionSnapshot.create!( unit: unit, - snapshot_date: Date.new(2026, 4, 2), - captured_at: Time.zone.parse('2026-04-02 10:00:00'), - stats: { - 'Melbourne' => { - 'LA011' => { - 'Task 1' => { - status_id.to_s => 2 - } + snapshot_timestamp: Time.zone.parse('2026-04-02 10:00:00').to_i.to_s + ) + + mid_payload = { + 'Melbourne' => { + 'LA011' => { + 'Task 1' => { + status_id.to_s => 2 } } } - ) + } latest_snapshot = TaskCompletionSnapshot.create!( unit: unit, - snapshot_date: Date.new(2026, 4, 3), - captured_at: Time.zone.parse('2026-04-03 10:00:00'), - stats: { - 'Melbourne' => { - 'LA011' => { - 'Task 1' => { - status_id.to_s => 2, - status_id => 3 - } + snapshot_timestamp: Time.zone.parse('2026-04-03 10:00:00').to_i.to_s + ) + + latest_payload = { + 'Melbourne' => { + 'LA011' => { + 'Task 1' => { + status_id.to_s => 2, + status_id => 3 } } } - ) + } + + older_snapshot.store_stats!(older_payload) + mid_snapshot.store_stats!(mid_payload) + latest_snapshot.store_stats!(latest_payload) add_auth_header_for(user: unit.main_convenor_user) header 'Host', 'localhost' @@ -696,25 +700,21 @@ def test_get_task_completion_snapshots_filters_by_date TaskCompletionSnapshot.create!( unit: unit, - snapshot_date: Date.new(2026, 3, 30), - captured_at: Time.zone.parse('2026-03-30 10:00:00'), - stats: { 'snapshot' => {} } + snapshot_timestamp: Time.zone.parse('2026-03-30 10:00:00').to_i.to_s ) included_snapshot = TaskCompletionSnapshot.create!( unit: unit, - snapshot_date: Date.new(2026, 4, 2), - captured_at: Time.zone.parse('2026-04-02 10:00:00'), - stats: { 'snapshot' => {} } + snapshot_timestamp: Time.zone.parse('2026-04-02 10:00:00').to_i.to_s ) TaskCompletionSnapshot.create!( unit: unit, - snapshot_date: Date.new(2026, 4, 5), - captured_at: Time.zone.parse('2026-04-05 10:00:00'), - stats: { 'snapshot' => {} } + snapshot_timestamp: Time.zone.parse('2026-04-05 10:00:00').to_i.to_s ) + unit.task_completion_snapshots.find_each { |snapshot| snapshot.store_stats!({ 'snapshot' => {} }) } + add_auth_header_for(user: unit.main_convenor_user) header 'Host', 'localhost' get "/api/units/#{unit.id}/stats/task_completion_snapshots", { @@ -731,9 +731,7 @@ def test_get_task_completion_snapshots_not_authorised unit = FactoryBot.create :unit, with_students: false, task_count: 0 TaskCompletionSnapshot.create!( unit: unit, - snapshot_date: Date.current, - captured_at: Time.zone.now, - stats: { 'snapshot' => {} } + snapshot_timestamp: Time.zone.now.to_i.to_s ) add_auth_header_for(user: User.where(role: Role.student).first) @@ -744,23 +742,28 @@ def test_get_task_completion_snapshots_not_authorised end def test_post_capture_task_completion_snapshot - unit = FactoryBot.create :unit + Sidekiq::Testing.inline! do + unit = FactoryBot.create :unit - count_before = TaskCompletionSnapshot.where(unit: unit).count + count_before = TaskCompletionSnapshot.where(unit: unit).count - add_auth_header_for(user: unit.main_convenor_user) - header 'Host', 'localhost' - post "/api/units/#{unit.id}/stats/task_completion_snapshots/capture" + add_auth_header_for(user: unit.main_convenor_user) + header 'Host', 'localhost' + post "/api/units/#{unit.id}/stats/task_completion_snapshots/capture" - assert_equal 201, last_response.status, last_response_body + assert_equal 201, last_response.status, last_response_body + assert_not_nil last_response_body['id'] - snapshot = TaskCompletionSnapshot.find_by(unit: unit, snapshot_date: Date.current) - assert_not_nil snapshot - assert_equal count_before + 1, TaskCompletionSnapshot.where(unit: unit).count + snapshot = TaskCompletionSnapshot.where(unit: unit).order(snapshot_timestamp: :desc).first + assert_not_nil snapshot + assert_equal count_before + 1, TaskCompletionSnapshot.where(unit: unit).count - assert_equal snapshot.snapshot_date.to_s, Date.current.to_s - assert_equal snapshot.stats, last_response_body['stats'] - assert_not_nil last_response_body['captured_at'] + assert_equal Date.current.to_s, snapshot.snapshot_date.to_s + assert_not_empty snapshot.load_stats + assert File.exist?(snapshot.snapshot_file_path) + ensure + Sidekiq::Testing.fake! + end end def test_post_capture_task_completion_snapshot_not_authorised diff --git a/test/factories/task_completion_snapshot_factory.rb b/test/factories/task_completion_snapshot_factory.rb index fb3fd06b0..871bd0611 100644 --- a/test/factories/task_completion_snapshot_factory.rb +++ b/test/factories/task_completion_snapshot_factory.rb @@ -1,8 +1,6 @@ FactoryBot.define do factory :task_completion_snapshot do unit - snapshot_date { Date.current } - captured_at { Time.current } - stats { { 'completed' => 5, 'in_progress' => 3, 'not_started' => 2 } } + snapshot_timestamp { Time.current.to_i.to_s } end end diff --git a/test/models/task_completion_snapshot_test.rb b/test/models/task_completion_snapshot_test.rb index b497786b5..47ef40c4c 100644 --- a/test/models/task_completion_snapshot_test.rb +++ b/test/models/task_completion_snapshot_test.rb @@ -6,115 +6,86 @@ class TaskCompletionSnapshotTest < ActiveSupport::TestCase @snapshot = FactoryBot.create(:task_completion_snapshot, unit: @unit) end - # Association tests test 'task_completion_snapshot belongs to unit' do assert @snapshot.unit.is_a?(Unit) assert_equal @unit, @snapshot.unit end - # Presence validation tests test 'task_completion_snapshot is valid with all attributes' do assert @snapshot.valid? end - test 'task_completion_snapshot is invalid without snapshot_date' do - snapshot = FactoryBot.build(:task_completion_snapshot, snapshot_date: nil) + test 'task_completion_snapshot is invalid without snapshot_timestamp' do + snapshot = FactoryBot.build(:task_completion_snapshot, snapshot_timestamp: nil) assert_not snapshot.valid? - assert snapshot.errors[:snapshot_date].include?("can't be blank") + assert snapshot.errors[:snapshot_timestamp].include?("can't be blank") end - test 'task_completion_snapshot is invalid without captured_at' do - snapshot = FactoryBot.build(:task_completion_snapshot, captured_at: nil) - assert_not snapshot.valid? - assert snapshot.errors[:captured_at].include?("can't be blank") - end - - test 'task_completion_snapshot is invalid without stats' do - snapshot = FactoryBot.build(:task_completion_snapshot, stats: nil) - assert_not snapshot.valid? - assert snapshot.errors[:stats].include?("can't be blank") - end + test 'task_completion_snapshot enforces unique snapshot_timestamp per unit' do + duplicate = FactoryBot.build( + :task_completion_snapshot, + unit: @unit, + snapshot_timestamp: @snapshot.snapshot_timestamp + ) - test 'task_completion_snapshot is invalid with empty stats hash' do - snapshot = FactoryBot.build(:task_completion_snapshot, stats: {}) - assert_not snapshot.valid? - assert snapshot.errors[:stats].include?("can't be blank") - end - - # Uniqueness validation tests - test 'task_completion_snapshot enforces unique snapshot_date per unit' do - duplicate = FactoryBot.build(:task_completion_snapshot, - unit: @unit, - snapshot_date: @snapshot.snapshot_date) assert_not duplicate.valid? - assert duplicate.errors[:snapshot_date].include?('has already been taken') + assert duplicate.errors[:snapshot_timestamp].include?('has already been taken') end - test 'task_completion_snapshot allows same snapshot_date for different units' do + test 'task_completion_snapshot allows same snapshot_timestamp for different units' do other_unit = FactoryBot.create(:unit) - snapshot = FactoryBot.build(:task_completion_snapshot, - unit: other_unit, - snapshot_date: @snapshot.snapshot_date) + snapshot = FactoryBot.build( + :task_completion_snapshot, + unit: other_unit, + snapshot_timestamp: @snapshot.snapshot_timestamp + ) + assert snapshot.valid? end - # Creation tests - test 'can create task_completion_snapshot with valid attributes' do - snapshot = FactoryBot.create(:task_completion_snapshot) - assert snapshot.persisted? - assert_not_nil snapshot.id - end + test 'store_stats! writes a json file that can be loaded' do + payload = { + 'Melbourne' => { + 'LA011' => { + 'Task 1' => { + TaskStatus.complete.id.to_s => 4 + } + } + } + } - test 'snapshot_date is stored correctly' do - date = Date.current - 5.days - snapshot = FactoryBot.create(:task_completion_snapshot, snapshot_date: date) - assert_equal date, snapshot.snapshot_date - end + @snapshot.store_stats!(payload) - test 'captured_at is stored correctly' do - time = Time.zone.now - 2.hours - snapshot = FactoryBot.create(:task_completion_snapshot, captured_at: time) - assert_equal time.to_i, snapshot.captured_at.to_i + assert File.exist?(@snapshot.snapshot_file_path) + assert_equal payload, @snapshot.load_stats end - test 'stats json is stored and retrieved correctly' do - stats_data = { - 'completed' => 10, - 'in_progress' => 7, - 'not_started' => 3, - 'not_required' => 5 - } - snapshot = FactoryBot.create(:task_completion_snapshot, stats: stats_data) - assert_equal stats_data, snapshot.stats - end + test 'load_stats returns empty hash if file missing' do + snapshot = FactoryBot.create( + :task_completion_snapshot, + unit: @unit, + snapshot_timestamp: (Time.zone.now.to_i + 100).to_s + ) - # Querying tests - test 'can find snapshot by unit and snapshot_date' do - found = TaskCompletionSnapshot.find_by(unit: @unit, snapshot_date: @snapshot.snapshot_date) - assert_equal @snapshot, found + FileUtils.rm_f(snapshot.snapshot_file_path) + assert_equal({}, snapshot.load_stats) end - test 'can query all snapshots for a unit' do - other_snapshot = FactoryBot.create(:task_completion_snapshot, - unit: @unit, - snapshot_date: Time.zone.today + 1.day) - snapshots = @unit.task_completion_snapshots - assert_includes snapshots, @snapshot - assert_includes snapshots, other_snapshot - assert_equal 2, snapshots.count - end + test 'deleting snapshot deletes associated json file' do + payload = { 'snapshot' => { 'x' => 1 } } + @snapshot.store_stats!(payload) - # Deletion tests - test 'can delete task_completion_snapshot' do - snapshot = FactoryBot.create(:task_completion_snapshot, snapshot_date: Date.current + 10.days) - id = snapshot.id - snapshot.destroy - assert_nil TaskCompletionSnapshot.find_by(id: id) + file_path = @snapshot.snapshot_file_path + assert File.exist?(file_path) + + @snapshot.destroy + assert_not File.exist?(file_path) end - test 'deleting snapshot does not affect unit' do - snapshot = FactoryBot.create(:task_completion_snapshot, unit: @unit, snapshot_date: Date.current + 5.days) - snapshot.destroy - assert_not_nil Unit.find(@unit.id) + test 'snapshot_date is derived from snapshot_timestamp' do + timestamp = Time.zone.local(2026, 4, 8, 23, 55, 0).to_i.to_s + snapshot = FactoryBot.build(:task_completion_snapshot, snapshot_timestamp: timestamp) + + assert_equal Date.new(2026, 4, 8), snapshot.snapshot_date end end diff --git a/test/models/unit_model_test.rb b/test/models/unit_model_test.rb index 4f29bf0ce..cbe38c5e2 100644 --- a/test/models/unit_model_test.rb +++ b/test/models/unit_model_test.rb @@ -1168,25 +1168,25 @@ def test_cant_disable_aip_only_while_aip_tasks_exist test 'capture-task-complete-stats-snapshot creates snapshot for date' do data = build_unit_with_controlled_task_statuses unit = data[:unit] - captured_at = Time.zone.local(2026, 4, 8, 23, 55, 0) + snapshot_time = Time.zone.local(2026, 4, 8, 23, 55, 0) expected_stats = unit.aggregate_task_complete_stats count_before = unit.task_completion_snapshots.count - snapshot = unit.capture_task_complete_stats_snapshot!(captured_at: captured_at) + snapshot = unit.capture_task_complete_stats_snapshot!(snapshot_time: snapshot_time) assert_equal count_before + 1, unit.task_completion_snapshots.count - assert_equal captured_at.to_date, snapshot.snapshot_date - assert_equal captured_at.to_i, snapshot.captured_at.to_i - assert_equal expected_stats, snapshot.stats + assert_equal snapshot_time.to_date, snapshot.snapshot_date + assert_equal snapshot_time.to_i.to_s, snapshot.snapshot_timestamp + assert_equal expected_stats, snapshot.load_stats - persisted_snapshot = unit.task_completion_snapshots.find_by(snapshot_date: captured_at.to_date) + persisted_snapshot = unit.task_completion_snapshots.find_by(snapshot_timestamp: snapshot_time.to_i.to_s) assert_not_nil persisted_snapshot assert_equal snapshot.id, persisted_snapshot.id ensure unit&.destroy end - test 'capture-task-complete-stats-snapshot updates existing snapshot for same date' do + test 'capture-task-complete-stats-snapshot creates a new snapshot for a new timestamp' do data = build_unit_with_controlled_task_statuses unit = data[:unit] task_definitions = data[:task_definitions] @@ -1195,21 +1195,21 @@ def test_cant_disable_aip_only_while_aip_tasks_exist first_time = Time.zone.local(2026, 4, 8, 9, 0, 0) second_time = Time.zone.local(2026, 4, 8, 20, 0, 0) - first_snapshot = unit.capture_task_complete_stats_snapshot!(captured_at: first_time) - first_stats = first_snapshot.stats.deep_dup + first_snapshot = unit.capture_task_complete_stats_snapshot!(snapshot_time: first_time) + first_stats = first_snapshot.load_stats.deep_dup count_before = unit.task_completion_snapshots.count # Change one task status so the new capture has different stats. student2.task_for_task_definition(task_definitions[0]).update!(task_status: TaskStatus.fail) expected_updated_stats = unit.aggregate_task_complete_stats - updated_snapshot = unit.capture_task_complete_stats_snapshot!(captured_at: second_time) + updated_snapshot = unit.capture_task_complete_stats_snapshot!(snapshot_time: second_time) - assert_equal count_before, unit.task_completion_snapshots.count - assert_equal first_snapshot.id, updated_snapshot.id - assert_equal second_time.to_i, updated_snapshot.captured_at.to_i - assert_not_equal first_stats, updated_snapshot.stats - assert_equal expected_updated_stats, updated_snapshot.stats + assert_equal count_before + 1, unit.task_completion_snapshots.count + assert_not_equal first_snapshot.id, updated_snapshot.id + assert_equal second_time.to_i.to_s, updated_snapshot.snapshot_timestamp + assert_not_equal first_stats, updated_snapshot.load_stats + assert_equal expected_updated_stats, updated_snapshot.load_stats ensure unit&.destroy end From 69b2f72b8c3bad55316cd053cfad42301bf47d53 Mon Sep 17 00:00:00 2001 From: Audrey Phommasone Date: Tue, 21 Apr 2026 11:59:23 +1000 Subject: [PATCH 12/17] feat: task completion snapshot captures data in CSV and stores individual data --- app/api/units_api.rb | 59 ++++++++--- app/helpers/file_helper.rb | 2 +- app/models/task_completion_snapshot.rb | 54 ++++++++-- app/models/unit.rb | 104 +++++++++++++++---- test/api/units_api_test.rb | 92 ++++++++-------- test/models/task_completion_snapshot_test.rb | 38 +++++-- test/models/unit_model_test.rb | 41 ++++++-- 7 files changed, 293 insertions(+), 97 deletions(-) diff --git a/app/api/units_api.rb b/app/api/units_api.rb index 3d69c2287..3a29e8b50 100644 --- a/app/api/units_api.rb +++ b/app/api/units_api.rb @@ -602,22 +602,12 @@ class UnitsApi < Grape::API snapshots = snapshots.limit([params[:limit].to_i, 365].min) present snapshots.map { |snapshot| - raw_stats = snapshot.load_stats - converted_stats = raw_stats.transform_values do |tutorials| - tutorials.transform_values do |task_defs| - task_defs.transform_values do |status_counts| - status_counts.each_with_object({}) do |(status_id, count), status_acc| - status_key = TaskStatus.id_to_key(status_id.to_i).to_s - status_acc[status_key] = status_acc[status_key].to_i + count - end - end - end - end + stats = aggregate_task_completion_snapshot_stats(snapshot) { snapshot_date: snapshot.snapshot_date, snapshot_timestamp: snapshot.snapshot_timestamp, - stats: converted_stats + stats: stats } }, with: Grape::Presenters::Presenter end @@ -643,6 +633,51 @@ class UnitsApi < Grape::API present job, with: Entities::SidekiqJobEntity end + helpers do + + def aggregate_task_completion_snapshot_stats(snapshot) + snapshot_contents = snapshot.snapshot_contents + return {} if snapshot_contents.blank? + + aggregate_csv_snapshot_stats(snapshot.unit, snapshot_contents) + rescue CSV::MalformedCSVError + {} + end + + def aggregate_csv_snapshot_stats(unit, csv_text) + csv = CSV.parse(csv_text, headers: true) + return {} if csv.empty? + + stream_headers = unit.tutorial_streams.pluck(:abbreviation) + stream_headers = ['Tutorial'] if stream_headers.empty? + task_definitions = unit.task_definitions_by_grade + + stats = Hash.new { |hash, key| hash[key] = Hash.new { |tutorial_hash, tutorial_key| tutorial_hash[tutorial_key] = Hash.new { |task_hash, task_key| task_hash[task_key] = Hash.new(0) } } } + + csv.each do |row| + stream_headers.each do |stream_header| + tutorial_name = row[stream_header].to_s.strip + next if tutorial_name.blank? + + campus_name = if stream_header == 'Tutorial' + unit.tutorials.find_by(abbreviation: tutorial_name)&.campus&.name || stream_header + else + stream_header + end + + task_definitions.each do |task_definition| + status_value = row[task_definition.abbreviation].to_s.strip + status_key = TaskStatus.id_to_key(status_value.to_i) || :not_started + stats[campus_name][tutorial_name][task_definition.abbreviation][status_key.to_s] += 1 + end + end + end + + stats + end + + end + desc 'Download stats related to the number of tasks assessed by each tutor' get '/csv/units/:id/tutor_assessments' do unit = Unit.find(params[:id]) diff --git a/app/helpers/file_helper.rb b/app/helpers/file_helper.rb index 3f859bfb6..6027c3155 100644 --- a/app/helpers/file_helper.rb +++ b/app/helpers/file_helper.rb @@ -293,7 +293,7 @@ def unit_task_status_snapshots_dir(unit, create: true, archived: true) end def unit_task_status_snapshot_path(unit, snapshot_timestamp, create: true, archived: true) - snapshot_filename = "#{sanitized_filename(snapshot_timestamp.to_s)}.json" + snapshot_filename = "#{sanitized_filename(snapshot_timestamp.to_s)}.csv" File.join(unit_task_status_snapshots_dir(unit, create: create, archived: archived), snapshot_filename) end diff --git a/app/models/task_completion_snapshot.rb b/app/models/task_completion_snapshot.rb index 81e8fb42b..47da38c01 100644 --- a/app/models/task_completion_snapshot.rb +++ b/app/models/task_completion_snapshot.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'csv' + class TaskCompletionSnapshot < ApplicationRecord include FileHelper @@ -14,6 +16,12 @@ def snapshot_file_path FileHelper.unit_task_status_snapshot_path(unit, snapshot_timestamp, create: true) end + def snapshot_contents + return File.read(snapshot_file_path) if File.exist?(snapshot_file_path) + + nil + end + def snapshot_date return nil if snapshot_timestamp.blank? @@ -27,12 +35,12 @@ def snapshot_time end def load_stats - if File.exist?(snapshot_file_path) - JSON.parse(File.read(snapshot_file_path)) - else - {} - end - rescue JSON::ParserError + snapshot_contents = self.snapshot_contents + + return {} if snapshot_contents.blank? + + parse_csv_stats(snapshot_contents) + rescue CSV::MalformedCSVError {} end @@ -40,7 +48,7 @@ def store_stats!(payload) FileUtils.mkdir_p(File.dirname(snapshot_file_path)) tmp_path = "#{snapshot_file_path}.tmp" - File.write(tmp_path, JSON.pretty_generate(payload)) + File.write(tmp_path, payload.to_s) FileUtils.mv(tmp_path, snapshot_file_path) ensure FileUtils.rm_f(tmp_path) if defined?(tmp_path) @@ -48,6 +56,38 @@ def store_stats!(payload) private + def parse_csv_stats(csv_text) + csv = CSV.parse(csv_text, headers: true) + return {} if csv.empty? + + stream_headers = unit.tutorial_streams.pluck(:abbreviation) + stream_headers = ['Tutorial'] if stream_headers.empty? + task_definitions = unit.task_definitions_by_grade + + stats = Hash.new { |hash, key| hash[key] = Hash.new { |tutorial_hash, tutorial_key| tutorial_hash[tutorial_key] = Hash.new { |task_hash, task_key| task_hash[task_key] = Hash.new(0) } } } + + csv.each do |row| + stream_headers.each do |stream_header| + tutorial_name = row[stream_header].to_s.strip + next if tutorial_name.blank? + + campus_name = if stream_header == 'Tutorial' + unit.tutorials.find_by(abbreviation: tutorial_name)&.campus&.name || stream_header + else + stream_header + end + + task_definitions.each do |task_definition| + status_value = row[task_definition.abbreviation].to_s.strip + status_key = TaskStatus.id_to_key(status_value.to_i) || :not_started + stats[campus_name][tutorial_name][task_definition.abbreviation][status_key.to_s] += 1 + end + end + end + + stats + end + def delete_snapshot_file FileUtils.rm_f(snapshot_file_path) end diff --git a/app/models/unit.rb b/app/models/unit.rb index 46cfa7432..7ccea153f 100644 --- a/app/models/unit.rb +++ b/app/models/unit.rb @@ -1805,7 +1805,7 @@ def task_completion_csv # Get the details to fetch for each task definition... td_select = task_def_by_grade.map do |td| result = [] - result << "MAX(CASE WHEN tasks.task_definition_id = #{td.id} THEN (CASE WHEN task_statuses.name IS NULL THEN 'Not Started' ELSE task_statuses.name END) ELSE NULL END) AS status_#{td.id}" + result << "MAX(CASE WHEN tasks.task_definition_id = #{td.id} THEN (CASE WHEN tasks.task_status_id IS NULL THEN #{TaskStatus.not_started.id} ELSE tasks.task_status_id END) ELSE NULL END) AS status_#{td.id}" result << "MAX(CASE WHEN tasks.task_definition_id = #{td.id} THEN tasks.grade ELSE NULL END) AS grade_#{td.id}" if td.is_graded? result << "MAX(CASE WHEN tasks.task_definition_id = #{td.id} THEN tasks.quality_pts ELSE NULL END) AS stars_#{td.id}" if td.has_stars? result << "MAX(CASE WHEN tasks.task_definition_id = #{td.id} THEN tasks.contribution_pts ELSE NULL END) AS people_#{td.id}" if td.is_group_task? @@ -1856,7 +1856,7 @@ def task_completion_csv end.flatten + grp_sets.map do |gs| row["grp_#{gs.id}"] end + task_def_by_grade.map do |td| - result = [row["status_#{td.id}"].nil? ? TaskStatus.not_started.name : row["status_#{td.id}"]] + result = [row["status_#{td.id}"].nil? ? TaskStatus.not_started.id : row["status_#{td.id}"].to_i] result << GradeHelper.short_grade_for(row["grade_#{td.id}"]) if td.is_graded? result << row["stars_#{td.id}"] if td.has_stars? result << row["people_#{td.id}"] if td.is_group_task? @@ -3453,28 +3453,92 @@ def get_tutor_times_csv(start_date: nil, end_date: nil, timezone: nil, ignore_se end def aggregate_task_complete_stats - result = {} - - active_projects.each do |project| - campus_name = project.campus.name - result[campus_name] ||= {} - - task_definitions.each do |td| - tutorial = project.tutorial_for(td) - tutorial_name = tutorial&.abbreviation || 'Unassigned' + task_def_by_grade = task_definitions_by_grade + streams = tutorial_streams + grp_sets = group_sets - result[campus_name][tutorial_name] ||= {} - result[campus_name][tutorial_name][td.abbreviation] ||= {} + CSV.generate() do |csv| + # Add header row + csv << ([ + 'Student ID', + 'Username', + 'Target Grade', + 'Portfolio', + 'Grade', + 'Rationale', + 'Assessor', + ] + + (streams.count > 0 ? streams.map { |t| t.abbreviation } : ['Tutorial']) + + grp_sets.map(&:name) + + task_def_by_grade.map do |task_definition| + result = [task_definition.abbreviation] + result << "#{task_definition.abbreviation} grade" if task_definition.is_graded? + result << "#{task_definition.abbreviation} stars" if task_definition.has_stars? + result << "#{task_definition.abbreviation} contribution" if task_definition.is_group_task? + result + end.flatten) - status_key = project.status_for_task_definition(td) - status = TaskStatus.status_for_name(status_key.to_s)&.id.to_s || TaskStatus.not_started.id.to_s + # Add projects data + # Get the details to fetch for each task definition... + td_select = task_def_by_grade.map do |td| + result = [] + result << "MAX(CASE WHEN tasks.task_definition_id = #{td.id} THEN (CASE WHEN tasks.task_status_id IS NULL THEN #{TaskStatus.not_started.id} ELSE tasks.task_status_id END) ELSE NULL END) AS status_#{td.id}" + result << "MAX(CASE WHEN tasks.task_definition_id = #{td.id} THEN tasks.grade ELSE NULL END) AS grade_#{td.id}" if td.is_graded? + result << "MAX(CASE WHEN tasks.task_definition_id = #{td.id} THEN tasks.quality_pts ELSE NULL END) AS stars_#{td.id}" if td.has_stars? + result << "MAX(CASE WHEN tasks.task_definition_id = #{td.id} THEN tasks.contribution_pts ELSE NULL END) AS people_#{td.id}" if td.is_group_task? + result + end.flatten - result[campus_name][tutorial_name][td.abbreviation][status] ||= 0 - result[campus_name][tutorial_name][td.abbreviation][status] += 1 - end + # Query across all projects, joined to task's via definitions to ensure all definitions are covered + active_projects + .joins( + :unit, + 'INNER JOIN users ON projects.user_id = users.id', + 'INNER JOIN task_definitions ON task_definitions.unit_id = units.id', + 'LEFT OUTER JOIN tutorial_streams ON tutorial_streams.unit_id = units.id', + 'LEFT OUTER JOIN tutorial_enrolments ON tutorial_enrolments.project_id = projects.id', + 'LEFT OUTER JOIN tutorials ON tutorials.id = tutorial_enrolments.tutorial_id AND (tutorials.tutorial_stream_id = tutorial_streams.id OR tutorials.tutorial_stream_id IS NULL)', + 'LEFT OUTER JOIN tasks ON tasks.task_definition_id = task_definitions.id AND projects.id = tasks.project_id', + 'LEFT OUTER JOIN task_statuses ON tasks.task_status_id = task_statuses.id', + 'LEFT OUTER JOIN group_memberships ON group_memberships.project_id = projects.id AND group_memberships.active = TRUE', + 'LEFT OUTER JOIN groups ON groups.id = group_memberships.group_id' + ).select( + 'projects.id as project_id', 'users.student_id as student_id', 'users.username as username', 'users.first_name as first_name', 'projects.assessor_id as project_assessor', + 'users.last_name as last_name', 'projects.target_grade', 'users.email as email', 'compile_portfolio', 'portfolio_production_date', 'grade', 'grade_rationale', + *td_select, + # Get tutorial for each stream in unit + *streams.map { |s| "MAX(CASE WHEN tutorials.tutorial_stream_id = #{s.id} OR tutorials.tutorial_stream_id IS NULL THEN tutorials.abbreviation ELSE NULL END) AS tutorial_#{s.id}" }, + # Get tutorial for case when no stream + "MAX(CASE WHEN tutorial_streams.id IS NULL THEN tutorials.abbreviation ELSE NULL END) AS tutorial", + *grp_sets.map { |gs| "MAX(CASE WHEN groups.group_set_id = #{gs.id} THEN groups.name ELSE NULL END) AS grp_#{gs.id}" } + ).group( + 'projects.id', 'student_id', 'username', 'first_name', 'last_name', 'target_grade', 'email', 'compile_portfolio', 'portfolio_production_date', 'grade', 'grade_rationale' + ).each do |row| + csv << ([ + row['student_id'], + row['username'], + GradeHelper.grade_for(row['target_grade']), + row['portfolio_production_date'].present? && !row['compile_portfolio'] && File.exist?(FileHelper.student_portfolio_path(self, row['username'], create: true)), + row['grade'] > 0 ? row['grade'] : nil, + row['grade_rationale'], + row['project_assessor'] + ] + [1].map do + if streams.empty? + [row['tutorial']] + else + streams.map { |ts| row["tutorial_#{ts.id}"] } + end + end.flatten + grp_sets.map do |gs| + row["grp_#{gs.id}"] + end + task_def_by_grade.map do |td| + result = [row["status_#{td.id}"].nil? ? nil : row["status_#{td.id}"].to_i] + result << GradeHelper.short_grade_for(row["grade_#{td.id}"]) if td.is_graded? + result << row["stars_#{td.id}"] if td.has_stars? + result << row["people_#{td.id}"] if td.is_group_task? + result + end.flatten) + end end - - result end def capture_task_complete_stats_snapshot!(snapshot_time: Time.zone.now) diff --git a/test/api/units_api_test.rb b/test/api/units_api_test.rb index 7713efd05..fe1ee7e8f 100644 --- a/test/api/units_api_test.rb +++ b/test/api/units_api_test.rb @@ -624,60 +624,28 @@ def test_draft_learning_summary_upload_requirements end def test_get_task_completion_snapshots - unit = FactoryBot.create :unit, with_students: false, task_count: 0 - - status_id = TaskStatus.complete.id - status_key = TaskStatus.id_to_key(status_id).to_s + unit = FactoryBot.create :unit, with_students: false, task_count: 1, stream_count: 0, tutorials: 1, campus_count: 1 + tutorial = unit.tutorials.first + task_definition = unit.task_definitions_by_grade.first older_snapshot = TaskCompletionSnapshot.create!( unit: unit, snapshot_timestamp: Time.zone.parse('2026-04-01 10:00:00').to_i.to_s ) - older_payload = { - 'Melbourne' => { - 'LA011' => { - 'Task 1' => { - status_id.to_s => 1 - } - } - } - } - mid_snapshot = TaskCompletionSnapshot.create!( unit: unit, snapshot_timestamp: Time.zone.parse('2026-04-02 10:00:00').to_i.to_s ) - mid_payload = { - 'Melbourne' => { - 'LA011' => { - 'Task 1' => { - status_id.to_s => 2 - } - } - } - } - latest_snapshot = TaskCompletionSnapshot.create!( unit: unit, snapshot_timestamp: Time.zone.parse('2026-04-03 10:00:00').to_i.to_s ) - latest_payload = { - 'Melbourne' => { - 'LA011' => { - 'Task 1' => { - status_id.to_s => 2, - status_id => 3 - } - } - } - } - - older_snapshot.store_stats!(older_payload) - mid_snapshot.store_stats!(mid_payload) - latest_snapshot.store_stats!(latest_payload) + older_snapshot.store_stats!(build_task_completion_snapshot_csv(tutorial, task_definition, [TaskStatus.not_started.id])) + mid_snapshot.store_stats!(build_task_completion_snapshot_csv(tutorial, task_definition, [TaskStatus.complete.id, TaskStatus.complete.id])) + latest_snapshot.store_stats!(build_task_completion_snapshot_csv(tutorial, task_definition, [TaskStatus.complete.id, TaskStatus.complete.id, TaskStatus.complete.id])) add_auth_header_for(user: unit.main_convenor_user) header 'Host', 'localhost' @@ -690,13 +658,15 @@ def test_get_task_completion_snapshots assert_equal mid_snapshot.snapshot_date.to_s, last_response_body[1]['snapshot_date'].to_date.to_s latest_stats = last_response_body[0]['stats'] - assert_equal 3, latest_stats['Melbourne']['LA011']['Task 1'][status_key] + assert_equal 3, latest_stats[tutorial.campus.name][tutorial.abbreviation][task_definition.abbreviation]['complete'] assert_not_equal older_snapshot.snapshot_date.to_s, last_response_body[1]['snapshot_date'].to_date.to_s end def test_get_task_completion_snapshots_filters_by_date - unit = FactoryBot.create :unit, with_students: false, task_count: 0 + unit = FactoryBot.create :unit, with_students: false, task_count: 1, stream_count: 0, tutorials: 1, campus_count: 1 + tutorial = unit.tutorials.first + task_definition = unit.task_definitions_by_grade.first TaskCompletionSnapshot.create!( unit: unit, @@ -713,7 +683,9 @@ def test_get_task_completion_snapshots_filters_by_date snapshot_timestamp: Time.zone.parse('2026-04-05 10:00:00').to_i.to_s ) - unit.task_completion_snapshots.find_each { |snapshot| snapshot.store_stats!({ 'snapshot' => {} }) } + unit.task_completion_snapshots.find_each do |snapshot| + snapshot.store_stats!(build_task_completion_snapshot_csv(tutorial, task_definition, [TaskStatus.complete.id])) + end add_auth_header_for(user: unit.main_convenor_user) header 'Host', 'localhost' @@ -775,4 +747,42 @@ def test_post_capture_task_completion_snapshot_not_authorised assert_equal 403, last_response.status end + + private + + def build_task_completion_snapshot_csv(tutorial, task_definition, statuses) + headers = [ + 'Student ID', + 'Username', + 'Student Name', + 'Target Grade', + 'Email', + 'Portfolio', + 'Grade', + 'Rationale', + 'Assessor', + 'Tutorial', + task_definition.abbreviation, + ] + + CSV.generate do |csv| + csv << headers + + statuses.each_with_index do |status, index| + csv << [ + "#{index + 1}", + "student-#{index + 1}", + "Student #{index + 1}", + '0', + "student-#{index + 1}@example.com", + 'false', + '', + '', + '', + tutorial.abbreviation, + status, + ] + end + end + end end diff --git a/test/models/task_completion_snapshot_test.rb b/test/models/task_completion_snapshot_test.rb index 47ef40c4c..5f9ed9e2d 100644 --- a/test/models/task_completion_snapshot_test.rb +++ b/test/models/task_completion_snapshot_test.rb @@ -2,7 +2,7 @@ class TaskCompletionSnapshotTest < ActiveSupport::TestCase setup do - @unit = FactoryBot.create(:unit) + @unit = FactoryBot.create(:unit, with_students: false, task_count: 1, stream_count: 0, tutorials: 1, campus_count: 1) @snapshot = FactoryBot.create(:task_completion_snapshot, unit: @unit) end @@ -43,12 +43,23 @@ class TaskCompletionSnapshotTest < ActiveSupport::TestCase assert snapshot.valid? end - test 'store_stats! writes a json file that can be loaded' do - payload = { - 'Melbourne' => { - 'LA011' => { - 'Task 1' => { - TaskStatus.complete.id.to_s => 4 + test 'store_stats! writes a csv file that can be loaded' do + tutorial = @unit.tutorials.first + task_definition = @unit.task_definitions_by_grade.first + + payload = CSV.generate do |csv| + csv << ['Student ID', 'Username', 'Student Name', 'Target Grade', 'Email', 'Portfolio', 'Grade', 'Rationale', 'Assessor', 'Tutorial', task_definition.abbreviation] + csv << ['1', 'student-1', 'Student 1', '0', 'student-1@example.com', 'false', '', '', '', tutorial.abbreviation, TaskStatus.complete.id] + csv << ['2', 'student-2', 'Student 2', '0', 'student-2@example.com', 'false', '', '', '', tutorial.abbreviation, TaskStatus.complete.id] + csv << ['3', 'student-3', 'Student 3', '0', 'student-3@example.com', 'false', '', '', '', tutorial.abbreviation, TaskStatus.complete.id] + csv << ['4', 'student-4', 'Student 4', '0', 'student-4@example.com', 'false', '', '', '', tutorial.abbreviation, TaskStatus.complete.id] + end + + expected = { + tutorial.campus.name => { + tutorial.abbreviation => { + task_definition.abbreviation => { + 'complete' => 4 } } } @@ -57,7 +68,7 @@ class TaskCompletionSnapshotTest < ActiveSupport::TestCase @snapshot.store_stats!(payload) assert File.exist?(@snapshot.snapshot_file_path) - assert_equal payload, @snapshot.load_stats + assert_equal expected, @snapshot.load_stats end test 'load_stats returns empty hash if file missing' do @@ -71,8 +82,15 @@ class TaskCompletionSnapshotTest < ActiveSupport::TestCase assert_equal({}, snapshot.load_stats) end - test 'deleting snapshot deletes associated json file' do - payload = { 'snapshot' => { 'x' => 1 } } + test 'deleting snapshot deletes associated csv file' do + tutorial = @unit.tutorials.first + task_definition = @unit.task_definitions_by_grade.first + + payload = CSV.generate do |csv| + csv << ['Student ID', 'Username', 'Student Name', 'Target Grade', 'Email', 'Portfolio', 'Grade', 'Rationale', 'Assessor', 'Tutorial', task_definition.abbreviation] + csv << ['1', 'student-1', 'Student 1', '0', 'student-1@example.com', 'false', '', '', '', tutorial.abbreviation, TaskStatus.complete.id] + end + @snapshot.store_stats!(payload) file_path = @snapshot.snapshot_file_path diff --git a/test/models/unit_model_test.rb b/test/models/unit_model_test.rb index cbe38c5e2..fa399d8e7 100644 --- a/test/models/unit_model_test.rb +++ b/test/models/unit_model_test.rb @@ -1150,17 +1150,17 @@ def test_cant_disable_aip_only_while_aip_tasks_exist tutorial.campus.name => { tutorial.abbreviation => { task_definitions[0].abbreviation => { - TaskStatus.complete.id.to_s => 2 + 'complete' => 2 }, task_definitions[1].abbreviation => { - TaskStatus.fail.id.to_s => 1, - TaskStatus.not_started.id.to_s => 1 + 'fail' => 1, + 'not_started' => 1 } } } } - assert_equal expected, stats + assert_equal expected, parse_task_completion_stats_csv(unit, stats) ensure unit&.destroy end @@ -1169,7 +1169,7 @@ def test_cant_disable_aip_only_while_aip_tasks_exist data = build_unit_with_controlled_task_statuses unit = data[:unit] snapshot_time = Time.zone.local(2026, 4, 8, 23, 55, 0) - expected_stats = unit.aggregate_task_complete_stats + expected_stats = parse_task_completion_stats_csv(unit, unit.aggregate_task_complete_stats) count_before = unit.task_completion_snapshots.count snapshot = unit.capture_task_complete_stats_snapshot!(snapshot_time: snapshot_time) @@ -1201,7 +1201,7 @@ def test_cant_disable_aip_only_while_aip_tasks_exist # Change one task status so the new capture has different stats. student2.task_for_task_definition(task_definitions[0]).update!(task_status: TaskStatus.fail) - expected_updated_stats = unit.aggregate_task_complete_stats + expected_updated_stats = parse_task_completion_stats_csv(unit, unit.aggregate_task_complete_stats) updated_snapshot = unit.capture_task_complete_stats_snapshot!(snapshot_time: second_time) @@ -1216,6 +1216,35 @@ def test_cant_disable_aip_only_while_aip_tasks_exist private + def parse_task_completion_stats_csv(unit, csv_text) + csv = CSV.parse(csv_text, headers: true) + streams = unit.tutorial_streams.pluck(:abbreviation) + streams = ['Tutorial'] if streams.empty? + task_definitions = unit.task_definitions_by_grade + + csv.each_with_object(Hash.new { |hash, key| hash[key] = {} }) do |row, stats| + streams.each do |stream_name| + tutorial_name = row[stream_name].to_s.strip + next if tutorial_name.blank? + + campus_name = if stream_name == 'Tutorial' + unit.tutorials.find_by(abbreviation: tutorial_name)&.campus&.name || stream_name + else + stream_name + end + + stats[campus_name][tutorial_name] ||= {} + + task_definitions.each do |task_definition| + status_name = row[task_definition.abbreviation].to_s.strip + status_key = TaskStatus.id_to_key(status_name.to_i) || :not_started + stats[campus_name][tutorial_name][task_definition.abbreviation] ||= Hash.new(0) + stats[campus_name][tutorial_name][task_definition.abbreviation][status_key.to_s] += 1 + end + end + end + end + def build_unit_with_controlled_task_statuses unit = FactoryBot.create(:unit, with_students: false, task_count: 2, stream_count: 0, tutorials: 1, campus_count: 1) tutorial = unit.tutorials.first From 60c147639efa1882ae8aab0316d985a9160358e2 Mon Sep 17 00:00:00 2001 From: Audrey Phommasone Date: Wed, 22 Apr 2026 13:09:30 +1000 Subject: [PATCH 13/17] fix: task completion stats csv uses task status names instead of id --- app/models/unit.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/unit.rb b/app/models/unit.rb index 7ccea153f..d4807304b 100644 --- a/app/models/unit.rb +++ b/app/models/unit.rb @@ -1805,7 +1805,7 @@ def task_completion_csv # Get the details to fetch for each task definition... td_select = task_def_by_grade.map do |td| result = [] - result << "MAX(CASE WHEN tasks.task_definition_id = #{td.id} THEN (CASE WHEN tasks.task_status_id IS NULL THEN #{TaskStatus.not_started.id} ELSE tasks.task_status_id END) ELSE NULL END) AS status_#{td.id}" + result << "MAX(CASE WHEN tasks.task_definition_id = #{td.id} THEN (CASE WHEN task_statuses.name IS NULL THEN 'Not Started' ELSE task_statuses.name END) ELSE NULL END) AS status_#{td.id}" result << "MAX(CASE WHEN tasks.task_definition_id = #{td.id} THEN tasks.grade ELSE NULL END) AS grade_#{td.id}" if td.is_graded? result << "MAX(CASE WHEN tasks.task_definition_id = #{td.id} THEN tasks.quality_pts ELSE NULL END) AS stars_#{td.id}" if td.has_stars? result << "MAX(CASE WHEN tasks.task_definition_id = #{td.id} THEN tasks.contribution_pts ELSE NULL END) AS people_#{td.id}" if td.is_group_task? @@ -1856,7 +1856,7 @@ def task_completion_csv end.flatten + grp_sets.map do |gs| row["grp_#{gs.id}"] end + task_def_by_grade.map do |td| - result = [row["status_#{td.id}"].nil? ? TaskStatus.not_started.id : row["status_#{td.id}"].to_i] + result = [row["status_#{td.id}"].nil? ? TaskStatus.not_started.name : row["status_#{td.id}"]] result << GradeHelper.short_grade_for(row["grade_#{td.id}"]) if td.is_graded? result << row["stars_#{td.id}"] if td.has_stars? result << row["people_#{td.id}"] if td.is_group_task? From 3ce1cf30179bd06ca0eb1a2edb73db3d31cad075 Mon Sep 17 00:00:00 2001 From: Audrey Phommasone Date: Wed, 22 Apr 2026 14:42:50 +1000 Subject: [PATCH 14/17] fix: task completion csv correctly uses task status names --- app/models/unit.rb | 108 +++++---------------------------- test/models/unit_model_test.rb | 30 +-------- 2 files changed, 18 insertions(+), 120 deletions(-) diff --git a/app/models/unit.rb b/app/models/unit.rb index d4807304b..8b6a82a7b 100644 --- a/app/models/unit.rb +++ b/app/models/unit.rb @@ -1774,6 +1774,10 @@ def days_awaiting_feedback_by_tutorial_csv end def task_completion_csv + task_completion_csv_generator() + end + + def task_completion_csv_generator(task_status_uses_id: false) task_def_by_grade = task_definitions_by_grade streams = tutorial_streams grp_sets = group_sets @@ -1805,7 +1809,11 @@ def task_completion_csv # Get the details to fetch for each task definition... td_select = task_def_by_grade.map do |td| result = [] - result << "MAX(CASE WHEN tasks.task_definition_id = #{td.id} THEN (CASE WHEN task_statuses.name IS NULL THEN 'Not Started' ELSE task_statuses.name END) ELSE NULL END) AS status_#{td.id}" + if task_status_uses_id + result << "MAX(CASE WHEN tasks.task_definition_id = #{td.id} THEN (CASE WHEN tasks.task_status_id IS NULL THEN #{TaskStatus.not_started.id} ELSE tasks.task_status_id END) ELSE NULL END) AS status_#{td.id}" + else + result << "MAX(CASE WHEN tasks.task_definition_id = #{td.id} THEN (CASE WHEN task_statuses.name IS NULL THEN 'Not Started' ELSE task_statuses.name END) ELSE NULL END) AS status_#{td.id}" + end result << "MAX(CASE WHEN tasks.task_definition_id = #{td.id} THEN tasks.grade ELSE NULL END) AS grade_#{td.id}" if td.is_graded? result << "MAX(CASE WHEN tasks.task_definition_id = #{td.id} THEN tasks.quality_pts ELSE NULL END) AS stars_#{td.id}" if td.has_stars? result << "MAX(CASE WHEN tasks.task_definition_id = #{td.id} THEN tasks.contribution_pts ELSE NULL END) AS people_#{td.id}" if td.is_group_task? @@ -1856,7 +1864,11 @@ def task_completion_csv end.flatten + grp_sets.map do |gs| row["grp_#{gs.id}"] end + task_def_by_grade.map do |td| - result = [row["status_#{td.id}"].nil? ? TaskStatus.not_started.name : row["status_#{td.id}"]] + if task_status_uses_id + result = [row["status_#{td.id}"].nil? ? TaskStatus.not_started.id : row["status_#{td.id}"].to_i] + else + result = [row["status_#{td.id}"].nil? ? TaskStatus.not_started.name : row["status_#{td.id}"]] + end result << GradeHelper.short_grade_for(row["grade_#{td.id}"]) if td.is_graded? result << row["stars_#{td.id}"] if td.has_stars? result << row["people_#{td.id}"] if td.is_group_task? @@ -3452,97 +3464,9 @@ def get_tutor_times_csv(start_date: nil, end_date: nil, timezone: nil, ignore_se end end - def aggregate_task_complete_stats - task_def_by_grade = task_definitions_by_grade - streams = tutorial_streams - grp_sets = group_sets - - CSV.generate() do |csv| - # Add header row - csv << ([ - 'Student ID', - 'Username', - 'Target Grade', - 'Portfolio', - 'Grade', - 'Rationale', - 'Assessor', - ] + - (streams.count > 0 ? streams.map { |t| t.abbreviation } : ['Tutorial']) + - grp_sets.map(&:name) + - task_def_by_grade.map do |task_definition| - result = [task_definition.abbreviation] - result << "#{task_definition.abbreviation} grade" if task_definition.is_graded? - result << "#{task_definition.abbreviation} stars" if task_definition.has_stars? - result << "#{task_definition.abbreviation} contribution" if task_definition.is_group_task? - result - end.flatten) - - # Add projects data - # Get the details to fetch for each task definition... - td_select = task_def_by_grade.map do |td| - result = [] - result << "MAX(CASE WHEN tasks.task_definition_id = #{td.id} THEN (CASE WHEN tasks.task_status_id IS NULL THEN #{TaskStatus.not_started.id} ELSE tasks.task_status_id END) ELSE NULL END) AS status_#{td.id}" - result << "MAX(CASE WHEN tasks.task_definition_id = #{td.id} THEN tasks.grade ELSE NULL END) AS grade_#{td.id}" if td.is_graded? - result << "MAX(CASE WHEN tasks.task_definition_id = #{td.id} THEN tasks.quality_pts ELSE NULL END) AS stars_#{td.id}" if td.has_stars? - result << "MAX(CASE WHEN tasks.task_definition_id = #{td.id} THEN tasks.contribution_pts ELSE NULL END) AS people_#{td.id}" if td.is_group_task? - result - end.flatten - - # Query across all projects, joined to task's via definitions to ensure all definitions are covered - active_projects - .joins( - :unit, - 'INNER JOIN users ON projects.user_id = users.id', - 'INNER JOIN task_definitions ON task_definitions.unit_id = units.id', - 'LEFT OUTER JOIN tutorial_streams ON tutorial_streams.unit_id = units.id', - 'LEFT OUTER JOIN tutorial_enrolments ON tutorial_enrolments.project_id = projects.id', - 'LEFT OUTER JOIN tutorials ON tutorials.id = tutorial_enrolments.tutorial_id AND (tutorials.tutorial_stream_id = tutorial_streams.id OR tutorials.tutorial_stream_id IS NULL)', - 'LEFT OUTER JOIN tasks ON tasks.task_definition_id = task_definitions.id AND projects.id = tasks.project_id', - 'LEFT OUTER JOIN task_statuses ON tasks.task_status_id = task_statuses.id', - 'LEFT OUTER JOIN group_memberships ON group_memberships.project_id = projects.id AND group_memberships.active = TRUE', - 'LEFT OUTER JOIN groups ON groups.id = group_memberships.group_id' - ).select( - 'projects.id as project_id', 'users.student_id as student_id', 'users.username as username', 'users.first_name as first_name', 'projects.assessor_id as project_assessor', - 'users.last_name as last_name', 'projects.target_grade', 'users.email as email', 'compile_portfolio', 'portfolio_production_date', 'grade', 'grade_rationale', - *td_select, - # Get tutorial for each stream in unit - *streams.map { |s| "MAX(CASE WHEN tutorials.tutorial_stream_id = #{s.id} OR tutorials.tutorial_stream_id IS NULL THEN tutorials.abbreviation ELSE NULL END) AS tutorial_#{s.id}" }, - # Get tutorial for case when no stream - "MAX(CASE WHEN tutorial_streams.id IS NULL THEN tutorials.abbreviation ELSE NULL END) AS tutorial", - *grp_sets.map { |gs| "MAX(CASE WHEN groups.group_set_id = #{gs.id} THEN groups.name ELSE NULL END) AS grp_#{gs.id}" } - ).group( - 'projects.id', 'student_id', 'username', 'first_name', 'last_name', 'target_grade', 'email', 'compile_portfolio', 'portfolio_production_date', 'grade', 'grade_rationale' - ).each do |row| - csv << ([ - row['student_id'], - row['username'], - GradeHelper.grade_for(row['target_grade']), - row['portfolio_production_date'].present? && !row['compile_portfolio'] && File.exist?(FileHelper.student_portfolio_path(self, row['username'], create: true)), - row['grade'] > 0 ? row['grade'] : nil, - row['grade_rationale'], - row['project_assessor'] - ] + [1].map do - if streams.empty? - [row['tutorial']] - else - streams.map { |ts| row["tutorial_#{ts.id}"] } - end - end.flatten + grp_sets.map do |gs| - row["grp_#{gs.id}"] - end + task_def_by_grade.map do |td| - result = [row["status_#{td.id}"].nil? ? nil : row["status_#{td.id}"].to_i] - result << GradeHelper.short_grade_for(row["grade_#{td.id}"]) if td.is_graded? - result << row["stars_#{td.id}"] if td.has_stars? - result << row["people_#{td.id}"] if td.is_group_task? - result - end.flatten) - end - end - end - def capture_task_complete_stats_snapshot!(snapshot_time: Time.zone.now) - snapshot_payload = aggregate_task_complete_stats + snapshot_payload = task_completion_csv_generator(task_status_uses_id: true) + timestamp = snapshot_time.to_i.to_s task_completion_snapshots diff --git a/test/models/unit_model_test.rb b/test/models/unit_model_test.rb index fa399d8e7..354f25c2b 100644 --- a/test/models/unit_model_test.rb +++ b/test/models/unit_model_test.rb @@ -1138,38 +1138,12 @@ def test_cant_disable_aip_only_while_aip_tasks_exist assert_includes unit.errors[:mark_late_submissions_as_assess_in_portfolio], 'cannot be disabled while tasks are in the Assess in Portfolio state' end - test 'aggregate-task-complete-stats groups by campus tutorial task and status' do - data = build_unit_with_controlled_task_statuses - unit = data[:unit] - tutorial = data[:tutorial] - task_definitions = data[:task_definitions] - - stats = unit.aggregate_task_complete_stats - - expected = { - tutorial.campus.name => { - tutorial.abbreviation => { - task_definitions[0].abbreviation => { - 'complete' => 2 - }, - task_definitions[1].abbreviation => { - 'fail' => 1, - 'not_started' => 1 - } - } - } - } - - assert_equal expected, parse_task_completion_stats_csv(unit, stats) - ensure - unit&.destroy - end test 'capture-task-complete-stats-snapshot creates snapshot for date' do data = build_unit_with_controlled_task_statuses unit = data[:unit] snapshot_time = Time.zone.local(2026, 4, 8, 23, 55, 0) - expected_stats = parse_task_completion_stats_csv(unit, unit.aggregate_task_complete_stats) + expected_stats = parse_task_completion_stats_csv(unit, unit.task_completion_csv_generator(task_status_uses_id: true)) count_before = unit.task_completion_snapshots.count snapshot = unit.capture_task_complete_stats_snapshot!(snapshot_time: snapshot_time) @@ -1201,7 +1175,7 @@ def test_cant_disable_aip_only_while_aip_tasks_exist # Change one task status so the new capture has different stats. student2.task_for_task_definition(task_definitions[0]).update!(task_status: TaskStatus.fail) - expected_updated_stats = parse_task_completion_stats_csv(unit, unit.aggregate_task_complete_stats) + expected_updated_stats = parse_task_completion_stats_csv(unit, unit.task_completion_csv_generator(task_status_uses_id: true)) updated_snapshot = unit.capture_task_complete_stats_snapshot!(snapshot_time: second_time) From f7c30d79c72fc1bf00e7b8b39abc65e31216103d Mon Sep 17 00:00:00 2001 From: Audrey Phommasone Date: Wed, 22 Apr 2026 15:04:23 +1000 Subject: [PATCH 15/17] feat: task completion snapshots include campus information --- app/models/unit.rb | 41 +++++++++++++++++++++------------- test/models/unit_model_test.rb | 28 +++++++++++++++++++++++ 2 files changed, 53 insertions(+), 16 deletions(-) diff --git a/app/models/unit.rb b/app/models/unit.rb index 8b6a82a7b..9ef6dfb54 100644 --- a/app/models/unit.rb +++ b/app/models/unit.rb @@ -1777,24 +1777,28 @@ def task_completion_csv task_completion_csv_generator() end - def task_completion_csv_generator(task_status_uses_id: false) + def task_completion_csv_generator(task_status_uses_id: false, includes_campus: false) task_def_by_grade = task_definitions_by_grade streams = tutorial_streams grp_sets = group_sets + base_headers = [ + 'Student ID', + 'Username', + 'Student Name', + ] + base_headers << 'Campus' if includes_campus + base_headers.concat([ + 'Target Grade', + 'Email', + 'Portfolio', + 'Grade', + 'Rationale', + 'Assessor', + ]) CSV.generate() do |csv| # Add header row - csv << ([ - 'Student ID', - 'Username', - 'Student Name', - 'Target Grade', - 'Email', - 'Portfolio', - 'Grade', - 'Rationale', - 'Assessor', - ] + + csv << (base_headers + (streams.count > 0 ? streams.map { |t| t.abbreviation } : ['Tutorial']) + grp_sets.map(&:name) + task_def_by_grade.map do |task_definition| @@ -1825,6 +1829,7 @@ def task_completion_csv_generator(task_status_uses_id: false) .joins( :unit, 'INNER JOIN users ON projects.user_id = users.id', + 'LEFT OUTER JOIN campuses ON campuses.id = projects.campus_id', 'INNER JOIN task_definitions ON task_definitions.unit_id = units.id', 'LEFT OUTER JOIN tutorial_streams ON tutorial_streams.unit_id = units.id', 'LEFT OUTER JOIN tutorial_enrolments ON tutorial_enrolments.project_id = projects.id', @@ -1835,7 +1840,7 @@ def task_completion_csv_generator(task_status_uses_id: false) 'LEFT OUTER JOIN groups ON groups.id = group_memberships.group_id' ).select( 'projects.id as project_id', 'users.student_id as student_id', 'users.username as username', 'users.first_name as first_name', 'projects.assessor_id as project_assessor', - 'users.last_name as last_name', 'projects.target_grade', 'users.email as email', 'compile_portfolio', 'portfolio_production_date', 'grade', 'grade_rationale', + 'users.last_name as last_name', 'campuses.abbreviation as campus_abbreviation', 'projects.target_grade', 'users.email as email', 'compile_portfolio', 'portfolio_production_date', 'grade', 'grade_rationale', *td_select, # Get tutorial for each stream in unit *streams.map { |s| "MAX(CASE WHEN tutorials.tutorial_stream_id = #{s.id} OR tutorials.tutorial_stream_id IS NULL THEN tutorials.abbreviation ELSE NULL END) AS tutorial_#{s.id}" }, @@ -1843,12 +1848,16 @@ def task_completion_csv_generator(task_status_uses_id: false) "MAX(CASE WHEN tutorial_streams.id IS NULL THEN tutorials.abbreviation ELSE NULL END) AS tutorial", *grp_sets.map { |gs| "MAX(CASE WHEN groups.group_set_id = #{gs.id} THEN groups.name ELSE NULL END) AS grp_#{gs.id}" } ).group( - 'projects.id', 'student_id', 'username', 'first_name', 'last_name', 'target_grade', 'email', 'compile_portfolio', 'portfolio_production_date', 'grade', 'grade_rationale' + 'projects.id', 'student_id', 'username', 'first_name', 'last_name', 'campus_abbreviation', 'target_grade', 'email', 'compile_portfolio', 'portfolio_production_date', 'grade', 'grade_rationale' ).each do |row| - csv << ([ + student_details = [ row['student_id'], row['username'], "#{row['first_name']} #{row['last_name']}", + ] + student_details << row['campus_abbreviation'] if includes_campus + + csv << (student_details + [ GradeHelper.grade_for(row['target_grade']), row['email'], row['portfolio_production_date'].present? && !row['compile_portfolio'] && File.exist?(FileHelper.student_portfolio_path(self, row['username'], create: true)), @@ -3465,7 +3474,7 @@ def get_tutor_times_csv(start_date: nil, end_date: nil, timezone: nil, ignore_se end def capture_task_complete_stats_snapshot!(snapshot_time: Time.zone.now) - snapshot_payload = task_completion_csv_generator(task_status_uses_id: true) + snapshot_payload = task_completion_csv_generator(task_status_uses_id: true, includes_campus: true) timestamp = snapshot_time.to_i.to_s diff --git a/test/models/unit_model_test.rb b/test/models/unit_model_test.rb index 354f25c2b..70a588ccd 100644 --- a/test/models/unit_model_test.rb +++ b/test/models/unit_model_test.rb @@ -626,6 +626,34 @@ def test_task_completion_csv_all_td_in_one_stream check_task_completion_csv unit end + def test_task_completion_csv_generator_includes_campus_when_requested + unit = FactoryBot.create :unit, campus_count: 2, tutorials: 2, stream_count: 1, task_count: 1, student_count: 5, unenrolled_student_count: 0, part_enrolled_student_count: 0 + + csv_str = unit.task_completion_csv_generator(includes_campus: true) + CSV.parse(csv_str, + headers: true, + return_headers: false, + header_converters: [->(body) { body&.encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '')&.downcase }], + converters: [->(body) { body&.encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') }]).each do |entry| + user = User.find_by(username: entry['username']) + assert user.present?, entry.inspect + + project = unit.active_projects.find_by(user_id: user.id) + assert project.present?, entry.inspect + + assert_equal project.campus&.abbreviation, entry['campus'], entry.inspect + end + end + + def test_task_completion_csv_generator_excludes_campus_by_default + unit = FactoryBot.create :unit, campus_count: 2, tutorials: 1, stream_count: 1, task_count: 1, student_count: 2, unenrolled_student_count: 0, part_enrolled_student_count: 0 + + csv_str = unit.task_completion_csv_generator + parsed = CSV.parse(csv_str, headers: true) + + assert_not_includes parsed.headers.map(&:downcase), 'campus' + end + def test_export_users unit = FactoryBot.create :unit, campus_count: 2, tutorials:2, stream_count:0, task_count:3, student_count:8, unenrolled_student_count: 0, part_enrolled_student_count: 0, set_one_of_each_task: true From 0a67fe38cc12e3b42987541b567cf41c22d170b2 Mon Sep 17 00:00:00 2001 From: Audrey Phommasone Date: Wed, 22 Apr 2026 17:31:25 +1000 Subject: [PATCH 16/17] feat: add campus to task completion snapshot --- app/api/units_api.rb | 47 +------------------- app/models/task_completion_snapshot.rb | 11 +++-- app/models/unit.rb | 4 +- test/api/units_api_test.rb | 2 + test/models/task_completion_snapshot_test.rb | 10 ++--- test/models/unit_model_test.rb | 18 ++++++-- 6 files changed, 30 insertions(+), 62 deletions(-) diff --git a/app/api/units_api.rb b/app/api/units_api.rb index 3a29e8b50..de8cb1d1b 100644 --- a/app/api/units_api.rb +++ b/app/api/units_api.rb @@ -602,7 +602,7 @@ class UnitsApi < Grape::API snapshots = snapshots.limit([params[:limit].to_i, 365].min) present snapshots.map { |snapshot| - stats = aggregate_task_completion_snapshot_stats(snapshot) + stats = snapshot.load_stats { snapshot_date: snapshot.snapshot_date, @@ -633,51 +633,6 @@ class UnitsApi < Grape::API present job, with: Entities::SidekiqJobEntity end - helpers do - - def aggregate_task_completion_snapshot_stats(snapshot) - snapshot_contents = snapshot.snapshot_contents - return {} if snapshot_contents.blank? - - aggregate_csv_snapshot_stats(snapshot.unit, snapshot_contents) - rescue CSV::MalformedCSVError - {} - end - - def aggregate_csv_snapshot_stats(unit, csv_text) - csv = CSV.parse(csv_text, headers: true) - return {} if csv.empty? - - stream_headers = unit.tutorial_streams.pluck(:abbreviation) - stream_headers = ['Tutorial'] if stream_headers.empty? - task_definitions = unit.task_definitions_by_grade - - stats = Hash.new { |hash, key| hash[key] = Hash.new { |tutorial_hash, tutorial_key| tutorial_hash[tutorial_key] = Hash.new { |task_hash, task_key| task_hash[task_key] = Hash.new(0) } } } - - csv.each do |row| - stream_headers.each do |stream_header| - tutorial_name = row[stream_header].to_s.strip - next if tutorial_name.blank? - - campus_name = if stream_header == 'Tutorial' - unit.tutorials.find_by(abbreviation: tutorial_name)&.campus&.name || stream_header - else - stream_header - end - - task_definitions.each do |task_definition| - status_value = row[task_definition.abbreviation].to_s.strip - status_key = TaskStatus.id_to_key(status_value.to_i) || :not_started - stats[campus_name][tutorial_name][task_definition.abbreviation][status_key.to_s] += 1 - end - end - end - - stats - end - - end - desc 'Download stats related to the number of tasks assessed by each tutor' get '/csv/units/:id/tutor_assessments' do unit = Unit.find(params[:id]) diff --git a/app/models/task_completion_snapshot.rb b/app/models/task_completion_snapshot.rb index 47da38c01..d329dc651 100644 --- a/app/models/task_completion_snapshot.rb +++ b/app/models/task_completion_snapshot.rb @@ -67,16 +67,15 @@ def parse_csv_stats(csv_text) stats = Hash.new { |hash, key| hash[key] = Hash.new { |tutorial_hash, tutorial_key| tutorial_hash[tutorial_key] = Hash.new { |task_hash, task_key| task_hash[task_key] = Hash.new(0) } } } csv.each do |row| + campus_abbreviation = row['Campus'].to_s.strip + next if campus_abbreviation.blank? + + campus_name = Campus.find_by(abbreviation: campus_abbreviation)&.name || campus_abbreviation + stream_headers.each do |stream_header| tutorial_name = row[stream_header].to_s.strip next if tutorial_name.blank? - campus_name = if stream_header == 'Tutorial' - unit.tutorials.find_by(abbreviation: tutorial_name)&.campus&.name || stream_header - else - stream_header - end - task_definitions.each do |task_definition| status_value = row[task_definition.abbreviation].to_s.strip status_key = TaskStatus.id_to_key(status_value.to_i) || :not_started diff --git a/app/models/unit.rb b/app/models/unit.rb index 9ef6dfb54..e5a7c4fe3 100644 --- a/app/models/unit.rb +++ b/app/models/unit.rb @@ -1787,14 +1787,14 @@ def task_completion_csv_generator(task_status_uses_id: false, includes_campus: f 'Student Name', ] base_headers << 'Campus' if includes_campus - base_headers.concat([ + base_headers.push( 'Target Grade', 'Email', 'Portfolio', 'Grade', 'Rationale', 'Assessor', - ]) + ) CSV.generate() do |csv| # Add header row diff --git a/test/api/units_api_test.rb b/test/api/units_api_test.rb index fe1ee7e8f..23add5b13 100644 --- a/test/api/units_api_test.rb +++ b/test/api/units_api_test.rb @@ -755,6 +755,7 @@ def build_task_completion_snapshot_csv(tutorial, task_definition, statuses) 'Student ID', 'Username', 'Student Name', + 'Campus', 'Target Grade', 'Email', 'Portfolio', @@ -773,6 +774,7 @@ def build_task_completion_snapshot_csv(tutorial, task_definition, statuses) "#{index + 1}", "student-#{index + 1}", "Student #{index + 1}", + tutorial.campus.abbreviation, '0', "student-#{index + 1}@example.com", 'false', diff --git a/test/models/task_completion_snapshot_test.rb b/test/models/task_completion_snapshot_test.rb index 5f9ed9e2d..45de34c74 100644 --- a/test/models/task_completion_snapshot_test.rb +++ b/test/models/task_completion_snapshot_test.rb @@ -48,11 +48,11 @@ class TaskCompletionSnapshotTest < ActiveSupport::TestCase task_definition = @unit.task_definitions_by_grade.first payload = CSV.generate do |csv| - csv << ['Student ID', 'Username', 'Student Name', 'Target Grade', 'Email', 'Portfolio', 'Grade', 'Rationale', 'Assessor', 'Tutorial', task_definition.abbreviation] - csv << ['1', 'student-1', 'Student 1', '0', 'student-1@example.com', 'false', '', '', '', tutorial.abbreviation, TaskStatus.complete.id] - csv << ['2', 'student-2', 'Student 2', '0', 'student-2@example.com', 'false', '', '', '', tutorial.abbreviation, TaskStatus.complete.id] - csv << ['3', 'student-3', 'Student 3', '0', 'student-3@example.com', 'false', '', '', '', tutorial.abbreviation, TaskStatus.complete.id] - csv << ['4', 'student-4', 'Student 4', '0', 'student-4@example.com', 'false', '', '', '', tutorial.abbreviation, TaskStatus.complete.id] + csv << ['Student ID', 'Username', 'Student Name', 'Campus', 'Target Grade', 'Email', 'Portfolio', 'Grade', 'Rationale', 'Assessor', 'Tutorial', task_definition.abbreviation] + csv << ['1', 'student-1', 'Student 1', tutorial.campus.abbreviation, '0', 'student-1@example.com', 'false', '', '', '', tutorial.abbreviation, TaskStatus.complete.id] + csv << ['2', 'student-2', 'Student 2', tutorial.campus.abbreviation, '0', 'student-2@example.com', 'false', '', '', '', tutorial.abbreviation, TaskStatus.complete.id] + csv << ['3', 'student-3', 'Student 3', tutorial.campus.abbreviation, '0', 'student-3@example.com', 'false', '', '', '', tutorial.abbreviation, TaskStatus.complete.id] + csv << ['4', 'student-4', 'Student 4', tutorial.campus.abbreviation, '0', 'student-4@example.com', 'false', '', '', '', tutorial.abbreviation, TaskStatus.complete.id] end expected = { diff --git a/test/models/unit_model_test.rb b/test/models/unit_model_test.rb index 70a588ccd..23e03b411 100644 --- a/test/models/unit_model_test.rb +++ b/test/models/unit_model_test.rb @@ -1171,7 +1171,7 @@ def test_cant_disable_aip_only_while_aip_tasks_exist data = build_unit_with_controlled_task_statuses unit = data[:unit] snapshot_time = Time.zone.local(2026, 4, 8, 23, 55, 0) - expected_stats = parse_task_completion_stats_csv(unit, unit.task_completion_csv_generator(task_status_uses_id: true)) + expected_stats = parse_task_completion_stats_csv(unit, unit.task_completion_csv_generator(task_status_uses_id: true, includes_campus: true)) count_before = unit.task_completion_snapshots.count snapshot = unit.capture_task_complete_stats_snapshot!(snapshot_time: snapshot_time) @@ -1203,7 +1203,7 @@ def test_cant_disable_aip_only_while_aip_tasks_exist # Change one task status so the new capture has different stats. student2.task_for_task_definition(task_definitions[0]).update!(task_status: TaskStatus.fail) - expected_updated_stats = parse_task_completion_stats_csv(unit, unit.task_completion_csv_generator(task_status_uses_id: true)) + expected_updated_stats = parse_task_completion_stats_csv(unit, unit.task_completion_csv_generator(task_status_uses_id: true, includes_campus: true)) updated_snapshot = unit.capture_task_complete_stats_snapshot!(snapshot_time: second_time) @@ -1223,13 +1223,25 @@ def parse_task_completion_stats_csv(unit, csv_text) streams = unit.tutorial_streams.pluck(:abbreviation) streams = ['Tutorial'] if streams.empty? task_definitions = unit.task_definitions_by_grade + campus_header = csv.headers.find { |header| header.to_s.casecmp('Campus').zero? } + + campus_names_by_abbreviation = if campus_header.present? + abbreviations = csv.map { |row| row[campus_header].to_s.strip }.reject(&:blank?).uniq + Campus.where(abbreviation: abbreviations).pluck(:abbreviation, :name).to_h + else + {} + end csv.each_with_object(Hash.new { |hash, key| hash[key] = {} }) do |row, stats| streams.each do |stream_name| tutorial_name = row[stream_name].to_s.strip next if tutorial_name.blank? - campus_name = if stream_name == 'Tutorial' + campus_abbreviation = campus_header.present? ? row[campus_header].to_s.strip : nil + + campus_name = if campus_abbreviation.present? + campus_names_by_abbreviation[campus_abbreviation] || campus_abbreviation + elsif stream_name == 'Tutorial' unit.tutorials.find_by(abbreviation: tutorial_name)&.campus&.name || stream_name else stream_name From 95c0d9bfc286be63e3aa02c98b83c60504cab4ea Mon Sep 17 00:00:00 2001 From: Audrey Phommasone Date: Wed, 22 Apr 2026 17:42:43 +1000 Subject: [PATCH 17/17] feat: store task completion snapshots as zip --- app/helpers/file_helper.rb | 2 +- app/models/task_completion_snapshot.rb | 23 +++++++++++++++++--- test/models/task_completion_snapshot_test.rb | 4 ++-- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/app/helpers/file_helper.rb b/app/helpers/file_helper.rb index 6027c3155..a42c29bf4 100644 --- a/app/helpers/file_helper.rb +++ b/app/helpers/file_helper.rb @@ -293,7 +293,7 @@ def unit_task_status_snapshots_dir(unit, create: true, archived: true) end def unit_task_status_snapshot_path(unit, snapshot_timestamp, create: true, archived: true) - snapshot_filename = "#{sanitized_filename(snapshot_timestamp.to_s)}.csv" + snapshot_filename = "#{sanitized_filename(snapshot_timestamp.to_s)}.zip" File.join(unit_task_status_snapshots_dir(unit, create: create, archived: archived), snapshot_filename) end diff --git a/app/models/task_completion_snapshot.rb b/app/models/task_completion_snapshot.rb index d329dc651..4b2468ac6 100644 --- a/app/models/task_completion_snapshot.rb +++ b/app/models/task_completion_snapshot.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'csv' +require 'zip' class TaskCompletionSnapshot < ApplicationRecord include FileHelper @@ -17,8 +18,11 @@ def snapshot_file_path end def snapshot_contents - return File.read(snapshot_file_path) if File.exist?(snapshot_file_path) - + if File.exist?(snapshot_file_path) + return read_csv_from_zip(snapshot_file_path) + end + nil + rescue Zip::Error nil end @@ -48,7 +52,11 @@ def store_stats!(payload) FileUtils.mkdir_p(File.dirname(snapshot_file_path)) tmp_path = "#{snapshot_file_path}.tmp" - File.write(tmp_path, payload.to_s) + Zip::OutputStream.open(tmp_path) do |zip| + zip.put_next_entry('snapshot.csv') + zip.write(payload.to_s) + end + FileUtils.mv(tmp_path, snapshot_file_path) ensure FileUtils.rm_f(tmp_path) if defined?(tmp_path) @@ -87,6 +95,15 @@ def parse_csv_stats(csv_text) stats end + def read_csv_from_zip(zip_path) + Zip::File.open(zip_path) do |zip_file| + entry = zip_file.find_entry('snapshot.csv') || zip_file.entries.first + return nil if entry.nil? + + entry.get_input_stream.read + end + end + def delete_snapshot_file FileUtils.rm_f(snapshot_file_path) end diff --git a/test/models/task_completion_snapshot_test.rb b/test/models/task_completion_snapshot_test.rb index 45de34c74..38300242b 100644 --- a/test/models/task_completion_snapshot_test.rb +++ b/test/models/task_completion_snapshot_test.rb @@ -43,7 +43,7 @@ class TaskCompletionSnapshotTest < ActiveSupport::TestCase assert snapshot.valid? end - test 'store_stats! writes a csv file that can be loaded' do + test 'store_stats! writes a csv file contained in a zip that can be loaded' do tutorial = @unit.tutorials.first task_definition = @unit.task_definitions_by_grade.first @@ -82,7 +82,7 @@ class TaskCompletionSnapshotTest < ActiveSupport::TestCase assert_equal({}, snapshot.load_stats) end - test 'deleting snapshot deletes associated csv file' do + test 'deleting snapshot deletes associated zip file' do tutorial = @unit.tutorials.first task_definition = @unit.task_definitions_by_grade.first