|
| 1 | +require "active_support/time" |
| 2 | + |
| 3 | +# TODO unit tests |
| 4 | +class Api::ProblemTimelineController < ApplicationController |
| 5 | + def show |
| 6 | + # Validate params |
| 7 | + course_id = params[:course_id].to_i |
| 8 | + assignment_id = params[:assignment_id].to_i |
| 9 | + user_id = params[:user_id].to_i |
| 10 | + |
| 11 | + course = Course.find_by(id: course_id) |
| 12 | + if course.nil? |
| 13 | + render json: { "error": "Course ID #{course_id} not found" }, status: :not_found |
| 14 | + return |
| 15 | + end |
| 16 | + |
| 17 | + assignment = course.assignments.find_by(id: assignment_id) |
| 18 | + if assignment.nil? |
| 19 | + render json: { "error": "Assignment ID #{assignment_id} not found within course ID #{course_id}" }, status: :not_found |
| 20 | + return |
| 21 | + end |
| 22 | + |
| 23 | + student = course.students.find_by(id: user_id) |
| 24 | + if student.nil? |
| 25 | + render json: { "error": "User ID #{user_id} not a student in course ID #{course_id}" }, status: :not_found |
| 26 | + return |
| 27 | + end |
| 28 | + |
| 29 | + # TODO error if student doesn't have any backups for this assignment and course |
| 30 | + |
| 31 | + problem_name_to_index = AssignmentProblem.where(assignment_id: assignment.id) |
| 32 | + .pluck(:display_name, :problem_index) |
| 33 | + .to_h |
| 34 | + |
| 35 | + backups = BackupMetadatum |
| 36 | + .where( |
| 37 | + course: course.okpy_endpoint, |
| 38 | + assignment: assignment.okpy_endpoint, |
| 39 | + student_email: student.email |
| 40 | + ) |
| 41 | + .order(:created) |
| 42 | + |
| 43 | + processed_backups = process_backups(backups, problem_name_to_index) |
| 44 | + sessions = get_sessions(processed_backups) |
| 45 | + |
| 46 | + render json: sessions, status: :ok |
| 47 | + end |
| 48 | + |
| 49 | + private |
| 50 | + |
| 51 | + SESSION_TIME_GAP_THRESHOLD = 15.minutes |
| 52 | + |
| 53 | + # TODO move this logic into frontend since it's about styling |
| 54 | + # color blind color palette from https://davidmathlogic.com/colorblind/#%23D81B60-%231E88E5-%23FFC107-%23004D40 |
| 55 | + PINK = "#D81B60" |
| 56 | + BLUE = "#1E88E5" |
| 57 | + YELLOW = "#FFC107" |
| 58 | + DARK_GREEN = "#004D40" |
| 59 | + |
| 60 | + def get_grading_backup_status(grading_message_question) |
| 61 | + # TODO separate label into backup type (unlock vs. correctness) and status (passed boolean), then format label in frontend |
| 62 | + if grading_message_question.failed == 0 |
| 63 | + { label: "Correctness Tests Passed", color: DARK_GREEN } |
| 64 | + else |
| 65 | + { label: "Correctness Tests Failed", color: PINK } |
| 66 | + end |
| 67 | + end |
| 68 | + |
| 69 | + # This works assuming that the unlock_message_cases are all belonging to the same problem |
| 70 | + def get_unlocking_backup_status(unlock_message_cases) |
| 71 | + if unlock_message_cases.map { |umc| umc.correct }.all? |
| 72 | + { label: "Unlocking Tests Passed", color: BLUE } |
| 73 | + else |
| 74 | + { label: "Unlocking Tests Failed", color: YELLOW } |
| 75 | + end |
| 76 | + end |
| 77 | + |
| 78 | + def process_backups(backups, problem_name_to_index) |
| 79 | + # returns an array of hashes where each hash represents relevant data from a single backup |
| 80 | + # hash has: timestamp, label, color, index, problem_name |
| 81 | + result = [] |
| 82 | + |
| 83 | + backups.each_with_index do |backup, index| |
| 84 | + if backup.grading_location.present? |
| 85 | + backup.grading_message_questions.each do |gmq| |
| 86 | + status = get_grading_backup_status(gmq) |
| 87 | + result << { timestamp: Time.iso8601(backup.created), index: index, problem_name: gmq.question_display_name, problem_index: problem_name_to_index[gmq.question_display_name] }.merge(status) |
| 88 | + end |
| 89 | + end |
| 90 | + |
| 91 | + if backup.unlock_location.present? |
| 92 | + # annoyingly, unlock.json doesn't have question display name so we fetch it from analytics.json |
| 93 | + backup_problem_names = backup.analytics_message.question_display_names |
| 94 | + umc_grouped_by_problem_name = backup_problem_names.map { |name| [ name, [] ] }.to_h |
| 95 | + backup.unlock_message_cases.each do |umc| |
| 96 | + # TODO dangerously (?) assume that unlock message cases |
| 97 | + # will always match exactly one of the backup problem names |
| 98 | + backup_problem_names.each do |name| |
| 99 | + if umc.case_id.start_with?(name) |
| 100 | + umc_grouped_by_problem_name[name] << umc |
| 101 | + break |
| 102 | + end |
| 103 | + end |
| 104 | + end |
| 105 | + |
| 106 | + umc_grouped_by_problem_name.each do |problem_name, unlock_message_cases| |
| 107 | + status = get_unlocking_backup_status(unlock_message_cases) |
| 108 | + result << { timestamp: Time.iso8601(backup.created), index: index, problem_name: problem_name, problem_index: problem_name_to_index[problem_name] }.merge(status) |
| 109 | + end |
| 110 | + end |
| 111 | + end |
| 112 | + |
| 113 | + result |
| 114 | + end |
| 115 | + |
| 116 | + def get_sessions(processed_backups) |
| 117 | + if processed_backups.empty? |
| 118 | + return [] |
| 119 | + end |
| 120 | + |
| 121 | + # A session is defined as a series of backups where: |
| 122 | + # 1. All problems worked on are the same within the session, AND |
| 123 | + # 2. All labels (and therefore colors) are the same within the session, AND |
| 124 | + # 3. Consecutive timestamps are <= SESSION_TIME_GAP_THRESHOLD |
| 125 | + |
| 126 | + result = [] |
| 127 | + # TODO only convert to camel case at the end for consistency. generally fix naming conventions in all files... |
| 128 | + curr_session = |
| 129 | + { |
| 130 | + startTime: processed_backups[0][:timestamp], |
| 131 | + endTime: processed_backups[0][:timestamp], |
| 132 | + label: processed_backups[0][:label], |
| 133 | + color: processed_backups[0][:color], |
| 134 | + startIndex: processed_backups[0][:index], |
| 135 | + endIndex: processed_backups[0][:index] + 1, |
| 136 | + problemName: processed_backups[0][:problem_name], |
| 137 | + problemIndex: processed_backups[0][:problem_index] |
| 138 | + } |
| 139 | + |
| 140 | + processed_backups.each_cons(2) do |a, b| |
| 141 | + problems_differ = a[:problem_index] != b[:problem_index] |
| 142 | + has_time_gap = b[:timestamp] - a[:timestamp] > SESSION_TIME_GAP_THRESHOLD |
| 143 | + labels_differ = a[:label] != b[:label] |
| 144 | + |
| 145 | + if problems_differ or has_time_gap or labels_differ |
| 146 | + result << curr_session |
| 147 | + curr_session = |
| 148 | + { |
| 149 | + startTime: b[:timestamp], |
| 150 | + endTime: b[:timestamp], |
| 151 | + label: b[:label], |
| 152 | + color: b[:color], |
| 153 | + startIndex: b[:index], |
| 154 | + endIndex: b[:index] + 1, |
| 155 | + problemName: b[:problem_name], |
| 156 | + problemIndex: b[:problem_index] |
| 157 | + } |
| 158 | + else |
| 159 | + curr_session[:endTime] = b[:timestamp] |
| 160 | + curr_session[:endIndex] = b[:index] + 1 |
| 161 | + end |
| 162 | + end |
| 163 | + |
| 164 | + result << curr_session |
| 165 | + result |
| 166 | + end |
| 167 | +end |
0 commit comments