AutorÃa | Ultima modificación | Ver Log |
<?php// This file is part of the Zoom plugin for Moodle - http://moodle.org///// Moodle is free software: you can redistribute it and/or modify// it under the terms of the GNU General Public License as published by// the Free Software Foundation, either version 3 of the License, or// (at your option) any later version.//// Moodle is distributed in the hope that it will be useful,// but WITHOUT ANY WARRANTY; without even the implied warranty of// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the// GNU General Public License for more details.//// You should have received a copy of the GNU General Public License// along with Moodle. If not, see <http://www.gnu.org/licenses/>./*** Task: get_meeting_reports** @package mod_zoom* @copyright 2018 UC Regents* @author Kubilay Agi* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later*/namespace mod_zoom\task;defined('MOODLE_INTERNAL') || die();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;/*** Scheduled task to get the meeting participants for each .*/class get_meeting_reports extends scheduled_task {/*** Percentage in which we want similar_text to reach before we consider* using its results.*/private const SIMILARNAME_THRESHOLD = 60;/*** Used to determine if debugging is turned on or off for outputting messages.* @var bool*/public $debuggingenabled = false;/*** The mod_zoom\webservice instance used to query for data. Can be stubbed* for unit testing.* @var mod_zoom\webservice*/public $service = null;/*** Sort meetings by end time.* @param array $a One meeting/webinar object array to compare.* @param array $b Another meeting/webinar object array to compare.*/private function cmp($a, $b) {if ($a->end_time == $b->end_time) {return 0;}return ($a->end_time < $b->end_time) ? -1 : 1;}/*** Gets the meeting IDs from the queue, retrieve the information for each* meeting, then remove the meeting from the queue.** @param string $paramstart If passed, will find meetings starting on given date. Format is YYYY-MM-DD.* @param string $paramend If passed, will find meetings ending on given date. Format is YYYY-MM-DD.* @param array $hostuuids If passed, will find only meetings for given array of host uuids.*/public function execute($paramstart = null, $paramend = null, $hostuuids = null) {try {$this->service = zoom_webservice();} catch (moodle_exception $exception) {mtrace('Skipping task - ', $exception->getMessage());return;}// See if we cannot make anymore API calls.$retryafter = get_config('zoom', 'retry-after');if (!empty($retryafter) && time() < $retryafter) {mtrace('Out of API calls, retry after ' . userdate($retryafter, get_string('strftimedaydatetime', 'core_langconfig')));return;}$this->debuggingenabled = debugging();// If running as a task, then record when we last left off if// interrupted or finish.$runningastask = true;if (!empty($hostuuids)) {$runningastask = false;}if (!empty($paramstart)) {$starttime = strtotime($paramstart);$runningastask = false;} else {$starttime = get_config('zoom', 'last_call_made_at');}if (empty($starttime)) {// Zoom only provides data from 30 days ago.$starttime = strtotime('-30 days');}if (!empty($paramend)) {$endtime = strtotime($paramend);$runningastask = false;}if (empty($endtime)) {$endtime = time();}// Zoom requires this format when passing the to and from arguments.// Zoom just returns all the meetings from the day range instead of// actual time range specified.$start = gmdate('Y-m-d', $starttime);$end = gmdate('Y-m-d', $endtime);mtrace(sprintf('Finding meetings between %s to %s', $start, $end));$recordedallmeetings = true;$dashboardscopes = ['dashboard_meetings:read:admin','dashboard_meetings:read:list_meetings:admin','dashboard_meetings:read:list_webinars:admin',];$reportscopes = ['report:read:admin','report:read:list_users:admin',];// Can only query on $hostuuids using Report API.if (empty($hostuuids) && $this->service->has_scope($dashboardscopes)) {$allmeetings = $this->get_meetings_via_dashboard($start, $end);} else if ($this->service->has_scope($reportscopes)) {$allmeetings = $this->get_meetings_via_reports($start, $end, $hostuuids);} else {mtrace('Skipping task - missing OAuth scopes required for reports');return;}// Sort all meetings based on end_time so that we know where to pick// up again if we run out of API calls.$allmeetings = array_map([$this, 'normalize_meeting'], $allmeetings);usort($allmeetings, [$this, 'cmp']);mtrace("Processing " . count($allmeetings) . " meetings");foreach ($allmeetings as $meeting) {// Only process meetings if they happened after the time we left off.$meetingtime = ($meeting->end_time == intval($meeting->end_time)) ? $meeting->end_time : strtotime($meeting->end_time);if ($runningastask && $meetingtime <= $starttime) {continue;}try {if (!$this->process_meeting_reports($meeting)) {// If returned false, then ran out of API calls or got// unrecoverable error. Try to pick up where we left off.if ($runningastask) {// Only want to resume if we were processing all reports.$recordedallmeetings = false;set_config('last_call_made_at', $meetingtime - 1, 'zoom');}break;}} catch (Exception $e) {mtrace($e->getMessage());mtrace($e->getTraceAsString());// Some unknown error, need to handle it so we can record// where we left off.if ($runningastask) {$recordedallmeetings = false;set_config('last_call_made_at', $meetingtime - 1, 'zoom');break;}}}if ($recordedallmeetings && $runningastask) {// All finished, so save the time that we set end time for the initial query.set_config('last_call_made_at', $endtime, 'zoom');}}/*** Formats participants array as a record for the database.** @param stdClass $participant Unformatted array received from web service API call.* @param int $detailsid The id to link to the zoom_meeting_details table.* @param array $names Array that contains mappings of user's moodle ID to the user's name.* @param array $emails Array that contains mappings of user's moodle ID to the user's email.* @return array Formatted array that is ready to be inserted into the database table.*/public function format_participant($participant, $detailsid, $names, $emails) {global $DB;$moodleuser = null;$moodleuserid = null;$name = null;// Consolidate fields.$participant->name = $participant->name ?? $participant->user_name ?? '';$participant->id = $participant->id ?? $participant->participant_user_id ?? '';$participant->user_email = $participant->user_email ?? $participant->email ?? '';// 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.$participantmatches = $DB->get_records('zoom_meeting_participants',['uuid' => $participant->id],null,'id, userid, name');if (!empty($participantmatches)) {// Found some previous matches. Find first one with userid set.foreach ($participantmatches as $participantmatch) {if (!empty($participantmatch->userid)) {$moodleuserid = $participantmatch->userid;$name = $participantmatch->name;break;}}}}// Did not find a previous match.if (empty($moodleuserid)) {if (!empty($participant->user_email) && ($moodleuserid = array_search(strtoupper($participant->user_email), $emails))) {// Found email from list of enrolled users.$name = $names[$moodleuserid];} else if (!empty($participant->name) && ($moodleuserid = array_search(strtoupper($participant->name), $names))) {// Found name from list of enrolled users.$name = $names[$moodleuserid];} else if (!empty($participant->user_email)&& ($moodleuser = $DB->get_record('user', ['email' => $participant->user_email,'deleted' => 0,'suspended' => 0,], '*', IGNORE_MULTIPLE))) {// This is the case where someone attends the meeting, but is not enrolled in the class.$moodleuserid = $moodleuser->id;$name = strtoupper(fullname($moodleuser));} else if (!empty($participant->name) && ($moodleuserid = $this->match_name($participant->name, $names))) {// Found name by using fuzzy text search.$name = $names[$moodleuserid];} else {// Did not find any matches, so use what is given by Zoom.$name = $participant->name;$moodleuserid = 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 === '') {$participant->id = null;}return ['name' => $name,'userid' => $moodleuserid,'detailsid' => $detailsid,'zoomuserid' => $participant->user_id,'uuid' => $participant->id,'user_email' => $participant->user_email,'join_time' => strtotime($participant->join_time),'leave_time' => strtotime($participant->leave_time),'duration' => $participant->duration,];}/*** Get enrollment for given course.** @param int $courseid* @return array Returns an array of names and emails.*/public function get_enrollments($courseid) {// Loop through each user to generate name->uids mapping.$coursecontext = context_course::instance($courseid);$enrolled = get_enrolled_users($coursecontext);$names = [];$emails = [];foreach ($enrolled as $user) {$name = strtoupper(fullname($user));$names[$user->id] = $name;$emails[$user->id] = strtoupper(zoom_get_api_identifier($user));}return [$names, $emails];}/*** Get meetings first by querying for active hostuuids for given time* period. Then find meetings that host have given in given time period.** This is the older method of querying for meetings. It has been superseded* by the Dashboard API. However, that API is only available for Business* accounts and higher. The Reports API is available for Pro user and up.** This method is kept for those users that have Pro accounts and using* this plugin.** @param string $start If passed, will find meetings starting on given date. Format is YYYY-MM-DD.* @param string $end If passed, will find meetings ending on given date. Format is YYYY-MM-DD.* @param array $hostuuids If passed, will find only meetings for given array of host uuids.** @return array*/public function get_meetings_via_reports($start, $end, $hostuuids) {global $DB;mtrace('Using Reports API');if (empty($hostuuids)) {$this->debugmsg('Empty hostuuids, querying all hosts');// Get all hosts.$activehostsuuids = $this->service->get_active_hosts_uuids($start, $end);} else {$this->debugmsg('Hostuuids passed');// Else we just want a specific hosts.$activehostsuuids = $hostuuids;}$allmeetings = [];$localhosts = $DB->get_records_menu('zoom', null, '', 'id, host_id');mtrace("Processing " . count($activehostsuuids) . " active host uuids");foreach ($activehostsuuids as $activehostsuuid) {// This API call returns information about meetings and webinars,// don't need extra functionality for webinars.$usersmeetings = [];if (in_array($activehostsuuid, $localhosts)) {$this->debugmsg('Getting meetings for host uuid ' . $activehostsuuid);try {$usersmeetings = $this->service->get_user_report($activehostsuuid, $start, $end);} catch (not_found_exception $e) {// Zoom API returned user not found for a user it said had,// meetings. Have to skip user.$this->debugmsg("Skipping $activehostsuuid because user does not exist on Zoom");continue;} catch (retry_failed_exception $e) {// Hit API limit, so cannot continue.mtrace($e->response . ': ' . $e->zoomerrorcode);return;}} else {// Ignore hosts who hosted meetings outside of integration.continue;}$this->debugmsg(sprintf('Found %d meetings for user', count($usersmeetings)));foreach ($usersmeetings as $usermeeting) {$allmeetings[] = $usermeeting;}}return $allmeetings;}/*** Get meetings and webinars using Dashboard API.** @param string $start If passed, will find meetings starting on given date. Format is YYYY-MM-DD.* @param string $end If passed, will find meetings ending on given date. Format is YYYY-MM-DD.** @return array*/public function get_meetings_via_dashboard($start, $end) {mtrace('Using Dashboard API');$meetingscopes = ['dashboard_meetings:read:admin','dashboard_meetings:read:list_meetings:admin',];$webinarscopes = ['dashboard_webinars:read:admin','dashboard_webinars:read:list_webinars:admin',];$meetings = [];if ($this->service->has_scope($meetingscopes)) {$meetings = $this->service->get_meetings($start, $end);}$webinars = [];if ($this->service->has_scope($webinarscopes)) {$webinars = $this->service->get_webinars($start, $end);}$allmeetings = array_merge($meetings, $webinars);return $allmeetings;}/*** Returns name of task.** @return string*/public function get_name() {return get_string('getmeetingreports', 'mod_zoom');}/*** Tries to match a given name to the roster using two different fuzzy text* matching algorithms and if they match, then returns the match.** @param string $nametomatch* @param array $rosternames Needs to be an array larger than 3 for any* meaningful results.** @return int Returns id for $rosternames. Returns false if no match found.*/private function match_name($nametomatch, $rosternames) {if (count($rosternames) < 3) {return false;}$nametomatch = strtoupper($nametomatch);$similartextscores = [];$levenshteinscores = [];foreach ($rosternames as $name) {similar_text($nametomatch, $name, $percentage);if ($percentage > self::SIMILARNAME_THRESHOLD) {$similartextscores[$name] = $percentage;$levenshteinscores[$name] = levenshtein($nametomatch, $name);}}// If we did not find any quality matches, then return false.if (empty($similartextscores)) {return false;}// Simlar text has better matches with higher numbers.arsort($similartextscores);reset($similartextscores); // Make sure key gets first element.$stmatch = key($similartextscores);// Levenshtein has better matches with lower numbers.asort($levenshteinscores);reset($levenshteinscores); // Make sure key gets first element.$lmatch = key($levenshteinscores);// If both matches, then we can be rather sure that it is the same user.if ($stmatch == $lmatch) {$moodleuserid = array_search($stmatch, $rosternames);return $moodleuserid;} else {return false;}}/*** Outputs finer grained debugging messaging if debug mode is on.** @param string $msg*/public function debugmsg($msg) {if ($this->debuggingenabled) {mtrace($msg);}}/*** Saves meeting details and participants for reporting.** @param object $meeting Normalized meeting object* @return boolean*/public function process_meeting_reports($meeting) {global $DB;$this->debugmsg(sprintf('Processing meeting %s|%s that occurred at %s',$meeting->meeting_id,$meeting->uuid,$meeting->start_time));// If meeting doesn't exist in the zoom database, the instance is// deleted, and we don't need reports for these.if (!($zoomrecord = $DB->get_record('zoom', ['meeting_id' => $meeting->meeting_id], '*', IGNORE_MULTIPLE))) {mtrace('Meeting does not exist locally; skipping');return true;}$meeting->zoomid = $zoomrecord->id;// Insert or update meeting details.if (!($DB->record_exists('zoom_meeting_details', ['uuid' => $meeting->uuid]))) {$this->debugmsg('Inserting zoom_meeting_details');$detailsid = $DB->insert_record('zoom_meeting_details', $meeting);} else {// Details entry already exists, so update it.$this->debugmsg('Updating zoom_meeting_details');$detailsid = $DB->get_field('zoom_meeting_details', 'id', ['uuid' => $meeting->uuid]);$meeting->id = $detailsid;$DB->update_record('zoom_meeting_details', $meeting);}try {$participants = $this->service->get_meeting_participants($meeting->uuid, $zoomrecord->webinar);} catch (not_found_exception $e) {mtrace(sprintf('Warning: Cannot find meeting %s|%s; skipping', $meeting->meeting_id, $meeting->uuid));return true; // Not really a show stopping error.} catch (webservice_exception $e) {mtrace($e->response . ': ' . $e->zoomerrorcode);return false;}// Loop through each user to generate name->uids mapping.[$names, $emails] = $this->get_enrollments($zoomrecord->course);$this->debugmsg(sprintf('Processing %d participants', count($participants)));// 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('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)',$rawparticipant->name,$rawparticipant->user_id,$rawparticipant->id));$participant = $this->format_participant($rawparticipant, $detailsid, $names, $emails);// 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();} catch (dml_exception $exception) {$transaction->rollback($exception);mtrace('ERROR: Cannot insert zoom_meeting_participants: ' . $exception->getMessage());return false;}$this->debugmsg('Finished updating meeting report');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('<br>', $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 .= '<br> ' . $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 .= '<br>' . $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 = "<p>$messagebody</p>";$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* database.** @param object $meeting* @return object Normalized meeting object*/public function normalize_meeting($meeting) {$normalizedmeeting = new stdClass();// Returned meeting object will not be using Zoom's id, because it is a// primary key in our own tables.$normalizedmeeting->meeting_id = $meeting->id;// Convert times to Unixtimestamps.$normalizedmeeting->start_time = strtotime($meeting->start_time);$normalizedmeeting->end_time = strtotime($meeting->end_time);// Copy values that are named the same.$normalizedmeeting->uuid = $meeting->uuid;$normalizedmeeting->topic = $meeting->topic;// Dashboard API has duration as H:M:S while report has it in minutes.$timeparts = explode(':', $meeting->duration);// Convert duration into minutes.if (count($timeparts) === 1) {// Time is already in minutes.$normalizedmeeting->duration = intval($meeting->duration);} else if (count($timeparts) === 2) {// Time is in MM:SS format.$normalizedmeeting->duration = $timeparts[0];} else {// Time is in HH:MM:SS format.$normalizedmeeting->duration = 60 * $timeparts[0] + $timeparts[1];}// Copy values that are named differently.$normalizedmeeting->participants_count = $meeting->participants ?? $meeting->participants_count;// Dashboard API does not have total_minutes.$normalizedmeeting->total_minutes = $meeting->total_minutes ?? null;return $normalizedmeeting;}/*** Get list of all users clicked (join meeting) in a given zoom instance.* @param object $zoomrecord* @return array<int>*/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 = :courseidAND 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;}}