MDL-61836 core_blog: Implement privacy API
[moodle.git] / blog / classes / privacy / provider.php
CommitLineData
ce1ec9b4
FM
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/>.
16
17/**
18 * Data provider.
19 *
20 * @package core_blog
21 * @copyright 2018 Frédéric Massart
22 * @author Frédéric Massart <fred@branchup.tech>
23 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24 */
25
26namespace core_blog\privacy;
27defined('MOODLE_INTERNAL') || die();
28
29use blog_entry;
30use context;
31use context_helper;
32use context_user;
33use context_system;
34use core_tag_tag;
35use core_privacy\local\metadata\collection;
36use core_privacy\local\request\approved_contextlist;
37use core_privacy\local\request\transform;
38use core_privacy\local\request\writer;
39
40require_once($CFG->dirroot . '/blog/locallib.php');
41
42/**
43 * Data provider class.
44 *
45 * @package core_blog
46 * @copyright 2018 Frédéric Massart
47 * @author Frédéric Massart <fred@branchup.tech>
48 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
49 */
50class provider implements
51 \core_privacy\local\metadata\provider,
52 \core_privacy\local\request\subsystem\provider {
53
54 /**
55 * Returns metadata.
56 *
57 * @param collection $collection The initialised collection to add items to.
58 * @return collection A listing of user data stored through this system.
59 */
60 public static function get_metadata(collection $collection) : collection {
61
62 $collection->add_database_table('post', [
63 'userid' => 'privacy:metadata:post:userid',
64 'subject' => 'privacy:metadata:post:subject',
65 'summary' => 'privacy:metadata:post:summary',
66 'uniquehash' => 'privacy:metadata:post:uniquehash',
67 'publishstate' => 'privacy:metadata:post:publishstate',
68 'created' => 'privacy:metadata:post:created',
69 'lastmodified' => 'privacy:metadata:post:lastmodified',
70
71 // The following columns are unused:
72 // coursemoduleid, courseid, moduleid, groupid, rating, usermodified.
73 ], 'privacy:metadata:post');
74
75 $collection->link_subsystem('core_comments', 'privacy:metadata:core_comments');
76 $collection->link_subsystem('core_files', 'privacy:metadata:core_files');
77 $collection->link_subsystem('core_tag', 'privacy:metadata:core_tag');
78
79 $collection->add_database_table('blog_external', [
80 'userid' => 'privacy:metadata:external:userid',
81 'name' => 'privacy:metadata:external:name',
82 'description' => 'privacy:metadata:external:description',
83 'url' => 'privacy:metadata:external:url',
84 'filtertags' => 'privacy:metadata:external:filtertags',
85 'timemodified' => 'privacy:metadata:external:timemodified',
86 'timefetched' => 'privacy:metadata:external:timefetched',
87 ], 'privacy:metadata:external');
88
89 // We do not report on blog_association because this is just context-related data.
90
91 return $collection;
92 }
93
94 /**
95 * Get the list of contexts that contain user information for the specified user.
96 *
97 * @param int $userid The user to search.
98 * @return contextlist $contextlist The contextlist containing the list of contexts used in this plugin.
99 */
100 public static function get_contexts_for_userid(int $userid) : \core_privacy\local\request\contextlist {
101 global $DB;
102 $contextlist = new \core_privacy\local\request\contextlist();
103
104 // There are at least one blog post.
105 if ($DB->record_exists_select('post', 'userid = :userid AND module IN (:blog, :blogext)', [
106 'userid' => $userid, 'blog' => 'blog', 'blogext' => 'blog_external'])) {
107 $sql = "
108 SELECT ctx.id
109 FROM {context} ctx
110 WHERE ctx.contextlevel = :ctxlevel
111 AND ctx.instanceid = :ctxuserid";
112 $params = [
113 'ctxlevel' => CONTEXT_USER,
114 'ctxuserid' => $userid,
115 ];
116 $contextlist->add_from_sql($sql, $params);
117
118 // Add the associated context of the blog posts.
119 $sql = "
120 SELECT DISTINCT ctx.id
121 FROM {post} p
122 JOIN {blog_association} ba
123 ON ba.blogid = p.id
124 JOIN {context} ctx
125 ON ctx.id = ba.contextid
126 WHERE p.userid = :userid";
127 $params = [
128 'userid' => $userid,
129 ];
130 $contextlist->add_from_sql($sql, $params);
131 }
132
133 // If there is at least one external blog, we add the user context. This is done this
134 // way because we can't directly add context to a contextlist.
135 if ($DB->record_exists('blog_external', ['userid' => $userid])) {
136 $sql = "
137 SELECT ctx.id
138 FROM {context}
139 WHERE ctx.contextlevel = :ctxlevel
140 AND ctx.instanceid = :ctxuserid";
141 $params = [
142 'ctxlevel' => CONTEXT_USER,
143 'ctxuserid' => $userid,
144 ];
145 $contextlist->add_from_sql($sql, $params);
146 }
147
148 // Include the user contexts in which the user comments.
149 $sql = "
150 SELECT DISTINCT ctx.id
151 FROM {context} ctx
152 JOIN {comments} c
153 ON c.contextid = ctx.id
154 WHERE c.component = :component
155 AND c.commentarea = :commentarea
156 AND c.userid = :userid";
157 $params = [
158 'component' => 'blog',
159 'commentarea' => 'format_blog',
160 'userid' => $userid
161 ];
162 $contextlist->add_from_sql($sql, $params);
163
164 return $contextlist;
165 }
166
167 /**
168 * Export all user data for the specified user, in the specified contexts.
169 *
170 * @param approved_contextlist $contextlist The approved contexts to export information for.
171 */
172 public static function export_user_data(approved_contextlist $contextlist) {
173 global $DB;
174
175 $sysctx = context_system::instance();
176 $fs = get_file_storage();
177 $userid = $contextlist->get_user()->id;
178 $ctxfields = context_helper::get_preload_record_columns_sql('ctx');
179 $rootpath = [get_string('blog', 'core_blog')];
180 $associations = [];
181
182 foreach ($contextlist as $context) {
183 switch ($context->contextlevel) {
184 case CONTEXT_USER:
185 $contextuserid = $context->instanceid;
186 $insql = ' > 0';
187 $inparams = [];
188
189 if ($contextuserid != $userid) {
190 // We will only be exporting comments, so fetch the IDs of the relevant entries.
191 $entryids = $DB->get_fieldset_sql("
192 SELECT DISTINCT c.itemid
193 FROM {comments} c
194 WHERE c.contextid = :contextid
195 AND c.userid = :userid
196 AND c.component = :component
197 AND c.commentarea = :commentarea", [
198 'contextid' => $context->id,
199 'userid' => $userid,
200 'component' => 'blog',
201 'commentarea' => 'format_blog'
202 ]);
203
204 if (empty($entryids)) {
205 // This should not happen, as the user context should not have been reported then.
206 continue;
207 }
208
209 list($insql, $inparams) = $DB->get_in_or_equal($entryids, SQL_PARAMS_NAMED);
210 }
211
212 // Loop over each blog entry in context.
213 $sql = "userid = :userid AND module IN (:blog, :blogext) AND id $insql";
214 $params = array_merge($inparams, ['userid' => $contextuserid, 'blog' => 'blog', 'blogext' => 'blog_external']);
215 $recordset = $DB->get_recordset_select('post', $sql, $params);
216 foreach ($recordset as $record) {
217
218 $subject = format_string($record->subject);
219 $path = array_merge($rootpath, [get_string('blogentries', 'core_blog'), $subject . " ({$record->id})"]);
220
221 // If the context is not mine, then we ONLY export the comments made by the exporting user.
222 if ($contextuserid != $userid) {
223 \core_comment\privacy\provider::export_comments($context, 'blog', 'format_blog',
224 $record->id, $path, true);
225 continue;
226 }
227
228 // Manually export the files as they reside in the system context so we can't use
229 // the write's helper methods. The same happens for attachments.
230 foreach ($fs->get_area_files($sysctx->id, 'blog', 'post', $record->id) as $f) {
231 writer::with_context($context)->export_file($path, $f);
232 }
233 foreach ($fs->get_area_files($sysctx->id, 'blog', 'attachment', $record->id) as $f) {
234 writer::with_context($context)->export_file($path, $f);
235 }
236
237 // Rewrite the summary files.
238 $summary = writer::with_context($context)->rewrite_pluginfile_urls($path, 'blog', 'post',
239 $record->id, $record->summary);
240
241 // Fetch associations.
242 $assocs = [];
243 $sql = "SELECT ba.contextid, $ctxfields
244 FROM {blog_association} ba
245 JOIN {context} ctx
246 ON ba.contextid = ctx.id
247 WHERE ba.blogid = :blogid";
248 $assocset = $DB->get_recordset_sql($sql, ['blogid' => $record->id]);
249 foreach ($assocset as $assocrec) {
250 context_helper::preload_from_record($assocrec);
251 $assocctx = context::instance_by_id($assocrec->contextid);
252 $assocs[] = $assocctx->get_context_name();
253 }
254 $assocset->close();
255
256 // Export associated tags.
257 \core_tag\privacy\provider::export_item_tags($userid, $context, $path, 'core', 'post', $record->id);
258
259 // Export all comments made on my post.
260 \core_comment\privacy\provider::export_comments($context, 'blog', 'format_blog',
261 $record->id, $path, false);
262
263 // Add blog entry data.
264 $entry = (object) [
265 'subject' => $subject,
266 'summary' => format_text($summary, $record->summaryformat),
267 'uniquehash' => $record->uniquehash,
268 'publishstate' => static::transform_publishstate($record->publishstate),
269 'created' => transform::datetime($record->created),
270 'lastmodified' => transform::datetime($record->lastmodified),
271 'associations' => $assocs
272 ];
273
274 writer::with_context($context)->export_data($path, $entry);
275 }
276 $recordset->close();
277
278 // Export external blogs.
279 $recordset = $DB->get_recordset('blog_external', ['userid' => $userid]);
280 foreach ($recordset as $record) {
281
282 $path = array_merge($rootpath, [get_string('externalblogs', 'core_blog'),
283 $record->name . " ({$record->id})"]);
284
285 // Export associated tags.
286 \core_tag\privacy\provider::export_item_tags($userid, $context, $path, 'core',
287 'blog_external', $record->id);
288
289 // Add data.
290 $external = (object) [
291 'name' => $record->name,
292 'description' => $record->description,
293 'url' => $record->url,
294 'filtertags' => $record->filtertags,
295 'modified' => transform::datetime($record->timemodified),
296 'lastfetched' => transform::datetime($record->timefetched),
297 ];
298
299 writer::with_context($context)->export_data($path, $external);
300 }
301 $recordset->close();
302 break;
303
304 case CONTEXT_COURSE:
305 case CONTEXT_MODULE:
306 $associations[] = $context->id;
307 break;
308 }
309 }
310
311 // Export associations.
312 if (!empty($associations)) {
313 list($insql, $inparams) = $DB->get_in_or_equal($associations, SQL_PARAMS_NAMED);
314 $sql = "
315 SELECT ba.contextid, p.subject, $ctxfields
316 FROM {post} p
317 JOIN {blog_association} ba
318 ON ba.blogid = p.id
319 JOIN {context} ctx
320 ON ctx.id = ba.contextid
321 WHERE ba.contextid $insql
322 AND p.userid = :userid
323 ORDER BY ba.contextid ASC";
324 $params = array_merge($inparams, ['userid' => $userid]);
325
326 $path = [get_string('privacy:path:blogassociations', 'core_blog')];
327
328 $flushassocs = function($context, $assocs) use ($path) {
329 writer::with_context($context)->export_data($path, (object) [
330 'associations' => $assocs
331 ]);
332 };
333
334 $lastcontextid = null;
335 $assocs = [];
336 $recordset = $DB->get_recordset_sql($sql, $params);
337 foreach ($recordset as $record) {
338 context_helper::preload_from_record($record);
339
340 if ($lastcontextid && $record->contextid != $lastcontextid) {
341 $flushassocs(context::instance_by_id($lastcontextid), $assocs);
342 $assocs = [];
343 }
344 $assocs[] = format_string($record->subject);
345 $lastcontextid = $record->contextid;
346 }
347
348 if ($lastcontextid) {
349 $flushassocs(context::instance_by_id($lastcontextid), $assocs);
350 }
351
352 $recordset->close();
353 }
354 }
355
356 /**
357 * Delete all data for all users in the specified context.
358 *
359 * @param context $context The specific context to delete data for.
360 */
361 public static function delete_data_for_all_users_in_context(context $context) {
362 global $DB;
363 switch ($context->contextlevel) {
364 case CONTEXT_USER:
365 static::delete_all_user_data($context);
366 break;
367
368 case CONTEXT_COURSE:
369 case CONTEXT_MODULE:
370 // We only delete associations here.
371 $DB->delete_records('blog_association', ['contextid' => $context->id]);
372 break;
373 }
374
375 // Delete all the comments.
376 \core_comment\privacy\provider::delete_comments_for_all_users_in_context($context);
377 }
378
379 /**
380 * Delete all user data for the specified user, in the specified contexts.
381 *
382 * @param approved_contextlist $contextlist The approved contexts and user information to delete information for.
383 */
384 public static function delete_data_for_user(approved_contextlist $contextlist) {
385 global $DB;
386
387 $userid = $contextlist->get_user()->id;
388 $associationcontextids = [];
389
390 foreach ($contextlist as $context) {
391 if ($context->contextlevel == CONTEXT_USER && $context->instanceid == $userid) {
392 static::delete_all_user_data($context);
393
394 } else if ($context->contextlevel == CONTEXT_COURSE) {
395 // Only delete the course associations.
396 $associationcontextids[] = $context->id;
397
398 } else if ($context->contextlevel == CONTEXT_MODULE) {
399 // Only delete the module associations.
400 $associationcontextids[] = $context->id;
401 }
402 }
403
404 // Delete the associations.
405 if (!empty($associationcontextids)) {
406 list($insql, $inparams) = $DB->get_in_or_equal($associationcontextids, SQL_PARAMS_NAMED);
407 $sql = "SELECT ba.id
408 FROM {blog_association} ba
409 JOIN {post} p
410 ON p.id = ba.blogid
411 WHERE ba.contextid $insql
412 AND p.userid = :userid";
413 $params = array_merge($inparams, ['userid' => $userid]);
414 $associds = $DB->get_fieldset_sql($sql, $params);
415
416 list($insql, $inparams) = $DB->get_in_or_equal($associds, SQL_PARAMS_NAMED, 'param', true);
417 $DB->delete_records_select('blog_association', "id $insql", $inparams);
418 }
419
420 // Delete the comments.
421 \core_comment\privacy\provider::delete_comments_for_user($contextlist);
422 }
423
424 /**
425 * Helper method to delete all user data.
426 *
427 * @param context_user $usercontext The user context.
428 * @return void
429 */
430 protected static function delete_all_user_data(context_user $usercontext) {
431 global $DB;
432 $userid = $usercontext->instanceid;
433
434 // Delete all blog posts.
435 $recordset = $DB->get_recordset_select('post', 'userid = :userid AND module IN (:blog, :blogext)', [
436 'userid' => $userid, 'blog' => 'blog', 'blogext' => 'blog_external']);
437 foreach ($recordset as $record) {
438 $entry = new blog_entry(null, $record);
439 $entry->delete(); // Takes care of files and associations.
440 }
441 $recordset->close();
442
443 // Delete all external blogs, and their associated tags.
444 $DB->delete_records('blog_external', ['userid' => $userid]);
445 core_tag_tag::delete_instances('core', 'blog_external', $usercontext->id);
446
447 // Delete all comments made in this context.
448 $DB->delete_records('comments', ['contextid' => $usercontext->id, 'component' => 'blog', 'commentarea' => 'format_blog']);
449 }
450
451 /**
452 * Transform a publish state.
453 *
454 * @param string $publishstate The publish state.
455 * @return string
456 */
457 public static function transform_publishstate($publishstate) {
458 switch ($publishstate) {
459 case 'draft':
460 return get_string('publishtonoone', 'core_blog');
461 case 'site':
462 return get_string('publishtosite', 'core_blog');
463 case 'public':
464 return get_string('publishtoworld', 'core_blog');
465 default:
466 }
467 return get_string('privacy:unknown', 'core_blog');
468 }
469}