diff --git a/app/models/quizzes/quiz.rb b/app/models/quizzes/quiz.rb index 74fd2ba388833..94682538506ce 100644 --- a/app/models/quizzes/quiz.rb +++ b/app/models/quizzes/quiz.rb @@ -23,6 +23,32 @@ class Quizzes::Quiz < ApplicationRecord self.table_name = "quizzes" + # Fields that trigger a quiz_updated live event when changed. + # Includes quiz-specific settings and availability dates. + QUIZ_EVENT_FIELDS = %w[ + access_code + allowed_attempts + anonymous_submissions + cant_go_back + disable_timer_autosubmission + due_at + hide_correct_answers_at + hide_results + ip_filter + lock_at + one_question_at_a_time + one_time_results + require_lockdown_browser + require_lockdown_browser_for_results + scoring_policy + show_correct_answers + show_correct_answers_at + show_correct_answers_last_attempt + shuffle_answers + time_limit + unlock_at + ].freeze + include Workflow include HasContentTags include CopyAuthorizedLinks @@ -75,6 +101,8 @@ class Quizzes::Quiz < ApplicationRecord before_save :build_assignment before_save :set_defaults after_save :update_assignment + after_save :fire_quiz_created_event + after_save :fire_quiz_updated_event before_save :check_if_needs_availability_cache_clear after_save :clear_availability_cache after_save :touch_context @@ -531,6 +559,29 @@ def update_assignment protected :update_assignment + def fire_quiz_created_event + return unless saved_changes.key?("id") # new record only + return if saved_by == :migration + + self.class.connection.after_transaction_commit do + Canvas::LiveEvents.quiz_created(self) + end + end + + private :fire_quiz_created_event + + def fire_quiz_updated_event + return if saved_changes.key?("id") # new record + return if saved_by == :migration + return unless (saved_changes.keys & QUIZ_EVENT_FIELDS).any? + + self.class.connection.after_transaction_commit do + Canvas::LiveEvents.quiz_updated(self) + end + end + + private :fire_quiz_updated_event + attr_reader :should_clear_availability_cache def check_if_needs_availability_cache_clear diff --git a/doc/api/data_services/data_services_events_loader.rb b/doc/api/data_services/data_services_events_loader.rb index 2f49c2bebddba..367301129a6f3 100644 --- a/doc/api/data_services/data_services_events_loader.rb +++ b/doc/api/data_services/data_services_events_loader.rb @@ -25,6 +25,8 @@ class DataServicesEventsLoader "doc/api/data_services/json/canvas/event-types/outcome_proficiency_created.json" => "learning", "doc/api/data_services/json/canvas/event-types/outcome_proficiency_updated.json" => "learning", "doc/api/data_services/json/caliper/event-types/quiz_submitted.json" => "assessment", + "doc/api/data_services/json/caliper/event-types/quiz_created.json" => "basic", + "doc/api/data_services/json/caliper/event-types/quiz_updated.json" => "basic", "doc/api/data_services/json/caliper/event-types/assignment_created.json" => "basic", "doc/api/data_services/json/caliper/event-types/assignment_updated.json" => "basic", "doc/api/data_services/json/caliper/event-types/assignment_override_created.json" => "basic", diff --git a/doc/api/data_services/json/caliper/event-types/quiz_created.json b/doc/api/data_services/json/caliper/event-types/quiz_created.json new file mode 100644 index 0000000000000..e91e0058350ed --- /dev/null +++ b/doc/api/data_services/json/caliper/event-types/quiz_created.json @@ -0,0 +1,114 @@ +{ + "name": "quiz_created", + "definition": "The event is emitted anytime a new classic quiz is created by an end user or API request.", + "trigger": "Triggered when a new classic quiz [old quizzes] is created in a course.", + "schema": { + "data[0].group.extensions[\"com.instructure.canvas\"]": { + "context_type": "Canvas context type where the action took place e.g context_type = Course.", + "entity_id": "Canvas context ID" + }, + "data[0].object.extensions[\"com.instructure.canvas\"]": { + "entity_id": "Canvas global ID of the object affected by the event", + "quiz_type": "The type of quiz (assignment, practice_quiz, graded_survey, survey).", + "time_limit": "The time limit for taking the quiz, in minutes.", + "access_code": "Whether the quiz requires an access code (boolean).", + "workflow_state": "Workflow state of the quiz (created, edited, available, unpublished, deleted)." + }, + "data[0].object": { + "type": "Assessment" + } + }, + "examples": [ + { + "payload": { + "sensor": "http://oxana.instructure.com/", + "sendTime": "2019-11-16T02:08:59.579Z", + "dataVersion": "http://purl.imsglobal.org/ctx/caliper/v1p1", + "data": [ + { + "@context": "http://purl.imsglobal.org/ctx/caliper/v1p1", + "id": "urn:uuid:3f672715-6aa8-4293-b62a-3b3319ff5701", + "type": "Event", + "actor": { + "id": "urn:instructure:canvas:user:21070000000000001", + "type": "Person", + "extensions": { + "com.instructure.canvas": { + "user_login": "oxana@example.com", + "user_sis_id": "456-T45", + "root_account_id": "21070000000000001", + "root_account_lti_guid": "VicYj3cu5BIFpoZhDVU4DZumnlBrWi1grgJEzADs.oxana.instructure.com", + "root_account_uuid": "VicYj3cu5BIFpoZhDVU4DZumnlBrWi1grgJEzADs", + "entity_id": "21070000000000001" + } + } + }, + "action": "Created", + "object": { + "id": "urn:instructure:canvas:quiz:21070000002030605", + "type": "Assessment", + "name": "Chapter 5 Quiz", + "dateCreated": "2019-11-01T19:11:11.323Z", + "extensions": { + "com.instructure.canvas": { + "quiz_type": "assignment", + "time_limit": 30, + "access_code": true, + "workflow_state": "created", + "entity_id": "21070000002030605" + } + }, + "dateToShow": "2019-11-01T00:00:00.000Z", + "dateToSubmit": "2019-11-05T13:38:00.218Z", + "maxScore": 100 + }, + "eventTime": "2019-11-01T19:11:11.323Z", + "edApp": { + "id": "http://oxana.instructure.com/", + "type": "SoftwareApplication" + }, + "group": { + "id": "urn:instructure:canvas:course:21070000001279362", + "type": "CourseOffering", + "extensions": { + "com.instructure.canvas": { + "context_type": "Course", + "entity_id": "21070000001279362" + } + } + }, + "membership": { + "id": "urn:instructure:canvas:course:21070000001279362:Instructor:21070000000000001", + "type": "Membership", + "member": { + "id": "urn:instructure:canvas:user:21070000000000001", + "type": "Person" + }, + "organization": { + "id": "urn:instructure:canvas:course:21070000001279362", + "type": "CourseOffering" + }, + "roles": [ + "Instructor" + ] + }, + "session": { + "id": "urn:instructure:canvas:session:ef686f8ed684abf78cbfa1f6a58112b5", + "type": "Session" + }, + "extensions": { + "com.instructure.canvas": { + "hostname": "oxana.instructure.com", + "request_id": "1dd9dc6f-2fb0-4c19-a6c5-7ee1bf3ed295", + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.103 Safari/537.36", + "client_ip": "93.184.216.34", + "request_url": "https://oxana.instructure.com/api/v1/courses/1279362/quizzes", + "version": "1.0.0" + } + } + } + ] + } + } + ] +} diff --git a/doc/api/data_services/json/caliper/event-types/quiz_updated.json b/doc/api/data_services/json/caliper/event-types/quiz_updated.json new file mode 100644 index 0000000000000..096c94b954737 --- /dev/null +++ b/doc/api/data_services/json/caliper/event-types/quiz_updated.json @@ -0,0 +1,114 @@ +{ + "name": "quiz_updated", + "definition": "The event is emitted anytime a classic quiz's settings are updated by an end user or API request. This covers quiz-specific fields such as access_code, time_limit, ip_filter, and other settings that are not reflected on the associated assignment.", + "trigger": "Triggered when a classic quiz [old quizzes] has been modified and quiz-specific settings have changed.", + "schema": { + "data[0].group.extensions[\"com.instructure.canvas\"]": { + "context_type": "Canvas context type where the action took place e.g context_type = Course.", + "entity_id": "Canvas context ID" + }, + "data[0].object.extensions[\"com.instructure.canvas\"]": { + "entity_id": "Canvas global ID of the object affected by the event", + "quiz_type": "The type of quiz (assignment, practice_quiz, graded_survey, survey).", + "time_limit": "The time limit for taking the quiz, in minutes.", + "access_code": "Whether the quiz requires an access code (boolean).", + "workflow_state": "Workflow state of the quiz (created, edited, available, unpublished, deleted)." + }, + "data[0].object": { + "type": "Assessment" + } + }, + "examples": [ + { + "payload": { + "sensor": "http://oxana.instructure.com/", + "sendTime": "2019-11-16T02:09:00.554Z", + "dataVersion": "http://purl.imsglobal.org/ctx/caliper/v1p1", + "data": [ + { + "@context": "http://purl.imsglobal.org/ctx/caliper/v1p1", + "id": "urn:uuid:0a2a8c4d-0ebc-4200-ab6f-095b3b16852d", + "type": "Event", + "actor": { + "id": "urn:instructure:canvas:user:21070000000000001", + "type": "Person", + "extensions": { + "com.instructure.canvas": { + "user_login": "oxana@example.com", + "user_sis_id": "456-T45", + "root_account_id": "21070000000000001", + "root_account_lti_guid": "VicYj3cu5BIFpoZhDVU4DZumnlBrWi1grgJEzADs.oxana.instructure.com", + "root_account_uuid": "VicYj3cu5BIFpoZhDVU4DZumnlBrWi1grgJEzADs", + "entity_id": "21070000000000001" + } + } + }, + "action": "Modified", + "object": { + "id": "urn:instructure:canvas:quiz:21070000002030605", + "type": "Assessment", + "name": "Chapter 5 Quiz", + "dateModified": "2019-11-05T13:38:00.218Z", + "extensions": { + "com.instructure.canvas": { + "quiz_type": "assignment", + "time_limit": 30, + "access_code": true, + "workflow_state": "available", + "entity_id": "21070000002030605" + } + }, + "dateToShow": "2019-11-01T00:00:00.000Z", + "dateToSubmit": "2019-11-05T13:38:00.218Z", + "maxScore": 100 + }, + "eventTime": "2019-11-01T19:11:14.005Z", + "edApp": { + "id": "http://oxana.instructure.com/", + "type": "SoftwareApplication" + }, + "group": { + "id": "urn:instructure:canvas:course:21070000001279362", + "type": "CourseOffering", + "extensions": { + "com.instructure.canvas": { + "context_type": "Course", + "entity_id": "21070000001279362" + } + } + }, + "membership": { + "id": "urn:instructure:canvas:course:21070000001279362:Instructor:21070000000000001", + "type": "Membership", + "member": { + "id": "urn:instructure:canvas:user:21070000000000001", + "type": "Person" + }, + "organization": { + "id": "urn:instructure:canvas:course:21070000001279362", + "type": "CourseOffering" + }, + "roles": [ + "Instructor" + ] + }, + "session": { + "id": "urn:instructure:canvas:session:ef686f8ed684abf78cbfa1f6a58112b5", + "type": "Session" + }, + "extensions": { + "com.instructure.canvas": { + "hostname": "oxana.instructure.com", + "request_id": "1dd9dc6f-2fb0-4c19-a6c5-7ee1bf3ed295", + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.103 Safari/537.36", + "client_ip": "93.184.216.34", + "request_url": "https://oxana.instructure.com/api/v1/courses/1279362/quizzes/2030605", + "version": "1.0.0" + } + } + } + ] + } + } + ] +} diff --git a/doc/api/data_services/json/canvas/event-types/quiz_created.json b/doc/api/data_services/json/canvas/event-types/quiz_created.json new file mode 100644 index 0000000000000..836207f67400f --- /dev/null +++ b/doc/api/data_services/json/canvas/event-types/quiz_created.json @@ -0,0 +1,108 @@ +{ + "name": "quiz_created", + "definition": "The event is emitted anytime a new classic quiz is created by an end user or API request.", + "trigger": "Triggered when a new classic quiz [old quizzes] is created in a course.", + "schema": { + "quiz_id": "The Canvas id of the quiz.", + "assignment_id": "The Canvas id of the associated assignment, if the quiz is graded.", + "context_id": "The Canvas id for the context the quiz is used in.", + "context_type": "The type of context the quiz is used in (usually Course).", + "context_uuid": "The uuid of the context associated with the quiz.", + "title": "The title of the quiz. NOTE: This field will be truncated to only include the first 8192 characters.", + "description": "The description of the quiz. NOTE: This field will be truncated to only include the first 8192 characters.", + "quiz_type": "The type of quiz (assignment, practice_quiz, graded_survey, survey).", + "points_possible": "The maximum points possible for the quiz.", + "due_at": "The due date for the quiz.", + "lock_at": "The lock date (quiz is locked after this date).", + "unlock_at": "The unlock date (quiz is unlocked after this date).", + "time_limit": "The time limit for taking the quiz, in minutes.", + "access_code": "Whether the quiz requires an access code (boolean, does not expose the actual code).", + "ip_filter": "The IP address filter for taking the quiz.", + "shuffle_answers": "Whether answer choices are shuffled for each student.", + "allowed_attempts": "The number of allowed attempts.", + "scoring_policy": "The scoring policy for multiple attempts (keep_highest, keep_latest).", + "hide_results": "When to hide results from students (null, always, until_after_last_attempt).", + "show_correct_answers": "Whether correct answers are shown to students after submission.", + "show_correct_answers_at": "The date after which correct answers are shown.", + "hide_correct_answers_at": "The date after which correct answers are hidden.", + "show_correct_answers_last_attempt": "Whether correct answers are only shown after the last attempt.", + "one_question_at_a_time": "Whether the quiz shows one question at a time.", + "cant_go_back": "Whether students cannot return to previous questions (only applies when one_question_at_a_time is true).", + "one_time_results": "Whether students can only view results once.", + "anonymous_submissions": "Whether submissions are anonymous.", + "require_lockdown_browser": "Whether the quiz requires a lockdown browser.", + "require_lockdown_browser_for_results": "Whether viewing results requires a lockdown browser.", + "disable_timer_autosubmission": "Whether timer autosubmission is disabled.", + "only_visible_to_overrides": "Whether the quiz is only visible to students with overrides.", + "workflow_state": "Workflow state of the quiz (created, edited, available, unpublished, deleted).", + "updated_at": "The time at which this quiz was last modified in any way." + }, + "examples": [ + { + "payload": { + "metadata": { + "client_ip": "93.184.216.34", + "context_account_id": "21070000000000079", + "context_id": "21070000001279362", + "context_role": "TeacherEnrollment", + "context_sis_source_id": "2017.100.101.101-1", + "context_type": "Course", + "developer_key_id": "170000000056", + "event_name": "quiz_created", + "event_time": "2019-11-01T19:11:11.323Z", + "hostname": "oxana.instructure.com", + "http_method": "POST", + "producer": "canvas", + "referrer": null, + "request_id": "1dd9dc6f-2fb0-4c19-a6c5-7ee1bf3ed295", + "root_account_id": "21070000000000001", + "root_account_lti_guid": "VicYj3cu5BIFpoZhDVU4DZumnlBrWi1grgJEzADs.oxana.instructure.com", + "root_account_uuid": "VicYj3cu5BIFpoZhDVU4DZumnlBrWi1grgJEzADs", + "session_id": "ef686f8ed684abf78cbfa1f6a58112b5", + "time_zone": "America/New_York", + "url": "https://oxana.instructure.com/api/v1/courses/1279362/quizzes", + "user_account_id": "21070000000000001", + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.103 Safari/537.36", + "user_id": "21070000000000001", + "user_login": "oxana@example.com", + "user_sis_id": "456-T45" + }, + "body": { + "quiz_id": "21070000002030605", + "assignment_id": "21070000002030606", + "context_id": "21070000001279362", + "context_type": "Course", + "context_uuid": "a1b2c3c4z9x8a1s2q5w6p9o8i7u6y5t6a2s3d4f5", + "title": "Chapter 5 Quiz", + "description": "

