From 2c00395189c42d550f3a4754e80460dd6bac97b5 Mon Sep 17 00:00:00 2001 From: Cameron Ball Date: Fri, 6 Jul 2018 11:04:18 +0800 Subject: [PATCH] Implement data privacy provider. --- classes/privacy/provider.php | 495 +++++++++++++++++++++++++++++++++++++++++++ lang/en/attendance.php | 24 ++- 2 files changed, 518 insertions(+), 1 deletion(-) create mode 100644 classes/privacy/provider.php diff --git a/classes/privacy/provider.php b/classes/privacy/provider.php new file mode 100644 index 0000000..d8c6e22 --- /dev/null +++ b/classes/privacy/provider.php @@ -0,0 +1,495 @@ +. + +/** + * mod_attendance Data provider. + * + * @package mod_attendance + * @copyright 2018 Cameron Ball + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_attendance\privacy; +defined('MOODLE_INTERNAL') || die(); + +use context; +use context_module; +use core_privacy\local\metadata\collection; +use core_privacy\local\request\{writer, transform, helper, contextlist, approved_contextlist}; +use stdClass; + +/** + * Data provider for mod_attendance. + * + * @copyright 2018 Cameron Ball + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +final class provider implements + \core_privacy\local\request\plugin\provider, + \core_privacy\local\metadata\provider +{ + + /** + * Returns meta data about this system. + * + * @param collection $collection The initialised collection to add items to. + * @return collection A listing of user data stored through this system. + */ + public static function get_metadata(collection $collection) : collection { + $collection->add_database_table( + 'attendance_log', + [ + 'sessionid' => 'privacy:metadata:sessionid', + 'studentid' => 'privacy:metadata:studentid', + 'statusid' => 'privacy:metadata:statusid', + 'statusset' => 'privacy:metadata:statusset', + 'timetaken' => 'privacy:metadata:timetaken', + 'takenby' => 'privacy:metadata:takenby', + 'remarks' => 'privacy:metadata:remarks', + 'ipaddress' => 'privacy:metadata:ipaddress' + ], + 'privacy:metadata:attendancelog' + ); + + $collection->add_database_table( + 'attendance_sessions', + [ + 'groupid' => 'privacy:metadata:groupid', + 'sessdate' => 'privacy:metadata:sessdate', + 'duration' => 'privacy:metadata:duration', + 'lasttaken' => 'privacy:metadata:lasttaken', + 'lasttakenby' => 'privacy:metadata:lasttakenby', + 'timemodified' => 'privacy:metadata:timemodified' + ], + 'privacy:metadata:attendancesessions' + ); + + $collection->add_database_table( + 'attendance_warning_done', + [ + 'notifyid' => 'privacy:metadata:notifyid', + 'userid' => 'privacy:metadata:userid', + 'timesent' => 'privacy:metadata:timesent' + ], + 'privacy:metadata:attendancewarningdone' + ); + + return $collection; + } + + /** + * Get the list of contexts that contain user information for the specified user. + * + * In the case of attendance, that is any attendance where a student has had their + * attendance taken or has taken attendance for someone else. + * + * @param int $userid The user to search. + * @return contextlist $contextlist The contextlist containing the list of contexts used in this plugin. + */ + public static function get_contexts_for_userid(int $userid) : contextlist { + return (new contextlist)->add_from_sql( + "SELECT ctx.id + FROM {course_modules} cm + JOIN {modules} m ON cm.module = m.id AND m.name = :modulename + JOIN {attendance} a ON cm.instance = a.id + JOIN {attendance_sessions} asess ON asess.attendanceid = a.id + JOIN {context} ctx ON cm.id = ctx.instanceid AND ctx.contextlevel = :contextlevel + JOIN {attendance_log} al ON asess.id = al.sessionid AND (al.studentid = :userid OR al.takenby = :takenbyid)", + [ + 'modulename' => 'attendance', + 'contextlevel' => CONTEXT_MODULE, + 'userid' => $userid, + 'takenbyid' => $userid + ] + ); + } + + /** + * Delete all data for all users in the specified context. + * + * @param context $context The specific context to delete data for. + */ + public static function delete_data_for_all_users_in_context(context $context) { + global $DB; + + if (!$context instanceof context_module) { + return; + } + + if (!$cm = get_coursemodule_from_id('attendance', $context->instanceid)) { + return; + } + + // Delete all information recorded against sessions associated with this module. + $DB->delete_records_select( + 'attendance_log', + "sessionid IN (SELECT id FROM {attendance_sessions} WHERE attendanceid = :attendanceid", + [ + 'attendanceid' => $cm->instance + ] + ); + + // Delete all completed warnings associated with a warning associated with this module. + $DB->delete_records_select( + 'attendance_warning_done', + "notifyid IN (SELECT id from {attendance_warning} WHERE idnumber = :attendanceid)", + ['attendanceid' => $cm->instance] + ); + } + + /** + * Delete all user data for the specified user, in the specified contexts. + * + * @param approved_contextlist $contextlist The approved contexts and user information to delete information for. + */ + public static function delete_data_for_user(approved_contextlist $contextlist) { + global $DB; + $userid = (int)$contextlist->get_user()->id; + + foreach ($contextlist as $context) { + if (!$context instanceof context_module) { + continue; + } + + if (!$cm = get_coursemodule_from_id('attendance', $context->instanceid)) { + continue; + } + + $attendanceid = (int)$DB->get_record('attendance', ['id' => $cm->instance])->id; + $sessionids = array_keys( + $DB->get_records('attendance_sessions', ['attendanceid' => $attendanceid]) + ); + + self::delete_user_from_session_attendance_log($userid, $sessionids); + self::delete_user_from_sessions($userid, $sessionids); + self::delete_user_from_attendance_warnings_log($userid, $attendanceid); + } + } + + /** + * Export all user data for the specified user, in the specified contexts. + * + * @param approved_contextlist $contextlist The approved contexts to export information for. + */ + public static function export_user_data(approved_contextlist $contextlist) { + global $DB; + + $params = [ + 'modulename' => 'attendance', + 'contextlevel' => CONTEXT_MODULE, + 'studentid' => $contextlist->get_user()->id, + 'takenby' => $contextlist->get_user()->id + ]; + + list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED); + + $sql = "SELECT + al.*, + asess.id as session, + asess.description, + ctx.id as contextid, + a.name as attendancename, + a.id as attendanceid, + statuses.description as statusdesc, statuses.grade as statusgrade + FROM {course_modules} cm + JOIN {attendance} a ON cm.instance = a.id + JOIN {attendance_sessions} asess ON asess.attendanceid = a.id + JOIN {attendance_log} al on (al.sessionid = asess.id AND (studentid = :studentid OR al.takenby = :takenby)) + JOIN {context} ctx ON cm.id = ctx.instanceid + JOIN {attendance_statuses} statuses ON statuses.id = al.statusid + WHERE (ctx.id {$contextsql})"; + + $attendances = $DB->get_records_sql($sql, $params + $contextparams); + + self::export_attendance_logs( + get_string('attendancestaken', 'mod_attendance'), + array_filter( + $attendances, + function(stdClass $attendance) use ($contextlist) : bool { + return $attendance->takenby == $contextlist->get_user()->id; + } + ) + ); + + self::export_attendance_logs( + get_string('attendanceslogged', 'mod_attendance'), + array_filter( + $attendances, + function(stdClass $attendance) use ($contextlist) : bool { + return $attendance->studentid == $contextlist->get_user()->id; + } + ) + ); + + self::export_attendances( + $contextlist->get_user(), + $attendances, + self::group_by_property( + $DB->get_records_sql( + "SELECT + *, + a.id as attendanceid + FROM {attendance_warning_done} awd + JOIN {attendance_warning} aw ON awd.notifyid = aw.id + JOIN {attendance} a on aw.idnumber = a.id + WHERE userid = :userid", + ['userid' => $contextlist->get_user()->id] + ), + 'notifyid' + ) + ); + } + + /** + * Delete a user from session logs. + * + * @param int $userid The id of the user to remove. + * @param array $sessionids Array of session ids from which to remove the student from the relevant logs. + */ + private static function delete_user_from_session_attendance_log(int $userid, array $sessionids) { + global $DB; + + // Delete records where user was marked as attending. + list($sessionsql, $sessionparams) = $DB->get_in_or_equal($sessionids, SQL_PARAMS_NAMED); + $DB->delete_records_select( + 'attendance_log', + "(studentid = :studentid) AND sessionid $sessionsql", + ['studentid' => $userid] + $sessionparams + ); + + // Get every log record where user took the attendance. + $attendancetakenids = array_keys( + $DB->get_records_sql( + "SELECT * from {attendance_log} + WHERE takenby = :takenbyid AND sessionid $sessionsql", + ['takenbyid' => $userid] + $sessionparams + ) + ); + + if (!$attendancetakenids) { + return; + } + + // Don't delete the record from the log, but update to site admin taking attendance. + list($attendancetakensql, $attendancetakenparams) = $DB->get_in_or_equal($attendancetakenids, SQL_PARAMS_NAMED); + $DB->set_field_select( + 'attendance_log', + 'takenby', + 2, + "id $attendancetakensql", + $attendancetakenparams + ); + } + + /** + * Delete a user from sessions. + * + * Not much user data is stored in a session, but it's possible that a user id is saved + * in the "lasttakenby" field. + * + * @param int $userid The id of the user to remove. + * @param array $sessionids Array of session ids from which to remove the student. + */ + private static function delete_user_from_sessions(int $userid, array $sessionids) { + global $DB; + + // Get all sessions where user was last to mark attendance. + list($sessionsql, $sessionparams) = $DB->get_in_or_equal($sessionids, SQL_PARAMS_NAMED); + $sessionstaken = $DB->get_records_sql( + "SELECT * from {attendance_sessions} + WHERE lasttakenby = :lasttakenbyid AND id $sessionsql", + ['lasttakenbyid' => $userid] + $sessionparams + ); + + if (!$sessionstaken) { + return; + } + + // Don't delete the session, but update last taken by to the site admin. + list($sessionstakensql, $sessionstakenparams) = $DB->get_in_or_equal(array_keys($sessionstaken), SQL_PARAMS_NAMED); + $DB->set_field_select( + 'attendance_sessions', + 'lasttakenby', + 2, + "id $sessionstakensql", + $sessionstakenparams + ); + } + + /** + * Delete a user from the attendance waring log. + * + * @param int $userid The id of the user to remove. + * @param int $attendanceid The id of the attendance instance to remove the relevant warnings from. + */ + private static function delete_user_from_attendance_warnings_log(int $userid, int $attendanceid) { + global $DB; + + // Get all warnings because the user could have their ID listed in the thirdpartyemails column as a comma delimited string. + $warnings = $DB->get_records( + 'attendance_warning', + ['idnumber' => $attendanceid] + ); + + if (!$warnings) { + return; + } + + // Update the third party emails list for all the relevant warnings. + $updatedwarnings = array_map( + function(stdClass $warning) use ($userid) : stdClass { + $warning->thirdpartyemails = implode(',', array_diff(explode(',', $warning->thirdpartyemails), [$userid])); + return $warning; + }, + array_filter( + $warnings, + function (stdClass $warning) use ($userid) : bool { + return in_array($userid, explode(',', $warning->thirdpartyemails)); + } + ) + ); + + // Sadly need to update each individually, no way to bulk update as all the thirdpartyemails field can be different. + foreach ($updatedwarnings as $updatedwarning) { + $DB->update_record('attendance_warning', $updatedwarning); + } + + // Delete any record of the user being notified. + list($warningssql, $warningsparams) = $DB->get_in_or_equal(array_keys($warnings), SQL_PARAMS_NAMED); + $DB->delete_records_select( + 'attendance_warning_done', + "userid = :userid AND notifyid $warningssql", + ['userid' => $userid] + $warningsparams + ); + } + + /** + * Helper function to group an array of stdClasses by a common property. + * + * @param array $classes An array of classes to group. + * @param string $property A common property to group the classes by. + */ + private static function group_by_property(array $classes, string $property) : array { + return array_reduce( + $classes, + function (array $classes, stdClass $class) use ($property) : array { + $classes[$class->{$property}][] = $class; + return $classes; + }, + [] + ); + } + + /** + * Helper function to transform a row from the database in to session data to export. + * + * The properties of the "dbrow" are very specific to the result of the SQL from + * the export_user_data function. + * + * @param stdClass $dbrow A row from the database containing session information. + * @return stdClass The transformed row. + */ + private static function transform_db_row_to_session_data(stdClass $dbrow) : stdClass { + return (object) [ + 'name' => $dbrow->attendancename, + 'session' => $dbrow->session, + 'takenbyid' => $dbrow->takenby, + 'studentid' => $dbrow->studentid, + 'status' => $dbrow->statusdesc, + 'grade' => $dbrow->statusgrade, + 'sessiondescription' => $dbrow->description, + 'timetaken' => transform::datetime($dbrow->timetaken), + 'remarks' => $dbrow->remarks, + 'ipaddress' => $dbrow->ipaddress + ]; + } + + /** + * Helper function to transform a row from the database in to warning data to export. + * + * The properties of the "dbrow" are very specific to the result of the SQL from + * the export_user_data function. + * + * @param stdClass $warning A row from the database containing warning information. + * @return stdClass The transformed row. + */ + private static function transform_warning_data(stdClass $warning) : stdClass { + return (object) [ + 'timesent' => transform::datetime($warning->timesent), + 'thirdpartyemails' => $warning->thirdpartyemails, + 'subject' => $warning->emailsubject, + 'body' => $warning->emailcontent + ]; + } + + /** + * Helper function to export attendance logs. + * + * The array of "attendances" is actually the result returned by the SQL in export_user_data. + * It is more of a list of sessions. Which is why it needs to be grouped by context id. + * + * @param string $path The path in the export (relative to the current context). + * @param array $attendances Array of attendances to export the logs for. + */ + private static function export_attendance_logs(string $path, array $attendances) { + $attendancesbycontextid = self::group_by_property($attendances, 'contextid'); + + foreach ($attendancesbycontextid as $contextid => $sessions) { + $context = context::instance_by_id($contextid); + $sessionsbyid = self::group_by_property($sessions, 'sessionid'); + + foreach ($sessionsbyid as $sessionid => $sessions) { + writer::with_context($context)->export_data( + [get_string('session', 'attendance') . ' ' . $sessionid, $path], + (object)[array_map([self::class, 'transform_db_row_to_session_data'], $sessions)] + ); + }; + } + } + + /** + * Helper function to export attendances (and associated warnings for the user). + * + * The array of "attendances" is actually the result returned by the SQL in export_user_data. + * It is more of a list of sessions. Which is why it needs to be grouped by context id. + * + * @param stdClass $user The user to export attendances for. This is needed to retrieve context data. + * @param array $attendances Array of attendances to export. + * @param array $warningsmap Mapping between an attendance id and warnings. + */ + private static function export_attendances(stdClass $user, array $attendances, array $warningsmap) { + $attendancesbycontextid = self::group_by_property($attendances, 'contextid'); + + foreach ($attendancesbycontextid as $contextid => $attendance) { + $context = context::instance_by_id($contextid); + + // It's "safe" to get the attendanceid from the first element in the array - since they're grouped by context. + // i.e., module context. + // The reason there can be more than one "attendance" is that the attendances array will contain multiple records + // for the same attendance instance if there are multiple sessions. It is not the same as a raw record from the + // attendances table. See the SQL in export_user_data. + $warnings = array_map([self::class, 'transform_warning_data'], $warningsmap[$attendance[0]->attendanceid] ?? []); + + writer::with_context($context)->export_data( + [], + (object)array_merge( + (array) helper::get_context_data($context, $user), + ['warnings' => $warnings] + ) + ); + } + } +} diff --git a/lang/en/attendance.php b/lang/en/attendance.php index d66899e..7d85379 100644 --- a/lang/en/attendance.php +++ b/lang/en/attendance.php @@ -61,6 +61,8 @@ $string['attendancenotset'] = 'You must set your attendance'; $string['attendancenotstarted'] = 'Attendance has not started yet for this course'; $string['attendancepercent'] = 'Attendance percent'; $string['attendancereport'] = 'Attendance report'; +$string['attendanceslogged'] = 'Attendances logged'; +$string['attendancestaken'] = 'Attendances taken'; $string['attendancesuccess'] = 'Attendance has been successfully taken'; $string['attendanceupdated'] = 'Attendance successfully updated'; $string['attforblockdirstillexists'] = 'old mod/attforblock directory still exists - you must delete this directory on your server before running this upgrade.'; @@ -519,4 +521,24 @@ $string['youcantdo'] = 'You can\'t do anything'; $string['includeabsentee'] = 'Include session when calculating absentee report'; $string['includeabsentee_help'] = 'If checked this session will be included in the absentee report calculations.'; $string['attendance_no_status'] = 'No valid status was available - you may be too late to record attendance.'; -$string['studentmarked'] = 'Your attendance in this session has been recorded.'; \ No newline at end of file +$string['studentmarked'] = 'Your attendance in this session has been recorded.'; +$string['privacy:metadata:sessionid'] = 'Attendance session ID.'; +$string['privacy:metadata:studentid'] = 'ID of student having attendance recorded.'; +$string['privacy:metadata:statusid'] = 'ID of student\'s attendance status.'; +$string['privacy:metadata:statusset'] = 'Status set to which status ID belongs.'; +$string['privacy:metadata:timetaken'] = 'Timestamp of when attendance was taken for the student.'; +$string['privacy:metadata:takenby'] = 'User ID of the user who took attendance for the student.'; +$string['privacy:metadata:remarks'] = 'Comments about the user\'s attendance.'; +$string['privacy:metadata:ipaddress'] = 'IP address attendance was marked from.'; +$string['privacy:metadata:groupid'] = 'Group ID associated with session.'; +$string['privacy:metadata:sessdate'] = 'Timestamp of when session starts.'; +$string['privacy:metadata:duration'] = 'Session duration in seconds'; +$string['privacy:metadata:lasttaken'] = 'Timestamp of when session attendance was last taken.'; +$string['privacy:metadata:lasttakenby'] = 'User ID of the last user to take attendance in this session'; +$string['privacy:metadata:timemodified'] = 'Timestamp of when session was last modified'; +$string['privacy:metadata:notifyid'] = 'ID of attendance session warning is associated with.'; +$string['privacy:metadata:userid'] = 'ID of user to send warning to.'; +$string['privacy:metadata:timesent'] = 'Timestamp when warning was sent.'; +$string['privacy:metadata:attendancelog'] = 'Log of user attendances recorded.'; +$string['privacy:metadata:attendancesessions'] = 'Sessions to which attendance will be recorded.'; +$string['privacy:metadata:attendancewarningdone'] = 'Log of warnings sent to users over their attendance record.'; -- 2.11.0