From f5bc19c8774a0a0babfecdc2ebdbab1a661eb706 Mon Sep 17 00:00:00 2001 From: Matteo Scaramuccia Date: Sun, 28 Feb 2016 12:42:11 +0100 Subject: [PATCH] MDL-48680 mod_scorm: Added new events: {scoreraw,status}_submitted. Thanks to Ben Johnson and Josh Willcock for their contribution to this patch. --- .../classes/event/cmielement_submitted.php | 105 ++++++++ .../classes/event/scoreraw_submitted.php | 71 +++++ mod/scorm/classes/event/status_submitted.php | 76 ++++++ mod/scorm/lang/en/scorm.php | 2 + mod/scorm/locallib.php | 31 +++ mod/scorm/tests/events_test.php | 248 +++++++++++++++++- mod/scorm/version.php | 2 +- 7 files changed, 533 insertions(+), 2 deletions(-) create mode 100644 mod/scorm/classes/event/cmielement_submitted.php create mode 100644 mod/scorm/classes/event/scoreraw_submitted.php create mode 100644 mod/scorm/classes/event/status_submitted.php diff --git a/mod/scorm/classes/event/cmielement_submitted.php b/mod/scorm/classes/event/cmielement_submitted.php new file mode 100644 index 00000000000..31281f3bbb3 --- /dev/null +++ b/mod/scorm/classes/event/cmielement_submitted.php @@ -0,0 +1,105 @@ +. + +/** + * The mod_scorm generic CMI element submitted event. + * + * @package mod_scorm + * @copyright 2016 onwards Matteo Scaramuccia + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_scorm\event; +defined('MOODLE_INTERNAL') || die(); + +/** + * The mod_scorm generic CMI element submitted event class. + * + * @property-read array $other { + * Extra information about event properties. + * + * - int attemptid: Attempt id. + * - string cmielement: CMI element. + * - string cmivalue: CMI value. + * } + * + * @package mod_scorm + * @since Moodle 3.1 + * @copyright 2016 onwards Matteo Scaramuccia + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class cmielement_submitted extends \core\event\base { + + /** + * Init method. + */ + protected function init() { + $this->data['crud'] = 'u'; + $this->data['edulevel'] = self::LEVEL_PARTICIPATING; + $this->data['objecttable'] = 'scorm_scoes_track'; + } + + /** + * Returns non-localised description of what happened. + * + * @return string + */ + public function get_description() { + return "The user with the id '$this->userid' submitted the element '{$this->other['cmielement']}' " . + "with the value of '{$this->other['cmivalue']}' " . + "for the attempt with the id '{$this->other['attemptid']}' " . + "for a scorm activity with the course module id '$this->contextinstanceid'."; + } + + /** + * Get URL related to the action + * + * @return \moodle_url + */ + public function get_url() { + return new \moodle_url('/mod/scorm/report/userreport.php', + array('id' => $this->contextinstanceid, 'user' => $this->userid, 'attempt' => $this->other['attemptid'])); + } + + /** + * Custom validation. + * + * @throws \coding_exception + * @return void + */ + protected function validate_data() { + parent::validate_data(); + + if (empty($this->other['attemptid'])) { + throw new \coding_exception("The 'attemptid' must be set in other."); + } + + if (empty($this->other['cmielement'])) { + throw new \coding_exception("The 'cmielement' must be set in other."); + } + // Trust that 'cmielement' represents a valid CMI datamodel element: + // just check that the given value starts with 'cmi.'. + if (strpos($this->other['cmielement'], 'cmi.', 0) !== 0) { + throw new \coding_exception( + "A valid 'cmielement' must start with 'cmi.' ({$this->other['cmielement']})."); + } + + // Warning: 'cmivalue' could be also "0" e.g. when 'cmielement' represents a score. + if (!isset($this->other['cmivalue'])) { + throw new \coding_exception("The 'cmivalue' must be set in other."); + } + } +} diff --git a/mod/scorm/classes/event/scoreraw_submitted.php b/mod/scorm/classes/event/scoreraw_submitted.php new file mode 100644 index 00000000000..a9a6f84d037 --- /dev/null +++ b/mod/scorm/classes/event/scoreraw_submitted.php @@ -0,0 +1,71 @@ +. + +/** + * The mod_scorm raw score submitted event. + * + * @package mod_scorm + * @copyright 2016 onwards Matteo Scaramuccia + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_scorm\event; +defined('MOODLE_INTERNAL') || die(); + +/** + * The mod_scorm raw score submitted event class. + * + * @property-read array $other { + * Extra information about event properties. + * + * - int attemptid: Attempt id. + * - string cmielement: CMI element representing a raw score. + * - string cmivalue: CMI value. + * } + * + * @package mod_scorm + * @since Moodle 3.1 + * @copyright 2016 onwards Matteo Scaramuccia + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class scoreraw_submitted extends cmielement_submitted { + + /** + * Returns localised general event name. + * + * @return string + */ + public static function get_name() { + return get_string('eventscorerawsubmitted', 'mod_scorm'); + } + + /** + * Custom validation. + * + * @throws \coding_exception + * @return void + */ + protected function validate_data() { + parent::validate_data(); + + if (!strstr($this->other['cmielement'], '.score.raw')) { + throw new \coding_exception( + "The 'cmielement' must represents a valid CMI raw score ({$this->other['cmielement']})."); + } + + // Note: we trust that 'cmivalue' represents a valid SCORM CMI score value. + } +} diff --git a/mod/scorm/classes/event/status_submitted.php b/mod/scorm/classes/event/status_submitted.php new file mode 100644 index 00000000000..05bbac5d60e --- /dev/null +++ b/mod/scorm/classes/event/status_submitted.php @@ -0,0 +1,76 @@ +. + +/** + * The mod_scorm status submitted event. + * + * @package mod_scorm + * @copyright 2016 onwards Matteo Scaramuccia + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_scorm\event; +defined('MOODLE_INTERNAL') || die(); + +/** + * The mod_scorm status submitted event class. + * + * @property-read array $other { + * Extra information about event properties. + * + * - int attemptid: Attempt id. + * - string cmielement: CMI element representing a status. + * - string cmivalue: CMI value. + * } + * + * @package mod_scorm + * @since Moodle 3.1 + * @copyright 2016 onwards Matteo Scaramuccia + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class status_submitted extends cmielement_submitted { + + /** + * Returns localised general event name. + * + * @return string + */ + public static function get_name() { + return get_string('eventstatussubmitted', 'mod_scorm'); + } + + /** + * Custom validation. + * + * @throws \coding_exception + * @return void + */ + protected function validate_data() { + parent::validate_data(); + + if (!in_array($this->other['cmielement'], + array('cmi.completion_status', 'cmi.core.lesson_status', 'cmi.success_status'))) { + throw new \coding_exception( + "The 'cmielement' must represents a valid CMI status element ({$this->other['cmielement']})."); + } + + if (!in_array($this->other['cmivalue'], + array('passed', 'completed', 'failed', 'incomplete', 'browsed', 'not attempted', 'unknown'))) { + throw new \coding_exception( + "The 'cmivalue' must represents a valid CMI status value ({$this->other['cmivalue']})."); + } + } +} diff --git a/mod/scorm/lang/en/scorm.php b/mod/scorm/lang/en/scorm.php index 60ad31c67df..caea8dd0f83 100644 --- a/mod/scorm/lang/en/scorm.php +++ b/mod/scorm/lang/en/scorm.php @@ -122,6 +122,8 @@ $string['eventattemptdeleted'] = 'Attempt deleted'; $string['eventinteractionsviewed'] = 'Interactions viewed'; $string['eventreportviewed'] = 'Report viewed'; $string['eventscolaunched'] = 'Sco launched'; +$string['eventscorerawsubmitted'] = 'Submitted SCORM raw score'; +$string['eventstatussubmitted'] = 'Submitted SCORM status'; $string['eventtracksviewed'] = 'Tracks viewed'; $string['eventuserreportviewed'] = 'User report viewed'; $string['everyday'] = 'Every day'; diff --git a/mod/scorm/locallib.php b/mod/scorm/locallib.php index b948906eb39..2640ecc08b8 100644 --- a/mod/scorm/locallib.php +++ b/mod/scorm/locallib.php @@ -533,8 +533,11 @@ function scorm_insert_track($userid, $scormid, $scoid, $attempt, $element, $valu $track->value = $value; $track->timemodified = time(); $id = $DB->insert_record('scorm_scoes_track', $track); + $track->id = $id; } + // Trigger updating grades based on a given set of SCORM CMI elements. + $scorm = false; if (strstr($element, '.score.raw') || (in_array($element, array('cmi.completion_status', 'cmi.core.lesson_status', 'cmi.success_status')) && in_array($track->value, array('completed', 'passed')))) { @@ -543,6 +546,34 @@ function scorm_insert_track($userid, $scormid, $scoid, $attempt, $element, $valu scorm_update_grades($scorm, $userid); } + // Trigger CMI element events. + if (strstr($element, '.score.raw') || + (in_array($element, array('cmi.completion_status', 'cmi.core.lesson_status', 'cmi.success_status')) + && in_array($track->value, array('completed', 'failed', 'passed')))) { + if (!$scorm) { + $scorm = $DB->get_record('scorm', array('id' => $scormid)); + } + $cm = get_coursemodule_from_instance('scorm', $scormid); + $data = array( + 'other' => array('attemptid' => $attempt, 'cmielement' => $element, 'cmivalue' => $track->value), + 'objectid' => $scorm->id, + 'context' => context_module::instance($cm->id), + 'relateduserid' => $userid + ); + if (strstr($element, '.score.raw')) { + // Create score submitted event. + $event = \mod_scorm\event\scoreraw_submitted::create($data); + } else { + // Create status submitted event. + $event = \mod_scorm\event\status_submitted::create($data); + } + // Trigger submitted event. + $event->add_record_snapshot('scorm_scoes_track', $track); + $event->add_record_snapshot('course_modules', $cm); + $event->add_record_snapshot('scorm', $scorm); + $event->trigger(); + } + return $id; } diff --git a/mod/scorm/tests/events_test.php b/mod/scorm/tests/events_test.php index 48a80a9e167..a52cb0505fd 100644 --- a/mod/scorm/tests/events_test.php +++ b/mod/scorm/tests/events_test.php @@ -383,5 +383,251 @@ class mod_scorm_event_testcase extends advanced_testcase { $this->assertInstanceOf('coding_exception', $e); } } -} + /** + * dataProvider for test_scoreraw_submitted_event(). + */ + public function get_scoreraw_submitted_event_provider() { + return array( + // SCORM 1.2. + // - cmi.core.score.raw. + 'cmi.core.score.raw => 100' => array('cmi.core.score.raw', '100'), + 'cmi.core.score.raw => 90' => array('cmi.core.score.raw', '90'), + 'cmi.core.score.raw => 50' => array('cmi.core.score.raw', '50'), + 'cmi.core.score.raw => 10' => array('cmi.core.score.raw', '10'), + // Check an edge case (PHP empty() vs isset()): score value equals to '0'. + 'cmi.core.score.raw => 0' => array('cmi.core.score.raw', '0'), + // SCORM 1.3 AKA 2004. + // - cmi.score.raw. + 'cmi.score.raw => 100' => array('cmi.score.raw', '100'), + 'cmi.score.raw => 90' => array('cmi.score.raw', '90'), + 'cmi.score.raw => 50' => array('cmi.score.raw', '50'), + 'cmi.score.raw => 10' => array('cmi.score.raw', '10'), + // Check an edge case (PHP empty() vs isset()): score value equals to '0'. + 'cmi.score.raw => 0' => array('cmi.score.raw', '0'), + ); + } + + /** + * Tests for score submitted event. + * + * There is no api involved so the best we can do is test data by triggering event manually. + * + * @dataProvider get_scoreraw_submitted_event_provider + * + * @param string $cmielement a valid CMI raw score element + * @param string $cmivalue a valid CMI raw score value + */ + public function test_scoreraw_submitted_event($cmielement, $cmivalue) { + $this->resetAfterTest(); + $event = \mod_scorm\event\scoreraw_submitted::create(array( + 'other' => array('attemptid' => '2', 'cmielement' => $cmielement, 'cmivalue' => $cmivalue), + 'objectid' => $this->eventscorm->id, + 'context' => context_module::instance($this->eventcm->id), + 'relateduserid' => $this->eventuser->id + )); + + // Trigger and capture the event. + $sink = $this->redirectEvents(); + $event->trigger(); + $events = $sink->get_events(); + $sink->close(); + $event = reset($events); + $this->assertEquals(2, $event->other['attemptid']); + $this->assertEquals($cmielement, $event->other['cmielement']); + $this->assertEquals($cmivalue, $event->other['cmivalue']); + + // Check that no legacy log data is provided. + $this->assertEventLegacyLogData(null, $event); + $this->assertEventContextNotUsed($event); + } + + /** + * dataProvider for test_scoreraw_submitted_event_validations(). + */ + public function get_scoreraw_submitted_event_validations() { + return array( + 'scoreraw_submitted => missing cmielement' => array( + null, '50', + "Event validation should not allow \\mod_scorm\\event\\scoreraw_submitted " . + "to be triggered without other['cmielement']", + 'Coding error detected, it must be fixed by a programmer: ' . + "The 'cmielement' must be set in other." + ), + 'scoreraw_submitted => missing cmivalue' => array( + 'cmi.core.score.raw', null, + "Event validation should not allow \\mod_scorm\\event\\scoreraw_submitted " . + "to be triggered without other['cmivalue']", + 'Coding error detected, it must be fixed by a programmer: ' . + "The 'cmivalue' must be set in other." + ), + 'scoreraw_submitted => wrong CMI element' => array( + 'cmi.core.lesson_status', '50', + "Event validation should not allow \\mod_scorm\\event\\scoreraw_submitted " . + 'to be triggered with a CMI element not representing a raw score', + 'Coding error detected, it must be fixed by a programmer: ' . + "The 'cmielement' must represents a valid CMI raw score (cmi.core.lesson_status)." + ), + ); + } + + /** + * Tests for score submitted event validations. + * + * @dataProvider get_scoreraw_submitted_event_validations + * + * @param string $cmielement a valid CMI raw score element + * @param string $cmivalue a valid CMI raw score value + * @param string $failmessage the message used to fail the test in case of missing to violate a validation rule + * @param string $excmessage the exception message when violating the validations rules + */ + public function test_scoreraw_submitted_event_validations($cmielement, $cmivalue, $failmessage, $excmessage) { + $this->resetAfterTest(); + try { + $data = array( + 'context' => context_module::instance($this->eventcm->id), + 'courseid' => $this->eventcourse->id, + 'other' => array('attemptid' => 2) + ); + if ($cmielement != null) { + $data['other']['cmielement'] = $cmielement; + } + if ($cmivalue != null) { + $data['other']['cmivalue'] = $cmivalue; + } + \mod_scorm\event\scoreraw_submitted::create($data); + $this->fail($failmessage); + } catch (Exception $e) { + $this->assertInstanceOf('coding_exception', $e); + $this->assertEquals($excmessage, $e->getMessage()); + } + } + + /** + * dataProvider for test_status_submitted_event(). + */ + public function get_status_submitted_event_provider() { + return array( + // SCORM 1.2. + // 1. Status: cmi.core.lesson_status. + 'cmi.core.lesson_status => passed' => array('cmi.core.lesson_status', 'passed'), + 'cmi.core.lesson_status => completed' => array('cmi.core.lesson_status', 'completed'), + 'cmi.core.lesson_status => failed' => array('cmi.core.lesson_status', 'failed'), + 'cmi.core.lesson_status => incomplete' => array('cmi.core.lesson_status', 'incomplete'), + 'cmi.core.lesson_status => browsed' => array('cmi.core.lesson_status', 'browsed'), + 'cmi.core.lesson_status => not attempted' => array('cmi.core.lesson_status', 'not attempted'), + // SCORM 1.3 AKA 2004. + // 1. Completion status: cmi.completion_status. + 'cmi.completion_status => completed' => array('cmi.completion_status', 'completed'), + 'cmi.completion_status => incomplete' => array('cmi.completion_status', 'incomplete'), + 'cmi.completion_status => not attempted' => array('cmi.completion_status', 'not attempted'), + 'cmi.completion_status => unknown' => array('cmi.completion_status', 'unknown'), + // 2. Success status: cmi.success_status. + 'cmi.success_status => passed' => array('cmi.success_status', 'passed'), + 'cmi.success_status => failed' => array('cmi.success_status', 'failed'), + 'cmi.success_status => unknown' => array('cmi.success_status', 'unknown') + ); + } + + /** + * Tests for status submitted event. + * + * There is no api involved so the best we can do is test data by triggering event manually. + * + * @dataProvider get_status_submitted_event_provider + * + * @param string $cmielement a valid CMI status element + * @param string $cmivalue a valid CMI status value + */ + public function test_status_submitted_event($cmielement, $cmivalue) { + $this->resetAfterTest(); + $event = \mod_scorm\event\status_submitted::create(array( + 'other' => array('attemptid' => '2', 'cmielement' => $cmielement, 'cmivalue' => $cmivalue), + 'objectid' => $this->eventscorm->id, + 'context' => context_module::instance($this->eventcm->id), + 'relateduserid' => $this->eventuser->id + )); + + // Trigger and capture the event. + $sink = $this->redirectEvents(); + $event->trigger(); + $events = $sink->get_events(); + $sink->close(); + $event = reset($events); + $this->assertEquals(2, $event->other['attemptid']); + $this->assertEquals($cmielement, $event->other['cmielement']); + $this->assertEquals($cmivalue, $event->other['cmivalue']); + + // Check that no legacy log data is provided. + $this->assertEventLegacyLogData(null, $event); + $this->assertEventContextNotUsed($event); + } + + /** + * dataProvider for test_status_submitted_event_validations(). + */ + public function get_status_submitted_event_validations() { + return array( + 'status_submitted => missing cmielement' => array( + null, 'passed', + "Event validation should not allow \\mod_scorm\\event\\status_submitted " . + "to be triggered without other['cmielement']", + 'Coding error detected, it must be fixed by a programmer: ' . + "The 'cmielement' must be set in other." + ), + 'status_submitted => missing cmivalue' => array( + 'cmi.core.lesson_status', null, + "Event validation should not allow \\mod_scorm\\event\\status_submitted " . + "to be triggered without other['cmivalue']", + 'Coding error detected, it must be fixed by a programmer: ' . + "The 'cmivalue' must be set in other." + ), + 'status_submitted => wrong CMI element' => array( + 'cmi.core.score.raw', 'passed', + "Event validation should not allow \\mod_scorm\\event\\status_submitted " . + 'to be triggered with a CMI element not representing a valid CMI status element', + 'Coding error detected, it must be fixed by a programmer: ' . + "The 'cmielement' must represents a valid CMI status element (cmi.core.score.raw)." + ), + 'status_submitted => wrong CMI value' => array( + 'cmi.core.lesson_status', 'blahblahblah', + "Event validation should not allow \\mod_scorm\\event\\status_submitted " . + 'to be triggered with a CMI element not representing a valid CMI status', + 'Coding error detected, it must be fixed by a programmer: ' . + "The 'cmivalue' must represents a valid CMI status value (blahblahblah)." + ), + ); + } + + /** + * Tests for status submitted event validations. + * + * @dataProvider get_status_submitted_event_validations + * + * @param string $cmielement a valid CMI status element + * @param string $cmivalue a valid CMI status value + * @param string $failmessage the message used to fail the test in case of missing to violate a validation rule + * @param string $excmessage the exception message when violating the validations rules + */ + public function test_status_submitted_event_validations($cmielement, $cmivalue, $failmessage, $excmessage) { + $this->resetAfterTest(); + try { + $data = array( + 'context' => context_module::instance($this->eventcm->id), + 'courseid' => $this->eventcourse->id, + 'other' => array('attemptid' => 2) + ); + if ($cmielement != null) { + $data['other']['cmielement'] = $cmielement; + } + if ($cmivalue != null) { + $data['other']['cmivalue'] = $cmivalue; + } + \mod_scorm\event\status_submitted::create($data); + $this->fail($failmessage); + } catch (Exception $e) { + $this->assertInstanceOf('coding_exception', $e); + $this->assertEquals($excmessage, $e->getMessage()); + } + } +} diff --git a/mod/scorm/version.php b/mod/scorm/version.php index 67d6043311a..02924bbb346 100644 --- a/mod/scorm/version.php +++ b/mod/scorm/version.php @@ -24,7 +24,7 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2016021001; // The current module version (Date: YYYYMMDDXX). +$plugin->version = 2016031300; // The current module version (Date: YYYYMMDDXX). $plugin->requires = 2015111000; // Requires this Moodle version. $plugin->component = 'mod_scorm'; // Full name of the plugin (used for diagnostics). $plugin->cron = 300;