Chapter 5 quiz covering key concepts.

", + "quiz_type": "assignment", + "points_possible": 100.0, + "due_at": "2019-11-05T13:38:00.218Z", + "lock_at": "2019-11-05T13:38:00.218Z", + "unlock_at": "2019-11-01T00:00:00.000Z", + "time_limit": 30, + "access_code": true, + "ip_filter": "192.168.0.0/24", + "shuffle_answers": true, + "allowed_attempts": 2, + "scoring_policy": "keep_highest", + "hide_results": null, + "show_correct_answers": true, + "show_correct_answers_at": null, + "hide_correct_answers_at": null, + "show_correct_answers_last_attempt": false, + "one_question_at_a_time": false, + "cant_go_back": false, + "one_time_results": false, + "anonymous_submissions": false, + "require_lockdown_browser": false, + "require_lockdown_browser_for_results": false, + "disable_timer_autosubmission": false, + "only_visible_to_overrides": false, + "workflow_state": "created", + "updated_at": "2019-11-01T19:11:11.323Z" + } + } + } + ] +} diff --git a/doc/api/data_services/json/canvas/event-types/quiz_updated.json b/doc/api/data_services/json/canvas/event-types/quiz_updated.json new file mode 100644 index 0000000000000..859bb9d6e2fdf --- /dev/null +++ b/doc/api/data_services/json/canvas/event-types/quiz_updated.json @@ -0,0 +1,108 @@ +{ + "name": "quiz_updated", + "definition": "The event is emitted anytime a classic quiz's settings are updated by an end user or API request. This covers quiz-specific fields such as access_code, time_limit, ip_filter, and other settings that are not reflected on the associated assignment.", + "trigger": "Triggered when a classic quiz [old quizzes] has been modified and quiz-specific settings have changed.", + "schema": { + "quiz_id": "The Canvas id of the quiz.", + "assignment_id": "The Canvas id of the associated assignment, if the quiz is graded.", + "context_id": "The Canvas id for the context the quiz is used in.", + "context_type": "The type of context the quiz is used in (usually Course).", + "context_uuid": "The uuid of the context associated with the quiz.", + "title": "The title of the quiz. NOTE: This field will be truncated to only include the first 8192 characters.", + "description": "The description of the quiz. NOTE: This field will be truncated to only include the first 8192 characters.", + "quiz_type": "The type of quiz (assignment, practice_quiz, graded_survey, survey).", + "points_possible": "The maximum points possible for the quiz.", + "due_at": "The due date for the quiz.", + "lock_at": "The lock date (quiz is locked after this date).", + "unlock_at": "The unlock date (quiz is unlocked after this date).", + "time_limit": "The time limit for taking the quiz, in minutes.", + "access_code": "Whether the quiz requires an access code (boolean, does not expose the actual code).", + "ip_filter": "The IP address filter for taking the quiz.", + "shuffle_answers": "Whether answer choices are shuffled for each student.", + "allowed_attempts": "The number of allowed attempts.", + "scoring_policy": "The scoring policy for multiple attempts (keep_highest, keep_latest).", + "hide_results": "When to hide results from students (null, always, until_after_last_attempt).", + "show_correct_answers": "Whether correct answers are shown to students after submission.", + "show_correct_answers_at": "The date after which correct answers are shown.", + "hide_correct_answers_at": "The date after which correct answers are hidden.", + "show_correct_answers_last_attempt": "Whether correct answers are only shown after the last attempt.", + "one_question_at_a_time": "Whether the quiz shows one question at a time.", + "cant_go_back": "Whether students cannot return to previous questions (only applies when one_question_at_a_time is true).", + "one_time_results": "Whether students can only view results once.", + "anonymous_submissions": "Whether submissions are anonymous.", + "require_lockdown_browser": "Whether the quiz requires a lockdown browser.", + "require_lockdown_browser_for_results": "Whether viewing results requires a lockdown browser.", + "disable_timer_autosubmission": "Whether timer autosubmission is disabled.", + "only_visible_to_overrides": "Whether the quiz is only visible to students with overrides.", + "workflow_state": "Workflow state of the quiz (created, edited, available, unpublished, deleted).", + "updated_at": "The time at which this quiz was last modified in any way." + }, + "examples": [ + { + "payload": { + "metadata": { + "client_ip": "93.184.216.34", + "context_account_id": "21070000000000079", + "context_id": "21070000001279362", + "context_role": "TeacherEnrollment", + "context_sis_source_id": "2017.100.101.101-1", + "context_type": "Course", + "developer_key_id": "170000000056", + "event_name": "quiz_updated", + "event_time": "2019-11-01T19:11:14.005Z", + "hostname": "oxana.instructure.com", + "http_method": "PUT", + "producer": "canvas", + "referrer": null, + "request_id": "1dd9dc6f-2fb0-4c19-a6c5-7ee1bf3ed295", + "root_account_id": "21070000000000001", + "root_account_lti_guid": "VicYj3cu5BIFpoZhDVU4DZumnlBrWi1grgJEzADs.oxana.instructure.com", + "root_account_uuid": "VicYj3cu5BIFpoZhDVU4DZumnlBrWi1grgJEzADs", + "session_id": "ef686f8ed684abf78cbfa1f6a58112b5", + "time_zone": "America/New_York", + "url": "https://oxana.instructure.com/api/v1/courses/1279362/quizzes/2030605", + "user_account_id": "21070000000000001", + "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.103 Safari/537.36", + "user_id": "21070000000000001", + "user_login": "oxana@example.com", + "user_sis_id": "456-T45" + }, + "body": { + "quiz_id": "21070000002030605", + "assignment_id": "21070000002030606", + "context_id": "21070000001279362", + "context_type": "Course", + "context_uuid": "a1b2c3c4z9x8a1s2q5w6p9o8i7u6y5t6a2s3d4f5", + "title": "Chapter 5 Quiz", + "description": "

