Proyectos de Subversion Moodle

Rev

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 = :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;
    }
}