MDL-63689 workshop: Add support for removal of multiple context users
authorDavid Mudrák <david@moodle.com>
Thu, 18 Oct 2018 19:51:06 +0000 (21:51 +0200)
committerDavid Mudrák <david@moodle.com>
Tue, 30 Oct 2018 13:29:40 +0000 (14:29 +0100)
The mod_workshop\privacy\provider now implements the new
core_userlist_provider interface. Deleting (erasing) data follows the
same principles as in other existing methods - we do not actually delete
data that might affect other users. Instead, we only erase the sensitive
owned personal information such as provided content.

mod/workshop/classes/privacy/provider.php
mod/workshop/lang/en/workshop.php
mod/workshop/tests/privacy_provider_test.php

index ed4eb6d..1a93a3c 100644 (file)
@@ -27,10 +27,12 @@ namespace mod_workshop\privacy;
 
 use core_privacy\local\metadata\collection;
 use core_privacy\local\request\approved_contextlist;
+use core_privacy\local\request\approved_userlist;
 use core_privacy\local\request\contextlist;
 use core_privacy\local\request\deletion_criteria;
 use core_privacy\local\request\helper;
 use core_privacy\local\request\transform;
+use core_privacy\local\request\userlist;
 use core_privacy\local\request\writer;
 
 defined('MOODLE_INTERNAL') || die();
@@ -45,6 +47,7 @@ require_once($CFG->dirroot.'/mod/workshop/locallib.php');
  */
 class provider implements
         \core_privacy\local\metadata\provider,
+        \core_privacy\local\request\core_userlist_provider,
         \core_privacy\local\request\user_preference_provider,
         \core_privacy\local\request\plugin\provider {
 
@@ -161,6 +164,63 @@ class provider implements
         return $contextlist;
     }
 
