MDL-63497 mod_data: Add support for removal of context users
[moodle.git] / mod / data / 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_data.
19  *
20  * @package    mod_data
21  * @copyright  2018 Marina Glancy
22  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
25 namespace mod_data\privacy;
27 use core_privacy\local\metadata\collection;
28 use core_privacy\local\request\approved_contextlist;
29 use core_privacy\local\request\approved_userlist;
30 use core_privacy\local\request\contextlist;
31 use core_privacy\local\request\helper;
32 use core_privacy\local\request\transform;
33 use core_privacy\local\request\userlist;
34 use core_privacy\local\request\writer;
35 use core_privacy\manager;
37 defined('MOODLE_INTERNAL') || die();
39 /**
40  * Implementation of the privacy subsystem plugin provider for the database activity module.
41  *
42  * @package    mod_data
43  * @copyright  2018 Marina Glancy
44  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
45  */
46 class provider implements
47         // This plugin stores personal data.
48         \core_privacy\local\metadata\provider,
50         // This plugin is capable of determining which users have data within it.
51         \core_privacy\local\request\core_userlist_provider,
53         // This plugin is a core_user_data_provider.
54         \core_privacy\local\request\plugin\provider {
56     /**
57      * Return the fields which contain personal data.
58      *
59      * @param collection $collection a reference to the collection to use to store the metadata.
60      * @return collection the updated collection of metadata items.
61      */
62     public static function get_metadata(collection $collection) : collection {
63         $collection->add_database_table(
64             'data_records',
65             [
66                 'userid' => 'privacy:metadata:data_records:userid',
67                 'groupid' => 'privacy:metadata:data_records:groupid',
68                 'timecreated' => 'privacy:metadata:data_records:timecreated',
69                 'timemodified' => 'privacy:metadata:data_records:timemodified',
70                 'approved' => 'privacy:metadata:data_records:approved',
71             ],
72             'privacy:metadata:data_records'
73         );
74         $collection->add_database_table(
75             'data_content',
76             [
77                 'fieldid' => 'privacy:metadata:data_content:fieldid',
78                 'content' => 'privacy:metadata:data_content:content',
79                 'content1' => 'privacy:metadata:data_content:content1',
80                 'content2' => 'privacy:metadata:data_content:content2',
81                 'content3' => 'privacy:metadata:data_content:content3',
82                 'content4' => 'privacy:metadata:data_content:content4',
83             ],
84             'privacy:metadata:data_content'
85         );
87         // Link to subplugins.
88         $collection->add_plugintype_link('datafield', [], 'privacy:metadata:datafieldnpluginsummary');
90         // Subsystems used.
91         $collection->link_subsystem('core_comment', 'privacy:metadata:commentpurpose');
92         $collection->link_subsystem('core_files', 'privacy:metadata:filepurpose');
93         $collection->link_subsystem('core_tag', 'privacy:metadata:tagpurpose');
94         $collection->link_subsystem('core_rating', 'privacy:metadata:ratingpurpose');
96         return $collection;
97     }
99     /**
100      * Get the list of contexts that contain user information for the specified user.
101      *
102      * @param int $userid the userid.
103      * @return contextlist the list of contexts containing user info for the user.
104      */
105     public static function get_contexts_for_userid(int $userid) : contextlist {
106         // Fetch all data records.
107         $sql = "SELECT c.id
108                   FROM {context} c
109             INNER JOIN {course_modules} cm ON cm.id = c.instanceid AND c.contextlevel = :contextlevel
110             INNER JOIN {modules} m ON m.id = cm.module AND m.name = :modname
111             INNER JOIN {data} d ON d.id = cm.instance
112             INNER JOIN {data_records} dr ON dr.dataid = d.id
113              LEFT JOIN {comments} com ON com.commentarea=:commentarea and com.itemid = dr.id AND com.userid = :userid1
114              LEFT JOIN {rating} r ON r.contextid = c.id AND r.itemid  = dr.id AND r.component = :moddata
115                        AND r.ratingarea = :ratingarea AND r.userid = :userid2
116                  WHERE dr.userid = :userid OR com.id IS NOT NULL OR r.id IS NOT NULL";
118         $params = [
119             'modname'       => 'data',
120             'contextlevel'  => CONTEXT_MODULE,
121             'userid'        => $userid,
122             'userid1'       => $userid,
123             'userid2'       => $userid,
124             'commentarea'   => 'database_entry',
125             'moddata'       => 'mod_data',
126             'ratingarea'    => 'entry',
127         ];
128         $contextlist = new contextlist();
129         $contextlist->add_from_sql($sql, $params);
131         return $contextlist;
132     }
134     /**
135      * Get the list of users who have data within a context.
136      *
137      * @param   userlist    $userlist   The userlist containing the list of users who have data in this context/plugin combination.
138      *
139      */
140     public static function get_users_in_context(userlist $userlist) {
141         $context = $userlist->get_context();
143         if (!is_a($context, \context_module::class)) {
144             return;
145         }
147         // Find users with data records.
148         $sql = "SELECT dr.userid
149                   FROM {context} c
150                   JOIN {course_modules} cm ON cm.id = c.instanceid AND c.contextlevel = :contextlevel
151                   JOIN {modules} m ON m.id = cm.module AND m.name = :modname
152                   JOIN {data} d ON d.id = cm.instance
153                   JOIN {data_records} dr ON dr.dataid = d.id
154                  WHERE c.id = :contextid";
156         $params = [
157             'modname'       => 'data',
158             'contextid'     => $context->id,
159             'contextlevel'  => CONTEXT_MODULE,
160         ];
162         $userlist->add_from_sql('userid', $sql, $params);
164         // Find users with comments.
165         $sql = "SELECT dr.id
166                   FROM {context} c
167                   JOIN {course_modules} cm ON cm.id = c.instanceid AND c.contextlevel = :contextlevel
168                   JOIN {modules} m ON m.id = cm.module AND m.name = :modname
169                   JOIN {data} d ON d.id = cm.instance
170                   JOIN {data_records} dr ON dr.dataid = d.id
171                  WHERE c.id = :contextid";
173         $params = [
174             'modname'       => 'data',
175             'contextid'     => $context->id,
176             'contextlevel'  => CONTEXT_MODULE,
177         ];
179         \core_comment\privacy\provider::get_users_in_context_from_sql(
180             $userlist, 'com', 'mod_data', 'database_entry', $sql, $params);
182         // Find users with ratings.
183         $sql = "SELECT dr.id
184                   FROM {context} c
185                   JOIN {course_modules} cm ON cm.id = c.instanceid AND c.contextlevel = :contextlevel
186                   JOIN {modules} m ON m.id = cm.module AND m.name = :modname
187                   JOIN {data} d ON d.id = cm.instance
188                   JOIN {data_records} dr ON dr.dataid = d.id
189                  WHERE c.id = :contextid";
191         $params = [
192             'modname'       => 'data',
193             'contextid'     => $context->id,
194             'contextlevel'  => CONTEXT_MODULE,
195         ];
197         \core_rating\privacy\provider::get_users_in_context_from_sql($userlist, 'rat', 'mod_data', 'entry', $sql, $params);
198     }
200     /**
201      * Creates an object from all fields in the $record where key starts with $prefix
202      *
203      * @param \stdClass $record
204      * @param string $prefix
205      * @param array $additionalfields
206      * @return \stdClass
207      */
208     protected static function extract_object_from_record($record, $prefix, $additionalfields = []) {
209         $object = new \stdClass();
210         foreach ($record as $key => $value) {
211             if (preg_match('/^'.preg_quote($prefix, '/').'(.*)/', $key, $matches)) {
212                 $object->{$matches[1]} = $value;
213             }
214         }
215         if ($additionalfields) {
216             foreach ($additionalfields as $key => $value) {
217                 $object->$key = $value;
218             }
219         }
220         return $object;
221     }
223     /**
224      * Export one field answer in a record in database activity module
225      *
226      * @param \context $context
227      * @param \stdClass $recordobj record from DB table {data_records}
228      * @param \stdClass $fieldobj record from DB table {data_fields}
229      * @param \stdClass $contentobj record from DB table {data_content}
230      */
231     protected static function export_data_content($context, $recordobj, $fieldobj, $contentobj) {
232         $value = (object)[
233             'field' => [
234                 // Name and description are displayed in mod_data without applying format_string().
235                 'name' => $fieldobj->name,
236                 'description' => $fieldobj->description,
237                 'type' => $fieldobj->type,
238                 'required' => transform::yesno($fieldobj->required),
239             ],
240             'content' => $contentobj->content
241         ];
242         foreach (['content1', 'content2', 'content3', 'content4'] as $key) {
243             if ($contentobj->$key !== null) {
244                 $value->$key = $contentobj->$key;
245             }
246         }
247         $classname = manager::get_provider_classname_for_component('datafield_' . $fieldobj->type);
248         if (class_exists($classname) && is_subclass_of($classname, datafield_provider::class)) {
249             component_class_callback($classname, 'export_data_content',
250                 [$context, $recordobj, $fieldobj, $contentobj, $value]);
251         } else {
252             // Data field plugin does not implement datafield_provider, just export default value.
253             writer::with_context($context)->export_data([$recordobj->id, $contentobj->id], $value);
254         }
255         writer::with_context($context)->export_area_files([$recordobj->id, $contentobj->id], 'mod_data',
256             'content', $contentobj->id);
257     }
259     /**
260      * SQL query that returns all fields from {data_content}, {data_fields} and {data_records} tables
261      *
262      * @return string
263      */
264     protected static function sql_fields() {
265         return 'd.id AS dataid, dc.id AS contentid, dc.fieldid, df.type AS fieldtype, df.name AS fieldname,
266                   df.description AS fielddescription, df.required AS fieldrequired,
267                   df.param1 AS fieldparam1, df.param2 AS fieldparam2, df.param3 AS fieldparam3, df.param4 AS fieldparam4,
268                   df.param5 AS fieldparam5, df.param6 AS fieldparam6, df.param7 AS fieldparam7, df.param8 AS fieldparam8,
269                   df.param9 AS fieldparam9, df.param10 AS fieldparam10,
270                   dc.content AS contentcontent, dc.content1 AS contentcontent1, dc.content2 AS contentcontent2,
271                   dc.content3 AS contentcontent3, dc.content4 AS contentcontent4,
272                   dc.recordid, dr.timecreated AS recordtimecreated, dr.timemodified AS recordtimemodified,
273                   dr.approved AS recordapproved, dr.groupid AS recordgroupid, dr.userid AS recorduserid';
274     }
276     /**
277      * Export personal data for the given approved_contextlist. User and context information is contained within the contextlist.
278      *
279      * @param approved_contextlist $contextlist a list of contexts approved for export.
280      */
281     public static function export_user_data(approved_contextlist $contextlist) {
282         global $DB;
284         if (!$contextlist->count()) {
285             return;
286         }
288         $user = $contextlist->get_user();
290         list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED);
291         $sql = "SELECT cm.id AS cmid, d.name AS dataname, cm.course AS courseid, " . self::sql_fields() . "
292                 FROM {context} ctx
293                 JOIN {course_modules} cm ON cm.id = ctx.instanceid
294                 JOIN {modules} m ON m.id = cm.module AND m.name = :modname
295                 JOIN {data} d ON d.id = cm.instance
296                 JOIN {data_records} dr ON dr.dataid = d.id
297                 JOIN {data_content} dc ON dc.recordid = dr.id
298                 JOIN {data_fields} df ON df.id = dc.fieldid
299                 WHERE ctx.id {$contextsql} AND ctx.contextlevel = :contextlevel
300                 AND dr.userid = :userid OR
301                   EXISTS (SELECT 1 FROM {comments} com WHERE com.commentarea=:commentarea
302                     AND com.itemid = dr.id AND com.userid = :userid1) OR
303                   EXISTS (SELECT 1 FROM {rating} r WHERE r.contextid = ctx.id AND r.itemid  = dr.id AND r.component = :moddata
304                     AND r.ratingarea = :ratingarea AND r.userid = :userid2)
305                 ORDER BY cm.id, dr.id, dc.fieldid";
306         $rs = $DB->get_recordset_sql($sql, $contextparams + ['contextlevel' => CONTEXT_MODULE,
307                 'modname' => 'data', 'userid' => $user->id, 'userid1' => $user->id, 'commentarea' => 'database_entry',
308                 'userid2' => $user->id, 'ratingarea' => 'entry', 'moddata' => 'mod_data']);
310         $context = null;
311         $recordobj = null;
312         foreach ($rs as $row) {
313             if (!$context || $context->instanceid != $row->cmid) {
314                 // This row belongs to the different data module than the previous row.
315                 // Export the data for the previous module.
316                 self::export_data($context, $user);
317                 // Start new data module.
318                 $context = \context_module::instance($row->cmid);
319             }
321             if (!$recordobj || $row->recordid != $recordobj->id) {
322                 // Export previous data record.
323                 self::export_data_record($context, $user, $recordobj);
324                 // Prepare for exporting new data record.
325                 $recordobj = self::extract_object_from_record($row, 'record', ['dataid' => $row->dataid]);
326             }
327             $fieldobj = self::extract_object_from_record($row, 'field', ['dataid' => $row->dataid]);
328             $contentobj = self::extract_object_from_record($row, 'content',
329                 ['fieldid' => $fieldobj->id, 'recordid' => $recordobj->id]);
330             self::export_data_content($context, $recordobj, $fieldobj, $contentobj);
331         }
332         $rs->close();
333         self::export_data_record($context, $user, $recordobj);
334         self::export_data($context, $user);
335     }
337     /**
338      * Export one entry in the database activity module (one record in {data_records} table)
339      *
340      * @param \context $context
341      * @param \stdClass $user
342      * @param \stdClass $recordobj
343      */
344     protected static function export_data_record($context, $user, $recordobj) {
345         if (!$recordobj) {
346             return;
347         }
348         $data = [
349             'userid' => transform::user($user->id),
350             'groupid' => $recordobj->groupid,
351             'timecreated' => transform::datetime($recordobj->timecreated),
352             'timemodified' => transform::datetime($recordobj->timemodified),
353             'approved' => transform::yesno($recordobj->approved),
354         ];
355         // Data about the record.
356         writer::with_context($context)->export_data([$recordobj->id], (object)$data);
357         // Related tags.
358         \core_tag\privacy\provider::export_item_tags($user->id, $context, [$recordobj->id],
359             'mod_data', 'data_records', $recordobj->id);
360         // Export comments. For records that were not made by this user export only this user's comments, for own records
361         // export comments made by everybody.
362         \core_comment\privacy\provider::export_comments($context, 'mod_data', 'database_entry', $recordobj->id,
363             [$recordobj->id], $recordobj->userid != $user->id);
364         // Export ratings. For records that were not made by this user export only this user's ratings, for own records
365         // export ratings from everybody.
366         \core_rating\privacy\provider::export_area_ratings($user->id, $context, [$recordobj->id], 'mod_data', 'entry',
367             $recordobj->id, $recordobj->userid != $user->id);
368     }
370     /**
371      * Export basic info about database activity module
372      *
373      * @param \context $context
374      * @param \stdClass $user
375      */
376     protected static function export_data($context, $user) {
377         if (!$context) {
378             return;
379         }
380         $contextdata = helper::get_context_data($context, $user);
381         helper::export_context_files($context, $user);
382         writer::with_context($context)->export_data([], $contextdata);
383     }
385     /**
386      * Delete all data for all users in the specified context.
387      *
388      * @param \context $context the context to delete in.
389      */
390     public static function delete_data_for_all_users_in_context(\context $context) {
391         global $DB;
393         if (!$context instanceof \context_module) {
394             return;
395         }
396         $recordstobedeleted = [];
398         $sql = "SELECT " . self::sql_fields() . "
399                 FROM {course_modules} cm
400                 JOIN {modules} m ON m.id = cm.module AND m.name = :modname
401                 JOIN {data} d ON d.id = cm.instance
402                 JOIN {data_records} dr ON dr.dataid = d.id
403                 LEFT JOIN {data_content} dc ON dc.recordid = dr.id
404                 LEFT JOIN {data_fields} df ON df.id = dc.fieldid
405                 WHERE cm.id = :cmid
406                 ORDER BY dr.id";
407         $rs = $DB->get_recordset_sql($sql, ['cmid' => $context->instanceid, 'modname' => 'data']);
408         foreach ($rs as $row) {
409             self::mark_data_content_for_deletion($context, $row);
410             $recordstobedeleted[$row->recordid] = $row->recordid;
411         }
412         $rs->close();
414         self::delete_data_records($context, $recordstobedeleted);
415     }
417     /**
418      * Delete all user data for the specified user, in the specified contexts.
419      *
420      * @param approved_contextlist $contextlist a list of contexts approved for deletion.
421      */
422     public static function delete_data_for_user(approved_contextlist $contextlist) {
423         global $DB;
425         if (empty($contextlist->count())) {
426             return;
427         }
429         $user = $contextlist->get_user();
430         $recordstobedeleted = [];
432         foreach ($contextlist->get_contexts() as $context) {
433             $sql = "SELECT " . self::sql_fields() . "
434                 FROM {context} ctx
435                 JOIN {course_modules} cm ON cm.id = ctx.instanceid
436                 JOIN {modules} m ON m.id = cm.module AND m.name = :modname
437                 JOIN {data} d ON d.id = cm.instance
438                 JOIN {data_records} dr ON dr.dataid = d.id AND dr.userid = :userid
439                 LEFT JOIN {data_content} dc ON dc.recordid = dr.id
440                 LEFT JOIN {data_fields} df ON df.id = dc.fieldid
441                 WHERE ctx.id = :ctxid AND ctx.contextlevel = :contextlevel
442                 ORDER BY dr.id";
443             $rs = $DB->get_recordset_sql($sql, ['ctxid' => $context->id, 'contextlevel' => CONTEXT_MODULE,
444                 'modname' => 'data', 'userid' => $user->id]);
445             foreach ($rs as $row) {
446                 self::mark_data_content_for_deletion($context, $row);
447                 $recordstobedeleted[$row->recordid] = $row->recordid;
448             }
449             $rs->close();
450             self::delete_data_records($context, $recordstobedeleted);
451         }
453         // Additionally remove comments this user made on other entries.
454         \core_comment\privacy\provider::delete_comments_for_user($contextlist, 'mod_data', 'database_entry');
456         // We do not delete ratings made by this user on other records because it may change grades.
457     }
459     /**
460      * Delete multiple users within a single context.
461      *
462      * @param   approved_userlist    $userlist The approved context and user information to delete information for.
463      */
464     public static function delete_data_for_users(approved_userlist $userlist) {
465         global $DB;
467         $context = $userlist->get_context();
468         $recordstobedeleted = [];
469         list($userinsql, $userinparams) = $DB->get_in_or_equal($userlist->get_userids(), SQL_PARAMS_NAMED);
471         $sql = "SELECT " . self::sql_fields() . "
472                   FROM {context} ctx
473                   JOIN {course_modules} cm ON cm.id = ctx.instanceid
474                   JOIN {modules} m ON m.id = cm.module AND m.name = :modname
475                   JOIN {data} d ON d.id = cm.instance
476                   JOIN {data_records} dr ON dr.dataid = d.id AND dr.userid {$userinsql}
477              LEFT JOIN {data_content} dc ON dc.recordid = dr.id
478              LEFT JOIN {data_fields} df ON df.id = dc.fieldid
479                  WHERE ctx.id = :ctxid AND ctx.contextlevel = :contextlevel
480               ORDER BY dr.id";
482         $params = [
483             'ctxid' => $context->id,
484             'contextlevel' => CONTEXT_MODULE,
485             'modname' => 'data',
486         ];
487         $params += $userinparams;
489         $rs = $DB->get_recordset_sql($sql, $params);
490         foreach ($rs as $row) {
491             self::mark_data_content_for_deletion($context, $row);
492             $recordstobedeleted[$row->recordid] = $row->recordid;
493         }
494         $rs->close();
496         self::delete_data_records($context, $recordstobedeleted);
498         // Additionally remove comments these users made on other entries.
499         \core_comment\privacy\provider::delete_comments_for_users($userlist, 'mod_data', 'database_entry');
501         // We do not delete ratings made by users on other records because it may change grades.
502     }
504     /**
505      * Marks a data_record/data_content for deletion
506      *
507      * Also invokes callback from datafield plugin in case it stores additional data that needs to be deleted
508      *
509      * @param \context $context
510      * @param \stdClass $row result of SQL query - tables data_content, data_record, data_fields join together
511      */
512     protected static function mark_data_content_for_deletion($context, $row) {
513         $recordobj = self::extract_object_from_record($row, 'record', ['dataid' => $row->dataid]);
514         if ($row->contentid && $row->fieldid) {
515             $fieldobj = self::extract_object_from_record($row, 'field', ['dataid' => $row->dataid]);
516             $contentobj = self::extract_object_from_record($row, 'content',
517                 ['fieldid' => $fieldobj->id, 'recordid' => $recordobj->id]);
519             // Allow datafield plugin to implement their own deletion.
520             $classname = manager::get_provider_classname_for_component('datafield_' . $fieldobj->type);
521             if (class_exists($classname) && is_subclass_of($classname, datafield_provider::class)) {
522                 component_class_callback($classname, 'delete_data_content',
523                     [$context, $recordobj, $fieldobj, $contentobj]);
524             }
525         }
526     }
528     /**
529      * Deletes records marked for deletion and all associated data
530      *
531      * Should be executed after all records were marked by {@link mark_data_content_for_deletion()}
532      *
533      * Deletes records from data_content and data_records tables, associated files, tags, comments and ratings.
534      *
535      * @param \context $context
536      * @param array $recordstobedeleted list of ids of the data records that need to be deleted
537      */
538     protected static function delete_data_records($context, $recordstobedeleted) {
539         global $DB;
540         if (empty($recordstobedeleted)) {
541             return;
542         }
544         list($sql, $params) = $DB->get_in_or_equal($recordstobedeleted, SQL_PARAMS_NAMED);
546         // Delete files.
547         get_file_storage()->delete_area_files_select($context->id, 'mod_data', 'data_records',
548             "IN (SELECT dc.id FROM {data_content} dc WHERE dc.recordid $sql)", $params);
549         // Delete from data_content.
550         $DB->delete_records_select('data_content', 'recordid ' . $sql, $params);
551         // Delete from data_records.
552         $DB->delete_records_select('data_records', 'id ' . $sql, $params);
553         // Delete tags.
554         \core_tag\privacy\provider::delete_item_tags_select($context, 'mod_data', 'data_records', $sql, $params);
555         // Delete comments.
556         \core_comment\privacy\provider::delete_comments_for_all_users_select($context, 'mod_data', 'database_entry', $sql, $params);
557         // Delete ratings.
558         \core_rating\privacy\provider::delete_ratings_select($context, 'mod_data', 'entry', $sql, $params);
559     }