-
Notifications
You must be signed in to change notification settings - Fork 443
feat: capture daily snapshots of aggregated task completion data #607
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 10.0.x
Are you sure you want to change the base?
Changes from all commits
ac589b1
2cee127
8064aa3
63afeff
e267436
169c2a2
3265a83
7c1fc5d
d9228f4
98f29d5
f876fc5
a589be0
69b2f72
60c1476
3ce1cf3
f7c30d7
0a67fe3
95c0d9b
8e4a98f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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)}.zip" | ||
| File.join(unit_task_status_snapshots_dir(unit, create: create, archived: archived), snapshot_filename) | ||
| end | ||
|
Comment on lines
+295
to
+298
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Rather than storing each snapshot in its own zip file, it would be more efficient to store a single You'll need to then search for the timestamped snapshot within the ZIP |
||
|
|
||
| # | ||
| # 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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| # 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 | ||
| FileHelper.unit_task_status_snapshot_path(unit, snapshot_timestamp, create: true) | ||
| end | ||
|
|
||
| def snapshot_contents | ||
| if File.exist?(snapshot_file_path) | ||
| return read_csv_from_zip(snapshot_file_path) | ||
| 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) | ||
| FileUtils.mkdir_p(File.dirname(snapshot_file_path)) | ||
|
|
||
| tmp_path = "#{snapshot_file_path}.tmp" | ||
| 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) | ||
| 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) | ||
| 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 | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| 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! | ||
| else | ||
| Unit.active_units.find_each(&:capture_task_complete_stats_snapshot!) | ||
| end | ||
|
|
||
| at(1) | ||
| logger.info 'Completed task completion stats aggregation!' | ||
| rescue StandardError => e | ||
| logger.error e | ||
| raise e | ||
| end | ||
| end |
Uh oh!
There was an error while loading. Please reload this page.