diff --git a/backup/moodle2/backup_zoom_stepslib.php b/backup/moodle2/backup_zoom_stepslib.php index 6442c49a..e745a859 100644 --- a/backup/moodle2/backup_zoom_stepslib.php +++ b/backup/moodle2/backup_zoom_stepslib.php @@ -40,7 +40,7 @@ class backup_activity_structure_step extends \backup_activity_structure_step { protected function define_structure() { // Define the root element describing the zoom instance. $zoom = new backup_nested_element('zoom', ['id'], [ - 'intro', 'introformat', 'grade', 'meeting_id', 'join_url', 'created_at', 'host_id', 'name', + 'intro', 'introformat', 'grade', 'grading_method', 'meeting_id', 'join_url', 'created_at', 'host_id', 'name', 'start_time', 'timemodified', 'recurring', 'recurrence_type', 'repeat_interval', 'weekly_days', 'monthly_day', 'monthly_week', 'monthly_week_day', 'monthly_repeat_option', 'end_times', 'end_date_time', 'end_date_option', 'webinar', 'duration', 'timezone', 'password', 'option_jbh', 'option_start_type', 'option_host_video', diff --git a/backup/moodle2/restore_zoom_stepslib.php b/backup/moodle2/restore_zoom_stepslib.php index 599fed1d..3c5b3d0a 100755 --- a/backup/moodle2/restore_zoom_stepslib.php +++ b/backup/moodle2/restore_zoom_stepslib.php @@ -58,7 +58,7 @@ protected function define_structure() { protected function process_zoom($data) { global $DB; - $data = (object)$data; + $data = (object) $data; // Update start_time before attempting to create a new meeting. $data->start_time = $this->apply_date_offset($data->start_time); diff --git a/classes/task/get_meeting_reports.php b/classes/task/get_meeting_reports.php index 1ba53d1e..8686648d 100644 --- a/classes/task/get_meeting_reports.php +++ b/classes/task/get_meeting_reports.php @@ -30,12 +30,17 @@ require_once($CFG->dirroot . '/mod/zoom/locallib.php'); use context_course; +use core\message\message; use core\task\scheduled_task; +use core_user; use dml_exception; +use Exception; +use html_writer; use mod_zoom\not_found_exception; use mod_zoom\retry_failed_exception; use mod_zoom\webservice_exception; use moodle_exception; +use moodle_url; use stdClass; /** @@ -176,7 +181,7 @@ public function execute($paramstart = null, $paramend = null, $hostuuids = null) break; } - } catch (\Exception $e) { + } catch (Exception $e) { mtrace($e->getMessage()); mtrace($e->getTraceAsString()); // Some unknown error, need to handle it so we can record @@ -218,6 +223,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. @@ -269,11 +282,15 @@ public function format_participant($participant, $detailsid, $names, $emails) { } } - if ($participant->user_email == '') { - $participant->user_email = null; + if ($participant->user_email === '') { + if (!empty($moodleuserid)) { + $participant->user_email = $DB->get_field('user', 'email', ['id' => $moodleuserid]); + } else { + $participant->user_email = null; + } } - if ($participant->id == '') { + if ($participant->id === '') { $participant->id = null; } @@ -477,7 +494,7 @@ 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) { @@ -526,18 +543,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)', @@ -546,8 +564,43 @@ 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. + // Check the grading method settings. + if (!empty($zoomrecord->grading_method)) { + $gradingmethod = $zoomrecord->grading_method; + } else if ($defaultgrading = get_config('gradingmethod', 'zoom')) { + $gradingmethod = $defaultgrading; + } else { + $gradingmethod = 'entry'; + } + + if ($recordupdated && $gradingmethod === 'period') { + // Grade users according to their duration in the meeting. + $this->grading_participant_upon_duration($zoomrecord, $detailsid); } $transaction->allow_commit(); @@ -561,6 +614,333 @@ 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 = min($userduration * $grademax / $meetingduration, $grademax); + + // 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_notfound', '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 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 = ['*' => ['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 @@ -607,4 +987,44 @@ 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, true) + ) { + $userids[] = $event->userid; + } + } + + return $userids; + } } diff --git a/db/install.xml b/db/install.xml index 2c67b348..e2c9c20a 100644 --- a/db/install.xml +++ b/db/install.xml @@ -1,5 +1,5 @@ - @@ -11,6 +11,7 @@ + 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/db/upgrade.php b/db/upgrade.php index b91c7428..9044e082 100755 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -933,5 +933,19 @@ function xmldb_zoom_upgrade($oldversion) { upgrade_mod_savepoint(true, 2023111600, 'zoom'); } + if ($oldversion < 2024010500) { + // Define field grading_method to be added to zoom. + $table = new xmldb_table('zoom'); + $field = new xmldb_field('grading_method', XMLDB_TYPE_CHAR, '10', null, null, null, null, 'grade'); + + // Conditionally launch add field grading_method. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Zoom savepoint reached. + upgrade_mod_savepoint(true, 2024010500, 'zoom'); + } + return true; } diff --git a/lang/en/zoom.php b/lang/en/zoom.php index 17ab2e30..06efb35e 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 joined the meeting but were not recognized as enroled users:\n"; +$string['grading_notfound'] = "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 to use when grading student participation.
+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/lib.php b/lib.php index 0a02ff36..967c3ba6 100755 --- a/lib.php +++ b/lib.php @@ -751,7 +751,7 @@ function zoom_grade_item_update(stdClass $zoom, $grades = null) { * Delete grade item for given zoom instance * * @param stdClass $zoom instance object - * @return grade_item + * @return int */ function zoom_grade_item_delete($zoom) { global $CFG; diff --git a/locallib.php b/locallib.php index 74f489f1..9e6d9b79 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,35 @@ 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. + if (!empty($zoom->grading_method)) { + $gradingmethod = $zoom->grading_method; + } else if ($defaultgrading = get_config('gradingmethod', 'zoom')) { + $gradingmethod = $defaultgrading; + } else { + $gradingmethod = 'entry'; } + 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/mod_form.php b/mod_form.php index 3441f7d6..e379de12 100644 --- a/mod_form.php +++ b/mod_form.php @@ -802,6 +802,26 @@ public function definition() { $this->add_action_buttons(); } + /** + * Add standard_grading_coursemodule_elements with grading for field. + * @return void + */ + public function standard_grading_coursemodule_elements() { + parent::standard_grading_coursemodule_elements(); + $mform = $this->_form; + $itemnumber = 0; + $component = "mod_{$this->_modname}"; + $gradefieldname = \core_grades\component_gradeitems::get_field_name_for_itemnumber($component, $itemnumber, 'grade'); + $options = [ + 'entry' => get_string('gradingentry', 'mod_zoom'), // All credit upon entry. + 'period' => get_string('gradingperiod', 'mod_zoom'), // Credit according to attend duration. + ]; + $mform->addElement('select', 'grading_method', get_string('gradingmethod', 'mod_zoom'), $options); + $mform->setDefault('grading_method', get_config('zoom', 'gradingmethod')); + $mform->addHelpButton('grading_method', 'gradingmethod', 'zoom'); + $mform->hideIf('grading_method', "{$gradefieldname}[modgrade_type]", 'eq', 'none'); + } + /** * Fill in the current page data for this course. */ diff --git a/settings.php b/settings.php index a8ef1d75..a5405c24 100644 --- a/settings.php +++ b/settings.php @@ -247,6 +247,21 @@ ); $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 +630,25 @@ $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 06d02208..bd1cde66 100644 --- a/tests/get_meeting_reports_test.php +++ b/tests/get_meeting_reports_test.php @@ -25,6 +25,9 @@ namespace mod_zoom; use advanced_testcase; +use context_course; +use html_writer; +use moodle_url; use stdClass; /** @@ -404,4 +407,322 @@ public function test_normalize_meeting(): void { $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(): void { + 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 = key($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); + } + [$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 grades. + $DB->set_field('zoom', 'grading_method', 'period', ['id' => $zoomrecord->id]); + + // 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)); + } } diff --git a/version.php b/version.php index 8b4428d7..b2977b3f 100755 --- a/version.php +++ b/version.php @@ -25,7 +25,7 @@ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'mod_zoom'; -$plugin->version = 2023121400; +$plugin->version = 2024010500; $plugin->release = 'v5.1.4'; $plugin->requires = 2019052000; $plugin->maturity = MATURITY_STABLE;