+    /**
+     * Get the list of users within a specific context.
+     *
+     * @param userlist $userlist To be filled list of users who have data in this context/plugin combination.
+     */
+    public static function get_users_in_context(userlist $userlist) {
+        global $DB;
+
+        $context = $userlist->get_context();
+
+        if (!$context instanceof \context_module) {
+            return;
+        }
+
+        $params = [
+            'instanceid' => $context->instanceid,
+            'module' => 'workshop',
+        ];
+
+        // One query to fetch them all, one query to find them, one query to bring them all and into the userlist add them.
+        $sql = "SELECT ws.authorid, ws.gradeoverby, wa.reviewerid, wa.gradinggradeoverby, wr.userid
+                  FROM {course_modules} cm
+                  JOIN {modules} m ON cm.module = m.id AND m.name = :module
+                  JOIN {workshop} w ON cm.instance = w.id
+                  JOIN {workshop_submissions} ws ON ws.workshopid = w.id
+             LEFT JOIN {workshop_assessments} wa ON wa.submissionid = ws.id
+             LEFT JOIN {workshop_aggregations} wr ON wr.workshopid = w.id
+                 WHERE cm.id = :instanceid";
+
+        $userids = [];
+        $rs = $DB->get_recordset_sql($sql, $params);
+
+        foreach ($rs as $r) {
+            if ($r->authorid) {
+                $userids[$r->authorid] = true;
+            }
+            if ($r->gradeoverby) {
+                $userids[$r->gradeoverby] = true;
+            }
+            if ($r->reviewerid) {
+                $userids[$r->reviewerid] = true;
+            }
+            if ($r->gradinggradeoverby) {
+                $userids[$r->gradinggradeoverby] = true;
+            }
+            if ($r->userid) {
+                $userids[$r->userid] = true;
+            }
+        }
+
+        $rs->close();
+
+        if ($userids) {
+            $userlist->add_users(array_keys($userids));
+        }
+    }
+
     /**
      * Export personal data stored in the given contexts.
      *
@@ -662,4 +722,119 @@ class provider implements
             \core_plagiarism\privacy\provider::delete_plagiarism_for_user($user->id, $context);
         }
     }
+
+    /**
+     * Delete personal data for multiple users within a single workshop context.
+     *
+     * See documentation for {@link self::delete_data_for_user()} for more details on what we do and don't actually
+     * delete and why.
+     *
+     * @param approved_userlist $userlist The approved context and user information to delete information for.
+     */
+    public static function delete_data_for_users(approved_userlist $userlist) {
+        global $DB;
+
+        $context = $userlist->get_context();
+        $fs = get_file_storage();
+
+        if ($context->contextlevel != CONTEXT_MODULE) {
+            // This should not happen but let's be double sure when it comes to deleting data.
+            return;
+        }
+
+        $cm = get_coursemodule_from_id('workshop', $context->instanceid, 0, false, IGNORE_MISSING);
+
+        if (!$cm) {
+            // Probably some kind of expired context.
+            return;
+        }
+
+        $userids = $userlist->get_userids();
+
+        if (!$userids) {
+            return;
+        }
+
+        list($usersql, $userparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
+
+        // Erase sensitive data in all submissions by all the users in the given context.
+
+        $sql = "SELECT ws.id AS submissionid
+                  FROM {workshop} w
+                  JOIN {workshop_submissions} ws ON ws.workshopid = w.id
+                 WHERE w.id = :workshopid AND ws.authorid $usersql";
+
+        $params = $userparams + [
+            'workshopid' => $cm->instance,
+        ];
+
+        $submissionids = $DB->get_fieldset_sql($sql, $params);
+
+        if ($submissionids) {
+            list($submissionidsql, $submissionidparams) = $DB->get_in_or_equal($submissionids, SQL_PARAMS_NAMED);
+
+            $DB->set_field_select('workshop_submissions', 'title', get_string('privacy:request:delete:title',
+                'mod_workshop'), "id $submissionidsql", $submissionidparams);
+            $DB->set_field_select('workshop_submissions', 'content', get_string('privacy:request:delete:content',
+                'mod_workshop'), "id $submissionidsql", $submissionidparams);
+            $DB->set_field_select('workshop_submissions', 'feedbackauthor', get_string('privacy:request:delete:content',
+                'mod_workshop'), "id $submissionidsql", $submissionidparams);
+
+            $fs->delete_area_files_select($context->id, 'mod_workshop', 'submission_content',
+                $submissionidsql, $submissionidparams);
+            $fs->delete_area_files_select($context->id, 'mod_workshop', 'submission_attachment',
+                $submissionidsql, $submissionidparams);
+        }
+
+        // Erase personal data in received assessments - feedback is seen as belonging to the recipient.
+
+        $sql = "SELECT wa.id AS assessmentid
+                  FROM {workshop} w
+                  JOIN {workshop_submissions} ws ON ws.workshopid = w.id
+                  JOIN {workshop_assessments} wa ON wa.submissionid = ws.id
+                 WHERE w.id = :workshopid AND ws.authorid $usersql";
+
+        $params = $userparams + [
+            'workshopid' => $cm->instance,
+        ];
+
+        $assessmentids = $DB->get_fieldset_sql($sql, $params);
+
+        if ($assessmentids) {
+            list($assessmentidsql, $assessmentidparams) = $DB->get_in_or_equal($assessmentids, SQL_PARAMS_NAMED);
+
+            $DB->set_field_select('workshop_assessments', 'feedbackauthor', get_string('privacy:request:delete:content',
+                'mod_workshop'), "id $assessmentidsql", $assessmentidparams);
+
+            $fs->delete_area_files_select($context->id, 'mod_workshop', 'overallfeedback_content',
+                $assessmentidsql, $assessmentidparams);
+            $fs->delete_area_files_select($context->id, 'mod_workshop', 'overallfeedback_attachment',
+                $assessmentidsql, $assessmentidparams);
+        }
+
+        // Erase sensitive data in provided assessments records.
+
+        $sql = "SELECT wa.id AS assessmentid
+                  FROM {workshop} w
+                  JOIN {workshop_submissions} ws ON ws.workshopid = w.id
+                  JOIN {workshop_assessments} wa ON wa.submissionid = ws.id
+                 WHERE w.id = :workshopid AND wa.reviewerid $usersql";
+
+        $params = $userparams + [
+            'workshopid' => $cm->instance,
+        ];
+
+        $assessmentids = $DB->get_fieldset_sql($sql, $params);
+
+        if ($assessmentids) {
+            list($assessmentidsql, $assessmentidparams) = $DB->get_in_or_equal($assessmentids, SQL_PARAMS_NAMED);
+
+            $DB->set_field_select('workshop_assessments', 'feedbackreviewer', get_string('privacy:request:delete:content',
+                'mod_workshop'), "id $assessmentidsql", $assessmentidparams);
+        }
+
+        foreach ($userids as $userid) {
+            \core_plagiarism\privacy\provider::delete_plagiarism_for_user($userid, $context);
+        }
+    }
 }
