diff --git a/mod/workshop/classes/privacy/provider.php b/mod/workshop/classes/privacy/provider.php new file mode 100644 index 00000000000..97d8b97de99 --- /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->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($userid) { + + $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($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, $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..f9200bc6ebc --- /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, $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..15a66cde8d7 --- /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, $assessmentid); +} diff --git a/mod/workshop/lang/en/workshop.php b/mod/workshop/lang/en/workshop.php index 1a4c2004e6f..5b6ce1be35d 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); + } + } + } +}