From 84b5f665ee8244651ee780ca4ab3d7e7c289dcab Mon Sep 17 00:00:00 2001 From: Andrew Nicols Date: Wed, 21 Feb 2018 17:37:53 +0800 Subject: [PATCH] MDL-61407 mod_quiz: Add initial privacy implementation --- lang/en/question.php | 1 + mod/quiz/classes/privacy/helper.php | 65 +++ .../privacy/legacy_quizaccess_polyfill.php | 66 +++ mod/quiz/classes/privacy/provider.php | 442 ++++++++++++++++++ .../classes/privacy/quizaccess_provider.php | 63 +++ mod/quiz/lang/en/quiz.php | 25 + mod/quiz/lib.php | 66 ++- mod/quiz/locallib.php | 36 +- ...rivacy_legacy_quizaccess_polyfill_test.php | 164 +++++++ mod/quiz/tests/privacy_provider_test.php | 279 +++++++++++ 10 files changed, 1184 insertions(+), 23 deletions(-) create mode 100644 mod/quiz/classes/privacy/helper.php create mode 100644 mod/quiz/classes/privacy/legacy_quizaccess_polyfill.php create mode 100644 mod/quiz/classes/privacy/provider.php create mode 100644 mod/quiz/classes/privacy/quizaccess_provider.php create mode 100644 mod/quiz/tests/privacy_legacy_quizaccess_polyfill_test.php create mode 100644 mod/quiz/tests/privacy_provider_test.php diff --git a/lang/en/question.php b/lang/en/question.php index 4444be6ceaa..cbf0afe6ec1 100644 --- a/lang/en/question.php +++ b/lang/en/question.php @@ -437,6 +437,7 @@ $string['specificfeedback_help'] = 'Feedback that depends on what response the s $string['started'] = 'Started'; $string['state'] = 'State'; $string['step'] = 'Step'; +$string['steps'] = 'Steps'; $string['submissionoutofsequence'] = 'Access out of sequence. Please do not click the back button when working on quiz questions.'; $string['submissionoutofsequencefriendlymessage'] = "You have entered data outside the normal sequence. This can occur if you use your browser's Back or Forward buttons; please don't use these during the test. It can also happen if you click on something while a page is loading. Click Continue to resume."; $string['submit'] = 'Submit'; diff --git a/mod/quiz/classes/privacy/helper.php b/mod/quiz/classes/privacy/helper.php new file mode 100644 index 00000000000..b58e32c2f88 --- /dev/null +++ b/mod/quiz/classes/privacy/helper.php @@ -0,0 +1,65 @@ +. + +/** + * Privacy Subsystem helper for mod_quiz. + * + * @package mod_quiz + * @copyright 2018 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_quiz\privacy; + +use \core_privacy\local\request\writer; +use \core_privacy\local\request\transform; +use \core_privacy\local\request\contextlist; +use \core_privacy\local\request\approved_contextlist; +use \core_privacy\local\request\deletion_criteria; +use \core_privacy\local\metadata\collection; +use \core_privacy\manager; + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->dirroot . '/mod/quiz/lib.php'); +require_once($CFG->dirroot . '/mod/quiz/locallib.php'); + +/** + * Privacy Subsystem implementation for mod_quiz. + * + * @copyright 2018 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class helper { + /** + * Determine the subcontext for the specified quiz attempt. + * + * @param \stdClass $attempt The attempt data retrieved from the database. + * @param \stdClass $user The user record. + * @return \array The calculated subcontext. + */ + public static function get_quiz_attempt_subcontext(\stdClass $attempt, \stdClass $user) { + $subcontext = [ + get_string('attempts', 'mod_quiz'), + ]; + if ($attempt->userid != $user->id) { + $subcontext[] = fullname($user); + } + $subcontext[] = $attempt->attempt; + + return $subcontext; + } +} diff --git a/mod/quiz/classes/privacy/legacy_quizaccess_polyfill.php b/mod/quiz/classes/privacy/legacy_quizaccess_polyfill.php new file mode 100644 index 00000000000..adb373ac832 --- /dev/null +++ b/mod/quiz/classes/privacy/legacy_quizaccess_polyfill.php @@ -0,0 +1,66 @@ +. + +/** + * This file contains the polyfil to allow a plugin to operate with Moodle 3.3 up. + * + * @package mod_quiz + * @copyright 2018 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace mod_quiz\privacy; + +defined('MOODLE_INTERNAL') || die(); + +/** + * The trait used to provide a backwards compatibility for third-party plugins. + * + * @package mod_quiz + * @copyright 2018 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +trait legacy_quizaccess_polyfill { + + /** + * Export all user data for the specified user, for the specified quiz. + * + * @param \quiz $quiz The quiz being exported + * @param \stdClass $user The user to export data for + * @return \stdClass The data to be exported for this access rule. + */ + public static function export_quizaccess_user_data(\quiz $quiz, \stdClass $user) { + return static::_export_quizaccess_user_data($quiz, $user); + } + + /** + * Delete all data for all users in the specified quiz. + * + * @param \quiz $quiz The quiz being deleted + */ + public static function delete_quizaccess_data_for_all_users_in_context(\quiz $quiz) { + static::_delete_quizaccess_data_for_all_users_in_context($quiz); + } + + /** + * Delete all user data for the specified user, in the specified quiz. + * + * @param \quiz $quiz The quiz being deleted + * @param \stdClass $user The user to export data for + */ + public static function delete_quizaccess_data_for_user(\quiz $quiz, \stdClass $user) { + static::_delete_quizaccess_data_for_user($quiz, $user); + } +} diff --git a/mod/quiz/classes/privacy/provider.php b/mod/quiz/classes/privacy/provider.php new file mode 100644 index 00000000000..a678a1088a3 --- /dev/null +++ b/mod/quiz/classes/privacy/provider.php @@ -0,0 +1,442 @@ +. + +/** + * Privacy Subsystem implementation for mod_quiz. + * + * @package mod_quiz + * @copyright 2018 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_quiz\privacy; + +use \core_privacy\local\request\writer; +use \core_privacy\local\request\transform; +use \core_privacy\local\request\contextlist; +use \core_privacy\local\request\approved_contextlist; +use \core_privacy\local\request\deletion_criteria; +use \core_privacy\local\metadata\collection; +use \core_privacy\manager; + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->dirroot . '/mod/quiz/lib.php'); +require_once($CFG->dirroot . '/mod/quiz/locallib.php'); + +/** + * Privacy Subsystem implementation for mod_quiz. + * + * @copyright 2018 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class provider implements + // This plugin has data. + \core_privacy\local\metadata\provider, + + // This plugin currently implements the original plugin_provider interface. + \core_privacy\local\request\plugin\provider { + + /** + * Get the list of contexts that contain user information for the specified user. + * + * @param collection $items The collection to add metadata to. + * @return collection The array of metadata + */ + public static function get_metadata(collection $items) { + // The table 'quiz' stores a record for each quiz. + // It does not contain user personal data, but data is returned from it for contextual requirements. + + // The table 'quiz_attempts' stores a record of each quiz attempt. + // It contains a userid which links to the user making the attempt and contains information about that attempt. + $items->add_database_table('quiz_attempts', [ + 'attempt' => 'privacy:metadata:quiz_attempts:attempt', + 'currentpage' => 'privacy:metadata:quiz_attempts:currentpage', + 'preview' => 'privacy:metadata:quiz_attempts:preview', + 'state' => 'privacy:metadata:quiz_attempts:state', + 'timestart' => 'privacy:metadata:quiz_attempts:timestart', + 'timefinish' => 'privacy:metadata:quiz_attempts:timefinish', + 'timemodified' => 'privacy:metadata:quiz_attempts:timemodified', + 'timemodifiedoffline' => 'privacy:metadata:quiz_attempts:timemodifiedoffline', + 'timecheckstate' => 'privacy:metadata:quiz_attempts:timecheckstate', + 'sumgrades' => 'privacy:metadata:quiz_attempts:sumgrades', + ], 'privacy:metadata:quiz_attempts'); + + // The table 'quiz_feedback' contains the feedback responses which will be shown to users depending upon the + // grade they achieve in the quiz. + // It does not identify the user who wrote the feedback item so cannot be returned directly and is not + // described, but relevant feedback items will be included with the quiz export for a user who has a grade. + + // The table 'quiz_grades' contains the current grade for each quiz/user combination. + $items->add_database_table('quiz_grades', [ + 'quiz' => 'privacy:metadata:quiz_grades:quiz', + 'userid' => 'privacy:metadata:quiz_grades:userid', + 'grade' => 'privacy:metadata:quiz_grades:grade', + 'timemodified' => 'privacy:metadata:quiz_grades:timemodified', + ], 'privacy:metadata:quiz_grades'); + + // The table 'quiz_overrides' contains any user or group overrides for users. + // It should be included where data exists for a user. + $items->add_database_table('quiz_overrides', [ + 'quiz' => 'privacy:metadata:quiz_overrides:quiz', + 'userid' => 'privacy:metadata:quiz_overrides:userid', + 'timeopen' => 'privacy:metadata:quiz_overrides:timeopen', + 'timeclose' => 'privacy:metadata:quiz_overrides:timeclose', + 'timelimit' => 'privacy:metadata:quiz_overrides:timelimit', + ], 'privacy:metadata:quiz_overrides'); + + // These define the structure of the quiz. + + // The table 'quiz_sections' contains data about the structure of a quiz. + // It does not contain any user identifying data and does not need a mapping. + + // The table 'quiz_slots' contains data about the structure of a quiz. + // It does not contain any user identifying data and does not need a mapping. + + // The table 'quiz_reports' does not contain any user identifying data and does not need a mapping. + + // The table 'quiz_statistics' contains abstract statistics about question usage and cannot be mapped to any + // specific user. + // It does not contain any user identifying data and does not need a mapping. + + // The quiz links to the 'core_question' subsystem for all question functionality. + $items->add_subsystem_link('core_question', [], 'privacy:metadata:core_question'); + + // The quiz has two subplugins.. + $items->add_plugintype_link('quiz', [], 'privacy:metadata:quiz'); + $items->add_plugintype_link('quizaccess', [], 'privacy:metadata:quizaccess'); + + // Although the quiz supports the core_completion API and defines custom completion items, these will be + // noted by the manager as all activity modules are capable of supporting this functionality. + + return $items; + } + + /** + * Get the list of contexts where the specified user has attempted a quiz, or been involved with manual marking + * and/or grading of a quiz. + * + * @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($userid) { + // Get the SQL used to link indirect question usages for the user. + // This includes where a user is the manual marker on a question attempt. + $qubaid = \core_question\privacy\provider::get_related_question_usages_for_user('rel', 'mod_quiz', 'qa.uniqueid', $userid); + + // Select the context of any quiz attempt where a user has an attempt, plus the related usages. + $sql = "SELECT c.id + FROM {context} c + JOIN {course_modules} cm ON cm.id = c.instanceid AND c.contextlevel = :contextlevel + JOIN {modules} m ON m.id = cm.module AND m.name = :modname + JOIN {quiz} q ON q.id = cm.instance + JOIN {quiz_attempts} qa ON qa.quiz = q.id + LEFT JOIN {quiz_overrides} qo ON qo.quiz = q.id AND qo.userid = :qouserid + " . $qubaid->from . " + WHERE ( + qa.userid = :qauserid OR + " . $qubaid->where() . " + ) AND qa.preview = 0 + "; + + $params = array_merge( + [ + 'contextlevel' => CONTEXT_MODULE, + 'modname' => 'quiz', + 'qauserid' => $userid, + 'qouserid' => $userid, + ], + $qubaid->from_where_params() + ); + + $resultset = new contextlist(); + $resultset->add_from_sql($sql, $params); + + return $resultset; + } + + /** + * Delete all data for all users in the specified context. + * + * @param approved_contextlist $contextlist The approved contexts to export information for. + */ + public static function export_user_data(approved_contextlist $contextlist) { + global $DB; + + if (!count($contextlist)) { + return; + } + + $user = $contextlist->get_user(); + $userid = $user->id; + list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED); + + $sql = "SELECT + q.*, + qg.id AS hasgrade, + qg.grade AS bestgrade, + qg.timemodified AS grademodified, + qo.id AS hasoverride, + qo.timeopen AS override_timeopen, + qo.timeclose AS override_timeclose, + qo.timelimit AS override_timelimit, + c.id AS contextid, + cm.id AS cmid + FROM {context} c + INNER JOIN {course_modules} cm ON cm.id = c.instanceid AND c.contextlevel = :contextlevel + INNER JOIN {modules} m ON m.id = cm.module AND m.name = 'quiz' + INNER JOIN {quiz} q ON q.id = cm.instance + LEFT JOIN {quiz_overrides} qo ON qo.quiz = q.id AND qo.userid = :qouserid + LEFT JOIN {quiz_grades} qg ON qg.quiz = q.id AND qg.userid = :qguserid + WHERE c.id {$contextsql}"; + + $params = [ + 'contextlevel' => CONTEXT_MODULE, + 'qguserid' => $userid, + 'qouserid' => $userid, + ]; + $params += $contextparams; + + // Fetch the individual quizzes. + $quizzes = $DB->get_recordset_sql($sql, $params); + foreach ($quizzes as $quiz) { + list($course, $cm) = get_course_and_cm_from_cmid($quiz->cmid, 'quiz'); + $quizobj = new \quiz($quiz, $cm, $course); + $context = $quizobj->get_context(); + + $quizdata = \core_privacy\local\request\helper::get_context_data($context, $contextlist->get_user()); + \core_privacy\local\request\helper::export_context_files($context, $contextlist->get_user()); + + if (!empty($quizdata->timeopen)) { + $quizdata->timeopen = transform::datetime($quiz->timeopen); + } + if (!empty($quizdata->timeclose)) { + $quizdata->timeclose = transform::datetime($quiz->timeclose); + } + if (!empty($quizdata->timelimit)) { + $quizdata->timelimit = $quiz->timelimit; + } + + if (!empty($quiz->hasoverride)) { + $quizdata->override = (object) []; + + if (!empty($quizdata->override_override_timeopen)) { + $quizdata->override->timeopen = transform::datetime($quiz->override_timeopen); + } + if (!empty($quizdata->override_timeclose)) { + $quizdata->override->timeclose = transform::datetime($quiz->override_timeclose); + } + if (!empty($quizdata->override_timelimit)) { + $quizdata->override->timelimit = $quiz->override_timelimit; + } + } + + $quizdata->accessdata = (object) []; + + $components = \core_component::get_plugin_list('quizaccess'); + $exportparams = [ + $quizobj, + $user, + ]; + foreach (array_keys($components) as $component) { + $classname = manager::get_provider_classname_for_component("quizaccess_$component"); + if (class_exists($classname) && is_subclass_of($classname, quizaccess_provider::class)) { + $result = component_class_callback($classname, 'export_quizaccess_user_data', $exportparams); + if (count((array) $result)) { + $quizdata->accessdata->$component = $result; + } + } + } + + if (empty((array) $quizdata->accessdata)) { + unset($quizdata->accessdata); + } + + writer::with_context($context) + ->export_data([], $quizdata); + } + $quizzes->close(); + + // Store all quiz attempt data. + static::export_quiz_attempts($contextlist); + } + + /** + * Delete all data for all users in the specified context. + * + * @param context $context The specific context to delete data for. + */ + public static function delete_data_for_all_users_in_context(\context $context) { + $cm = get_coursemodule_from_id('quiz', $context->instanceid); + if (!$cm) { + // Only quiz module will be handled. + return; + } + $quiz = \quiz::create($cm->instance); + + // Handle the 'quizaccess' subplugin. + manager::plugintype_class_callback( + 'quizaccess', + quizaccess_provider::class, + 'delete_subplugin_data_for_all_users_in_context', + [$quiz] + ); + + // Delete all overrides - do not log. + quiz_delete_all_overrides($quiz, false); + + // This will delete all question attempts, quiz attempts, and quiz grades for this quiz. + quiz_delete_all_attempts($quiz); + } + + /** + * 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; + + foreach ($contextlist as $context) { + $cm = get_coursemodule_from_id('quiz', $context->instanceid); + $quiz = \quiz::create($cm->instance); + $user = $contextlist->get_user(); + + // Handle the 'quizaccess' quizaccess. + manager::plugintype_class_callback( + 'quizaccess', + quizaccess_provider::class, + 'delete_quizaccess_data_for_user', + [$quiz, $user] + ); + + $overrides = $DB->get_records('quiz_overrides' , [ + 'quiz' => $quiz->get_quizid(), + 'userid' => $user->id, + ]); + + foreach ($overrides as $override) { + quiz_delete_override($quiz, $override->id, false); + } + + // This will delete all question attempts, quiz attempts, and quiz grades for this quiz. + quiz_delete_user_attempts($quiz, $user); + } + } + + /** + * Store all quiz attempts for the contextlist. + * + * @param approved_contextlist $contextlist + */ + protected static function export_quiz_attempts(approved_contextlist $contextlist) { + global $DB; + + $userid = $contextlist->get_user()->id; + list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED); + $qubaid = \core_question\privacy\provider::get_related_question_usages_for_user('rel', 'mod_quiz', 'qa.uniqueid', $userid); + + $sql = "SELECT + c.id AS contextid, + cm.id AS cmid, + qa.* + FROM {context} c + JOIN {course_modules} cm ON cm.id = c.instanceid AND c.contextlevel = :contextlevel + JOIN {modules} m ON m.id = cm.module AND m.name = :modname + JOIN {quiz} q ON q.id = cm.instance + JOIN {quiz_attempts} qa ON qa.quiz = q.id + " . $qubaid->from. " + WHERE ( + qa.userid = :qauserid OR + " . $qubaid->where() . " + ) AND qa.preview = 0 + "; + + $params = array_merge( + [ + 'contextlevel' => CONTEXT_MODULE, + 'modname' => 'quiz', + 'qauserid' => $userid, + ], + $qubaid->from_where_params() + ); + + $attempts = $DB->get_recordset_sql($sql, $params); + foreach ($attempts as $attempt) { + $quiz = $DB->get_record('quiz', ['id' => $attempt->quiz]); + $context = \context_module::instance($attempt->cmid); + $attemptsubcontext = helper::get_quiz_attempt_subcontext($attempt, $contextlist->get_user()); + $options = quiz_get_review_options($quiz, $attempt, $context); + + if ($attempt->userid == $userid) { + // This attempt was made by the user. + // They 'own' all data on it. + // Store the question usage data. + \core_question\privacy\provider::export_question_usage($userid, + $context, + $attemptsubcontext, + $attempt->uniqueid, + $options, + true + ); + + // Store the quiz attempt data. + $data = (object) [ + 'state' => \quiz_attempt::state_name($attempt->state), + ]; + + if (!empty($attempt->timestart)) { + $data->timestart = transform::datetime($attempt->timestart); + } + if (!empty($attempt->timefinish)) { + $data->timefinish = transform::datetime($attempt->timefinish); + } + if (!empty($attempt->timemodified)) { + $data->timemodified = transform::datetime($attempt->timemodified); + } + if (!empty($attempt->timemodifiedoffline)) { + $data->timemodifiedoffline = transform::datetime($attempt->timemodifiedoffline); + } + if (!empty($attempt->timecheckstate)) { + $data->timecheckstate = transform::datetime($attempt->timecheckstate); + } + + if ($options->marks == \question_display_options::MARK_AND_MAX) { + $grade = quiz_rescale_grade($attempt->sumgrades, $quiz, false); + $data->grade = (object) [ + 'grade' => quiz_format_grade($quiz, $grade), + 'feedback' => quiz_feedback_for_grade($grade, $quiz, $context), + ]; + } + + writer::with_context($context) + ->export_data($attemptsubcontext, $data); + } else { + // This attempt was made by another user. + // The current user may have marked part of the quiz attempt. + \core_question\privacy\provider::export_question_usage( + $userid, + $context, + $attemptsubcontext, + $attempt->uniqueid, + $options, + false + ); + } + } + $attempts->close(); + } +} diff --git a/mod/quiz/classes/privacy/quizaccess_provider.php b/mod/quiz/classes/privacy/quizaccess_provider.php new file mode 100644 index 00000000000..2cc6cbe5ed8 --- /dev/null +++ b/mod/quiz/classes/privacy/quizaccess_provider.php @@ -0,0 +1,63 @@ +. + +/** + * The quizaccess_provider interface provides the expected interface for all 'quizaccess' quizaccesss. + * + * @package mod_quiz + * @copyright 2018 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace mod_quiz\privacy; + +defined('MOODLE_INTERNAL') || die(); + +use \core_privacy\local\request\contextlist; +use \core_privacy\local\request\approved_contextlist; + +/** + * The quizaccess_provider interface provides the expected interface for all 'quizaccess' quizaccesss. + * + * @package mod_quiz + * @copyright 2018 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +interface quizaccess_provider extends \core_privacy\local\request\plugin\subplugin_provider { + + /** + * Export all user data for the specified user, for the specified quiz. + * + * @param \quiz $quiz The quiz being exported + * @param \stdClass $user The user to export data for + * @return \stdClass The data to be exported for this access rule. + */ + public static function export_quizaccess_user_data(\quiz $quiz, \stdClass $user); + + /** + * Delete all data for all users in the specified quiz. + * + * @param \quiz $quiz The quiz being deleted + */ + public static function delete_quizaccess_data_for_all_users_in_context(\quiz $quiz); + + /** + * Delete all user data for the specified user, in the specified quiz. + * + * @param \quiz $quiz The quiz being deleted + * @param \stdClass $user The user to export data for + */ + public static function delete_quizaccess_data_for_user(\quiz $quiz, \stdClass $user); +} diff --git a/mod/quiz/lang/en/quiz.php b/mod/quiz/lang/en/quiz.php index 65a488c051d..26d59977643 100644 --- a/mod/quiz/lang/en/quiz.php +++ b/mod/quiz/lang/en/quiz.php @@ -626,6 +626,31 @@ $string['previewquestion'] = 'Preview question'; $string['previewquiz'] = 'Preview {$a}'; $string['previewquiznow'] = 'Preview quiz now'; $string['previous'] = 'Previous state'; +$string['privacy:metadata:core_question'] = 'The quiz activity stores question usage information in the core_question subsystem.'; +$string['privacy:metadata:quiz'] = 'The quiz activity makes use of quiz reports.'; +$string['privacy:metadata:quiz_attempts'] = 'Details about each attempt on a quiz.'; +$string['privacy:metadata:quiz_attempts:attempt'] = 'The attempt number.'; +$string['privacy:metadata:quiz_attempts:currentpage'] = 'The current page that the user is on.'; +$string['privacy:metadata:quiz_attempts:preview'] = 'Whether this is a preview of the quiz.'; +$string['privacy:metadata:quiz_attempts:state'] = 'The current state of the attempt.'; +$string['privacy:metadata:quiz_attempts:sumgrades'] = 'The sum of grades in the attempt.'; +$string['privacy:metadata:quiz_attempts:timecheckstate'] = 'The time that the state was checked.'; +$string['privacy:metadata:quiz_attempts:timefinish'] = 'The time that the attempt was completed.'; +$string['privacy:metadata:quiz_attempts:timemodified'] = 'The time that the attempt was updated.'; +$string['privacy:metadata:quiz_attempts:timemodifiedoffline'] = 'The time that the attempt was updated via an offline update.'; +$string['privacy:metadata:quiz_attempts:timestart'] = 'The time that the attempt was started.'; +$string['privacy:metadata:quiz_grades'] = 'Details about the overall grade for this quiz.'; +$string['privacy:metadata:quiz_grades:grade'] = 'The overall grade for this quiz.'; +$string['privacy:metadata:quiz_grades:quiz'] = 'The quiz that was graded.'; +$string['privacy:metadata:quiz_grades:timemodified'] = 'The time that the grade was modified.'; +$string['privacy:metadata:quiz_grades:userid'] = 'The user who was graded.'; +$string['privacy:metadata:quiz_overrides'] = 'Details about overrides for this quiz.'; +$string['privacy:metadata:quiz_overrides:quiz'] = 'The quiz with override information.'; +$string['privacy:metadata:quiz_overrides:timeclose'] = 'The new close time for the quiz.'; +$string['privacy:metadata:quiz_overrides:timelimit'] = 'The new time limit for the quiz.'; +$string['privacy:metadata:quiz_overrides:timeopen'] = 'The new open time for the quiz.'; +$string['privacy:metadata:quiz_overrides:userid'] = 'The user being overridden.'; +$string['privacy:metadata:quizaccess'] = 'The quiz activity makes use of quiz access rules.'; $string['publish'] = 'Publish'; $string['publishedit'] = 'You must have permission in the publishing course to add or edit questions in this category'; $string['qname'] = 'name'; diff --git a/mod/quiz/lib.php b/mod/quiz/lib.php index 6f383ff10d3..67600ab95b6 100644 --- a/mod/quiz/lib.php +++ b/mod/quiz/lib.php @@ -212,9 +212,10 @@ function quiz_delete_instance($id) { * * @param object $quiz The quiz object. * @param int $overrideid The id of the override being deleted + * @param bool $log Whether to trigger logs. * @return bool true on success */ -function quiz_delete_override($quiz, $overrideid) { +function quiz_delete_override($quiz, $overrideid, $log = true) { global $DB; if (!isset($quiz->cmid)) { @@ -242,26 +243,28 @@ function quiz_delete_override($quiz, $overrideid) { $DB->delete_records('quiz_overrides', array('id' => $overrideid)); - // Set the common parameters for one of the events we will be triggering. - $params = array( - 'objectid' => $override->id, - 'context' => context_module::instance($quiz->cmid), - 'other' => array( - 'quizid' => $override->quiz - ) - ); - // Determine which override deleted event to fire. - if (!empty($override->userid)) { - $params['relateduserid'] = $override->userid; - $event = \mod_quiz\event\user_override_deleted::create($params); - } else { - $params['other']['groupid'] = $override->groupid; - $event = \mod_quiz\event\group_override_deleted::create($params); - } + if ($log) { + // Set the common parameters for one of the events we will be triggering. + $params = array( + 'objectid' => $override->id, + 'context' => context_module::instance($quiz->cmid), + 'other' => array( + 'quizid' => $override->quiz + ) + ); + // Determine which override deleted event to fire. + if (!empty($override->userid)) { + $params['relateduserid'] = $override->userid; + $event = \mod_quiz\event\user_override_deleted::create($params); + } else { + $params['other']['groupid'] = $override->groupid; + $event = \mod_quiz\event\group_override_deleted::create($params); + } - // Trigger the override deleted event. - $event->add_record_snapshot('quiz_overrides', $override); - $event->trigger(); + // Trigger the override deleted event. + $event->add_record_snapshot('quiz_overrides', $override); + $event->trigger(); + } return true; } @@ -270,13 +273,14 @@ function quiz_delete_override($quiz, $overrideid) { * Deletes all quiz overrides from the database and clears any corresponding calendar events * * @param object $quiz The quiz object. + * @param bool $log Whether to trigger logs. */ -function quiz_delete_all_overrides($quiz) { +function quiz_delete_all_overrides($quiz, $log = true) { global $DB; $overrides = $DB->get_records('quiz_overrides', array('quiz' => $quiz->id), 'id'); foreach ($overrides as $override) { - quiz_delete_override($quiz, $override->id); + quiz_delete_override($quiz, $override->id, $log); } } @@ -403,6 +407,24 @@ function quiz_delete_all_attempts($quiz) { $DB->delete_records('quiz_grades', array('quiz' => $quiz->id)); } +/** + * Delete all the attempts belonging to a user in a particular quiz. + * + * @param object $quiz The quiz object. + * @param object $user The user object. + */ +function quiz_delete_user_attempts($quiz, $user) { + global $CFG, $DB; + require_once($CFG->dirroot . '/mod/quiz/locallib.php'); + question_engine::delete_questions_usage_by_activities(new qubaids_for_quiz_user($quiz->get_quizid(), $user->id)); + $params = [ + 'quiz' => $quiz->get_quizid(), + 'userid' => $user->id, + ]; + $DB->delete_records('quiz_attempts', $params); + $DB->delete_records('quiz_grades', $params); +} + /** * Get the best current grade for a particular user in a quiz. * diff --git a/mod/quiz/locallib.php b/mod/quiz/locallib.php index 4072b84c120..bdf80037aff 100644 --- a/mod/quiz/locallib.php +++ b/mod/quiz/locallib.php @@ -1970,7 +1970,6 @@ class mod_quiz_display_options extends question_display_options { } } - /** * A {@link qubaid_condition} for finding all the question usages belonging to * a particular quiz. @@ -1996,6 +1995,41 @@ class qubaids_for_quiz extends qubaid_join { } } +/** + * A {@link qubaid_condition} for finding all the question usages belonging to a particular user and quiz combination. + * + * @copyright 2018 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qubaids_for_quiz_user extends qubaid_join { + /** + * Constructor for this qubaid. + * + * @param int $quizid The quiz to search. + * @param int $userid The user to filter on + * @param bool $includepreviews Whether to include preview attempts + * @param bool $onlyfinished Whether to only include finished attempts or not + */ + public function __construct($quizid, $userid, $includepreviews = true, $onlyfinished = false) { + $where = 'quiza.quiz = :quizaquiz AND quiza.userid = :quizauserid'; + $params = [ + 'quizaquiz' => $quizid, + 'quizauserid' => $userid, + ]; + + if (!$includepreviews) { + $where .= ' AND preview = 0'; + } + + if ($onlyfinished) { + $where .= ' AND state = :statefinished'; + $params['statefinished'] = quiz_attempt::FINISHED; + } + + parent::__construct('{quiz_attempts} quiza', 'quiza.uniqueid', $where, $params); + } +} + /** * Creates a textual representation of a question for display. * diff --git a/mod/quiz/tests/privacy_legacy_quizaccess_polyfill_test.php b/mod/quiz/tests/privacy_legacy_quizaccess_polyfill_test.php new file mode 100644 index 00000000000..ac272cf10a5 --- /dev/null +++ b/mod/quiz/tests/privacy_legacy_quizaccess_polyfill_test.php @@ -0,0 +1,164 @@ +. + +/** + * Unit tests for the privacy legacy polyfill for quiz access rules. + * + * @package mod_quiz + * @category test + * @copyright 2018 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/mod/quiz/attemptlib.php'); + +/** + * Unit tests for the privacy legacy polyfill for quiz access rules. + * + * @copyright 2018 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class core_privacy_legacy_quizaccess_polyfill_test extends advanced_testcase { + /** + * Test that the core_quizaccess\privacy\legacy_polyfill works and that the static _export_quizaccess_user_data can + * be called. + */ + public function test_export_quizaccess_user_data() { + $quiz = $this->createMock(quiz::class); + $user = (object) []; + $returnvalue = (object) []; + + $mock = $this->createMock(test_privacy_legacy_quizaccess_polyfill_mock_wrapper::class); + $mock->expects($this->once()) + ->method('get_return_value') + ->with('_export_quizaccess_user_data', [$quiz, $user]) + ->willReturn($returnvalue); + + test_privacy_legacy_quizaccess_polyfill_provider::$mock = $mock; + $result = test_privacy_legacy_quizaccess_polyfill_provider::export_quizaccess_user_data($quiz, $user); + $this->assertSame($returnvalue, $result); + } + + /** + * Test the _delete_quizaccess_for_context shim. + */ + public function test_delete_quizaccess_for_context() { + $context = context_system::instance(); + + $quiz = $this->createMock(quiz::class); + + $mock = $this->createMock(test_privacy_legacy_quizaccess_polyfill_mock_wrapper::class); + $mock->expects($this->once()) + ->method('get_return_value') + ->with('_delete_quizaccess_data_for_all_users_in_context', [$quiz]); + + test_privacy_legacy_quizaccess_polyfill_provider::$mock = $mock; + test_privacy_legacy_quizaccess_polyfill_provider::delete_quizaccess_data_for_all_users_in_context($quiz); + } + + /** + * Test the _delete_quizaccess_for_context shim. + */ + public function test_delete_quizaccess_for_user() { + $context = context_system::instance(); + + $quiz = $this->createMock(quiz::class); + $user = (object) []; + + $mock = $this->createMock(test_privacy_legacy_quizaccess_polyfill_mock_wrapper::class); + $mock->expects($this->once()) + ->method('get_return_value') + ->with('_delete_quizaccess_data_for_user', [$quiz, $user]); + + test_privacy_legacy_quizaccess_polyfill_provider::$mock = $mock; + test_privacy_legacy_quizaccess_polyfill_provider::delete_quizaccess_data_for_user($quiz, $user); + } +} + +/** + * Legacy polyfill test class for the quizaccess_provider. + * + * @copyright 2018 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class test_privacy_legacy_quizaccess_polyfill_provider implements + \core_privacy\local\metadata\provider, + \mod_quiz\privacy\quizaccess_provider { + + use \mod_quiz\privacy\legacy_quizaccess_polyfill; + use \core_privacy\local\legacy_polyfill; + + /** + * @var test_privacy_legacy_quizaccess_polyfill_provider $mock. + */ + public static $mock = null; + + /** + * Export all user data for the quizaccess plugin. + * + * @param \quiz $quiz + * @param \stdClass $user + */ + protected static function _export_quizaccess_user_data($quiz, $user) { + return static::$mock->get_return_value(__FUNCTION__, func_get_args()); + } + + /** + * Deletes all user data for the given context. + * + * @param \quiz $quiz + */ + protected static function _delete_quizaccess_data_for_all_users_in_context($quiz) { + static::$mock->get_return_value(__FUNCTION__, func_get_args()); + } + + /** + * Delete personal data for the given user and context. + * + * @param \quiz $quiz The quiz being deleted + * @param \stdClass $user The user to export data for + */ + protected static function _delete_quizaccess_data_for_user($quiz, $user) { + static::$mock->get_return_value(__FUNCTION__, func_get_args()); + } + + /** + * Returns metadata about this plugin. + * + * @param \core_privacy\local\metadata\collection $collection The initialised collection to add items to. + * @return \core_privacy\local\metadata\collection A listing of user data stored through this system. + */ + protected static function _get_metadata(\core_privacy\local\metadata\collection $collection) { + return $collection; + } +} + +/** + * Called inside the polyfill methods in the test polyfill provider, allowing us to ensure these are called with correct params. + * + * @copyright 2018 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class test_privacy_legacy_quizaccess_polyfill_mock_wrapper { + /** + * Get the return value for the specified item. + */ + public function get_return_value() { + } +} diff --git a/mod/quiz/tests/privacy_provider_test.php b/mod/quiz/tests/privacy_provider_test.php new file mode 100644 index 00000000000..13a43eb2bab --- /dev/null +++ b/mod/quiz/tests/privacy_provider_test.php @@ -0,0 +1,279 @@ +. + +/** + * Privacy provider tests. + * + * @package mod_quiz + * @copyright 2018 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +use core_privacy\local\metadata\collection; +use core_privacy\local\request\deletion_criteria; +use core_privacy\local\request\writer; +use mod_quiz\privacy\provider; +use mod_quiz\privacy\helper; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/question/tests/privacy_helper.php'); + +/** + * Privacy provider tests class. + * + * @package mod_quiz + * @copyright 2018 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class mod_quiz_privacy_provider_testcase extends \core_privacy\tests\provider_testcase { + + use core_question_privacy_helper; + + /** + * Test that a user who has no data gets no contexts + */ + public function test_get_contexts_for_userid_no_data() { + global $USER; + $this->resetAfterTest(); + $this->setAdminUser(); + + $contextlist = provider::get_contexts_for_userid($USER->id); + $this->assertEmpty($contextlist); + } + + /** + * The export function should handle an empty contextlist properly. + */ + public function test_export_user_data_no_data() { + global $USER; + $this->resetAfterTest(); + $this->setAdminUser(); + + $approvedcontextlist = new \core_privacy\tests\request\approved_contextlist( + \core_user::get_user($USER->id), + 'mod_quiz', + [] + ); + + provider::export_user_data($approvedcontextlist); + $this->assertDebuggingNotCalled(); + + // No data should have been exported. + $writer = \core_privacy\local\request\writer::with_context(\context_system::instance()); + $this->assertFalse($writer->has_any_data_in_any_context()); + } + + /** + * The delete function should handle an empty contextlist properly. + */ + public function test_delete_data_for_user_no_data() { + global $USER; + $this->resetAfterTest(); + $this->setAdminUser(); + + $approvedcontextlist = new \core_privacy\tests\request\approved_contextlist( + \core_user::get_user($USER->id), + 'mod_quiz', + [] + ); + + provider::delete_data_for_user($approvedcontextlist); + $this->assertDebuggingNotCalled(); + } + + /** + * Export + Delete quiz data for a user who has made a single attempt. + */ + public function test_user_with_data() { + global $DB; + $this->resetAfterTest(true); + + // Make a quiz. + $course = $this->getDataGenerator()->create_course(); + $user = $this->getDataGenerator()->create_user(); + $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); + + $quiz = $quizgenerator->create_instance([ + 'course' => $course->id, + 'questionsperpage' => 0, + 'grade' => 100.0, + 'sumgrades' => 2, + ]); + + // Create a couple of questions. + $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); + $cat = $questiongenerator->create_question_category(); + + $saq = $questiongenerator->create_question('shortanswer', null, array('category' => $cat->id)); + quiz_add_quiz_question($saq->id, $quiz); + $numq = $questiongenerator->create_question('numerical', null, array('category' => $cat->id)); + quiz_add_quiz_question($numq->id, $quiz); + + // Run as the user and make an attempt on the quiz. + $this->setUser($user); + $starttime = time(); + $quizobj = quiz::create($quiz->id, $user->id); + $context = $quizobj->get_context(); + + $quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); + $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); + + // Start the attempt. + $attempt = quiz_create_attempt($quizobj, 1, false, $starttime, false, $user->id); + quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $starttime); + quiz_attempt_save_started($quizobj, $quba, $attempt); + + // Answer the questions. + $attemptobj = quiz_attempt::create($attempt->id); + + $tosubmit = [ + 1 => ['answer' => 'frog'], + 2 => ['answer' => '3.14'], + ]; + + $attemptobj->process_submitted_actions($starttime, false, $tosubmit); + + // Finish the attempt. + $attemptobj = quiz_attempt::create($attempt->id); + $this->assertTrue($attemptobj->has_response_to_at_least_one_graded_question()); + $attemptobj->process_finish($starttime, false); + + // Fetch the contexts -only one context should be returned. + $this->setUser(); + $contextlist = provider::get_contexts_for_userid($user->id); + $this->assertCount(1, $contextlist); + $this->assertEquals($context, $contextlist->current()); + + // Perform the export and check the data. + $this->setUser($user); + $approvedcontextlist = new \core_privacy\tests\request\approved_contextlist( + \core_user::get_user($user->id), + 'mod_quiz', + $contextlist->get_contextids() + ); + provider::export_user_data($approvedcontextlist); + + // Ensure that the quiz data was exported correctly. + $writer = writer::with_context($context); + $this->assertTrue($writer->has_any_data()); + + $quizdata = $writer->get_data([]); + $this->assertEquals($quizobj->get_quiz_name(), $quizdata->name); + + // Every module has an intro. + $this->assertTrue(isset($quizdata->intro)); + + // Fetch the attempt data. + $attemptsubcontext = [ + get_string('attempts', 'mod_quiz'), + $attempt->attempt, + ]; + $attemptdata = writer::with_context($context)->get_data($attemptsubcontext); + + $attempt = $attemptobj->get_attempt(); + $this->assertTrue(isset($attemptdata->state)); + $this->assertEquals(\quiz_attempt::state_name($attemptobj->get_state()), $attemptdata->state); + $this->assertTrue(isset($attemptdata->timestart)); + $this->assertTrue(isset($attemptdata->timefinish)); + $this->assertTrue(isset($attemptdata->timemodified)); + $this->assertFalse(isset($attemptdata->timemodifiedoffline)); + $this->assertFalse(isset($attemptdata->timecheckstate)); + + $this->assertTrue(isset($attemptdata->grade)); + $this->assertEquals(100.00, $attemptdata->grade->grade); + + // Check that the exported question attempts are correct. + $attemptsubcontext = helper::get_quiz_attempt_subcontext($attemptobj->get_attempt(), $user); + $this->assert_question_attempt_exported( + $context, + $attemptsubcontext, + \question_engine::load_questions_usage_by_activity($attemptobj->get_uniqueid()), + quiz_get_review_options($quiz, $attemptobj->get_attempt(), $context), + $user + ); + + // Delete the data and check it is removed. + $this->setUser(); + provider::delete_data_for_user($approvedcontextlist); + $this->expectException(\dml_missing_record_exception::class); + \quiz_attempt::create($attemptobj->get_quizid()); + } + + /** + * Export + Delete quiz data for a user who has made a single attempt. + */ + public function test_user_with_preview() { + global $DB; + $this->resetAfterTest(true); + + // Make a quiz. + $course = $this->getDataGenerator()->create_course(); + $user = $this->getDataGenerator()->create_user(); + $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); + + $quiz = $quizgenerator->create_instance([ + 'course' => $course->id, + 'questionsperpage' => 0, + 'grade' => 100.0, + 'sumgrades' => 2, + ]); + + // Create a couple of questions. + $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); + $cat = $questiongenerator->create_question_category(); + + $saq = $questiongenerator->create_question('shortanswer', null, array('category' => $cat->id)); + quiz_add_quiz_question($saq->id, $quiz); + $numq = $questiongenerator->create_question('numerical', null, array('category' => $cat->id)); + quiz_add_quiz_question($numq->id, $quiz); + + // Run as the user and make an attempt on the quiz. + $this->setUser($user); + $starttime = time(); + $quizobj = quiz::create($quiz->id, $user->id); + $context = $quizobj->get_context(); + + $quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); + $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); + + // Start the attempt. + $attempt = quiz_create_attempt($quizobj, 1, false, $starttime, true, $user->id); + quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $starttime); + quiz_attempt_save_started($quizobj, $quba, $attempt); + + // Answer the questions. + $attemptobj = quiz_attempt::create($attempt->id); + + $tosubmit = [ + 1 => ['answer' => 'frog'], + 2 => ['answer' => '3.14'], + ]; + + $attemptobj->process_submitted_actions($starttime, false, $tosubmit); + + // Finish the attempt. + $attemptobj = quiz_attempt::create($attempt->id); + $this->assertTrue($attemptobj->has_response_to_at_least_one_graded_question()); + $attemptobj->process_finish($starttime, false); + + // Fetch the contexts - no context should be returned. + $this->setUser(); + $contextlist = provider::get_contexts_for_userid($user->id); + $this->assertCount(0, $contextlist); + } +}