Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ac589b1
feat: create scheduled task-completion snapshots
audreypho Mar 23, 2026
2cee127
feat: task completion stats aggregate by tutorial and job scheduled t…
audreypho Apr 2, 2026
8064aa3
feat: add tests for task completion snapshots retrieval and capture
audreypho Apr 8, 2026
63afeff
feat: add factory and tests for task_completion_snapshot model
audreypho Apr 8, 2026
e267436
feat: add tests for aggregating and capturing task-completion-stats s…
audreypho Apr 8, 2026
169c2a2
Merge branch '10.0.x' into task-completion-snapshots
audreypho Apr 19, 2026
3265a83
refactor: remove foreign key constraint from task_completion_snapshot…
audreypho Apr 19, 2026
7c1fc5d
fix: `aggregate_task_complete_stats` uses existing status_for_task_de…
audreypho Apr 19, 2026
d9228f4
refactor: task completion stats uses async sidekiq job for snapshots
audreypho Apr 19, 2026
98f29d5
feat: add convenor permission for capturing task completion snapshots
audreypho Apr 19, 2026
f876fc5
feat: add rate limit to task completion snapshot (30mins)
audreypho Apr 19, 2026
a589be0
refactor: change snapshots to be stored as JSON files
audreypho Apr 20, 2026
69b2f72
feat: task completion snapshot captures data in CSV and stores indivi…
audreypho Apr 21, 2026
60c1476
fix: task completion stats csv uses task status names instead of id
audreypho Apr 22, 2026
3ce1cf3
fix: task completion csv correctly uses task status names
audreypho Apr 22, 2026
f7c30d7
feat: task completion snapshots include campus information
audreypho Apr 22, 2026
0a67fe3
feat: add campus to task completion snapshot
audreypho Apr 22, 2026
95c0d9b
feat: store task completion snapshots as zip
audreypho Apr 22, 2026
8e4a98f
fix: simplify task_completion_csv_generator, keeping campus column fo…
audreypho May 12, 2026
8261705
fix: update task_completion related unit tests in `unit_model_test`
audreypho May 14, 2026
fad6511
refactor: store task completion snapshots as zip containing csv files
audreypho May 14, 2026
b849a58
fix: formatting
audreypho May 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions app/api/units_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,61 @@ 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(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|
stats = snapshot.load_stats

{
snapshot_date: snapshot.snapshot_date,
snapshot_timestamp: snapshot.snapshot_timestamp,
stats: stats
}
}, with: Grape::Presenters::Presenter
end

desc 'Capture task completion snapshot immediately for this unit'
post '/units/:id/stats/task_completion_snapshots/capture' do
Comment thread
audreypho marked this conversation as resolved.
unit = Unit.find(params[:id])
unless authorise? current_user, unit, :capture_task_completion_snapshot
error!({ error: "Not authorised to capture stats of student tasks in #{unit.code}" }, 403)
end

Comment thread
audreypho marked this conversation as resolved.
# Check if a snapshot was captured within the past 30 minutes
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?
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_time.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
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])
Expand Down
22 changes: 22 additions & 0 deletions app/helpers/file_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,25 @@ 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_snapshot_path(unit, create: true, archived: true)
analytics_dir = unit_analytics_dir(unit, create: create, archived: archived)
FileUtils.mkdir_p(analytics_dir) if create
File.join(analytics_dir, 'task-status-snapshots.zip')
end

def snapshot_csv_filename(snapshot_timestamp)
return nil if snapshot_timestamp.blank?
"#{sanitized_filename(snapshot_timestamp.to_s)}.csv"
end

#
# Generates a path for storing student portfolios
#
Expand Down Expand Up @@ -778,6 +797,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_snapshot_path
module_function :snapshot_csv_filename
module_function :unit_work_root
module_function :project_work_root
module_function :student_portfolio_dir
Expand Down
176 changes: 176 additions & 0 deletions app/models/task_completion_snapshot.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
# frozen_string_literal: true

require 'csv'
require 'zip'

class TaskCompletionSnapshot < ApplicationRecord
include FileHelper

belongs_to :unit

validates :snapshot_timestamp, presence: true
validates :snapshot_timestamp, uniqueness: { scope: :unit_id }

after_destroy :delete_snapshot_file

def snapshot_file_path
return nil if unit.blank?
FileHelper.unit_task_status_snapshot_path(unit, create: true)
end

def snapshot_contents
file_path = snapshot_file_path
return nil if file_path.blank?
if File.exist?(file_path)
return read_csv_from_zip(file_path, snapshot_timestamp)
end
nil
rescue Zip::Error
nil
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
snapshot_contents = self.snapshot_contents

return {} if snapshot_contents.blank?

parse_csv_stats(snapshot_contents)
rescue CSV::MalformedCSVError
{}
end

def store_stats!(payload)
file_path = snapshot_file_path
raise 'Cannot store stats without a unit' if file_path.blank?

FileUtils.mkdir_p(File.dirname(file_path))

csv_filename = FileHelper.snapshot_csv_filename(snapshot_timestamp)
raise 'Cannot store stats without a valid snapshot timestamp' if csv_filename.blank?

tmp_path = "#{file_path}.tmp"

# Read existing zip entries (if file exists)
existing_entries = {}
if File.exist?(file_path)
Zip::File.open(file_path) do |zip_file|
zip_file.each do |entry|
next if entry.directory?
existing_entries[entry.name] = entry.get_input_stream.read
end
end
end

# Update or add the current snapshot entry
existing_entries[csv_filename] = payload.to_s

# Write the zip file with all entries
Zip::OutputStream.open(tmp_path) do |zip|
existing_entries.each do |filename, content|
zip.put_next_entry(filename)
zip.write(content)
end
end

FileUtils.mv(tmp_path, file_path)
ensure
FileUtils.rm_f(tmp_path) if defined?(tmp_path) && tmp_path
end

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|
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?

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 read_csv_from_zip(zip_path, snapshot_timestamp)
csv_filename = FileHelper.snapshot_csv_filename(snapshot_timestamp)
Zip::File.open(zip_path) do |zip_file|
entry = zip_file.find_entry(csv_filename)
return nil if entry.nil?

entry.get_input_stream.read
end
end

def delete_snapshot_file
return if snapshot_timestamp.blank?

file_path = snapshot_file_path
return if file_path.blank? || !File.exist?(file_path)

csv_filename = FileHelper.snapshot_csv_filename(snapshot_timestamp)
return if csv_filename.blank?

tmp_path = "#{file_path}.tmp"

begin
# Read existing zip entries excluding the one we want to delete
remaining_entries = {}
Zip::File.open(file_path) do |zip_file|
zip_file.each do |entry|
next if entry.directory?
next if entry.name == csv_filename
remaining_entries[entry.name] = entry.get_input_stream.read
end
end

if remaining_entries.empty?
# If no entries left, just delete the zip file
FileUtils.rm_f(file_path)
else
# Write the zip file with remaining entries
Zip::OutputStream.open(tmp_path) do |zip|
remaining_entries.each do |filename, content|
zip.put_next_entry(filename)
zip.write(content)
end
end
FileUtils.mv(tmp_path, file_path)
end
rescue StandardError => e
# If anything goes wrong with zip operations, just clean up and log
logger.error("Error managing snapshot zip file: #{e.message}")
FileUtils.rm_f(tmp_path)
end
end
end
Loading
Loading