diff --git a/mod/scorm/classes/privacy/provider.php b/mod/scorm/classes/privacy/provider.php new file mode 100644 index 00000000000..855499ed998 --- /dev/null +++ b/mod/scorm/classes/privacy/provider.php @@ -0,0 +1,295 @@ +. + +/** + * Privacy class for requesting user data. + * + * @package mod_scorm + * @copyright 2018 Sara Arjona + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_scorm\privacy; + +defined('MOODLE_INTERNAL') || die(); + +use core_privacy\local\metadata\collection; +use core_privacy\local\request\contextlist; +use core_privacy\local\request\approved_contextlist; +use core_privacy\local\request\transform; +use core_privacy\local\request\writer; + +/** + * Privacy class for requesting user data. + * + * @copyright 2018 Sara Arjona + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class provider implements + \core_privacy\local\metadata\provider, + \core_privacy\local\request\plugin\provider { + + /** + * Return the fields which contain personal data. + * + * @param collection $collection The initialised collection to add items to. + * @return collection A listing of user data stored through this system. + */ + public static function get_metadata(collection $collection) : collection { + $collection->add_database_table('scorm_scoes_track', [ + 'userid' => 'privacy:metadata:userid', + 'attempt' => 'privacy:metadata:attempt', + 'element' => 'privacy:metadata:scoes_track:element', + 'value' => 'privacy:metadata:scoes_track:value', + 'timemodified' => 'privacy:metadata:timemodified' + ], 'privacy:metadata:scorm_scoes_track'); + + $collection->add_database_table('scorm_aicc_session', [ + 'userid' => 'privacy:metadata:userid', + 'scormmode' => 'privacy:metadata:aicc_session:scormmode', + 'scormstatus' => 'privacy:metadata:aicc_session:scormstatus', + 'attempt' => 'privacy:metadata:attempt', + 'lessonstatus' => 'privacy:metadata:aicc_session:lessonstatus', + 'sessiontime' => 'privacy:metadata:aicc_session:sessiontime', + 'timecreated' => 'privacy:metadata:aicc_session:timecreated', + 'timemodified' => 'privacy:metadata:timemodified', + ], 'privacy:metadata:scorm_aicc_session'); + + $collection->add_external_location_link('aicc', [ + 'data' => 'privacy:metadata:aicc:data' + ], 'privacy:metadata:aicc:externalpurpose'); + + return $collection; + } + + /** + * Get the list of contexts that contain user information for the specified user. + * + * @param int $userid The user to search. + * @return contextlist $contextlist The contextlist containing the list of contexts used in this plugin. + */ + public static function get_contexts_for_userid(int $userid) : contextlist { + $sql = "SELECT ctx.id + FROM {%s} ss + JOIN {modules} m + ON m.name = 'scorm' + JOIN {course_modules} cm + ON cm.instance = ss.scormid + AND cm.module = m.id + JOIN {context} ctx + ON ctx.instanceid = cm.id + AND ctx.contextlevel = :modlevel + WHERE ss.userid = :userid"; + + $params = ['modlevel' => CONTEXT_MODULE, 'userid' => $userid]; + $contextlist = new contextlist(); + $contextlist->add_from_sql(sprintf($sql, 'scorm_scoes_track'), $params); + $contextlist->add_from_sql(sprintf($sql, 'scorm_aicc_session'), $params); + + return $contextlist; + } + + /** + * Export all user data for the specified user, in the specified contexts. + * + * @param approved_contextlist $contextlist The approved contexts to export information for. + */ + public static function export_user_data(approved_contextlist $contextlist) { + global $DB; + + // Remove contexts different from COURSE_MODULE. + $contexts = array_reduce($contextlist->get_contexts(), function($carry, $context) { + if ($context->contextlevel == CONTEXT_MODULE) { + $carry[] = $context->id; + } + return $carry; + }, []); + + if (empty($contexts)) { + return; + } + + $userid = $contextlist->get_user()->id; + list($insql, $inparams) = $DB->get_in_or_equal($contexts, SQL_PARAMS_NAMED); + + // Get scoes_track data. + $sql = "SELECT ss.id, + ss.attempt, + ss.element, + ss.value, + ss.timemodified, + ctx.id as contextid + FROM {scorm_scoes_track} ss + JOIN {course_modules} cm + ON cm.instance = ss.scormid + JOIN {context} ctx + ON ctx.instanceid = cm.id + WHERE ctx.id $insql + AND ss.userid = :userid"; + $params = array_merge($inparams, ['userid' => $userid]); + + $alldata = []; + $scoestracks = $DB->get_recordset_sql($sql, $params); + foreach ($scoestracks as $track) { + $alldata[$track->contextid][$track->attempt][] = (object)[ + 'element' => $track->element, + 'value' => $track->value, + 'timemodified' => transform::datetime($track->timemodified), + ]; + } + $scoestracks->close(); + + // The scoes_track data is organised in: {Course name}/{SCORM activity name}/attempt-X.json. + // where X is the attempt number. + array_walk($alldata, function($attemptsdata, $contextid) { + $context = \context::instance_by_id($contextid); + array_walk($attemptsdata, function($data, $attempt) use ($context) { + writer::with_context($context)->export_related_data( + [], + 'attempt-'.$attempt, + (object)['scoestrack' => $data] + ); + }); + }); + + // Get aicc_session data. + $sql = "SELECT ss.id, + ss.scormmode, + ss.scormstatus, + ss.attempt, + ss.lessonstatus, + ss.sessiontime, + ss.timecreated, + ss.timemodified, + ctx.id as contextid + FROM {scorm_aicc_session} ss + JOIN {course_modules} cm + ON cm.instance = ss.scormid + JOIN {context} ctx + ON ctx.instanceid = cm.id + WHERE ctx.id $insql + AND ss.userid = :userid"; + $params = array_merge($inparams, ['userid' => $userid]); + + $alldata = []; + $aiccsessions = $DB->get_recordset_sql($sql, $params); + foreach ($aiccsessions as $aiccsession) { + $alldata[$aiccsession->contextid][] = (object)[ + 'scormmode' => $aiccsession->scormmode, + 'scormstatus' => $aiccsession->scormstatus, + 'lessonstatus' => $aiccsession->lessonstatus, + 'attempt' => $aiccsession->attempt, + 'sessiontime' => $aiccsession->sessiontime, + 'timecreated' => transform::datetime($aiccsession->timecreated), + 'timemodified' => transform::datetime($aiccsession->timemodified), + ]; + } + $aiccsessions->close(); + + // The aicc_session data is organised in: {Course name}/{SCORM activity name}/aiccsession.json. + // In this case, the attempt hasn't been included in the json file because it can be null. + array_walk($alldata, function($data, $contextid) { + $context = \context::instance_by_id($contextid); + writer::with_context($context)->export_related_data( + [], + 'aiccsession', + (object)['sessions' => $data] + ); + }); + } + + /** + * Delete all user data which matches the specified context. + * + * @param context $context A user context. + */ + public static function delete_data_for_all_users_in_context(\context $context) { + // This should not happen, but just in case. + if ($context->contextlevel != CONTEXT_MODULE) { + return; + } + + // Prepare SQL to gather all IDs to delete. + $sql = "SELECT ss.id + FROM {%s} ss + JOIN {modules} m + ON m.name = 'scorm' + JOIN {course_modules} cm + ON cm.instance = ss.scormid + AND cm.module = m.id + WHERE cm.id = :cmid"; + $params = ['cmid' => $context->instanceid]; + + static::delete_data('scorm_scoes_track', $sql, $params); + static::delete_data('scorm_aicc_session', $sql, $params); + } + + /** + * Delete all user data for the specified user, in the specified contexts. + * + * @param approved_contextlist $contextlist The approved contexts and user information to delete information for. + */ + public static function delete_data_for_user(approved_contextlist $contextlist) { + global $DB; + + // Remove contexts different from COURSE_MODULE. + $contextids = array_reduce($contextlist->get_contexts(), function($carry, $context) { + if ($context->contextlevel == CONTEXT_MODULE) { + $carry[] = $context->id; + } + return $carry; + }, []); + + if (empty($contextids)) { + return; + } + $userid = $contextlist->get_user()->id; + // Prepare SQL to gather all completed IDs. + list($insql, $inparams) = $DB->get_in_or_equal($contextids, SQL_PARAMS_NAMED); + $sql = "SELECT ss.id + FROM {%s} ss + JOIN {modules} m + ON m.name = 'scorm' + JOIN {course_modules} cm + ON cm.instance = ss.scormid + AND cm.module = m.id + JOIN {context} ctx + ON ctx.instanceid = cm.id + WHERE ss.userid = :userid + AND ctx.id $insql"; + $params = array_merge($inparams, ['userid' => $userid]); + + static::delete_data('scorm_scoes_track', $sql, $params); + static::delete_data('scorm_aicc_session', $sql, $params); + } + + /** + * Delete data from $tablename with the IDs returned by $sql query. + * + * @param string $tablename Table name where executing the SQL query. + * @param string $sql SQL query for getting the IDs of the scoestrack entries to delete. + * @param array $params SQL params for the query. + */ + protected static function delete_data(string $tablename, string $sql, array $params) { + global $DB; + + $scoestracksids = $DB->get_fieldset_sql(sprintf($sql, $tablename), $params); + if (!empty($scoestracksids)) { + list($insql, $inparams) = $DB->get_in_or_equal($scoestracksids, SQL_PARAMS_NAMED); + $DB->delete_records_select($tablename, "id $insql", $inparams); + } + } +} diff --git a/mod/scorm/lang/en/scorm.php b/mod/scorm/lang/en/scorm.php index fc7ada119df..c541a076ee0 100644 --- a/mod/scorm/lang/en/scorm.php +++ b/mod/scorm/lang/en/scorm.php @@ -332,6 +332,20 @@ $string['position_error'] = 'The {$a->tag} tag can\'t be child of {$a->parent} t $string['preferencesuser'] = 'Preferences for this report'; $string['preferencespage'] = 'Preferences just for this page'; $string['prev'] = 'Previous'; +$string['privacy:metadata:aicc:data'] = 'Personal data passed through from the AICC/SCORM subsystem.'; +$string['privacy:metadata:aicc:externalpurpose'] = 'This plugin sends data externally using the AICC HACP.'; +$string['privacy:metadata:aicc_session:lessonstatus'] = 'The lesson status to be tracked'; +$string['privacy:metadata:aicc_session:scormmode'] = 'The mode of the element to be tracked'; +$string['privacy:metadata:aicc_session:scormstatus'] = 'The status of the element to be tracked'; +$string['privacy:metadata:aicc_session:sessiontime'] = 'The session time to be tracked'; +$string['privacy:metadata:aicc_session:timecreated'] = 'The time when the tracked element was created'; +$string['privacy:metadata:attempt'] = 'The attempt number'; +$string['privacy:metadata:scoes_track:element'] = 'The name of the element to be tracked'; +$string['privacy:metadata:scoes_track:value'] = 'The value of the given element'; +$string['privacy:metadata:scorm_aicc_session'] = 'The session information of the AICC HACP'; +$string['privacy:metadata:scorm_scoes_track'] = 'The tracked data of the SCOes belonging to the activity'; +$string['privacy:metadata:timemodified'] = 'The time when the tracked element was last modified'; +$string['privacy:metadata:userid'] = 'The ID of the user who accessed the SCORM activity'; $string['protectpackagedownloads'] = 'Protect package downloads'; $string['protectpackagedownloads_desc'] = 'If enabled, SCORM packages can be downloaded only if the user has the course:manageactivities capability. If disabled, SCORM packages can always be downloaded (by mobile or other means).'; $string['raw'] = 'Raw score'; diff --git a/mod/scorm/tests/privacy_test.php b/mod/scorm/tests/privacy_test.php new file mode 100644 index 00000000000..096988d3afa --- /dev/null +++ b/mod/scorm/tests/privacy_test.php @@ -0,0 +1,243 @@ +. + +/** + * Base class for unit tests for mod_scorm. + * + * @package mod_scorm + * @category test + * @copyright 2018 Sara Arjona + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +use mod_scorm\privacy\provider; +use core_privacy\local\request\approved_contextlist; +use core_privacy\local\request\writer; +use core_privacy\tests\provider_testcase; + +/** + * Unit tests for mod\scorm\classes\privacy\provider.php + * + * @copyright 2018 Sara Arjona + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class mod_scorm_testcase extends provider_testcase { + + /** @var stdClass User without any AICC/SCORM attempt. */ + protected $student0; + + /** @var stdClass User with some AICC/SCORM attempt. */ + protected $student1; + + /** @var stdClass User with some AICC/SCORM attempt. */ + protected $student2; + + /** @var context context_module of the SCORM activity. */ + protected $context; + + /** + * Test getting the context for the user ID related to this plugin. + */ + public function test_get_contexts_for_userid() { + $this->resetAfterTest(true); + $this->setAdminUser(); + $this->scorm_setup_test_scenario_data(); + + // The student0 hasn't any attempt. + $contextlist = provider::get_contexts_for_userid($this->student0->id); + $this->assertCount(0, (array) $contextlist->get_contextids()); + + // The student1 has data in the SCORM context. + $contextlist = provider::get_contexts_for_userid($this->student1->id); + $this->assertCount(1, (array) $contextlist->get_contextids()); + $this->assertContains($this->context->id, $contextlist->get_contextids()); + } + + /** + * Test that data is exported correctly for this plugin. + */ + public function test_export_user_data() { + $this->resetAfterTest(true); + $this->setAdminUser(); + $this->scorm_setup_test_scenario_data(); + + // Validate exported data for student0 (without any AICC/SCORM attempt). + $this->setUser($this->student0); + $writer = writer::with_context($this->context); + $this->export_context_data_for_user($this->student0->id, $this->context, 'mod_scorm'); + $data = $writer->get_related_data([], 'attempt-1'); + $this->assertEmpty($data); + $this->export_context_data_for_user($this->student0->id, $this->context, 'mod_scorm'); + $data = $writer->get_related_data([], 'aiccsession'); + $this->assertEmpty($data); + + // Validate exported data for student1. + writer::reset(); + $this->setUser($this->student1); + $writer = writer::with_context($this->context); + $this->assertFalse($writer->has_any_data()); + $this->export_context_data_for_user($this->student1->id, $this->context, 'mod_scorm'); + $data = $writer->get_related_data([], 'attempt-1'); + $this->assertCount(1, (array) $data); + $this->assertCount(2, (array) reset($data)); + $data = $writer->get_related_data([], 'attempt-2'); + $this->assertCount(2, (array) reset($data)); + // The student1 has only 2 scoes_track attempts. + $data = $writer->get_related_data([], 'attempt-3'); + $this->assertEmpty($data); + // The student1 has only 1 aicc_session. + $this->export_context_data_for_user($this->student1->id, $this->context, 'mod_scorm'); + $data = $writer->get_related_data([], 'aiccsession'); + $this->assertCount(1, (array) $data); + } + + /** + * Test for provider::delete_data_for_all_users_in_context(). + */ + public function test_delete_data_for_all_users_in_context() { + global $DB; + + $this->resetAfterTest(true); + $this->setAdminUser(); + $this->scorm_setup_test_scenario_data(); + + // Before deletion, we should have 8 entries in the scorm_scoes_track table. + $count = $DB->count_records('scorm_scoes_track'); + $this->assertEquals(8, $count); + // Before deletion, we should have 4 entries in the scorm_aicc_session table. + $count = $DB->count_records('scorm_aicc_session'); + $this->assertEquals(4, $count); + + // Delete data based on the context. + provider::delete_data_for_all_users_in_context($this->context); + + // After deletion, the scorm_scoes_track entries should have been deleted. + $count = $DB->count_records('scorm_scoes_track'); + $this->assertEquals(0, $count); + // After deletion, the scorm_aicc_session entries should have been deleted. + $count = $DB->count_records('scorm_aicc_session'); + $this->assertEquals(0, $count); + } + + /** + * Test for provider::delete_data_for_user(). + */ + public function test_delete_data_for_user() { + global $DB; + + $this->resetAfterTest(true); + $this->setAdminUser(); + $this->scorm_setup_test_scenario_data(); + + // Before deletion, we should have 8 entries in the scorm_scoes_track table. + $count = $DB->count_records('scorm_scoes_track'); + $this->assertEquals(8, $count); + // Before deletion, we should have 4 entries in the scorm_aicc_session table. + $count = $DB->count_records('scorm_aicc_session'); + $this->assertEquals(4, $count); + + $approvedcontextlist = new approved_contextlist($this->student1, 'scorm', [$this->context->id]); + provider::delete_data_for_user($approvedcontextlist); + + // After deletion, the scorm_scoes_track entries for the first student should have been deleted. + $count = $DB->count_records('scorm_scoes_track', ['userid' => $this->student1->id]); + $this->assertEquals(0, $count); + $count = $DB->count_records('scorm_scoes_track'); + $this->assertEquals(4, $count); + // After deletion, the scorm_aicc_session entries for the first student should have been deleted. + $count = $DB->count_records('scorm_aicc_session', ['userid' => $this->student1->id]); + $this->assertEquals(0, $count); + $count = $DB->count_records('scorm_aicc_session'); + $this->assertEquals(2, $count); + + // Confirm that the SCORM hasn't been removed. + $scormcount = $DB->get_records('scorm'); + $this->assertCount(1, (array) $scormcount); + + // Delete scoes_track for student0 (nothing has to be removed). + $approvedcontextlist = new approved_contextlist($this->student0, 'scorm', [$this->context->id]); + provider::delete_data_for_user($approvedcontextlist); + $count = $DB->count_records('scorm_scoes_track'); + $this->assertEquals(4, $count); + $count = $DB->count_records('scorm_aicc_session'); + $this->assertEquals(2, $count); + } + + /** + * Helper function to setup 3 users and 2 SCORM attempts for student1 and student2. + * $this->student0 is always created withot any attempt. + */ + protected function scorm_setup_test_scenario_data() { + global $DB; + + set_config('allowaicchacp', 1, 'scorm'); + + // Setup test data. + $course = $this->getDataGenerator()->create_course(); + $scorm = $this->getDataGenerator()->create_module('scorm', array('course' => $course->id)); + $this->context = \context_module::instance($scorm->cmid); + + // Users enrolments. + $studentrole = $DB->get_record('role', array('shortname' => 'student')); + + // Create student0 withot any SCORM attempt. + $this->student0 = self::getDataGenerator()->create_user(); + $this->getDataGenerator()->enrol_user($this->student0->id, $course->id, $studentrole->id, 'manual'); + + // Create student1 with 2 SCORM attempts and 1 AICC session. + $this->student1 = self::getDataGenerator()->create_user(); + $this->getDataGenerator()->enrol_user($this->student1->id, $course->id, $studentrole->id, 'manual'); + static::scorm_insert_attempt($scorm, $this->student1->id, 1); + static::scorm_insert_attempt($scorm, $this->student1->id, 2); + + // Create student2 with 2 SCORM attempts and 1 AICC session. + $this->student2 = self::getDataGenerator()->create_user(); + $this->getDataGenerator()->enrol_user($this->student2->id, $course->id, $studentrole->id, 'manual'); + static::scorm_insert_attempt($scorm, $this->student2->id, 1); + static::scorm_insert_attempt($scorm, $this->student2->id, 2); + } + + /** + * Create a SCORM attempt. + * + * @param object $scorm SCORM activity. + * @param int $userid Userid who is doing the attempt. + * @param int $attempt Number of attempt. + */ + protected function scorm_insert_attempt($scorm, $userid, $attempt) { + global $DB; + + $newattempt = 'on'; + $mode = 'normal'; + scorm_check_mode($scorm, $newattempt, $attempt, $userid, $mode); + $scoes = scorm_get_scoes($scorm->id); + $sco = array_pop($scoes); + scorm_insert_track($userid, $scorm->id, $sco->id, $attempt, 'cmi.core.lesson_status', 'completed'); + scorm_insert_track($userid, $scorm->id, $sco->id, $attempt, 'cmi.score.min', '0'); + $now = time(); + $hacpsession = [ + 'scormid' => $scorm->id, + 'attempt' => $attempt, + 'hacpsession' => random_string(20), + 'userid' => $userid, + 'timecreated' => $now, + 'timemodified' => $now + ]; + $DB->insert_record('scorm_aicc_session', $hacpsession); + } +}