Chapter 5 quiz covering key concepts.

", + "quiz_type": "assignment", + "points_possible": 100.0, + "due_at": "2019-11-05T13:38:00.218Z", + "lock_at": "2019-11-05T13:38:00.218Z", + "unlock_at": "2019-11-01T00:00:00.000Z", + "time_limit": 30, + "access_code": true, + "ip_filter": "192.168.0.0/24", + "shuffle_answers": true, + "allowed_attempts": 2, + "scoring_policy": "keep_highest", + "hide_results": null, + "show_correct_answers": true, + "show_correct_answers_at": null, + "hide_correct_answers_at": null, + "show_correct_answers_last_attempt": false, + "one_question_at_a_time": false, + "cant_go_back": false, + "one_time_results": false, + "anonymous_submissions": false, + "require_lockdown_browser": false, + "require_lockdown_browser_for_results": false, + "disable_timer_autosubmission": false, + "only_visible_to_overrides": false, + "workflow_state": "available", + "updated_at": "2019-11-05T13:38:00.218Z" + } + } + } + ] +} diff --git a/lib/canvas/live_events.rb b/lib/canvas/live_events.rb index 7c1bfba8189bf..18c03d30c92cf 100644 --- a/lib/canvas/live_events.rb +++ b/lib/canvas/live_events.rb @@ -598,6 +598,52 @@ def self.logged_out post_event_stringified("logged_out", {}) end + def self.quiz_created(quiz) + post_event_stringified("quiz_created", get_quiz_data(quiz)) + end + + def self.quiz_updated(quiz) + post_event_stringified("quiz_updated", get_quiz_data(quiz)) + end + + def self.get_quiz_data(quiz) + { + quiz_id: quiz.global_id, + assignment_id: quiz.assignment_id ? quiz.global_assignment_id : nil, + context_id: quiz.global_context_id, + context_type: quiz.context_type, + context_uuid: quiz.context.uuid, + title: LiveEvents.truncate(quiz.title), + description: LiveEvents.truncate(quiz.description), + quiz_type: quiz.quiz_type, + points_possible: quiz.points_possible, + due_at: quiz.due_at, + lock_at: quiz.lock_at, + unlock_at: quiz.unlock_at, + time_limit: quiz.time_limit, + access_code: quiz.access_code.present?, + ip_filter: quiz.ip_filter, + shuffle_answers: quiz.shuffle_answers, + allowed_attempts: quiz.allowed_attempts, + scoring_policy: quiz.scoring_policy, + hide_results: quiz.hide_results, + show_correct_answers: quiz.show_correct_answers, + show_correct_answers_at: quiz.show_correct_answers_at, + hide_correct_answers_at: quiz.hide_correct_answers_at, + show_correct_answers_last_attempt: quiz.show_correct_answers_last_attempt, + one_question_at_a_time: quiz.one_question_at_a_time, + cant_go_back: quiz.cant_go_back, + one_time_results: quiz.one_time_results, + anonymous_submissions: quiz.anonymous_submissions, + require_lockdown_browser: quiz.require_lockdown_browser, + require_lockdown_browser_for_results: quiz.require_lockdown_browser_for_results, + disable_timer_autosubmission: quiz.disable_timer_autosubmission, + only_visible_to_overrides: quiz.only_visible_to_overrides, + workflow_state: quiz.workflow_state, + updated_at: quiz.updated_at + } + end + def self.quiz_submitted(submission) # TODO: include score, for automatically graded portions? post_event_stringified("quiz_submitted", { diff --git a/spec/lib/canvas/live_events_spec.rb b/spec/lib/canvas/live_events_spec.rb index 7cecb9225d32a..8e8b511af21e5 100644 --- a/spec/lib/canvas/live_events_spec.rb +++ b/spec/lib/canvas/live_events_spec.rb @@ -1559,6 +1559,85 @@ def settings end end + describe ".quiz_created" do + before :once do + course_factory + @quiz = @course.quizzes.create!( + title: "New Quiz", + quiz_type: "assignment", + time_limit: 45, + access_code: "pass123", + shuffle_answers: true + ) + end + + it "triggers a live event with quiz details" do + expect_event("quiz_created", + hash_including({ + quiz_id: @quiz.global_id.to_s, + context_id: @course.global_id.to_s, + context_uuid: @course.uuid, + context_type: "Course", + title: @quiz.title, + quiz_type: "assignment", + time_limit: 45, + access_code: true, + shuffle_answers: true, + workflow_state: @quiz.workflow_state + })).once + + Canvas::LiveEvents.quiz_created(@quiz) + end + end + + describe ".quiz_updated" do + before :once do + course_factory + @quiz = @course.quizzes.create!( + title: "Test Quiz", + quiz_type: "assignment", + time_limit: 60, + access_code: "secret", + ip_filter: "192.168.0.1", + shuffle_answers: true, + allowed_attempts: 2, + scoring_policy: "keep_highest" + ) + end + + it "triggers a live event with quiz details" do + expect_event("quiz_updated", + hash_including({ + quiz_id: @quiz.global_id.to_s, + context_id: @course.global_id.to_s, + context_uuid: @course.uuid, + context_type: "Course", + title: @quiz.title, + quiz_type: "assignment", + time_limit: 60, + access_code: true, + ip_filter: "192.168.0.1", + shuffle_answers: true, + allowed_attempts: 2, + scoring_policy: "keep_highest", + workflow_state: @quiz.workflow_state + })).once + + Canvas::LiveEvents.quiz_updated(@quiz) + end + + it "does not expose the actual access_code value" do + event_data = Canvas::LiveEvents.get_quiz_data(@quiz) + expect(event_data[:access_code]).to eq true + end + + it "sets access_code to false when no access code is set" do + quiz = @course.quizzes.create!(title: "No Code Quiz", quiz_type: "practice_quiz") + event_data = Canvas::LiveEvents.get_quiz_data(quiz) + expect(event_data[:access_code]).to eq false + end + end + describe ".content_migration_completed" do let(:course) { course_factory } let(:source_course) { course_factory } diff --git a/spec/models/quizzes/quiz_spec.rb b/spec/models/quizzes/quiz_spec.rb index a75caa48887c2..c4b91342b8c7a 100644 --- a/spec/models/quizzes/quiz_spec.rb +++ b/spec/models/quizzes/quiz_spec.rb @@ -330,6 +330,107 @@ def create_quiz_with_submission(opts) end end + describe "#fire_quiz_created_event" do + it "fires quiz_created when a new quiz is created" do + expect(Canvas::LiveEvents).to receive(:quiz_created) + @course.quizzes.create!(title: "New Quiz", quiz_type: "assignment") + end + + it "fires quiz_created for practice quizzes" do + expect(Canvas::LiveEvents).to receive(:quiz_created) + @course.quizzes.create!(title: "Practice", quiz_type: "practice_quiz") + end + + it "does not fire quiz_created when an existing quiz is updated" do + quiz = @course.quizzes.create!(title: "Test Quiz", quiz_type: "assignment") + quiz.reload + expect(Canvas::LiveEvents).not_to receive(:quiz_created) + quiz.update!(access_code: "secret123") + end + + it "does not fire quiz_created during content migrations" do + expect(Canvas::LiveEvents).not_to receive(:quiz_created) + quiz = @course.quizzes.build(title: "Migrated Quiz", quiz_type: "assignment") + quiz.saved_by = :migration + quiz.save! + end + end + + describe "#fire_quiz_updated_event" do + before :once do + @quiz = @course.quizzes.create!(title: "Test Quiz", quiz_type: "assignment") + @quiz.reload + end + + it "fires quiz_updated when access_code changes" do + expect(Canvas::LiveEvents).to receive(:quiz_updated).with(@quiz) + @quiz.update!(access_code: "secret123") + end + + it "fires quiz_updated when time_limit changes" do + expect(Canvas::LiveEvents).to receive(:quiz_updated).with(@quiz) + @quiz.update!(time_limit: 30) + end + + it "fires quiz_updated when ip_filter changes" do + expect(Canvas::LiveEvents).to receive(:quiz_updated).with(@quiz) + @quiz.update!(ip_filter: "192.168.0.1") + end + + it "fires quiz_updated when shuffle_answers changes" do + expect(Canvas::LiveEvents).to receive(:quiz_updated).with(@quiz) + @quiz.update!(shuffle_answers: true) + end + + it "fires quiz_updated when allowed_attempts changes" do + expect(Canvas::LiveEvents).to receive(:quiz_updated).with(@quiz) + @quiz.update!(allowed_attempts: 3) + end + + it "fires quiz_updated for practice quizzes too" do + practice = @course.quizzes.create!(title: "Practice", quiz_type: "practice_quiz") + practice.reload + expect(Canvas::LiveEvents).to receive(:quiz_updated).with(practice) + practice.update!(access_code: "secret") + end + + it "does not fire quiz_updated for a new quiz" do + expect(Canvas::LiveEvents).not_to receive(:quiz_updated) + @course.quizzes.create!(title: "New Quiz", quiz_type: "practice_quiz", access_code: "pw") + end + + it "fires quiz_updated when due_at changes" do + expect(Canvas::LiveEvents).to receive(:quiz_updated).with(@quiz) + @quiz.update!(due_at: 3.days.from_now) + end + + it "fires quiz_updated when lock_at changes" do + expect(Canvas::LiveEvents).to receive(:quiz_updated).with(@quiz) + @quiz.update!(lock_at: 5.days.from_now) + end + + it "fires quiz_updated when unlock_at changes" do + expect(Canvas::LiveEvents).to receive(:quiz_updated).with(@quiz) + @quiz.update!(unlock_at: 1.day.from_now) + end + + it "does not fire quiz_updated when no event fields change" do + expect(Canvas::LiveEvents).not_to receive(:quiz_updated) + @quiz.update!(title: "Updated Title") + end + + it "fires quiz_updated exactly once when multiple event fields change" do + expect(Canvas::LiveEvents).to receive(:quiz_updated).with(@quiz).once + @quiz.update!(access_code: "secret123", time_limit: 30, ip_filter: "10.0.0.1") + end + + it "does not fire quiz_updated during content migrations" do + expect(Canvas::LiveEvents).not_to receive(:quiz_updated) + @quiz.saved_by = :migration + @quiz.update!(access_code: "migrated_code") + end + end + it_behaves_like "Canvas::DraftStateValidations" it "infers the times if none given" do