From 84a57322c2cbe5a832b8b38ce7938b6c3c697114 Mon Sep 17 00:00:00 2001 From: =?utf8?q?David=20Mudr=C3=A1k?= Date: Fri, 27 Apr 2018 10:53:58 +0200 Subject: [PATCH] MDL-61905 workshop: Implement the privacy API in the workshop core Workshop module stores personal data in its tables, via user preference and via core_files and core_plagiarism subsystems. When exporting the data, we export not only data created by users themselves (such as their submissions and provided peer-assessments) but also all relevant data that can (or must) be used to interpret created content and evaluate the user's performance and skills. On the other hand, when deleting data at user's request, we delete only those data that do not affect other users' performance evaluation. The reasoning is that one's right for privacy does not overweight someone else's right for fair assessment. For that reason, we can't fully delete whole provided peer-assessments, for example. Because they are used in cross-comparison and grading evaluation of all other peers who assessed the same submission. So instead, we replace provided texts but still keep the original record. Workshop defines the interface for its grading strategy subplugins to allow them attach personal data under their control to the exported structures. --- mod/workshop/classes/privacy/provider.php | 668 +++++++++++++++++++++ .../privacy/workshopform_legacy_polyfill.php | 58 ++ .../classes/privacy/workshopform_provider.php | 47 ++ mod/workshop/lang/en/workshop.php | 42 ++ mod/workshop/tests/generator/lib.php | 2 + mod/workshop/tests/privacy_provider_test.php | 379 ++++++++++++ 6 files changed, 1196 insertions(+) create mode 100644 mod/workshop/classes/privacy/provider.php create mode 100644 mod/workshop/classes/privacy/workshopform_legacy_polyfill.php create mode 100644 mod/workshop/classes/privacy/workshopform_provider.php create mode 100644 mod/workshop/tests/privacy_provider_test.php diff --git a/mod/workshop/classes/privacy/provider.php b/mod/workshop/classes/privacy/provider.php new file mode 100644 index 00000000000..3a073abf87d --- /dev/null +++ b/mod/workshop/classes/privacy/provider.php @@ -0,0 +1,668 @@ +. + +/** + * Defines {@link \mod_workshop\privacy\provider} class. + * + * @package mod_workshop + * @category privacy + * @copyright 2018 David Mudrák + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_workshop\privacy; + +use core_privacy\local\metadata\collection; +use core_privacy\local\request\approved_contextlist; +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\writer; + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->dirroot.'/mod/workshop/locallib.php'); + +/** + * Privacy API implementation for the Workshop activity module. + * + * @copyright 2018 David Mudrák + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class provider implements + \core_privacy\local\metadata\provider, + \core_privacy\local\request\user_preference_provider, + \core_privacy\local\request\plugin\provider { + + /** + * Describe all the places where the Workshop module stores some personal data. + * + * @param collection $collection Collection of items to add metadata to. + * @return collection Collection with our added items. + */ + public static function get_metadata(collection $collection) : collection { + + $collection->add_database_table('workshop_submissions', [ + 'workshopid' => 'privacy:metadata:workshopid', + 'authorid' => 'privacy:metadata:authorid', + 'example' => 'privacy:metadata:example', + 'timecreated' => 'privacy:metadata:timecreated', + 'timemodified' => 'privacy:metadata:timemodified', + 'title' => 'privacy:metadata:submissiontitle', + 'content' => 'privacy:metadata:submissioncontent', + 'contentformat' => 'privacy:metadata:submissioncontentformat', + 'grade' => 'privacy:metadata:submissiongrade', + 'gradeover' => 'privacy:metadata:submissiongradeover', + 'feedbackauthor' => 'privacy:metadata:feedbackauthor', + 'feedbackauthorformat' => 'privacy:metadata:feedbackauthorformat', + 'published' => 'privacy:metadata:published', + 'late' => 'privacy:metadata:late', + ], 'privacy:metadata:workshopsubmissions'); + + $collection->add_database_table('workshop_assessments', [ + 'submissionid' => 'privacy:metadata:submissionid', + 'reviewerid' => 'privacy:metadata:reviewerid', + 'weight' => 'privacy:metadata:weight', + 'timecreated' => 'privacy:metadata:timecreated', + 'timemodified' => 'privacy:metadata:timemodified', + 'grade' => 'privacy:metadata:assessmentgrade', + 'gradinggrade' => 'privacy:metadata:assessmentgradinggrade', + 'gradinggradeover' => 'privacy:metadata:assessmentgradinggradeover', + 'feedbackauthor' => 'privacy:metadata:feedbackauthor', + 'feedbackauthorformat' => 'privacy:metadata:feedbackauthorformat', + 'feedbackreviewer' => 'privacy:metadata:feedbackreviewer', + 'feedbackreviewerformat' => 'privacy:metadata:feedbackreviewerformat', + ], 'privacy:metadata:workshopassessments'); + + $collection->add_database_table('workshop_grades', [ + 'assessmentid' => 'privacy:metadata:assessmentid', + 'strategy' => 'privacy:metadata:strategy', + 'dimensionid' => 'privacy:metadata:dimensionid', + 'grade' => 'privacy:metadata:dimensiongrade', + 'peercomment' => 'privacy:metadata:peercomment', + 'peercommentformat' => 'privacy:metadata:peercommentformat', + ], 'privacy:metadata:workshopgrades'); + + $collection->add_database_table('workshop_aggregations', [ + 'workshopid' => 'privacy:metadata:workshopid', + 'userid' => 'privacy:metadata:userid', + 'gradinggrade' => 'privacy:metadata:aggregatedgradinggrade', + 'timegraded' => 'privacy:metadata:timeaggregated', + ], 'privacy:metadata:workshopaggregations'); + + $collection->add_subsystem_link('core_files', [], 'privacy:metadata:subsystem:corefiles'); + $collection->add_subsystem_link('core_plagiarism', [], 'privacy:metadata:subsystem:coreplagiarism'); + + $collection->add_user_preference('workshop_perpage', 'privacy:metadata:preference:perpage'); + + return $collection; + } + + /** + * Get the list of contexts that contain personal data for the specified user. + * + * User has personal data in the workshop if any of the following cases happens: + * + * - the user has submitted in the workshop + * - the user has overridden a submission grade + * - the user has been assigned as a reviewer of a submission + * - the user has overridden a grading grade + * - the user has a grading grade (existing or to be calculated) + * + * @param int $userid ID of the user. + * @return contextlist List of contexts containing the user's personal data. + */ + public static function get_contexts_for_userid(int $userid) : contextlist { + + $contextlist = new contextlist(); + + $sql = "SELECT ctx.id + FROM {course_modules} cm + JOIN {modules} m ON cm.module = m.id AND m.name = :module + JOIN {context} ctx ON ctx.contextlevel = :contextlevel AND ctx.instanceid = cm.id + JOIN {workshop} w ON cm.instance = w.id + LEFT 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 ws.authorid = :wsauthorid + OR ws.gradeoverby = :wsgradeoverby + OR wa.reviewerid = :wareviewerid + OR wa.gradinggradeoverby = :wagradinggradeoverby + OR wr.userid = :wruserid"; + + $params = [ + 'module' => 'workshop', + 'contextlevel' => CONTEXT_MODULE, + 'wsauthorid' => $userid, + 'wsgradeoverby' => $userid, + 'wareviewerid' => $userid, + 'wagradinggradeoverby' => $userid, + 'wruserid' => $userid, + ]; + + $contextlist->add_from_sql($sql, $params); + + return $contextlist; + } + + /** + * Export personal data stored in the given contexts. + * + * @param approved_contextlist $contextlist List of contexts approved for export. + */ + public static function export_user_data(approved_contextlist $contextlist) { + global $DB; + + if (!count($contextlist)) { + return; + } + + $user = $contextlist->get_user(); + + // Export general information about all workshops. + foreach ($contextlist->get_contexts() as $context) { + if ($context->contextlevel != CONTEXT_MODULE) { + continue; + } + $data = helper::get_context_data($context, $user); + static::append_extra_workshop_data($context, $user, $data, []); + writer::with_context($context)->export_data([], $data); + helper::export_context_files($context, $user); + } + + // Export the user's own submission and all example submissions he/she created. + static::export_submissions($contextlist); + + // Export all given assessments. + static::export_assessments($contextlist); + } + + /** + * Export user preferences controlled by this plugin. + * + * @param int $userid ID of the user we are exporting data for + */ + public static function export_user_preferences(int $userid) { + + $perpage = get_user_preferences('workshop_perpage', null, $userid); + + if ($perpage !== null) { + writer::export_user_preference('mod_workshop', 'workshop_perpage', $perpage, + get_string('privacy:metadata:preference:perpage', 'mod_workshop')); + } + } + + /** + * Append additional relevant data into the base data about the workshop instance. + * + * Relevant are data that are important for interpreting or evaluating the performance of the user expressed in + * his/her exported personal data. For example, we need to know what were the instructions for submissions or what + * was the phase of the workshop when it was exported. + * + * @param context $context Workshop module content. + * @param stdClass $user User for which we are exporting data. + * @param stdClass $data Base data about the workshop instance to append to. + * @param array $subcontext Subcontext path items to eventually write files into. + */ + protected static function append_extra_workshop_data(\context $context, \stdClass $user, \stdClass $data, array $subcontext) { + global $DB; + + if ($context->contextlevel != CONTEXT_MODULE) { + throw new \coding_exception('Unexpected context provided'); + } + + $sql = "SELECT w.instructauthors, w.instructauthorsformat, w.instructreviewers, w.instructreviewersformat, w.phase, + w.strategy, w.evaluation, w.latesubmissions, w.submissionstart, w.submissionend, w.assessmentstart, + w.assessmentend, w.conclusion, w.conclusionformat + FROM {course_modules} cm + JOIN {workshop} w ON cm.instance = w.id + WHERE cm.id = :cmid"; + + $params = [ + 'cmid' => $context->instanceid, + ]; + + $record = $DB->get_record_sql($sql, $params, MUST_EXIST); + $writer = writer::with_context($context); + + if ($record->phase >= \workshop::PHASE_SUBMISSION) { + $data->instructauthors = $writer->rewrite_pluginfile_urls($subcontext, 'mod_workshop', 'instructauthors', 0, + $record->instructauthors); + $data->instructauthorsformat = $record->instructauthorsformat; + } + + if ($record->phase >= \workshop::PHASE_ASSESSMENT) { + $data->instructreviewers = $writer->rewrite_pluginfile_urls($subcontext, 'mod_workshop', 'instructreviewers', 0, + $record->instructreviewers); + $data->instructreviewersformat = $record->instructreviewersformat; + } + + if ($record->phase >= \workshop::PHASE_CLOSED) { + $data->conclusion = $writer->rewrite_pluginfile_urls($subcontext, 'mod_workshop', 'conclusion', 0, $record->conclusion); + $data->conclusionformat = $record->conclusionformat; + } + + $data->strategy = \workshop::available_strategies_list()[$record->strategy]; + $data->evaluation = \workshop::available_evaluators_list()[$record->evaluation]; + $data->latesubmissions = transform::yesno($record->latesubmissions); + $data->submissionstart = $record->submissionstart ? transform::datetime($record->submissionstart) : null; + $data->submissionend = $record->submissionend ? transform::datetime($record->submissionend) : null; + $data->assessmentstart = $record->assessmentstart ? transform::datetime($record->assessmentstart) : null; + $data->assessmentend = $record->assessmentend ? transform::datetime($record->assessmentend) : null; + + switch ($record->phase) { + case \workshop::PHASE_SETUP: + $data->phase = get_string('phasesetup', 'mod_workshop'); + break; + case \workshop::PHASE_SUBMISSION: + $data->phase = get_string('phasesubmission', 'mod_workshop'); + break; + case \workshop::PHASE_ASSESSMENT: + $data->phase = get_string('phaseassessment', 'mod_workshop'); + break; + case \workshop::PHASE_EVALUATION: + $data->phase = get_string('phaseevaluation', 'mod_workshop'); + break; + case \workshop::PHASE_CLOSED: + $data->phase = get_string('phaseclosed', 'mod_workshop'); + break; + } + + $writer->export_area_files($subcontext, 'mod_workshop', 'instructauthors', 0); + $writer->export_area_files($subcontext, 'mod_workshop', 'instructreviewers', 0); + $writer->export_area_files($subcontext, 'mod_workshop', 'conclusion', 0); + } + + /** + * Export all user's submissions and example submissions he/she created in the given contexts. + * + * @param approved_contextlist $contextlist List of contexts approved for export. + */ + protected static function export_submissions(approved_contextlist $contextlist) { + global $DB; + + list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED); + $user = $contextlist->get_user(); + + $sql = "SELECT ws.id, ws.authorid, ws.example, ws.timecreated, ws.timemodified, ws.title, + ws.content, ws.contentformat, ws.grade, ws.gradeover, ws.feedbackauthor, ws.feedbackauthorformat, + ws.published, ws.late, + w.phase, w.course, cm.id AS cmid, ".\context_helper::get_preload_record_columns_sql('ctx')." + FROM {course_modules} cm + JOIN {modules} m ON cm.module = m.id AND m.name = :module + JOIN {context} ctx ON ctx.contextlevel = :contextlevel AND ctx.instanceid = cm.id + JOIN {workshop} w ON cm.instance = w.id + JOIN {workshop_submissions} ws ON ws.workshopid = w.id + WHERE ctx.id {$contextsql} + AND ws.authorid = :authorid"; + + $params = $contextparams + [ + 'module' => 'workshop', + 'contextlevel' => CONTEXT_MODULE, + 'authorid' => $user->id, + ]; + + $rs = $DB->get_recordset_sql($sql, $params); + + foreach ($rs as $record) { + \context_helper::preload_from_record($record); + $context = \context_module::instance($record->cmid); + $writer = \core_privacy\local\request\writer::with_context($context); + + if ($record->example) { + $subcontext = [get_string('examplesubmissions', 'mod_workshop'), $record->id]; + $mysubmission = null; + } else { + $subcontext = [get_string('mysubmission', 'mod_workshop')]; + $mysubmission = $record; + } + + $phase = $record->phase; + $courseid = $record->course; + + $data = (object) [ + 'example' => transform::yesno($record->example), + 'timecreated' => transform::datetime($record->timecreated), + 'timemodified' => $record->timemodified ? transform::datetime($record->timemodified) : null, + 'title' => $record->title, + 'content' => $writer->rewrite_pluginfile_urls($subcontext, 'mod_workshop', + 'submission_content', $record->id, $record->content), + 'contentformat' => $record->contentformat, + 'grade' => $record->grade, + 'gradeover' => $record->gradeover, + 'feedbackauthor' => $record->feedbackauthor, + 'feedbackauthorformat' => $record->feedbackauthorformat, + 'published' => transform::yesno($record->published), + 'late' => transform::yesno($record->late), + ]; + + $writer->export_data($subcontext, $data); + $writer->export_area_files($subcontext, 'mod_workshop', 'submission_content', $record->id); + $writer->export_area_files($subcontext, 'mod_workshop', 'submission_attachment', $record->id); + + // Export peer-assessments of my submission if the workshop was closed. We do not export received + // assessments from peers before they were actually effective. Before the workshop is closed, grades are not + // pushed into the gradebook. So peer assessments did not affect evaluation of the user's performance and + // they should not be considered as their personal data. This is different from assessments given by the + // user that are always exported. + if ($mysubmission && $phase == \workshop::PHASE_CLOSED) { + $assessments = $DB->get_records('workshop_assessments', ['submissionid' => $mysubmission->id], '', + 'id, reviewerid, weight, timecreated, timemodified, grade, feedbackauthor, feedbackauthorformat'); + + foreach ($assessments as $assessment) { + $assid = $assessment->id; + $assessment->selfassessment = transform::yesno($assessment->reviewerid == $user->id); + $assessment->timecreated = transform::datetime($assessment->timecreated); + $assessment->timemodified = $assessment->timemodified ? transform::datetime($assessment->timemodified) : null; + $assessment->feedbackauthor = $writer->rewrite_pluginfile_urls($subcontext, + 'mod_workshop', 'overallfeedback_content', $assid, $assessment->feedbackauthor); + + $assessmentsubcontext = array_merge($subcontext, [get_string('assessments', 'mod_workshop'), $assid]); + + unset($assessment->id); + unset($assessment->reviewerid); + + $writer->export_data($assessmentsubcontext, $assessment); + $writer->export_area_files($assessmentsubcontext, 'mod_workshop', 'overallfeedback_content', $assid); + $writer->export_area_files($assessmentsubcontext, 'mod_workshop', 'overallfeedback_attachment', $assid); + + // Export details of how the assessment forms were filled. + static::export_assessment_forms($user, $context, $assessmentsubcontext, $assid); + } + } + + // Export plagiarism data related to the submission content. + // The last $linkarray argument consistent with how we call {@link plagiarism_get_links()} in the renderer. + \core_plagiarism\privacy\provider::export_plagiarism_user_data($user->id, $context, $subcontext, [ + 'userid' => $user->id, + 'content' => format_text($data->content, $data->contentformat, ['overflowdiv' => true]), + 'cmid' => $context->instanceid, + 'course' => $courseid, + ]); + } + + $rs->close(); + } + + /** + * Export all assessments given by the user. + * + * @param approved_contextlist $contextlist List of contexts approved for export. + */ + protected static function export_assessments(approved_contextlist $contextlist) { + global $DB; + + list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED); + $user = $contextlist->get_user(); + + $sql = "SELECT ws.authorid, ws.example, ws.timecreated, ws.timemodified, ws.title, ws.content, ws.contentformat, + wa.id, wa.submissionid, wa.reviewerid, wa.weight, wa.timecreated, wa.timemodified, wa.grade, + wa.gradinggrade, wa.gradinggradeover, wa.feedbackauthor, wa.feedbackauthorformat, wa.feedbackreviewer, + wa.feedbackreviewerformat, cm.id AS cmid, ".\context_helper::get_preload_record_columns_sql('ctx')." + FROM {course_modules} cm + JOIN {modules} m ON cm.module = m.id AND m.name = :module + JOIN {context} ctx ON cm.id = ctx.instanceid AND ctx.contextlevel = :contextlevel + JOIN {workshop} w ON cm.instance = w.id + JOIN {workshop_submissions} ws ON ws.workshopid = w.id + JOIN {workshop_assessments} wa ON wa.submissionid = ws.id + WHERE ctx.id {$contextsql} + AND wa.reviewerid = :reviewerid"; + + $params = $contextparams + [ + 'module' => 'workshop', + 'contextlevel' => CONTEXT_MODULE, + 'reviewerid' => $user->id, + ]; + + $rs = $DB->get_recordset_sql($sql, $params); + + foreach ($rs as $record) { + \context_helper::preload_from_record($record); + $context = \context_module::instance($record->cmid); + $writer = \core_privacy\local\request\writer::with_context($context); + $subcontext = [get_string('myassessments', 'mod_workshop'), $record->id]; + + $data = (object) [ + 'weight' => $record->weight, + 'timecreated' => transform::datetime($record->timecreated), + 'timemodified' => $record->timemodified ? transform::datetime($record->timemodified) : null, + 'grade' => $record->grade, + 'gradinggrade' => $record->gradinggrade, + 'gradinggradeover' => $record->gradinggradeover, + 'feedbackauthor' => $writer->rewrite_pluginfile_urls($subcontext, 'mod_workshop', + 'overallfeedback_content', $record->id, $record->feedbackauthor), + 'feedbackauthorformat' => $record->feedbackauthorformat, + 'feedbackreviewer' => $record->feedbackreviewer, + 'feedbackreviewerformat' => $record->feedbackreviewerformat, + ]; + + $submission = (object) [ + 'myownsubmission' => transform::yesno($record->authorid == $user->id), + 'example' => transform::yesno($record->example), + 'timecreated' => transform::datetime($record->timecreated), + 'timemodified' => $record->timemodified ? transform::datetime($record->timemodified) : null, + 'title' => $record->title, + 'content' => $writer->rewrite_pluginfile_urls($subcontext, 'mod_workshop', + 'submission_content', $record->submissionid, $record->content), + 'contentformat' => $record->contentformat, + ]; + + $writer->export_data($subcontext, $data); + $writer->export_related_data($subcontext, 'submission', $submission); + $writer->export_area_files($subcontext, 'mod_workshop', 'overallfeedback_content', $record->id); + $writer->export_area_files($subcontext, 'mod_workshop', 'overallfeedback_attachment', $record->id); + $writer->export_area_files($subcontext, 'mod_workshop', 'submission_content', $record->submissionid); + $writer->export_area_files($subcontext, 'mod_workshop', 'submission_attachment', $record->submissionid); + + // Export details of how the assessment forms were filled. + static::export_assessment_forms($user, $context, $subcontext, $record->id); + } + + $rs->close(); + } + + /** + * Export the grading strategy data related to the particular assessment. + * + * @param stdClass $user User we are exporting for + * @param context $context Workshop activity content + * @param array $subcontext Subcontext path of the assessment + * @param int $assessmentid ID of the exported assessment + */ + protected static function export_assessment_forms(\stdClass $user, \context $context, array $subcontext, int $assessmentid) { + + foreach (\workshop::available_strategies_list() as $strategy => $title) { + $providername = '\workshopform_'.$strategy.'\privacy\provider'; + + if (is_subclass_of($providername, '\mod_workshop\privacy\workshopform_provider')) { + component_class_callback($providername, 'export_assessment_form', + [ + $user, + $context, + array_merge($subcontext, [get_string('assessmentform', 'mod_workshop'), $title]), + $assessmentid, + ] + ); + + } else { + debugging('Missing class '.$providername.' implementing workshopform_provider interface', DEBUG_DEVELOPER); + } + } + } + + /** + * Delete personal data for all users in the context. + * + * @param context $context Context to delete personal data from. + */ + public static function delete_data_for_all_users_in_context(\context $context) { + global $CFG, $DB; + require_once($CFG->libdir.'/gradelib.php'); + + if ($context->contextlevel != CONTEXT_MODULE) { + return; + } + + $cm = get_coursemodule_from_id('workshop', $context->instanceid, 0, false, IGNORE_MISSING); + + if (!$cm) { + // Probably some kind of expired context. + return; + } + + $workshop = $DB->get_record('workshop', ['id' => $cm->instance], 'id, course', MUST_EXIST); + + $submissions = $DB->get_records('workshop_submissions', ['workshopid' => $workshop->id], '', 'id'); + $assessments = $DB->get_records_list('workshop_assessments', 'submissionid', array_keys($submissions), '', 'id'); + + $DB->delete_records('workshop_aggregations', ['workshopid' => $workshop->id]); + $DB->delete_records_list('workshop_grades', 'assessmentid', array_keys($assessments)); + $DB->delete_records_list('workshop_assessments', 'id', array_keys($assessments)); + $DB->delete_records_list('workshop_submissions', 'id', array_keys($submissions)); + + $fs = get_file_storage(); + $fs->delete_area_files($context->id, 'mod_workshop', 'submission_content'); + $fs->delete_area_files($context->id, 'mod_workshop', 'submission_attachment'); + $fs->delete_area_files($context->id, 'mod_workshop', 'overallfeedback_content'); + $fs->delete_area_files($context->id, 'mod_workshop', 'overallfeedback_attachment'); + + grade_update('mod/workshop', $workshop->course, 'mod', 'workshop', $workshop->id, 0, null, ['reset' => true]); + grade_update('mod/workshop', $workshop->course, 'mod', 'workshop', $workshop->id, 1, null, ['reset' => true]); + + \core_plagiarism\privacy\provider::delete_plagiarism_for_context($context); + } + + /** + * Delete personal data for the user in a list of contexts. + * + * Removing assessments of submissions from the Workshop is not trivial. Removing one user's data can easily affect + * other users' grades and completion criteria. So we replace the non-essential contents with a "deleted" message, + * but keep the actual info in place. The argument is that one's right for privacy should not overweight others' + * right for accessing their own personal data and be evaluated on their basis. + * + * @param approved_contextlist $contextlist List of contexts to delete data from. + */ + public static function delete_data_for_user(approved_contextlist $contextlist) { + global $DB; + + list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED); + $user = $contextlist->get_user(); + $fs = get_file_storage(); + + // Replace sensitive data in all submissions by the user in the given contexts. + + $sql = "SELECT ws.id AS submissionid + FROM {course_modules} cm + JOIN {modules} m ON cm.module = m.id AND m.name = :module + JOIN {context} ctx ON ctx.contextlevel = :contextlevel AND ctx.instanceid = cm.id + JOIN {workshop} w ON cm.instance = w.id + JOIN {workshop_submissions} ws ON ws.workshopid = w.id + WHERE ctx.id {$contextsql} + AND ws.authorid = :authorid"; + + $params = $contextparams + [ + 'module' => 'workshop', + 'contextlevel' => CONTEXT_MODULE, + 'authorid' => $user->id, + ]; + + $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); + + foreach ($contextlist->get_contextids() as $contextid) { + $fs->delete_area_files_select($contextid, 'mod_workshop', 'submission_content', + $submissionidsql, $submissionidparams); + $fs->delete_area_files_select($contextid, 'mod_workshop', 'submission_attachment', + $submissionidsql, $submissionidparams); + } + } + + // Replace personal data in received assessments - feedback is seen as belonging to the recipient. + + $sql = "SELECT wa.id AS assessmentid + FROM {course_modules} cm + JOIN {modules} m ON cm.module = m.id AND m.name = :module + JOIN {context} ctx ON cm.id = ctx.instanceid AND ctx.contextlevel = :contextlevel + JOIN {workshop} w ON cm.instance = w.id + JOIN {workshop_submissions} ws ON ws.workshopid = w.id + JOIN {workshop_assessments} wa ON wa.submissionid = ws.id + WHERE ctx.id {$contextsql} + AND ws.authorid = :authorid"; + + $params = $contextparams + [ + 'module' => 'workshop', + 'contextlevel' => CONTEXT_MODULE, + 'authorid' => $user->id, + ]; + + $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); + + foreach ($contextlist->get_contextids() as $contextid) { + $fs->delete_area_files_select($contextid, 'mod_workshop', 'overallfeedback_content', + $assessmentidsql, $assessmentidparams); + $fs->delete_area_files_select($contextid, 'mod_workshop', 'overallfeedback_attachment', + $assessmentidsql, $assessmentidparams); + } + } + + // Replace sensitive data in provided assessments records. + + $sql = "SELECT wa.id AS assessmentid + FROM {course_modules} cm + JOIN {modules} m ON cm.module = m.id AND m.name = :module + JOIN {context} ctx ON cm.id = ctx.instanceid AND ctx.contextlevel = :contextlevel + JOIN {workshop} w ON cm.instance = w.id + JOIN {workshop_submissions} ws ON ws.workshopid = w.id + JOIN {workshop_assessments} wa ON wa.submissionid = ws.id + WHERE ctx.id {$contextsql} + AND wa.reviewerid = :reviewerid"; + + $params = $contextparams + [ + 'module' => 'workshop', + 'contextlevel' => CONTEXT_MODULE, + 'reviewerid' => $user->id, + ]; + + $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 ($contextlist as $context) { + \core_plagiarism\privacy\provider::delete_plagiarism_for_user($user->id, $context); + } + } +} diff --git a/mod/workshop/classes/privacy/workshopform_legacy_polyfill.php b/mod/workshop/classes/privacy/workshopform_legacy_polyfill.php new file mode 100644 index 00000000000..bb0094f4cfa --- /dev/null +++ b/mod/workshop/classes/privacy/workshopform_legacy_polyfill.php @@ -0,0 +1,58 @@ +. + +/** + * Provides {@link mod_workshop\privacy\workshopform_legacy_polyfill} trait. + * + * @package mod_workshop + * @category privacy + * @copyright 2018 David Mudrák + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_workshop\privacy; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Trait allowing additional (contrib) plugins to have single codebase for 3.3 and 3.4. + * + * The signature of the method in the {@link \mod_workshop\privacy\workshopform_provider} interface makes use of scalar + * type hinting that is available in PHP 7.0 only. If a plugin wants to implement the interface in 3.3 (and therefore + * PHP 5.6) with the same codebase, they can make use of this trait. Instead of implementing the interface directly, the + * workshopform plugin can implement the required logic in the method (note the underscore and missing "int" hint): + * + * public static function _export_assessment_form(\stdClass $user, \context $context, array $subcontext, $assessmentid) + * + * and then simply use this trait in their provider class. + * + * @copyright 2018 David Mudrák + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +trait workshopform_legacy_polyfill { + + /** + * Return details of the filled assessment form. + * + * @param stdClass $user User we are exporting data for + * @param context $context The workshop activity context + * @param array $subcontext Subcontext within the context to export to + * @param int $assessmentid ID of the assessment + */ + public static function export_assessment_form(\stdClass $user, \context $context, array $subcontext, int $assessmentid) { + return static::_export_assessment_form($user, $context, $subcontext, $assessmentid); + } +} diff --git a/mod/workshop/classes/privacy/workshopform_provider.php b/mod/workshop/classes/privacy/workshopform_provider.php new file mode 100644 index 00000000000..152ead34ec8 --- /dev/null +++ b/mod/workshop/classes/privacy/workshopform_provider.php @@ -0,0 +1,47 @@ +. + +/** + * Provides {@link mod_workshop\privacy\workshopform_provider} interface. + * + * @package mod_workshop + * @category privacy + * @copyright 2018 David Mudrák + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_workshop\privacy; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Interface for grading strategy subplugins implementing the privacy API. + * + * @copyright 2018 David Mudrák + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +interface workshopform_provider extends \core_privacy\local\request\plugin\subplugin_provider { + + /** + * Return details of the filled assessment form. + * + * @param stdClass $user User we are exporting data for + * @param context $context The workshop activity context + * @param array $subcontext Subcontext within the context to export to + * @param int $assessmentid ID of the assessment + */ + public static function export_assessment_form(\stdClass $user, \context $context, array $subcontext, int $assessmentid); +} diff --git a/mod/workshop/lang/en/workshop.php b/mod/workshop/lang/en/workshop.php index a7b383f3877..6c669e5dfa7 100644 --- a/mod/workshop/lang/en/workshop.php +++ b/mod/workshop/lang/en/workshop.php @@ -64,6 +64,7 @@ $string['assessmentofsubmission'] = 'Assessment $timenow, 'timemodified' => $timenow, 'grade' => null, + 'feedbackauthor' => '', + 'feedbackreviewer' => '', ); $id = $DB->insert_record('workshop_assessments', $record); diff --git a/mod/workshop/tests/privacy_provider_test.php b/mod/workshop/tests/privacy_provider_test.php new file mode 100644 index 00000000000..5c6951617e0 --- /dev/null +++ b/mod/workshop/tests/privacy_provider_test.php @@ -0,0 +1,379 @@ +. + +/** + * Provides the {@link mod_workshop_privacy_provider_testcase} class. + * + * @package mod_workshop + * @category test + * @copyright 2018 David Mudrák + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; + +use core_privacy\local\request\writer; + +/** + * Unit tests for the privacy API implementation. + * + * @copyright 2018 David Mudrák + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class mod_workshop_privacy_provider_testcase extends advanced_testcase { + + /** @var testing_data_generator */ + protected $generator; + + /** @var mod_workshop_generator */ + protected $workshopgenerator; + + /** @var stdClass */ + protected $course1; + + /** @var stdClass */ + protected $course2; + + /** @var stdClass */ + protected $student1; + + /** @var stdClass */ + protected $student2; + + /** @var stdClass */ + protected $student3; + + /** @var stdClass */ + protected $teacher4; + + /** @var stdClass first workshop in course1 */ + protected $workshop11; + + /** @var stdClass second workshop in course1 */ + protected $workshop12; + + /** @var stdClass first workshop in course2 */ + protected $workshop21; + + /** @var int ID of the submission in workshop11 by student1 */ + protected $submission111; + + /** @var int ID of the submission in workshop12 by student1 */ + protected $submission121; + + /** @var int ID of the submission in workshop12 by student2 */ + protected $submission122; + + /** @var int ID of the submission in workshop21 by student2 */ + protected $submission212; + + /** @var int ID of the assessment of submission111 by student1 */ + protected $assessment1111; + + /** @var int ID of the assessment of submission111 by student2 */ + protected $assessment1112; + + /** @var int ID of the assessment of submission111 by student3 */ + protected $assessment1113; + + /** @var int ID of the assessment of submission121 by student2 */ + protected $assessment1212; + + /** @var int ID of the assessment of submission212 by student1 */ + protected $assessment2121; + + /** + * Set up the test environment. + * + * course1 + * | + * +--workshop11 (first digit matches the course, second is incremental) + * | | + * | +--submission111 (first two digits match the workshop, last one matches the author) + * | | + * | +--assessment1111 (first three digits match the submission, last one matches the reviewer) + * | +--assessment1112 + * | +--assessment1113 + * | + * +--workshop12 + * | + * +--submission121 + * | | + * | +--assessment1212 + * | + * +--submission122 + * + * etc. + */ + protected function setUp() { + global $DB; + $this->resetAfterTest(); + $this->setAdminUser(); + + $this->generator = $this->getDataGenerator(); + $this->workshopgenerator = $this->generator->get_plugin_generator('mod_workshop'); + + $this->course1 = $this->generator->create_course(); + $this->course2 = $this->generator->create_course(); + + $this->workshop11 = $this->generator->create_module('workshop', [ + 'course' => $this->course1, + 'name' => 'Workshop11', + ]); + $DB->set_field('workshop', 'phase', 50, ['id' => $this->workshop11->id]); + + $this->workshop12 = $this->generator->create_module('workshop', ['course' => $this->course1]); + $this->workshop21 = $this->generator->create_module('workshop', ['course' => $this->course2]); + + $this->student1 = $this->generator->create_user(); + $this->student2 = $this->generator->create_user(); + $this->student3 = $this->generator->create_user(); + $this->teacher4 = $this->generator->create_user(); + + $this->submission111 = $this->workshopgenerator->create_submission($this->workshop11->id, $this->student1->id); + $this->submission121 = $this->workshopgenerator->create_submission($this->workshop12->id, $this->student1->id, + ['gradeoverby' => $this->teacher4->id]); + $this->submission122 = $this->workshopgenerator->create_submission($this->workshop12->id, $this->student2->id); + $this->submission212 = $this->workshopgenerator->create_submission($this->workshop21->id, $this->student2->id); + + $this->assessment1111 = $this->workshopgenerator->create_assessment($this->submission111, $this->student1->id, [ + 'grade' => null, + ]); + $this->assessment1112 = $this->workshopgenerator->create_assessment($this->submission111, $this->student2->id, [ + 'grade' => 92, + ]); + $this->assessment1113 = $this->workshopgenerator->create_assessment($this->submission111, $this->student3->id); + + $this->assessment1212 = $this->workshopgenerator->create_assessment($this->submission121, $this->student2->id, [ + 'feedbackauthor' => 'This is what student 2 thinks about submission 121', + 'feedbackreviewer' => 'This is what the teacher thinks about this assessment', + ]); + + $this->assessment2121 = $this->workshopgenerator->create_assessment($this->submission212, $this->student1->id, [ + 'grade' => 68, + 'gradinggradeover' => 80, + 'gradinggradeoverby' => $this->teacher4->id, + 'feedbackauthor' => 'This is what student 1 thinks about submission 212', + 'feedbackreviewer' => 'This is what the teacher thinks about this assessment', + ]); + } + + /** + * Test {@link \mod_workshop\privacy\provider::get_contexts_for_userid()} implementation. + */ + public function test_get_contexts_for_userid() { + + $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); + + // Student1 has data in workshop11 (author + self reviewer), workshop12 (author) and workshop21 (reviewer). + $contextlist = \mod_workshop\privacy\provider::get_contexts_for_userid($this->student1->id); + $this->assertInstanceOf(\core_privacy\local\request\contextlist::class, $contextlist); + $this->assertEquals([$context11->id, $context12->id, $context21->id], $contextlist->get_contextids(), null, 0.0, 10, true); + + // Student2 has data in workshop11 (reviewer), workshop12 (reviewer) and workshop21 (author). + $contextlist = \mod_workshop\privacy\provider::get_contexts_for_userid($this->student2->id); + $this->assertEquals([$context11->id, $context12->id, $context21->id], $contextlist->get_contextids(), null, 0.0, 10, true); + + // Student3 has data in workshop11 (reviewer). + $contextlist = \mod_workshop\privacy\provider::get_contexts_for_userid($this->student3->id); + $this->assertEquals([$context11->id], $contextlist->get_contextids(), null, 0.0, 10, true); + + // Teacher4 has data in workshop12 (gradeoverby) and workshop21 (gradinggradeoverby). + $contextlist = \mod_workshop\privacy\provider::get_contexts_for_userid($this->teacher4->id); + $this->assertEquals([$context21->id, $context12->id], $contextlist->get_contextids(), null, 0.0, 10, true); + } + + /** + * Test {@link \mod_workshop\privacy\provider::export_user_data()} implementation. + */ + public function test_export_user_data_1() { + + $contextlist = new \core_privacy\local\request\approved_contextlist($this->student1, 'mod_workshop', [ + \context_module::instance($this->workshop11->cmid)->id, + \context_module::instance($this->workshop12->cmid)->id, + ]); + + \mod_workshop\privacy\provider::export_user_data($contextlist); + + $writer = writer::with_context(\context_module::instance($this->workshop11->cmid)); + + $workshop = $writer->get_data([]); + $this->assertEquals('Workshop11', $workshop->name); + $this->assertObjectHasAttribute('phase', $workshop); + + $mysubmission = $writer->get_data([ + get_string('mysubmission', 'mod_workshop'), + ]); + + $mysubmissionselfassessmentwithoutgrade = $writer->get_data([ + get_string('mysubmission', 'mod_workshop'), + get_string('assessments', 'mod_workshop'), + $this->assessment1111, + ]); + $this->assertNull($mysubmissionselfassessmentwithoutgrade->grade); + $this->assertEquals(get_string('yes'), $mysubmissionselfassessmentwithoutgrade->selfassessment); + + $mysubmissionassessmentwithgrade = $writer->get_data([ + get_string('mysubmission', 'mod_workshop'), + get_string('assessments', 'mod_workshop'), + $this->assessment1112, + ]); + $this->assertEquals(92, $mysubmissionassessmentwithgrade->grade); + $this->assertEquals(get_string('no'), $mysubmissionassessmentwithgrade->selfassessment); + + $mysubmissionassessmentwithoutgrade = $writer->get_data([ + get_string('mysubmission', 'mod_workshop'), + get_string('assessments', 'mod_workshop'), + $this->assessment1113, + ]); + $this->assertEquals(null, $mysubmissionassessmentwithoutgrade->grade); + $this->assertEquals(get_string('no'), $mysubmissionassessmentwithoutgrade->selfassessment); + + $myassessments = $writer->get_data([ + get_string('myassessments', 'mod_workshop'), + ]); + $this->assertEmpty($myassessments); + } + + /** + * Test {@link \mod_workshop\privacy\provider::export_user_data()} implementation. + */ + public function test_export_user_data_2() { + + $contextlist = new \core_privacy\local\request\approved_contextlist($this->student2, 'mod_workshop', [ + \context_module::instance($this->workshop11->cmid)->id, + ]); + + \mod_workshop\privacy\provider::export_user_data($contextlist); + + $writer = writer::with_context(\context_module::instance($this->workshop11->cmid)); + + $assessedsubmission = $writer->get_related_data([ + get_string('myassessments', 'mod_workshop'), + $this->assessment1112, + ], 'submission'); + $this->assertEquals(get_string('no'), $assessedsubmission->myownsubmission); + } + + /** + * Test {@link \mod_workshop\privacy\provider::delete_data_for_all_users_in_context()} implementation. + */ + public function test_delete_data_for_all_users_in_context() { + global $DB; + + $this->assertTrue($DB->record_exists('workshop_submissions', ['workshopid' => $this->workshop11->id])); + + // Passing a non-module context does nothing. + \mod_workshop\privacy\provider::delete_data_for_all_users_in_context(\context_course::instance($this->course1->id)); + $this->assertTrue($DB->record_exists('workshop_submissions', ['workshopid' => $this->workshop11->id])); + + // Passing a workshop context removes all data. + \mod_workshop\privacy\provider::delete_data_for_all_users_in_context(\context_module::instance($this->workshop11->cmid)); + $this->assertFalse($DB->record_exists('workshop_submissions', ['workshopid' => $this->workshop11->id])); + } + + /** + * Test {@link \mod_workshop\privacy\provider::delete_data_for_user()} implementation. + */ + public function test_delete_data_for_user() { + global $DB; + + $student1submissions = $DB->get_records('workshop_submissions', [ + 'workshopid' => $this->workshop12->id, + 'authorid' => $this->student1->id, + ]); + + $student2submissions = $DB->get_records('workshop_submissions', [ + 'workshopid' => $this->workshop12->id, + 'authorid' => $this->student2->id, + ]); + + $this->assertNotEmpty($student1submissions); + $this->assertNotEmpty($student2submissions); + + foreach ($student1submissions as $submission) { + $this->assertNotEquals(get_string('privacy:request:delete:title', 'mod_workshop'), $submission->title); + } + + foreach ($student2submissions as $submission) { + $this->assertNotEquals(get_string('privacy:request:delete:title', 'mod_workshop'), $submission->title); + } + + $contextlist = new \core_privacy\local\request\approved_contextlist($this->student1, 'mod_workshop', [ + \context_module::instance($this->workshop12->cmid)->id, + \context_module::instance($this->workshop21->cmid)->id, + ]); + + \mod_workshop\privacy\provider::delete_data_for_user($contextlist); + + $student1submissions = $DB->get_records('workshop_submissions', [ + 'workshopid' => $this->workshop12->id, + 'authorid' => $this->student1->id, + ]); + + $student2submissions = $DB->get_records('workshop_submissions', [ + 'workshopid' => $this->workshop12->id, + 'authorid' => $this->student2->id, + ]); + + $this->assertNotEmpty($student1submissions); + $this->assertNotEmpty($student2submissions); + + foreach ($student1submissions as $submission) { + $this->assertEquals(get_string('privacy:request:delete:title', 'mod_workshop'), $submission->title); + } + + foreach ($student2submissions as $submission) { + $this->assertNotEquals(get_string('privacy:request:delete:title', 'mod_workshop'), $submission->title); + } + + $student1assessments = $DB->get_records('workshop_assessments', [ + 'submissionid' => $this->submission212, + 'reviewerid' => $this->student1->id, + ]); + $this->assertNotEmpty($student1assessments); + + foreach ($student1assessments as $assessment) { + // In Moodle, feedback is seen to belong to the recipient user. + $this->assertNotEquals(get_string('privacy:request:delete:content', 'mod_workshop'), $assessment->feedbackauthor); + $this->assertEquals(get_string('privacy:request:delete:content', 'mod_workshop'), $assessment->feedbackreviewer); + // We delete what we can without affecting others' grades. + $this->assertEquals(68, $assessment->grade); + } + + $assessments = $DB->get_records_list('workshop_assessments', 'submissionid', array_keys($student1submissions)); + $this->assertNotEmpty($assessments); + + foreach ($assessments as $assessment) { + if ($assessment->reviewerid == $this->student1->id) { + $this->assertNotEquals(get_string('privacy:request:delete:content', 'mod_workshop'), $assessment->feedbackauthor); + $this->assertNotEquals(get_string('privacy:request:delete:content', 'mod_workshop'), $assessment->feedbackreviewer); + + } else { + $this->assertEquals(get_string('privacy:request:delete:content', 'mod_workshop'), $assessment->feedbackauthor); + $this->assertNotEquals(get_string('privacy:request:delete:content', 'mod_workshop'), $assessment->feedbackreviewer); + } + } + } +} -- 2.11.4.GIT