MDL-61407 mod_quiz: Add initial privacy implementation
[moodle.git] / mod / quiz / classes / privacy / provider.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17 /**
18  * Privacy Subsystem implementation for mod_quiz.
19  *
20  * @package    mod_quiz
21  * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
22  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
25 namespace mod_quiz\privacy;
27 use \core_privacy\local\request\writer;
28 use \core_privacy\local\request\transform;
29 use \core_privacy\local\request\contextlist;
30 use \core_privacy\local\request\approved_contextlist;
31 use \core_privacy\local\request\deletion_criteria;
32 use \core_privacy\local\metadata\collection;
33 use \core_privacy\manager;
35 defined('MOODLE_INTERNAL') || die();
37 require_once($CFG->dirroot . '/mod/quiz/lib.php');
38 require_once($CFG->dirroot . '/mod/quiz/locallib.php');
40 /**
41  * Privacy Subsystem implementation for mod_quiz.
42  *
43  * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
44  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
45  */
46 class provider implements
47     // This plugin has data.
48     \core_privacy\local\metadata\provider,
50     // This plugin currently implements the original plugin_provider interface.
51     \core_privacy\local\request\plugin\provider {
53     /**
54      * Get the list of contexts that contain user information for the specified user.
55      *
56      * @param   collection  $items  The collection to add metadata to.
57      * @return  collection  The array of metadata
58      */
59     public static function get_metadata(collection $items) : collection {
60         // The table 'quiz' stores a record for each quiz.
61         // It does not contain user personal data, but data is returned from it for contextual requirements.
63         // The table 'quiz_attempts' stores a record of each quiz attempt.
64         // It contains a userid which links to the user making the attempt and contains information about that attempt.
65         $items->add_database_table('quiz_attempts', [
66                 'attempt'               => 'privacy:metadata:quiz_attempts:attempt',
67                 'currentpage'           => 'privacy:metadata:quiz_attempts:currentpage',
68                 'preview'               => 'privacy:metadata:quiz_attempts:preview',
69                 'state'                 => 'privacy:metadata:quiz_attempts:state',
70                 'timestart'             => 'privacy:metadata:quiz_attempts:timestart',
71                 'timefinish'            => 'privacy:metadata:quiz_attempts:timefinish',
72                 'timemodified'          => 'privacy:metadata:quiz_attempts:timemodified',
73                 'timemodifiedoffline'   => 'privacy:metadata:quiz_attempts:timemodifiedoffline',
74                 'timecheckstate'        => 'privacy:metadata:quiz_attempts:timecheckstate',
75                 'sumgrades'             => 'privacy:metadata:quiz_attempts:sumgrades',
76             ], 'privacy:metadata:quiz_attempts');
78         // The table 'quiz_feedback' contains the feedback responses which will be shown to users depending upon the
79         // grade they achieve in the quiz.
80         // It does not identify the user who wrote the feedback item so cannot be returned directly and is not
81         // described, but relevant feedback items will be included with the quiz export for a user who has a grade.
83         // The table 'quiz_grades' contains the current grade for each quiz/user combination.
84         $items->add_database_table('quiz_grades', [
85                 'quiz'                  => 'privacy:metadata:quiz_grades:quiz',
86                 'userid'                => 'privacy:metadata:quiz_grades:userid',
87                 'grade'                 => 'privacy:metadata:quiz_grades:grade',
88                 'timemodified'          => 'privacy:metadata:quiz_grades:timemodified',
89             ], 'privacy:metadata:quiz_grades');
91         // The table 'quiz_overrides' contains any user or group overrides for users.
92         // It should be included where data exists for a user.
93         $items->add_database_table('quiz_overrides', [
94                 'quiz'                  => 'privacy:metadata:quiz_overrides:quiz',
95                 'userid'                => 'privacy:metadata:quiz_overrides:userid',
96                 'timeopen'              => 'privacy:metadata:quiz_overrides:timeopen',
97                 'timeclose'             => 'privacy:metadata:quiz_overrides:timeclose',
98                 'timelimit'             => 'privacy:metadata:quiz_overrides:timelimit',
99             ], 'privacy:metadata:quiz_overrides');
101         // These define the structure of the quiz.
103         // The table 'quiz_sections' contains data about the structure of a quiz.
104         // It does not contain any user identifying data and does not need a mapping.
106         // The table 'quiz_slots' contains data about the structure of a quiz.
107         // It does not contain any user identifying data and does not need a mapping.
109         // The table 'quiz_reports' does not contain any user identifying data and does not need a mapping.
111         // The table 'quiz_statistics' contains abstract statistics about question usage and cannot be mapped to any
112         // specific user.
113         // It does not contain any user identifying data and does not need a mapping.
115         // The quiz links to the 'core_question' subsystem for all question functionality.
116         $items->add_subsystem_link('core_question', [], 'privacy:metadata:core_question');
118         // The quiz has two subplugins..
119         $items->add_plugintype_link('quiz', [], 'privacy:metadata:quiz');
120         $items->add_plugintype_link('quizaccess', [], 'privacy:metadata:quizaccess');
122         // Although the quiz supports the core_completion API and defines custom completion items, these will be
123         // noted by the manager as all activity modules are capable of supporting this functionality.
125         return $items;
126     }
128     /**
129      * Get the list of contexts where the specified user has attempted a quiz, or been involved with manual marking
130      * and/or grading of a quiz.
131      *
132      * @param   int             $userid The user to search.
133      * @return  contextlist     $contextlist The contextlist containing the list of contexts used in this plugin.
134      */
135     public static function get_contexts_for_userid(int $userid) : contextlist {
136         // Get the SQL used to link indirect question usages for the user.
137         // This includes where a user is the manual marker on a question attempt.
138         $qubaid = \core_question\privacy\provider::get_related_question_usages_for_user('rel', 'mod_quiz', 'qa.uniqueid', $userid);
140         // Select the context of any quiz attempt where a user has an attempt, plus the related usages.
141         $sql = "SELECT c.id
142                   FROM {context} c
143                   JOIN {course_modules} cm ON cm.id = c.instanceid AND c.contextlevel = :contextlevel
144                   JOIN {modules} m ON m.id = cm.module AND m.name = :modname
145                   JOIN {quiz} q ON q.id = cm.instance
146                   JOIN {quiz_attempts} qa ON qa.quiz = q.id
147              LEFT JOIN {quiz_overrides} qo ON qo.quiz = q.id AND qo.userid = :qouserid
148             " . $qubaid->from . "
149             WHERE (
150                 qa.userid = :qauserid OR
151                 " . $qubaid->where() . "
152             ) AND qa.preview = 0
153         ";
155         $params = array_merge(
156                 [
157                     'contextlevel'      => CONTEXT_MODULE,
158                     'modname'           => 'quiz',
159                     'qauserid'          => $userid,
160                     'qouserid'          => $userid,
161                 ],
162                 $qubaid->from_where_params()
163             );
165         $resultset = new contextlist();
166         $resultset->add_from_sql($sql, $params);
168         return $resultset;
169     }
171     /**
172      * Delete all data for all users in the specified context.
173      *
174      * @param   approved_contextlist    $contextlist    The approved contexts to export information for.
175      */
176     public static function export_user_data(approved_contextlist $contextlist) {
177         global $DB;
179         if (!count($contextlist)) {
180             return;
181         }
183         $user = $contextlist->get_user();
184         $userid = $user->id;
185         list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED);
187         $sql = "SELECT
188                     q.*,
189                     qg.id AS hasgrade,
190                     qg.grade AS bestgrade,
191                     qg.timemodified AS grademodified,
192                     qo.id AS hasoverride,
193                     qo.timeopen AS override_timeopen,
194                     qo.timeclose AS override_timeclose,
195                     qo.timelimit AS override_timelimit,
196                     c.id AS contextid,
197                     cm.id AS cmid
198                   FROM {context} c
199             INNER JOIN {course_modules} cm ON cm.id = c.instanceid AND c.contextlevel = :contextlevel
200             INNER JOIN {modules} m ON m.id = cm.module AND m.name = :modname
201             INNER JOIN {quiz} q ON q.id = cm.instance
202              LEFT JOIN {quiz_overrides} qo ON qo.quiz = q.id AND qo.userid = :qouserid
203              LEFT JOIN {quiz_grades} qg ON qg.quiz = q.id AND qg.userid = :qguserid
204                  WHERE c.id {$contextsql}";
206         $params = [
207             'contextlevel'      => CONTEXT_MODULE,
208             'modname'           => 'quiz',
209             'qguserid'          => $userid,
210             'qouserid'          => $userid,
211         ];
212         $params += $contextparams;
214         // Fetch the individual quizzes.
215         $quizzes = $DB->get_recordset_sql($sql, $params);
216         foreach ($quizzes as $quiz) {
217             list($course, $cm) = get_course_and_cm_from_cmid($quiz->cmid, 'quiz');
218             $quizobj = new \quiz($quiz, $cm, $course);
219             $context = $quizobj->get_context();
221             $quizdata = \core_privacy\local\request\helper::get_context_data($context, $contextlist->get_user());
222             \core_privacy\local\request\helper::export_context_files($context, $contextlist->get_user());
224             if (!empty($quizdata->timeopen)) {
225                 $quizdata->timeopen = transform::datetime($quiz->timeopen);
226             }
227             if (!empty($quizdata->timeclose)) {
228                 $quizdata->timeclose = transform::datetime($quiz->timeclose);
229             }
230             if (!empty($quizdata->timelimit)) {
231                 $quizdata->timelimit = $quiz->timelimit;
232             }
234             if (!empty($quiz->hasoverride)) {
235                 $quizdata->override = (object) [];
237                 if (!empty($quizdata->override_override_timeopen)) {
238                     $quizdata->override->timeopen = transform::datetime($quiz->override_timeopen);
239                 }
240                 if (!empty($quizdata->override_timeclose)) {
241                     $quizdata->override->timeclose = transform::datetime($quiz->override_timeclose);
242                 }
243                 if (!empty($quizdata->override_timelimit)) {
244                     $quizdata->override->timelimit = $quiz->override_timelimit;
245                 }
246             }
248             $quizdata->accessdata = (object) [];
250             $components = \core_component::get_plugin_list('quizaccess');
251             $exportparams = [
252                     $quizobj,
253                     $user,
254                 ];
255             foreach (array_keys($components) as $component) {
256                 $classname = manager::get_provider_classname_for_component("quizaccess_$component");
257                 if (class_exists($classname) && is_subclass_of($classname, quizaccess_provider::class)) {
258                     $result = component_class_callback($classname, 'export_quizaccess_user_data', $exportparams);
259                     if (count((array) $result)) {
260                         $quizdata->accessdata->$component = $result;
261                     }
262                 }
263             }
265             if (empty((array) $quizdata->accessdata)) {
266                 unset($quizdata->accessdata);
267             }
269             writer::with_context($context)
270                 ->export_data([], $quizdata);
271         }
272         $quizzes->close();
274         // Store all quiz attempt data.
275         static::export_quiz_attempts($contextlist);
276     }
278     /**
279      * Delete all data for all users in the specified context.
280      *
281      * @param   context                 $context   The specific context to delete data for.
282      */
283     public static function delete_data_for_all_users_in_context(\context $context) {
284         $cm = get_coursemodule_from_id('quiz', $context->instanceid);
285         if (!$cm) {
286             // Only quiz module will be handled.
287             return;
288         }
289         $quiz = \quiz::create($cm->instance);
291         // Handle the 'quizaccess' subplugin.
292         manager::plugintype_class_callback(
293                 'quizaccess',
294                 quizaccess_provider::class,
295                 'delete_subplugin_data_for_all_users_in_context',
296                 [$quiz]
297             );
299         // Delete all overrides - do not log.
300         quiz_delete_all_overrides($quiz, false);
302         // This will delete all question attempts, quiz attempts, and quiz grades for this quiz.
303         quiz_delete_all_attempts($quiz);
304     }
306     /**
307      * Delete all user data for the specified user, in the specified contexts.
308      *
309      * @param   approved_contextlist    $contextlist    The approved contexts and user information to delete information for.
310      */
311     public static function delete_data_for_user(approved_contextlist $contextlist) {
312         global $DB;
314         foreach ($contextlist as $context) {
315             $cm = get_coursemodule_from_id('quiz', $context->instanceid);
316             $quiz = \quiz::create($cm->instance);
317             $user = $contextlist->get_user();
319             // Handle the 'quizaccess' quizaccess.
320             manager::plugintype_class_callback(
321                     'quizaccess',
322                     quizaccess_provider::class,
323                     'delete_quizaccess_data_for_user',
324                     [$quiz, $user]
325                 );
327             $overrides = $DB->get_records('quiz_overrides' , [
328                     'quiz' => $quiz->get_quizid(),
329                     'userid' => $user->id,
330                 ]);
332             foreach ($overrides as $override) {
333                 quiz_delete_override($quiz, $override->id, false);
334             }
336             // This will delete all question attempts, quiz attempts, and quiz grades for this quiz.
337             quiz_delete_user_attempts($quiz, $user);
338         }
339     }
341     /**
342      * Store all quiz attempts for the contextlist.
343      *
344      * @param   approved_contextlist    $contextlist
345      */
346     protected static function export_quiz_attempts(approved_contextlist $contextlist) {
347         global $DB;
349         $userid = $contextlist->get_user()->id;
350         list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED);
351         $qubaid = \core_question\privacy\provider::get_related_question_usages_for_user('rel', 'mod_quiz', 'qa.uniqueid', $userid);
353         $sql = "SELECT
354                     c.id AS contextid,
355                     cm.id AS cmid,
356                     qa.*
357                   FROM {context} c
358                   JOIN {course_modules} cm ON cm.id = c.instanceid AND c.contextlevel = :contextlevel
359                   JOIN {modules} m ON m.id = cm.module AND m.name = 'quiz'
360                   JOIN {quiz} q ON q.id = cm.instance
361                   JOIN {quiz_attempts} qa ON qa.quiz = q.id
362             " . $qubaid->from. "
363             WHERE (
364                 qa.userid = :qauserid OR
365                 " . $qubaid->where() . "
366             ) AND qa.preview = 0
367         ";
369         $params = array_merge(
370                 [
371                     'contextlevel'      => CONTEXT_MODULE,
372                     'qauserid'          => $userid,
373                 ],
374                 $qubaid->from_where_params()
375             );
377         $attempts = $DB->get_recordset_sql($sql, $params);
378         foreach ($attempts as $attempt) {
379             $quiz = $DB->get_record('quiz', ['id' => $attempt->quiz]);
380             $context = \context_module::instance($attempt->cmid);
381             $attemptsubcontext = helper::get_quiz_attempt_subcontext($attempt, $contextlist->get_user());
382             $options = quiz_get_review_options($quiz, $attempt, $context);
384             if ($attempt->userid == $userid) {
385                 // This attempt was made by the user.
386                 // They 'own' all data on it.
387                 // Store the question usage data.
388                 \core_question\privacy\provider::export_question_usage($userid,
389                         $context,
390                         $attemptsubcontext,
391                         $attempt->uniqueid,
392                         $options,
393                         true
394                     );
396                 // Store the quiz attempt data.
397                 $data = (object) [
398                     'state' => \quiz_attempt::state_name($attempt->state),
399                 ];
401                 if (!empty($attempt->timestart)) {
402                     $data->timestart = transform::datetime($attempt->timestart);
403                 }
404                 if (!empty($attempt->timefinish)) {
405                     $data->timefinish = transform::datetime($attempt->timefinish);
406                 }
407                 if (!empty($attempt->timemodified)) {
408                     $data->timemodified = transform::datetime($attempt->timemodified);
409                 }
410                 if (!empty($attempt->timemodifiedoffline)) {
411                     $data->timemodifiedoffline = transform::datetime($attempt->timemodifiedoffline);
412                 }
413                 if (!empty($attempt->timecheckstate)) {
414                     $data->timecheckstate = transform::datetime($attempt->timecheckstate);
415                 }
417                 if ($options->marks == \question_display_options::MARK_AND_MAX) {
418                     $grade = quiz_rescale_grade($attempt->sumgrades, $quiz, false);
419                     $data->grade = (object) [
420                             'grade' => quiz_format_grade($quiz, $grade),
421                             'feedback' => quiz_feedback_for_grade($grade, $quiz, $context),
422                         ];
423                 }
425                 writer::with_context($context)
426                     ->export_data($attemptsubcontext, $data);
427             } else {
428                 // This attempt was made by another user.
429                 // The current user may have marked part of the quiz attempt.
430                 \core_question\privacy\provider::export_question_usage(
431                         $userid,
432                         $context,
433                         $attemptsubcontext,
434                         $attempt->uniqueid,
435                         $options,
436                         false
437                     );
438             }
439         }
440         $attempts->close();
441     }