index bf559e1..bb2978a 100644 (file)
@@ -276,7 +276,7 @@ $string['privacy:metadata:workshopgrades'] = 'Holds information about how the as
 $string['privacy:metadata:workshopid'] = 'ID of the workshop activity';
 $string['privacy:metadata:workshopsubmissions'] = 'Holds information about workshop module submissions';
 $string['privacy:request:delete:title'] = '[Deleted]';
-$string['privacy:request:delete:content'] = 'The content has been deleted at the request of the user.';
+$string['privacy:request:delete:content'] = 'The content has been deleted (requested by the user or expired)';
 $string['publishedsubmissions'] = 'Published submissions';
 $string['publishsubmission'] = 'Publish submission';
 $string['publishsubmission_help'] = 'Published submissions are available to the others when the workshop is closed.';
index 5c69516..e0c6d81 100644 (file)
@@ -204,6 +204,53 @@ class mod_workshop_privacy_provider_testcase extends advanced_testcase {
         $this->assertEquals([$context21->id, $context12->id], $contextlist->get_contextids(), null, 0.0, 10, true);
     }
 
+    /**
+     * Test {@link \mod_workshop\privacy\provider::get_users_in_context()} implementation.
+     */
+    public function test_get_users_in_context() {
+
+        $cm11 = get_coursemodule_from_instance('workshop', $this->workshop11->id);
+        $cm12 = get_coursemodule_from_instance('workshop', $this->workshop12->id);
+        $cm21 = get_coursemodule_from_instance('workshop', $this->workshop21->id);
+
+        $context11 = context_module::instance($cm11->id);
+        $context12 = context_module::instance($cm12->id);
+        $context21 = context_module::instance($cm21->id);
+
+        // Users in the workshop11.
+        $userlist11 = new \core_privacy\local\request\userlist($context11, 'mod_workshop');
+        \mod_workshop\privacy\provider::get_users_in_context($userlist11);
+        $expected11 = [
+            $this->student1->id, // Student1 has data in workshop11 (author + self reviewer).
+            $this->student2->id, // Student2 has data in workshop11 (reviewer).
+            $this->student3->id, // Student3 has data in workshop11 (reviewer).
+        ];
+        $actual11 = $userlist11->get_userids();
+        $this->assertEquals($expected11, $actual11, '', 0, 10, true);
+
+        // Users in the workshop12.
+        $userlist12 = new \core_privacy\local\request\userlist($context12, 'mod_workshop');
+        \mod_workshop\privacy\provider::get_users_in_context($userlist12);
+        $expected12 = [
+            $this->student1->id, // Student1 has data in workshop12 (author).
+            $this->student2->id, // Student2 has data in workshop12 (reviewer).
+            $this->teacher4->id, // Teacher4 has data in workshop12 (gradeoverby).
+        ];
+        $actual12 = $userlist12->get_userids();
+        $this->assertEquals($expected12, $actual12, '', 0, 10, true);
+
+        // Users in the workshop21.
+        $userlist21 = new \core_privacy\local\request\userlist($context21, 'mod_workshop');
+        \mod_workshop\privacy\provider::get_users_in_context($userlist21);
+        $expected21 = [
+            $this->student1->id, // Student1 has data in workshop21 (reviewer).
+            $this->student2->id, // Student2 has data in workshop21 (author).
+            $this->teacher4->id, // Teacher4 has data in workshop21 (gradinggradeoverby).
+        ];
+        $actual21 = $userlist21->get_userids();
+        $this->assertEquals($expected21, $actual21, '', 0, 10, true);
+    }
+
     /**
      * Test {@link \mod_workshop\privacy\provider::export_user_data()} implementation.
      */
@@ -376,4 +423,116 @@ class mod_workshop_privacy_provider_testcase extends advanced_testcase {
             }
         }
     }
