-
Notifications
You must be signed in to change notification settings - Fork 442
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
Open
audreypho
wants to merge
22
commits into
doubtfire-lms:10.0.x
Choose a base branch
from
audreypho:task-completion-snapshots
base: 10.0.x
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
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 2cee127
feat: task completion stats aggregate by tutorial and job scheduled t…
audreypho 8064aa3
feat: add tests for task completion snapshots retrieval and capture
audreypho 63afeff
feat: add factory and tests for task_completion_snapshot model
audreypho e267436
feat: add tests for aggregating and capturing task-completion-stats s…
audreypho 169c2a2
Merge branch '10.0.x' into task-completion-snapshots
audreypho 3265a83
refactor: remove foreign key constraint from task_completion_snapshot…
audreypho 7c1fc5d
fix: `aggregate_task_complete_stats` uses existing status_for_task_de…
audreypho d9228f4
refactor: task completion stats uses async sidekiq job for snapshots
audreypho 98f29d5
feat: add convenor permission for capturing task completion snapshots
audreypho f876fc5
feat: add rate limit to task completion snapshot (30mins)
audreypho a589be0
refactor: change snapshots to be stored as JSON files
audreypho 69b2f72
feat: task completion snapshot captures data in CSV and stores indivi…
audreypho 60c1476
fix: task completion stats csv uses task status names instead of id
audreypho 3ce1cf3
fix: task completion csv correctly uses task status names
audreypho f7c30d7
feat: task completion snapshots include campus information
audreypho 0a67fe3
feat: add campus to task completion snapshot
audreypho 95c0d9b
feat: store task completion snapshots as zip
audreypho 8e4a98f
fix: simplify task_completion_csv_generator, keeping campus column fo…
audreypho 8261705
fix: update task_completion related unit tests in `unit_model_test`
audreypho fad6511
refactor: store task completion snapshots as zip containing csv files
audreypho b849a58
fix: formatting
audreypho File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.