From 2ec0e5b5575d8f062db9dd308cb737756c11c7cd Mon Sep 17 00:00:00 2001 From: Mo Farouk <122031508+fmido88@users.noreply.github.com> Date: Mon, 1 May 2023 22:29:49 +0300 Subject: [PATCH] add duration-based grading method - Grade according to the duration of meeting attendance - set the id with the name for non-registered users so we use it a unique identifier for the user. - updating records of participants instate of adding new record in case if the non-registered user left the meeting and return fixing some codes and try to fix phpunit test add name condition add a condition for checking the existence of the participant by name along with user id to avoid update existence record if the user id is null this should pass the phpunit test adding a condition to check the existence of a data in zoom_meeting_participants by name along with user id to prevent updating records of users with null id. options for grading methods and display name - Adding setting to choose the grading method. - Adding setting to choose the display name. - Removing the overlapped time when merging a participant record in duration. get_participant_overlap_time creating a function that properly calculate the overlap timing in the meeting reports. Just make the code checker happier. Update get_meeting_reports.php forgot to include join_time field. Creating a function to calculate users grades - Creating a function to calculate users grades according to their duration in the meeting. - Fixing the function get_participant_overlap_time(). - Move the code to calculate the grades outside the loop. - return the original inserting data to the participants table. - prevent inserting multiple data for exist record. - not updating grades until the new grade is larger than the old one. testing the new grading method Notify teachers about grades Send teachers a notification about grades in meeting according to duration. test the notification update tests try preventResetByRollback() maybe messages test work! try to make messages work changing teacher role to editingteacher change graders array key fix message index fix misspelling Add and fix comments remove wrong using class and fix some comments check the existence of user after integer check Try not to be spamy As the task get_meeting_reports run, don't send notifications unless there is new records in meeting participants table Help teacher to fine ungraded users Narrowing the options for teachers needing to grade non-recognized students in participant meeting report. Adding a function to get a list of users clicked (join meeting) using this function and excluding already recognized students displaying a list of the rest student in the notification message to teachers try to solve null readers try to skip error: Call to a member function get_events_select() on null on PHPUnit test. Co-authored-by: Jonathan Champ --- classes/task/get_meeting_reports.php | 426 ++++++++++++++++++++++++++- db/messages.php | 29 ++ lang/en/zoom.php | 42 +++ locallib.php | 64 ++-- settings.php | 24 ++ tests/get_meeting_reports_test.php | 316 ++++++++++++++++++++ 6 files changed, 874 insertions(+), 27 deletions(-) create mode 100644 db/messages.php diff --git a/classes/task/get_meeting_reports.php b/classes/task/get_meeting_reports.php index e83296d1..22efd7fd 100644 --- a/classes/task/get_meeting_reports.php +++ b/classes/task/get_meeting_reports.php @@ -209,6 +209,14 @@ public function format_participant($participant, $detailsid, $names, $emails) { // Cleanup the name. For some reason # gets into the name instead of a comma. $participant->name = str_replace('#', ',', $participant->name); + // Extract the ID and name from the participant's name if it is in the format "(id)Name". + if (preg_match('/^\((\d+)\)(.+)$/', $participant->name, $matches)) { + $moodleuserid = $matches[1]; + $name = trim($matches[2]); + } else { + $name = $participant->name; + } + // Try to see if we successfully queried for this user and found a Moodle id before. if (!empty($participant->id)) { // Sometimes uuid is blank from Zoom. @@ -261,7 +269,11 @@ public function format_participant($participant, $detailsid, $names, $emails) { } if ($participant->user_email == '') { - $participant->user_email = null; + if (!empty($moodleuserid)) { + $participant->user_email = $DB->get_field('user', 'email', ['id' => $moodleuserid]); + } else { + $participant->user_email = null; + } } if ($participant->id == '') { @@ -468,11 +480,11 @@ public function debugmsg($msg) { /** * Saves meeting details and participants for reporting. * - * @param array $meeting Normalized meeting object + * @param object $meeting Normalized meeting object * @return boolean */ public function process_meeting_reports($meeting) { - global $DB; + global $DB, $CFG; $this->debugmsg(sprintf( 'Processing meeting %s|%s that occurred at %s', @@ -517,18 +529,19 @@ public function process_meeting_reports($meeting) { $this->debugmsg(sprintf('Processing %d participants', count($participants))); - // Now try to insert participants, first drop any records for given - // meeting and then add. There is no unique key that we can use for - // knowing what users existed before. + // Now try to insert new participant records. + // There is no unique key, so we make sure each record's data is distinct. try { $transaction = $DB->start_delegated_transaction(); $count = $DB->count_records('zoom_meeting_participants', ['detailsid' => $detailsid]); if (!empty($count)) { - $this->debugmsg(sprintf('Dropping previous records of %d participants', $count)); - $DB->delete_records('zoom_meeting_participants', ['detailsid' => $detailsid]); + $this->debugmsg(sprintf('Existing participant records: %d', $count)); + // No need to delete old records, we don't insert matching records. } + // To prevent sending notifications every time the task ran check if there is inserted new records. + $recordupdated = false; foreach ($participants as $rawparticipant) { $this->debugmsg(sprintf( 'Working on %s (user_id: %d, uuid: %s)', @@ -537,8 +550,34 @@ public function process_meeting_reports($meeting) { $rawparticipant->id )); $participant = $this->format_participant($rawparticipant, $detailsid, $names, $emails); - $recordid = $DB->insert_record('zoom_meeting_participants', $participant, true); - $this->debugmsg('Inserted record ' . $recordid); + + // These conditions are enough. + $conditions = [ + 'name' => $participant['name'], + 'userid' => $participant['userid'], + 'detailsid' => $participant['detailsid'], + 'zoomuserid' => $participant['zoomuserid'], + 'join_time' => $participant['join_time'], + 'leave_time' => $participant['leave_time'] + ]; + + // Check if the record already exists. + if ($record = $DB->get_record('zoom_meeting_participants', $conditions)) { + // The exact record already exists, so do nothing. + $this->debugmsg('Record already exists ' . $record->id); + } else { + // Insert all new records. + $recordid = $DB->insert_record('zoom_meeting_participants', $participant, true); + // At least one new record inserted. + $recordupdated = true; + $this->debugmsg('Inserted record ' . $recordid); + } + } + + // If there are new records and the grading method is attendance duration. + if ($recordupdated && get_config('zoom', 'gradingmethod') === 'period') { + // Grade users according to their duration in the meeting. + $this->grading_participant_upon_duration($zoomrecord, $detailsid); } $transaction->allow_commit(); @@ -552,6 +591,334 @@ public function process_meeting_reports($meeting) { return true; } + /** + * Update the grades of users according to their duration in the meeting. + * @param object $zoomrecord + * @param int $detailsid + * @return void + */ + public function grading_participant_upon_duration($zoomrecord, $detailsid) { + global $CFG, $DB; + + require_once($CFG->libdir . '/gradelib.php'); + $courseid = $zoomrecord->course; + $context = \context_course::instance($courseid); + // Get grade list for items. + $gradelist = grade_get_grades($courseid, 'mod', 'zoom', $zoomrecord->id); + + // Is this meeting is not gradable, return. + if (empty($gradelist->items)) { + return; + } + + $gradeitem = $gradelist->items[0]; + $itemid = $gradeitem->id; + $grademax = $gradeitem->grademax; + $oldgrades = $gradeitem->grades; + + // After check and testing, these timings are the actual meeting timings returned from zoom + // ... (i.e.when the host start and end the meeting). + // Not like those on 'zoom' table which represent the settings from zoom activity. + $meetingtime = $DB->get_record('zoom_meeting_details', ['id' => $detailsid], 'start_time, end_time'); + if (empty($zoomrecord->recurring)) { + $end = min($meetingtime->end_time, $zoomrecord->start_time + $zoomrecord->duration); + $start = max($meetingtime->start_time, $zoomrecord->start_time); + $meetingduration = $end - $start; + } else { + $meetingduration = $meetingtime->end_time - $meetingtime->start_time; + } + + // Get the required records again. + $records = $DB->get_records('zoom_meeting_participants', ['detailsid' => $detailsid], 'join_time ASC'); + // Initialize the data arrays, indexing them later with userids. + $durations = []; + $join = []; + $leave = []; + // Looping the data to calculate the duration of each user. + foreach ($records as $record) { + $userid = $record->userid; + if (empty($userid)) { + if (is_numeric($record->name)) { + // In case the participant name looks like an integer, we need to avoid a conflict. + $userid = '~' . $record->name . '~'; + } else { + $userid = $record->name; + } + } + + // Check if there is old duration stored for this user. + if (!empty($durations[$userid])) { + $old = new \stdClass; + $old->duration = $durations[$userid]; + $old->join_time = $join[$userid]; + $old->leave_time = $leave[$userid]; + // Calculating the overlap time. + $overlap = $this->get_participant_overlap_time($old, $record); + + // Set the new data for next use. + $leave[$userid] = max($old->leave_time, $record->leave_time); + $join[$userid] = min($old->join_time, $record->join_time); + $durations[$userid] = $old->duration + $record->duration - $overlap; + } else { + $leave[$userid] = $record->leave_time; + $join[$userid] = $record->join_time; + $durations[$userid] = $record->duration; + } + } + + // Used to count the number of users being graded. + $graded = 0; + $alreadygraded = 0; + + // Array of unidentified users that need to be graded manually. + $needgrade = []; + + // Array of found user ids. + $found = []; + + // Array of non-enrolled users. + $notenrolled = []; + + // Now check the duration for each user and grade them according to it. + foreach ($durations as $userid => $userduration) { + // Setup the grade according to the duration. + $newgrade = ($userduration * $grademax / $meetingduration); + + // Double check that this is a Moodle user. + if (is_integer($userid) && (isset($found[$userid]) || $DB->record_exists('user', ['id' => $userid]))) { + // Successfully found this user in Moodle. + if (!isset($found[$userid])) { + $found[$userid] = true; + } + + $oldgrade = null; + if (isset($oldgrades[$userid])) { + $oldgrade = $oldgrades[$userid]->grade; + } + + // Check if the user is enrolled before assign the grade. + if (is_enrolled($context, $userid)) { + // Compare with the old grade and only update if the new grade is higher. + // Use number_format because the old stored grade only contains 5 decimals. + if (empty($oldgrade) || $oldgrade < number_format($newgrade, 5)) { + $gradegrade = [ + 'rawgrade' => $newgrade, + 'userid' => $userid, + 'usermodified' => $userid, + 'dategraded' => '', + 'feedbackformat' => '', + 'feedback' => '', + ]; + + zoom_grade_item_update($zoomrecord, $gradegrade); + $graded++; + $this->debugmsg('grade updated for user with id: ' . $userid + . ', duration =' . $userduration + . ', maxgrade =' . $grademax + . ', meeting duration =' . $meetingduration + . ', User grade:' . $newgrade); + } else { + $alreadygraded++; + $this->debugmsg('User already has a higher grade. Old grade: ' . $oldgrade + . ', New grade: ' . $newgrade); + } + } else { + $notenrolled[$userid] = fullname(\core_user::get_user($userid)); + } + + } else { + // This means that this user was not identified. + // Provide information about participants that need to be graded manually. + $a = [ + 'userid' => $userid, + 'grade' => $newgrade, + ]; + $needgrade[] = get_string('nonrecognizedusergrade', 'mod_zoom', $a); + } + } + + // Get the list of users who clicked join meeting and were not recognized by the participant report. + $allusers = $this->get_users_clicked_join($zoomrecord); + $notfound = []; + foreach ($allusers as $userid) { + if (!isset($found[$userid])) { + $notfound[$userid] = fullname(\core_user::get_user($userid)); + } + } + + // Try not to spam the instructors, only notify them when grades have changed. + if ($graded > 0) { + // Sending a notification to teachers in this course about grades, and users that need to be graded manually. + $notifydata = [ + 'graded' => $graded, + 'alreadygraded' => $alreadygraded, + 'needgrade' => $needgrade, + 'courseid' => $courseid, + 'zoomid' => $zoomrecord->id, + 'itemid' => $itemid, + 'name' => $zoomrecord->name, + 'notfound' => $notfound, + 'notenrolled' => $notenrolled, + ]; + $this->notify_teachers($notifydata); + } + } + + /** + * Calculate the overlap time for a participant. + * + * @param object $record1 Record data 1. + * @param object $record2 Record data 2. + * @return int the overlap time + */ + public function get_participant_overlap_time($record1, $record2) { + // Determine which record starts first. + if ($record1->join_time < $record2->join_time) { + $old = $record1; + $new = $record2; + } else { + $old = $record2; + $new = $record1; + } + + $oldjoin = (int) $old->join_time; + $oldleave = (int) $old->leave_time; + $newjoin = (int) $new->join_time; + $newleave = (int) $new->leave_time; + + // There are three possible cases. + if ($newjoin >= $oldleave) { + // First case - No overlap. + // Example: old(join: 15:00 leave: 15:30), new(join: 15:35 leave: 15:50). + // No overlap. + $overlap = 0; + } else if ($newleave > $oldleave) { + // Second case - Partial overlap. + // Example: new(join: 15:15 leave: 15:45), old(join: 15:00 leave: 15:30). + // 15 min overlap. + $overlap = $oldleave - $newjoin; + } else { + // Third case - Complete overlap. + // Example: new(join: 15:15 leave: 15:29), old(join: 15:00 leave: 15:30). + // 14 min overlap (new duration). + $overlap = $new->duration; + } + + return $overlap; + } + + /** + * Sending a notification to all teachers in the course notify them about grading + * also send the names of the users needing a manual grading. + * return array of messages ids and false if there is no users in this course + * with the capability of edit grades. + * + * @param array $data + * @return array|bool + */ + public function notify_teachers($data) { + // Number of users graded automatically. + $graded = $data['graded']; + // Number of users already graded. + $alreadygraded = $data['alreadygraded']; + // Number of users need to be graded. + $needgradenumber = count($data['needgrade']); + // List of users need grading. + $needstring = get_string('grading_needgrade', 'mod_zoom'); + $needgrade = (!empty($data['needgrade'])) ? $needstring.implode('
', $data['needgrade'])."\n" : ''; + + $zoomid = $data['zoomid']; + $itemid = $data['itemid']; + $name = $data['name']; + $courseid = $data['courseid']; + $context = \context_course::instance($courseid); + // Get teachers in the course (actually those with the ability to edit grades). + $teachers = get_enrolled_users($context, 'moodle/grade:edit', 0, 'u.*', null, 0, 0, true); + + // Grading item url. + $gurl = new \moodle_url( + '/grade/report/singleview/index.php', + [ + 'id' => $courseid, + 'item' => 'grade', + 'itemid' => $itemid, + ] + ); + $gradeurl = \html_writer::link($gurl, get_string('gradinglink', 'mod_zoom')); + + // Zoom instance url. + $zurl = new \moodle_url('/mod/zoom/view.php', ['id' => $zoomid]); + $zoomurl = \html_writer::link($zurl, $name); + + // Data object used in lang strings. + $a = (object) [ + 'name' => $name, + 'graded' => $graded, + 'alreadygraded' => $alreadygraded, + 'needgrade' => $needgrade, + 'number' => $needgradenumber, + 'gradeurl' => $gradeurl, + 'zoomurl' => $zoomurl, + 'notfound' => '', + 'notenrolled' => '', + ]; + // Get the list of users clicked join meeting but not graded or reconized. + // This helps the teacher to grade them manually. + $notfound = $data['notfound']; + if (!empty($notfound)) { + $a->notfound = get_string('grading_notfoud', 'mod_zoom'); + foreach ($notfound as $userid => $fullname) { + $params = ['item' => 'user', 'id' => $courseid, 'userid' => $userid]; + $url = new \moodle_url('/grade/report/singleview/index.php', $params); + $userurl = \html_writer::link($url, $fullname . ' (' . $userid . ')'); + $a->notfound .= '
' . $userurl; + } + } + + $notenrolled = $data['notenrolled']; + if (!empty($notenrolled)) { + $a->notenrolled = get_string('grading_notenrolled', 'mod_zoom'); + foreach ($notenrolled as $userid => $fullname) { + $userurl = new \moodle_url('/user/profile.php', ['id' => $userid]); + $profile = \html_writer::link($userurl, $fullname); + $a->notenrolled .= '
' . $profile; + } + } + + // Prepare the message. + $message = new \core\message\message(); + $message->component = 'mod_zoom'; + $message->name = 'teacher_notification'; // The notification name from message.php. + $message->userfrom = \core_user::get_noreply_user(); + + $message->subject = get_string('gradingmessagesubject', 'mod_zoom', $a); + + $messagebody = get_string('gradingmessagebody', 'mod_zoom', $a); + $message->fullmessage = $messagebody; + + $message->fullmessageformat = FORMAT_MARKDOWN; + $message->fullmessagehtml = "