+
+    /**
+     * Test {@link \mod_workshop\privacy\provider::delete_data_for_users()} implementation.
+     */
+    public function test_delete_data_for_users() {
+        global $DB;
+
+        // Student1 has submissions in two workshops.
+        $this->assertFalse($this->is_submission_erased($this->submission111));
+        $this->assertFalse($this->is_submission_erased($this->submission121));
+
+        // Student1 has self-assessed one their submission.
+        $this->assertFalse($this->is_given_assessment_erased($this->assessment1111));
+        $this->assertFalse($this->is_received_assessment_erased($this->assessment1111));
+
+        // Student2 and student3 peer-assessed student1's submission.
+        $this->assertFalse($this->is_given_assessment_erased($this->assessment1112));
+        $this->assertFalse($this->is_given_assessment_erased($this->assessment1113));
+
+        // Delete data owned by student1 and student3 in the workshop11.
+
+        $context11 = \context_module::instance($this->workshop11->cmid);
+
+        $approveduserlist = new \core_privacy\local\request\approved_userlist($context11, 'mod_workshop', [
+            $this->student1->id,
+            $this->student3->id,
+        ]);
+        \mod_workshop\privacy\provider::delete_data_for_users($approveduserlist);
+
+        // Student1's submission is erased in workshop11 but not in the other workshop12.
+        $this->assertTrue($this->is_submission_erased($this->submission111));
+        $this->assertFalse($this->is_submission_erased($this->submission121));
+
+        // Student1's self-assessment is erased.
+        $this->assertTrue($this->is_given_assessment_erased($this->assessment1111));
+        $this->assertTrue($this->is_received_assessment_erased($this->assessment1111));
+
+        // Student1's received peer-assessments are also erased because they are "owned" by the recipient of the assessment.
+        $this->assertTrue($this->is_received_assessment_erased($this->assessment1112));
+        $this->assertTrue($this->is_received_assessment_erased($this->assessment1113));
+
+        // Student2's owned data in the given assessment are not erased.
+        $this->assertFalse($this->is_given_assessment_erased($this->assessment1112));
+
+        // Student3's owned data in the given assessment were erased because she/he was in the userlist.
+        $this->assertTrue($this->is_given_assessment_erased($this->assessment1113));
+
+        // Personal data in other contexts are not affected.
+        $this->assertFalse($this->is_submission_erased($this->submission121));
+        $this->assertFalse($this->is_given_assessment_erased($this->assessment2121));
+        $this->assertFalse($this->is_received_assessment_erased($this->assessment2121));
+    }
+
+    /**
+     * Check if the given submission has the author's personal data erased.
+     *
+     * @param int $submissionid Identifier of the submission.
+     * @return boolean
+     */
+    protected function is_submission_erased(int $submissionid) {
+        global $DB;
+
+        $submission = $DB->get_record('workshop_submissions', ['id' => $submissionid], 'id, title, content', MUST_EXIST);
+
+        $titledeleted = $submission->title === get_string('privacy:request:delete:title', 'mod_workshop');
+        $contentdeleted = $submission->content === get_string('privacy:request:delete:content', 'mod_workshop');
+
+        if ($titledeleted && $contentdeleted) {
+            return true;
+
+        } else {
+            return false;
+        }
+    }
+
+    /**
+     * Check is the received assessment has recipient's (author's) personal data erased.
+     *
+     * @param int $assessmentid Identifier of the assessment.
+     * @return boolean
+     */
+    protected function is_received_assessment_erased(int $assessmentid) {
+        global $DB;
+
+        $assessment = $DB->get_record('workshop_assessments', ['id' => $assessmentid], 'id, feedbackauthor', MUST_EXIST);
+
+        if ($assessment->feedbackauthor === get_string('privacy:request:delete:content', 'mod_workshop')) {
+            return true;
+
+        } else {
+            return false;
+        }
+    }
+
+    /**
+     * Check is the given assessment has reviewer's personal data erased.
+     *
+     * @param int $assessmentid Identifier of the assessment.
+     * @return boolean
+     */
+    protected function is_given_assessment_erased(int $assessmentid) {
+        global $DB;
+
+        $assessment = $DB->get_record('workshop_assessments', ['id' => $assessmentid], 'id, feedbackreviewer', MUST_EXIST);
+
+        if ($assessment->feedbackreviewer === get_string('privacy:request:delete:content', 'mod_workshop')) {
+            return true;
+
+        } else {
+            return false;
+        }
+    }
 }