$messagebody

"; + $message->smallmessage = get_string('gradingsmallmeassage', 'mod_zoom', $a); + $message->notification = 1; + $message->contexturl = $gurl; // This link redirect the teacher to the page of item's grades. + $message->contexturlname = get_string('gradinglink', 'mod_zoom'); + // Email content. + $content = array('*' => array('header' => $message->subject, 'footer' => '')); + $message->set_additional_content('email', $content); + $messageids = []; + if (!empty($teachers)) { + foreach ($teachers as $teacher) { + $message->userto = $teacher; + // Actually send the message for each teacher. + $messageids[] = message_send($message); + } + } else { + return false; + } + + return $messageids; + } + /** * The meeting object from the Dashboard API differs from the Report API, so * normalize the meeting object to conform to what is expected it the @@ -598,4 +965,43 @@ public function normalize_meeting($meeting) { return $normalizedmeeting; } + + /** + * Get list of all users clicked (join meeting) in a given zoom instance. + * @param object $zoomrecord + * @return array + */ + public function get_users_clicked_join($zoomrecord) { + global $DB; + $logmanager = get_log_manager(); + if (!$readers = $logmanager->get_readers('core\log\sql_reader')) { + // Should be using 2.8, use old class. + $readers = $logmanager->get_readers('core\log\sql_select_reader'); + } + + $reader = array_pop($readers); + if ($reader === null) { + return []; + } + + $params = [ + 'courseid' => $zoomrecord->course, + 'objectid' => $zoomrecord->id, + ]; + $selectwhere = "eventname = '\\\\mod_zoom\\\\event\\\\join_meeting_button_clicked' + AND courseid = :courseid + AND objectid = :objectid"; + $events = $reader->get_events_select($selectwhere, $params, 'userid ASC', 0, 0); + + $userids = []; + foreach ($events as $event) { + if ($event->other['meetingid'] == $zoomrecord->meeting_id && + !in_array($event->userid, $userids)) { + + $userids[] = $event->userid; + } + } + + return $userids; + } } diff --git a/db/messages.php b/db/messages.php new file mode 100644 index 00000000..6b655bf1 --- /dev/null +++ b/db/messages.php @@ -0,0 +1,29 @@ +. + +/** + * Defines message providers for mod_zoom. + * + * @package mod_zoom + * @copyright 2023 Mo Farouk + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$messageproviders = [ + 'teacher_notification' => [], +]; diff --git a/lang/en/zoom.php b/lang/en/zoom.php index 17ab2e30..8fa30f8b 100644 --- a/lang/en/zoom.php +++ b/lang/en/zoom.php @@ -93,6 +93,10 @@ $string['displayleadtime'] = 'Display lead time'; $string['displayleadtime_desc'] = 'If enabled, the leadtime will be displayed to the users. This way, users are informed that / when they can join the meeting before the scheduled start time. This might keep users from constantly reloading the page until they can join.'; $string['displayleadtime_nohideif'] = 'Please note: This setting is only processed if the \'{$a}\' setting is set to a value greater than zero.'; +$string['displayfullname'] = 'Full name'; +$string['displayfirstname'] = 'First name only'; +$string['displayidfullname'] = '(user id) followed by fullname'; +$string['displayid'] = '(user id) only'; $string['displaypassword'] = 'Display passcode'; $string['displaypassword_help'] = 'If enabled the meeting passcode will always be displayed to non-hosts.'; $string['downloadical'] = 'Download iCal'; @@ -138,6 +142,40 @@ $string['getmeetingrecordings'] = 'Get meeting recordings from Zoom'; $string['globalsettings'] = 'Global settings'; $string['globalsettings_desc'] = 'These settings apply to the Zoom plugin as a whole.'; +$string['grading_needgrade'] = "The following users need to be graded manually as they could not be identified:\n"; +$string['grading_notenrolled'] = 'The following users joind the meeting but not recognized as enroled users:
'; +$string['grading_notfoud'] = "List of users who clicked to join the meeting, but were not recognized in the participant report:\n"; +$string['gradinglink'] = 'Review or update grades'; +$string['gradingmessagesubject'] = 'User grades for Zoom meeting: {$a->name}'; +$string['gradingmessagebody'] = 'For Zoom Meeting session: {$a->zoomurl}; +
+Number of users that have been automatically graded according to their duration in the meeting: {$a->graded}. +
+Number of users that were already graded: {$a->alreadygraded}. +
+{$a->needgrade}
+Review or update users grades here: {$a->gradeurl} +
+{$a->notfound} +
+{$a->notenrolled}'; +$string['gradingsmallmeassage'] = 'User grades quick report for {$a->name}: +
+Need manual grading: {$a->number} +
+Graded users: {$a->graded + $a->alreadygraded}'; +$string['gradingmethod_heading'] = 'Options for grading method'; +$string['gradingmethod_heading_help'] = 'Decide which method to use when grading Zoom participation.'; +$string['gradingmethod'] = 'Grading method'; +$string['gradingmethod_help'] = 'Choose the method used to grade student participant.
+Upon entry: the student receives full marks (max grade) when they click to join the meeting in Moodle.
+Attendance duration: the student receives a score based on the percentage of their meeting attendance compared to the total meeting duration.
+Notes regarding Attendance duration method:
+- This method requires the display name to contain id or fullname.
+- It is recommended to set the setting \'zoom | defaultjoinbeforehost\' to (No) so the meeting duration is accurate.
+- Some students who are already signed in to the Zoom client with details not matching those in Moodle must be graded manually after reviewing the meeting report.'; +$string['gradingentry'] = 'Upon entry'; +$string['gradingperiod'] = 'Attendance Duration'; $string['host'] = 'Host'; $string['hostintro'] = 'Alternative Hosts can start Zoom meetings and manage the Waiting Room.'; $string['indicator:cognitivedepth'] = 'Zoom cognitive'; @@ -207,6 +245,7 @@ $string['meetingcapacitywarningcontactalthost'] = 'Please ask the host to turn to the Zoom account owner to obtain a larger Zoom license if all of these course participants need to join the meeting.'; $string['meetingparticipantsdeleted'] = 'Meeting participant user data deleted.'; $string['meetingrecordingviewsdeleted'] = 'Meeting recording user view data deleted.'; +$string['messageprovider:teacher_notification'] = 'Notify teachers about user grades (according to duration) in a Zoom session'; $string['modulename'] = 'Zoom meeting'; $string['modulenameplural'] = 'Zoom Meetings'; $string['modulename_help'] = 'Zoom is a video and web conferencing platform that gives authorized users the ability to host online meetings.'; @@ -215,6 +254,7 @@ $string['nextoccurrence'] = 'Next occurrence'; $string['newmeetings'] = 'New Meetings'; $string['nomeetinginstances'] = 'No sessions found for this meeting.'; +$string['nonrecognizedusergrade'] = '(Name: {$a->userid}, grade: {$a->grade})'; $string['nooccurrenceleft'] = 'The last occurrence is already over'; $string['noparticipants'] = 'No participants found for this session at this time.'; $string['norecordings'] = 'No recordings found for this meeting at this time.'; @@ -371,6 +411,8 @@ $string['unavailablenotstartedyet'] = 'The meeting has not started yet.'; $string['updatemeetings'] = 'Update meeting settings from Zoom'; $string['updatetrackingfields'] = 'Update tracking field settings from Zoom'; +$string['unamedisplay'] = 'User display name'; +$string['unamedisplay_help'] = 'How the name of a user should be displayed in meetings (only works for users who are not logged in to the Zoom client).'; $string['usepersonalmeeting'] = 'Use personal meeting ID {$a}'; $string['waitingroom'] = 'Waiting room'; $string['waitingroomenable'] = 'Enable waiting room'; diff --git a/locallib.php b/locallib.php index 74f489f1..73231609 100755 --- a/locallib.php +++ b/locallib.php @@ -970,7 +970,28 @@ function zoom_load_meeting($id, $context, $usestarturl = true) { $url = $registrantjoinurl; } - $returns['nexturl'] = new moodle_url($url, ['uname' => fullname($USER)]); + $unamesetting = get_config('zoom', 'unamedisplay'); + switch ($unamesetting) { + case 'fullname': + default: + $unamedisplay = fullname($USER); + break; + + case 'firstname': + $unamedisplay = $USER->firstname; + break; + + case 'idfullname': + $unamedisplay = '(' . $USER->id . ') ' . fullname($USER); + break; + + case 'id': + $unamedisplay = '(' . $USER->id . ')'; + break; + } + + // Try to send the user email (not guaranteed). + $returns['nexturl'] = new moodle_url($url, ['uname' => $unamedisplay, 'uemail' => $USER->email]); } // If the user is pre-registering, skip grading/completion. @@ -993,24 +1014,33 @@ function zoom_load_meeting($id, $context, $usestarturl = true) { $completion = new completion_info($course); $completion->set_module_viewed($cm); - // Check whether user has a grade. If not, then assign full credit to them. - $gradelist = grade_get_grades($course->id, 'mod', 'zoom', $cm->instance, $USER->id); - - // Assign full credits for user who has no grade yet, if this meeting is gradable (i.e. the grade type is not "None"). - if (!empty($gradelist->items) && empty($gradelist->items[0]->grades[$USER->id]->grade)) { - $grademax = $gradelist->items[0]->grademax; - $grades = [ - 'rawgrade' => $grademax, - 'userid' => $USER->id, - 'usermodified' => $USER->id, - 'dategraded' => '', - 'feedbackformat' => '', - 'feedback' => '', - ]; - - zoom_grade_item_update($zoom, $grades); + // Check the grading method settings. + $gradingmethod = get_config('zoom', 'gradingmethod'); + if (empty($gradingmethod)) { + $gradingmethod = 'entry'; + // Because this is the default grading method. } + if ($gradingmethod === 'entry') { + // Check whether user has a grade. If not, then assign full credit to them. + $gradelist = grade_get_grades($course->id, 'mod', 'zoom', $cm->instance, $USER->id); + + // Assign full credits for user who has no grade yet, if this meeting is gradable (i.e. the grade type is not "None"). + if (!empty($gradelist->items) && empty($gradelist->items[0]->grades[$USER->id]->grade)) { + $grademax = $gradelist->items[0]->grademax; + $grades = [ + 'rawgrade' => $grademax, + 'userid' => $USER->id, + 'usermodified' => $USER->id, + 'dategraded' => '', + 'feedbackformat' => '', + 'feedback' => '', + ]; + + zoom_grade_item_update($zoom, $grades); + } + } // Otherwise, the get_meetings_report task calculates the grades according to duration. + // Upgrade host upon joining meeting, if host is not Licensed. if ($userishost) { $config = get_config('zoom'); diff --git a/settings.php b/settings.php index a8ef1d75..9ca96cb6 100644 --- a/settings.php +++ b/settings.php @@ -247,6 +247,16 @@ ); $settings->add($viewrecordings); + // Adding options for the display name using uname parameter in zoom join_url. + $options = [ + 'fullname' => get_string('displayfullname', 'mod_zoom'), + 'firstname' => get_string('displayfirstname', 'mod_zoom'), + 'idfullname' => get_string('displayidfullname', 'mod_zoom'), + 'id' => get_string('displayid', 'mod_zoom'), + ]; + $settings->add(new admin_setting_configselect('zoom/unamedisplay', get_string('unamedisplay', 'mod_zoom'), + get_string('unamedisplay_help', 'mod_zoom'), 'fullname', $options)); + // Supplementary features settings. $settings->add(new admin_setting_heading( 'zoom/supplementaryfeaturessettings', @@ -615,4 +625,18 @@ $settings->hide_if('zoom/invitation_invite', 'zoom/invitationremoveinvite', 'eq', 0); $settings->hide_if('zoom/invitation_icallink', 'zoom/invitationremoveicallink', 'eq', 0); } + + // Adding options for grading methods. + $settings->add(new admin_setting_heading('zoom/gradingmethod', + get_string('gradingmethod_heading', 'mod_zoom'), + get_string('gradingmethod_heading_help', 'mod_zoom'))); + + // Grading method upon entry: the user gets the full score when clicking to join the session through Moodle. + // Grading method upon period: the user is graded based on how long they attended the actual session. + $options = [ + 'entry' => get_string('gradingentry', 'mod_zoom'), + 'period' => get_string('gradingperiod', 'mod_zoom'), + ]; + $settings->add(new admin_setting_configselect('zoom/gradingmethod', get_string('gradingmethod', 'mod_zoom'), + get_string('gradingmethod_help', 'mod_zoom'), 'entry', $options)); } diff --git a/tests/get_meeting_reports_test.php b/tests/get_meeting_reports_test.php index 09b264d9..df52875a 100644 --- a/tests/get_meeting_reports_test.php +++ b/tests/get_meeting_reports_test.php @@ -404,4 +404,320 @@ public function test_normalize_meeting() { $this->assertEquals($reportmeeting['participants_count'], $meeting->participants_count); $this->assertEquals($reportmeeting['total_minutes'], $meeting->total_minutes); } + + /** + * Testing the grading method according to users duration in a meeting. + * @return void + */ + public function test_grading_method() { + global $DB; + $this->setAdminUser(); + // Make sure we start with nothing. + // Deleting all records from previous tests. + if ($DB->count_records('zoom_meeting_details') > 0) { + $DB->delete_records('zoom_meeting_details'); + } + + if ($DB->count_records('zoom_meeting_participants') > 0) { + $DB->delete_records('zoom_meeting_participants'); + } + + // Generate fake course. + $course = $this->getDataGenerator()->create_course(); + $teacher = $this->getDataGenerator()->create_and_enrol($course, 'editingteacher'); + + // Check that this teacher has the required capability to receive notification. + $context = \context_course::instance($course->id); + $graders = get_users_by_capability($context, 'moodle/grade:edit'); + $this->assertEquals(1, count($graders)); + $firstkey = array_key_first($graders); + $this->assertEquals($graders[$firstkey]->id, $teacher->id); + // Now fake the meeting details. + $meeting = new stdClass(); + $meeting->id = 456123; + $meeting->topic = 'Some meeting'; + $meeting->start_time = '2020-04-01T15:00:00Z'; + $meeting->end_time = '2020-04-01T17:00:00Z'; + $meeting->uuid = 'someuuid123'; + $meeting->duration = 120; // In minutes. + $meeting->participants = 4; + + // Create a new zoom instance. + $params = [ + 'course' => $course->id, + 'meeting_id' => $meeting->id, + 'grade' => 60, + 'name' => 'Zoom', + 'exists_on_zoom' => ZOOM_MEETING_EXISTS, + 'start_time' => strtotime('2020-04-01T15:00:00Z'), + 'duration' => 120 * 60, // In seconds. + ]; + + $generator = $this->getDataGenerator()->get_plugin_generator('mod_zoom'); + $instance = $generator->create_instance($params); + $id = $instance->id; + // Normalize the meeting. + $meeting = $this->meetingtask->normalize_meeting($meeting); + $meeting->zoomid = $id; + + $detailsid = $DB->insert_record('zoom_meeting_details', $meeting); + + $zoomrecord = $DB->get_record('zoom', ['id' => $id]); + // Create users and corresponding meeting participants. + $rawparticipants = []; + $participants = []; + // Enroll a bunch of users. Note: names were generated by + // https://www.behindthename.com/random/ and any similarity to anyone + // real or fictional is coincidence and not intentional. + $users[0] = $this->getDataGenerator()->create_user([ + 'lastname' => 'Arytis', + 'firstname' => 'Oitaa', + ]); + + $users[1] = $this->getDataGenerator()->create_user([ + 'lastname' => 'Chouxuong', + 'firstname' => 'Khah', + ]); + $users[2] = $this->getDataGenerator()->create_user([ + 'lastname' => 'Spialdiouniem', + 'firstname' => 'Basem', + ]); + $users[3] = $this->getDataGenerator()->create_user([ + 'lastname' => 'Padhzinnuj', + 'firstname' => 'Nibba', + ]); + $users[4] = $this->getDataGenerator()->create_user([ + 'lastname' => 'Apea', + 'firstname' => 'Ziqit', + ]); + + foreach ($users as $user) { + $this->getDataGenerator()->enrol_user($user->id, $course->id); + } + list($names, $emails) = $this->meetingtask->get_enrollments($course->id); + + // Create a participant with 5 min overlap. + // Total time 35 min, total grade 17.5 . + $rawparticipants[1] = (object)[ + 'id' => 32132165, + 'user_id' => 4456, + 'name' => 'Oitaa Arytis', + 'user_email' => '', + 'join_time' => '2023-05-01T15:05:00Z', + 'leave_time' => '2023-05-01T15:35:00Z', + 'duration' => 30 * 60 + ]; + $participants[1] = (object)$this->meetingtask->format_participant($rawparticipants[1], $detailsid, $names, $emails); + $rawparticipants[2] = (object)[ + 'id' => 32132165, + 'user_id' => 4456, + 'name' => 'Oitaa Arytis', + 'user_email' => '', + 'join_time' => '2023-05-01T15:30:00Z', + 'leave_time' => '2023-05-01T15:40:00Z', + 'duration' => 10 * 60 + ]; + $participants[2] = (object)$this->meetingtask->format_participant($rawparticipants[2], $detailsid, $names, $emails); + $overlap = $this->meetingtask->get_participant_overlap_time($participants[1], $participants[2]); + $this->assertEquals(5 * 60, $overlap); + // Also check for the same result if the data inverted. + $overlap = $this->meetingtask->get_participant_overlap_time($participants[2], $participants[1]); + $this->assertEquals(5 * 60, $overlap); + + // Create a participant with 30 min overlap. + // Total duration 60 min. expect a mark of 30 . + $rawparticipants[3] = (object)[ + 'id' => '', + 'user_id' => 1234, + 'name' => 'Chouxuong Khah', + 'user_email' => '', + 'join_time' => '2023-05-01T15:00:00Z', + 'leave_time' => '2023-05-01T16:00:00Z', + 'duration' => 60 * 60 + ]; + $participants[3] = (object)$this->meetingtask->format_participant($rawparticipants[3], $detailsid, $names, $emails); + $rawparticipants[4] = (object)[ + 'id' => '', + 'user_id' => 1234, + 'name' => 'Chouxuong Khah', + 'user_email' => '', + 'join_time' => '2023-05-01T15:30:00Z', + 'leave_time' => '2023-05-01T16:00:00Z', + 'duration' => 30 * 60 + ]; + $participants[4] = (object)$this->meetingtask->format_participant($rawparticipants[4], $detailsid, $names, $emails); + $overlap = $this->meetingtask->get_participant_overlap_time($participants[3], $participants[4]); + $this->assertEquals(30 * 60, $overlap); + // Also check for the same result if the data inverted. + $overlap = $this->meetingtask->get_participant_overlap_time($participants[4], $participants[3]); + $this->assertEquals(30 * 60, $overlap); + + // Another user with no overlaping. + // Total duration 60 min. Expect mark 30 . + $rawparticipants[5] = (object)[ + 'id' => '', + 'user_id' => 564312, + 'name' => 'Spialdiouniem Basem', + 'user_email' => '', + 'join_time' => '2023-05-01T15:10:00Z', + 'leave_time' => '2023-05-01T16:00:00Z', + 'duration' => 50 * 60 + ]; + $participants[5] = (object)$this->meetingtask->format_participant($rawparticipants[5], $detailsid, $names, $emails); + $rawparticipants[6] = (object)[ + 'id' => '', + 'user_id' => 564312, + 'name' => 'Spialdiouniem Basem', + 'user_email' => '', + 'join_time' => '2023-05-01T16:30:00Z', + 'leave_time' => '2023-05-01T16:40:00Z', + 'duration' => 10 * 60 + ]; + $participants[6] = (object)$this->meetingtask->format_participant($rawparticipants[6], $detailsid, $names, $emails); + + $overlap = $this->meetingtask->get_participant_overlap_time($participants[5], $participants[6]); + $this->assertEquals(0, $overlap); + // Also check for the same result if the data inverted. + $overlap = $this->meetingtask->get_participant_overlap_time($participants[6], $participants[5]); + $this->assertEquals(0, $overlap); + + // Adding another participant. + // Total duration 90 min, expect mark 45 . + $rawparticipants[7] = (object)[ + 'id' => '', + 'user_id' => 789453, + 'name' => 'Padhzinnuj Nibba', + 'user_email' => '', + 'join_time' => '2023-05-01T15:30:00Z', + 'leave_time' => '2023-05-01T17:00:00Z', + 'duration' => 90 * 60 + ]; + + // Adding a participant at which matching names will fail. + // His duration is 110 min, this grant him a grade of 55. + $rawparticipants[8] = (object)[ + 'id' => '', + 'user_id' => 168452, + 'name' => 'Farouk', + 'user_email' => '', + 'join_time' => '2023-05-01T15:10:00Z', + 'leave_time' => '2023-05-01T17:00:00Z', + 'duration' => 110 * 60 + ]; + $this->mockparticipantsdata['someuuid123'] = $rawparticipants; + // First mock the webservice object, so we can inject the return values + // for get_meeting_participants. + $mockwwebservice = $this->createMock('\mod_zoom_webservice'); + $this->meetingtask->service = $mockwwebservice; + // Make get_meeting_participants() return our results array. + $mockwwebservice->method('get_meeting_participants') + ->will($this->returnCallback([$this, 'mock_get_meeting_participants'])); + + $this->assertEquals($this->mockparticipantsdata['someuuid123'], + $mockwwebservice->get_meeting_participants('someuuid123', false)); + + // Now let's test the grads. + set_config('gradingmethod', 'period', 'zoom'); + + // Prepare messages. + $this->preventResetByRollback(); // Messaging does not like transactions... + $sink = $this->redirectMessages(); + // Process meeting reports should call the function grading_participant_upon_duration + // and insert grades. + $this->assertTrue($this->meetingtask->process_meeting_reports($meeting)); + $this->assertEquals(1, $DB->count_records('zoom_meeting_details')); + $this->assertEquals(8, $DB->count_records('zoom_meeting_participants')); + + $usersids = []; + foreach ($users as $user) { + $usersids[] = $user->id; + } + // Get the gradelist for all users created. + $gradelist = grade_get_grades($course->id, 'mod', 'zoom', $zoomrecord->id, $usersids); + + $gradelistitems = $gradelist->items; + $grades = $gradelistitems[0]->grades; + // Check grades of first user. + $grade = $grades[$users[0]->id]->grade; + $this->assertEquals(17.5, $grade); + // Check grades of second user. + $grade = $grades[$users[1]->id]->grade; + $this->assertEquals(30, $grade); + // Check grades of third user. + $grade = $grades[$users[2]->id]->grade; + $this->assertEquals(30, $grade); + // Check grades for fourth user. + $grade = $grades[$users[3]->id]->grade; + $this->assertEquals(45, $grade); + // This user didn't enter the meeting. + $grade = $grades[$users[4]->id]->grade; + $this->assertEquals(null, $grade); + // Let's check the teacher notification if it is ok? + $messages = $sink->get_messages(); + // Only one teacher, means only one message. + $this->assertEquals(1, count($messages)); + // Verify that it has been sent to the teacher. + $this->assertEquals($teacher->id, $messages[0]->useridto); + // Check the content of the message. + // Grading item url. + $gurl = new \moodle_url( + '/grade/report/singleview/index.php', + [ + 'id' => $course->id, + 'item' => 'grade', + 'itemid' => $gradelistitems[0]->id, + ] + ); + $gradeurl = \html_writer::link($gurl, get_string('gradinglink', 'mod_zoom')); + + // Zoom instance url. + $zurl = new \moodle_url('/mod/zoom/view.php', ['id' => $id]); + $zoomurl = \html_writer::link($zurl, $zoomrecord->name); + // The user need grading. + $needgradestr = get_string('grading_needgrade', 'mod_zoom'); + $needgrade[] = '(Name: Farouk, grade: 55)'; + $needgrade = $needgradestr.implode('
', $needgrade)."\n"; + + $a = (object)[ + 'name' => $zoomrecord->name, + 'graded' => 4, + 'alreadygraded' => 0, + 'needgrade' => $needgrade, + 'number' => 1, + 'gradeurl' => $gradeurl, + 'zoomurl' => $zoomurl, + 'notfound' => '', + 'notenrolled' => '', + ]; + $messagecontent = get_string('gradingmessagebody', 'mod_zoom', $a); + $this->assertStringContainsString($messagecontent, $messages[0]->fullmessage); + + // Redo the process again to be sure that no grades have been changed. + $this->assertTrue($this->meetingtask->process_meeting_reports($meeting)); + $this->assertEquals(1, $DB->count_records('zoom_meeting_details')); + $this->assertEquals(8, $DB->count_records('zoom_meeting_participants')); + $gradelist = grade_get_grades($course->id, 'mod', 'zoom', $zoomrecord->id, $usersids); + $gradelistitems = $gradelist->items; + $grades = $gradelistitems[0]->grades; + // Check grade for first user. + $grade = $grades[$users[0]->id]->grade; + $this->assertEquals(17.5, $grade); + // Check grade for second user. + $grade = $grades[$users[1]->id]->grade; + $this->assertEquals(30, $grade); + // Check grade for third user. + $grade = $grades[$users[2]->id]->grade; + $this->assertEquals(30, $grade); + // Check grade for fourth user. + $grade = $grades[$users[3]->id]->grade; + $this->assertEquals(45, $grade); + // This user didn't enter the meeting. + $grade = $grades[$users[4]->id]->grade; + $this->assertEquals(null, $grade); + + // Let's check if the teacher notification is ok. + $messages = $sink->get_messages(); + // No new messages as there has not been an update for participants. + $this->assertEquals(1, count($messages)); + } }