Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 1
<?php
2
// This file is part of the Zoom plugin for Moodle - http://moodle.org/
3
//
4
// Moodle is free software: you can redistribute it and/or modify
5
// it under the terms of the GNU General Public License as published by
6
// the Free Software Foundation, either version 3 of the License, or
7
// (at your option) any later version.
8
//
9
// Moodle is distributed in the hope that it will be useful,
10
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
// GNU General Public License for more details.
13
//
14
// You should have received a copy of the GNU General Public License
15
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
16
 
17
/**
18
 * Task: get_meeting_reports
19
 *
20
 * @package    mod_zoom
21
 * @copyright  2018 UC Regents
22
 * @author     Kubilay Agi
23
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24
 */
25
 
26
namespace mod_zoom\task;
27
 
28
defined('MOODLE_INTERNAL') || die();
29
 
30
require_once($CFG->dirroot . '/mod/zoom/locallib.php');
31
 
32
use context_course;
33
use core\message\message;
34
use core\task\scheduled_task;
35
use core_user;
36
use dml_exception;
37
use Exception;
38
use html_writer;
39
use mod_zoom\not_found_exception;
40
use mod_zoom\retry_failed_exception;
41
use mod_zoom\webservice_exception;
42
use moodle_exception;
43
use moodle_url;
44
use stdClass;
45
 
46
/**
47
 * Scheduled task to get the meeting participants for each .
48
 */
49
class get_meeting_reports extends scheduled_task {
50
    /**
51
     * Percentage in which we want similar_text to reach before we consider
52
     * using its results.
53
     */
54
    private const SIMILARNAME_THRESHOLD = 60;
55
 
56
    /**
57
     * Used to determine if debugging is turned on or off for outputting messages.
58
     * @var bool
59
     */
60
    public $debuggingenabled = false;
61
 
62
    /**
63
     * The mod_zoom\webservice instance used to query for data. Can be stubbed
64
     * for unit testing.
65
     * @var mod_zoom\webservice
66
     */
67
    public $service = null;
68
 
69
    /**
70
     * Sort meetings by end time.
71
     * @param array $a One meeting/webinar object array to compare.
72
     * @param array $b Another meeting/webinar object array to compare.
73
     */
74
    private function cmp($a, $b) {
75
        if ($a->end_time == $b->end_time) {
76
            return 0;
77
        }
78
 
79
        return ($a->end_time < $b->end_time) ? -1 : 1;
80
    }
81
 
82
    /**
83
     * Gets the meeting IDs from the queue, retrieve the information for each
84
     * meeting, then remove the meeting from the queue.
85
     *
86
     * @param string $paramstart    If passed, will find meetings starting on given date. Format is YYYY-MM-DD.
87
     * @param string $paramend      If passed, will find meetings ending on given date. Format is YYYY-MM-DD.
88
     * @param array $hostuuids      If passed, will find only meetings for given array of host uuids.
89
     */
90
    public function execute($paramstart = null, $paramend = null, $hostuuids = null) {
91
        try {
92
            $this->service = zoom_webservice();
93
        } catch (moodle_exception $exception) {
94
            mtrace('Skipping task - ', $exception->getMessage());
95
            return;
96
        }
97
 
98
        // See if we cannot make anymore API calls.
99
        $retryafter = get_config('zoom', 'retry-after');
100
        if (!empty($retryafter) && time() < $retryafter) {
101
            mtrace('Out of API calls, retry after ' . userdate($retryafter, get_string('strftimedaydatetime', 'core_langconfig')));
102
            return;
103
        }
104
 
105
        $this->debuggingenabled = debugging();
106
 
107
        // If running as a task, then record when we last left off if
108
        // interrupted or finish.
109
        $runningastask = true;
110
 
111
        if (!empty($hostuuids)) {
112
            $runningastask = false;
113
        }
114
 
115
        if (!empty($paramstart)) {
116
            $starttime = strtotime($paramstart);
117
            $runningastask = false;
118
        } else {
119
            $starttime = get_config('zoom', 'last_call_made_at');
120
        }
121
 
122
        if (empty($starttime)) {
123
            // Zoom only provides data from 30 days ago.
124
            $starttime = strtotime('-30 days');
125
        }
126
 
127
        if (!empty($paramend)) {
128
            $endtime = strtotime($paramend);
129
            $runningastask = false;
130
        }
131
 
132
        if (empty($endtime)) {
133
            $endtime = time();
134
        }
135
 
136
        // Zoom requires this format when passing the to and from arguments.
137
        // Zoom just returns all the meetings from the day range instead of
138
        // actual time range specified.
139
        $start = gmdate('Y-m-d', $starttime);
140
        $end = gmdate('Y-m-d', $endtime);
141
 
142
        mtrace(sprintf('Finding meetings between %s to %s', $start, $end));
143
 
144
        $recordedallmeetings = true;
145
 
146
        $dashboardscopes = [
147
            'dashboard_meetings:read:admin',
148
            'dashboard_meetings:read:list_meetings:admin',
149
            'dashboard_meetings:read:list_webinars:admin',
150
        ];
151
 
152
        $reportscopes = [
153
            'report:read:admin',
154
            'report:read:list_users:admin',
155
        ];
156
 
157
        // Can only query on $hostuuids using Report API.
158
        if (empty($hostuuids) && $this->service->has_scope($dashboardscopes)) {
159
            $allmeetings = $this->get_meetings_via_dashboard($start, $end);
160
        } else if ($this->service->has_scope($reportscopes)) {
161
            $allmeetings = $this->get_meetings_via_reports($start, $end, $hostuuids);
162
        } else {
163
            mtrace('Skipping task - missing OAuth scopes required for reports');
164
            return;
165
        }
166
 
167
        // Sort all meetings based on end_time so that we know where to pick
168
        // up again if we run out of API calls.
169
        $allmeetings = array_map([$this, 'normalize_meeting'], $allmeetings);
170
        usort($allmeetings, [$this, 'cmp']);
171
 
172
        mtrace("Processing " . count($allmeetings) . " meetings");
173
 
174
        foreach ($allmeetings as $meeting) {
175
            // Only process meetings if they happened after the time we left off.
176
            $meetingtime = ($meeting->end_time == intval($meeting->end_time)) ? $meeting->end_time : strtotime($meeting->end_time);
177
            if ($runningastask && $meetingtime <= $starttime) {
178
                continue;
179
            }
180
 
181
            try {
182
                if (!$this->process_meeting_reports($meeting)) {
183
                    // If returned false, then ran out of API calls or got
184
                    // unrecoverable error. Try to pick up where we left off.
185
                    if ($runningastask) {
186
                        // Only want to resume if we were processing all reports.
187
                        $recordedallmeetings = false;
188
                        set_config('last_call_made_at', $meetingtime - 1, 'zoom');
189
                    }
190
 
191
                    break;
192
                }
193
            } catch (Exception $e) {
194
                mtrace($e->getMessage());
195
                mtrace($e->getTraceAsString());
196
                // Some unknown error, need to handle it so we can record
197
                // where we left off.
198
                if ($runningastask) {
199
                    $recordedallmeetings = false;
200
                    set_config('last_call_made_at', $meetingtime - 1, 'zoom');
201
                    break;
202
                }
203
            }
204
        }
205
 
206
        if ($recordedallmeetings && $runningastask) {
207
            // All finished, so save the time that we set end time for the initial query.
208
            set_config('last_call_made_at', $endtime, 'zoom');
209
        }
210
    }
211
 
212
    /**
213
     * Formats participants array as a record for the database.
214
     *
215
     * @param stdClass $participant Unformatted array received from web service API call.
216
     * @param int $detailsid The id to link to the zoom_meeting_details table.
217
     * @param array $names Array that contains mappings of user's moodle ID to the user's name.
218
     * @param array $emails Array that contains mappings of user's moodle ID to the user's email.
219
     * @return array Formatted array that is ready to be inserted into the database table.
220
     */
221
    public function format_participant($participant, $detailsid, $names, $emails) {
222
        global $DB;
223
        $moodleuser = null;
224
        $moodleuserid = null;
225
        $name = null;
226
 
227
        // Consolidate fields.
228
        $participant->name = $participant->name ?? $participant->user_name ?? '';
229
        $participant->id = $participant->id ?? $participant->participant_user_id ?? '';
230
        $participant->user_email = $participant->user_email ?? $participant->email ?? '';
231
 
232
        // Cleanup the name. For some reason # gets into the name instead of a comma.
233
        $participant->name = str_replace('#', ',', $participant->name);
234
 
235
        // Extract the ID and name from the participant's name if it is in the format "(id)Name".
236
        if (preg_match('/^\((\d+)\)(.+)$/', $participant->name, $matches)) {
237
            $moodleuserid = $matches[1];
238
            $name = trim($matches[2]);
239
        } else {
240
            $name = $participant->name;
241
        }
242
 
243
        // Try to see if we successfully queried for this user and found a Moodle id before.
244
        if (!empty($participant->id)) {
245
            // Sometimes uuid is blank from Zoom.
246
            $participantmatches = $DB->get_records(
247
                'zoom_meeting_participants',
248
                ['uuid' => $participant->id],
249
                null,
250
                'id, userid, name'
251
            );
252
 
253
            if (!empty($participantmatches)) {
254
                // Found some previous matches. Find first one with userid set.
255
                foreach ($participantmatches as $participantmatch) {
256
                    if (!empty($participantmatch->userid)) {
257
                        $moodleuserid = $participantmatch->userid;
258
                        $name = $participantmatch->name;
259
                        break;
260
                    }
261
                }
262
            }
263
        }
264
 
265
        // Did not find a previous match.
266
        if (empty($moodleuserid)) {
267
            if (!empty($participant->user_email) && ($moodleuserid = array_search(strtoupper($participant->user_email), $emails))) {
268
                // Found email from list of enrolled users.
269
                $name = $names[$moodleuserid];
270
            } else if (!empty($participant->name) && ($moodleuserid = array_search(strtoupper($participant->name), $names))) {
271
                // Found name from list of enrolled users.
272
                $name = $names[$moodleuserid];
273
            } else if (
274
                !empty($participant->user_email)
275
                && ($moodleuser = $DB->get_record('user', [
276
                    'email' => $participant->user_email,
277
                    'deleted' => 0,
278
                    'suspended' => 0,
279
                ], '*', IGNORE_MULTIPLE))
280
            ) {
281
                // This is the case where someone attends the meeting, but is not enrolled in the class.
282
                $moodleuserid = $moodleuser->id;
283
                $name = strtoupper(fullname($moodleuser));
284
            } else if (!empty($participant->name) && ($moodleuserid = $this->match_name($participant->name, $names))) {
285
                // Found name by using fuzzy text search.
286
                $name = $names[$moodleuserid];
287
            } else {
288
                // Did not find any matches, so use what is given by Zoom.
289
                $name = $participant->name;
290
                $moodleuserid = null;
291
            }
292
        }
293
 
294
        if ($participant->user_email === '') {
295
            if (!empty($moodleuserid)) {
296
                $participant->user_email = $DB->get_field('user', 'email', ['id' => $moodleuserid]);
297
            } else {
298
                $participant->user_email = null;
299
            }
300
        }
301
 
302
        if ($participant->id === '') {
303
            $participant->id = null;
304
        }
305
 
306
        return [
307
            'name' => $name,
308
            'userid' => $moodleuserid,
309
            'detailsid' => $detailsid,
310
            'zoomuserid' => $participant->user_id,
311
            'uuid' => $participant->id,
312
            'user_email' => $participant->user_email,
313
            'join_time' => strtotime($participant->join_time),
314
            'leave_time' => strtotime($participant->leave_time),
315
            'duration' => $participant->duration,
316
        ];
317
    }
318
 
319
    /**
320
     * Get enrollment for given course.
321
     *
322
     * @param int $courseid
323
     * @return array    Returns an array of names and emails.
324
     */
325
    public function get_enrollments($courseid) {
326
        // Loop through each user to generate name->uids mapping.
327
        $coursecontext = context_course::instance($courseid);
328
        $enrolled = get_enrolled_users($coursecontext);
329
        $names = [];
330
        $emails = [];
331
        foreach ($enrolled as $user) {
332
            $name = strtoupper(fullname($user));
333
            $names[$user->id] = $name;
334
            $emails[$user->id] = strtoupper(zoom_get_api_identifier($user));
335
        }
336
 
337
        return [$names, $emails];
338
    }
339
 
340
    /**
341
     * Get meetings first by querying for active hostuuids for given time
342
     * period. Then find meetings that host have given in given time period.
343
     *
344
     * This is the older method of querying for meetings. It has been superseded
345
     * by the Dashboard API. However, that API is only available for Business
346
     * accounts and higher. The Reports API is available for Pro user and up.
347
     *
348
     * This method is kept for those users that have Pro accounts and using
349
     * this plugin.
350
     *
351
     * @param string $start    If passed, will find meetings starting on given date. Format is YYYY-MM-DD.
352
     * @param string $end      If passed, will find meetings ending on given date. Format is YYYY-MM-DD.
353
     * @param array $hostuuids If passed, will find only meetings for given array of host uuids.
354
     *
355
     * @return array
356
     */
357
    public function get_meetings_via_reports($start, $end, $hostuuids) {
358
        global $DB;
359
        mtrace('Using Reports API');
360
        if (empty($hostuuids)) {
361
            $this->debugmsg('Empty hostuuids, querying all hosts');
362
            // Get all hosts.
363
            $activehostsuuids = $this->service->get_active_hosts_uuids($start, $end);
364
        } else {
365
            $this->debugmsg('Hostuuids passed');
366
            // Else we just want a specific hosts.
367
            $activehostsuuids = $hostuuids;
368
        }
369
 
370
        $allmeetings = [];
371
        $localhosts = $DB->get_records_menu('zoom', null, '', 'id, host_id');
372
 
373
        mtrace("Processing " . count($activehostsuuids) . " active host uuids");
374
 
375
        foreach ($activehostsuuids as $activehostsuuid) {
376
            // This API call returns information about meetings and webinars,
377
            // don't need extra functionality for webinars.
378
            $usersmeetings = [];
379
            if (in_array($activehostsuuid, $localhosts)) {
380
                $this->debugmsg('Getting meetings for host uuid ' . $activehostsuuid);
381
                try {
382
                    $usersmeetings = $this->service->get_user_report($activehostsuuid, $start, $end);
383
                } catch (not_found_exception $e) {
384
                    // Zoom API returned user not found for a user it said had,
385
                    // meetings. Have to skip user.
386
                    $this->debugmsg("Skipping $activehostsuuid because user does not exist on Zoom");
387
                    continue;
388
                } catch (retry_failed_exception $e) {
389
                    // Hit API limit, so cannot continue.
390
                    mtrace($e->response . ': ' . $e->zoomerrorcode);
391
                    return;
392
                }
393
            } else {
394
                // Ignore hosts who hosted meetings outside of integration.
395
                continue;
396
            }
397
 
398
            $this->debugmsg(sprintf('Found %d meetings for user', count($usersmeetings)));
399
            foreach ($usersmeetings as $usermeeting) {
400
                $allmeetings[] = $usermeeting;
401
            }
402
        }
403
 
404
        return $allmeetings;
405
    }
406
 
407
    /**
408
     * Get meetings and webinars using Dashboard API.
409
     *
410
     * @param string $start    If passed, will find meetings starting on given date. Format is YYYY-MM-DD.
411
     * @param string $end      If passed, will find meetings ending on given date. Format is YYYY-MM-DD.
412
     *
413
     * @return array
414
     */
415
    public function get_meetings_via_dashboard($start, $end) {
416
        mtrace('Using Dashboard API');
417
 
418
        $meetingscopes = [
419
            'dashboard_meetings:read:admin',
420
            'dashboard_meetings:read:list_meetings:admin',
421
        ];
422
 
423
        $webinarscopes = [
424
            'dashboard_webinars:read:admin',
425
            'dashboard_webinars:read:list_webinars:admin',
426
        ];
427
 
428
        $meetings = [];
429
        if ($this->service->has_scope($meetingscopes)) {
430
            $meetings = $this->service->get_meetings($start, $end);
431
        }
432
 
433
        $webinars = [];
434
        if ($this->service->has_scope($webinarscopes)) {
435
            $webinars = $this->service->get_webinars($start, $end);
436
        }
437
 
438
        $allmeetings = array_merge($meetings, $webinars);
439
 
440
        return $allmeetings;
441
    }
442
 
443
    /**
444
     * Returns name of task.
445
     *
446
     * @return string
447
     */
448
    public function get_name() {
449
        return get_string('getmeetingreports', 'mod_zoom');
450
    }
451
 
452
    /**
453
     * Tries to match a given name to the roster using two different fuzzy text
454
     * matching algorithms and if they match, then returns the match.
455
     *
456
     * @param string $nametomatch
457
     * @param array $rosternames    Needs to be an array larger than 3 for any
458
     *                              meaningful results.
459
     *
460
     * @return int  Returns id for $rosternames. Returns false if no match found.
461
     */
462
    private function match_name($nametomatch, $rosternames) {
463
        if (count($rosternames) < 3) {
464
            return false;
465
        }
466
 
467
        $nametomatch = strtoupper($nametomatch);
468
        $similartextscores = [];
469
        $levenshteinscores = [];
470
        foreach ($rosternames as $name) {
471
            similar_text($nametomatch, $name, $percentage);
472
            if ($percentage > self::SIMILARNAME_THRESHOLD) {
473
                $similartextscores[$name] = $percentage;
474
                $levenshteinscores[$name] = levenshtein($nametomatch, $name);
475
            }
476
        }
477
 
478
        // If we did not find any quality matches, then return false.
479
        if (empty($similartextscores)) {
480
            return false;
481
        }
482
 
483
        // Simlar text has better matches with higher numbers.
484
        arsort($similartextscores);
485
        reset($similartextscores);  // Make sure key gets first element.
486
        $stmatch = key($similartextscores);
487
 
488
        // Levenshtein has better matches with lower numbers.
489
        asort($levenshteinscores);
490
        reset($levenshteinscores);  // Make sure key gets first element.
491
        $lmatch = key($levenshteinscores);
492
 
493
        // If both matches, then we can be rather sure that it is the same user.
494
        if ($stmatch == $lmatch) {
495
            $moodleuserid = array_search($stmatch, $rosternames);
496
            return $moodleuserid;
497
        } else {
498
            return false;
499
        }
500
    }
501
 
502
    /**
503
     * Outputs finer grained debugging messaging if debug mode is on.
504
     *
505
     * @param string $msg
506
     */
507
    public function debugmsg($msg) {
508
        if ($this->debuggingenabled) {
509
            mtrace($msg);
510
        }
511
    }
512
 
513
    /**
514
     * Saves meeting details and participants for reporting.
515
     *
516
     * @param object $meeting    Normalized meeting object
517
     * @return boolean
518
     */
519
    public function process_meeting_reports($meeting) {
520
        global $DB;
521
 
522
        $this->debugmsg(sprintf(
523
            'Processing meeting %s|%s that occurred at %s',
524
            $meeting->meeting_id,
525
            $meeting->uuid,
526
            $meeting->start_time
527
        ));
528
 
529
        // If meeting doesn't exist in the zoom database, the instance is
530
        // deleted, and we don't need reports for these.
531
        if (!($zoomrecord = $DB->get_record('zoom', ['meeting_id' => $meeting->meeting_id], '*', IGNORE_MULTIPLE))) {
532
            mtrace('Meeting does not exist locally; skipping');
533
            return true;
534
        }
535
 
536
        $meeting->zoomid = $zoomrecord->id;
537
 
538
        // Insert or update meeting details.
539
        if (!($DB->record_exists('zoom_meeting_details', ['uuid' => $meeting->uuid]))) {
540
            $this->debugmsg('Inserting zoom_meeting_details');
541
            $detailsid = $DB->insert_record('zoom_meeting_details', $meeting);
542
        } else {
543
            // Details entry already exists, so update it.
544
            $this->debugmsg('Updating zoom_meeting_details');
545
            $detailsid = $DB->get_field('zoom_meeting_details', 'id', ['uuid' => $meeting->uuid]);
546
            $meeting->id = $detailsid;
547
            $DB->update_record('zoom_meeting_details', $meeting);
548
        }
549
 
550
        try {
551
            $participants = $this->service->get_meeting_participants($meeting->uuid, $zoomrecord->webinar);
552
        } catch (not_found_exception $e) {
553
            mtrace(sprintf('Warning: Cannot find meeting %s|%s; skipping', $meeting->meeting_id, $meeting->uuid));
554
            return true;    // Not really a show stopping error.
555
        } catch (webservice_exception $e) {
556
            mtrace($e->response . ': ' . $e->zoomerrorcode);
557
            return false;
558
        }
559
 
560
        // Loop through each user to generate name->uids mapping.
561
        [$names, $emails] = $this->get_enrollments($zoomrecord->course);
562
 
563
        $this->debugmsg(sprintf('Processing %d participants', count($participants)));
564
 
565
        // Now try to insert new participant records.
566
        // There is no unique key, so we make sure each record's data is distinct.
567
        try {
568
            $transaction = $DB->start_delegated_transaction();
569
 
570
            $count = $DB->count_records('zoom_meeting_participants', ['detailsid' => $detailsid]);
571
            if (!empty($count)) {
572
                $this->debugmsg(sprintf('Existing participant records: %d', $count));
573
                // No need to delete old records, we don't insert matching records.
574
            }
575
 
576
            // To prevent sending notifications every time the task ran check if there is inserted new records.
577
            $recordupdated = false;
578
            foreach ($participants as $rawparticipant) {
579
                $this->debugmsg(sprintf(
580
                    'Working on %s (user_id: %d, uuid: %s)',
581
                    $rawparticipant->name,
582
                    $rawparticipant->user_id,
583
                    $rawparticipant->id
584
                ));
585
                $participant = $this->format_participant($rawparticipant, $detailsid, $names, $emails);
586
 
587
                // These conditions are enough.
588
                $conditions = [
589
                    'name' => $participant['name'],
590
                    'userid' => $participant['userid'],
591
                    'detailsid' => $participant['detailsid'],
592
                    'zoomuserid' => $participant['zoomuserid'],
593
                    'join_time' => $participant['join_time'],
594
                    'leave_time' => $participant['leave_time'],
595
                ];
596
 
597
                // Check if the record already exists.
598
                if ($record = $DB->get_record('zoom_meeting_participants', $conditions)) {
599
                    // The exact record already exists, so do nothing.
600
                    $this->debugmsg('Record already exists ' . $record->id);
601
                } else {
602
                    // Insert all new records.
603
                    $recordid = $DB->insert_record('zoom_meeting_participants', $participant, true);
604
                    // At least one new record inserted.
605
                    $recordupdated = true;
606
                    $this->debugmsg('Inserted record ' . $recordid);
607
                }
608
            }
609
 
610
            // If there are new records and the grading method is attendance duration.
611
            // Check the grading method settings.
612
            if (!empty($zoomrecord->grading_method)) {
613
                $gradingmethod = $zoomrecord->grading_method;
614
            } else if ($defaultgrading = get_config('gradingmethod', 'zoom')) {
615
                $gradingmethod = $defaultgrading;
616
            } else {
617
                $gradingmethod = 'entry';
618
            }
619
 
620
            if ($recordupdated && $gradingmethod === 'period') {
621
                // Grade users according to their duration in the meeting.
622
                $this->grading_participant_upon_duration($zoomrecord, $detailsid);
623
            }
624
 
625
            $transaction->allow_commit();
626
        } catch (dml_exception $exception) {
627
            $transaction->rollback($exception);
628
            mtrace('ERROR: Cannot insert zoom_meeting_participants: ' . $exception->getMessage());
629
            return false;
630
        }
631
 
632
        $this->debugmsg('Finished updating meeting report');
633
        return true;
634
    }
635
 
636
    /**
637
     * Update the grades of users according to their duration in the meeting.
638
     * @param object $zoomrecord
639
     * @param int $detailsid
640
     * @return void
641
     */
642
    public function grading_participant_upon_duration($zoomrecord, $detailsid) {
643
        global $CFG, $DB;
644
 
645
        require_once($CFG->libdir . '/gradelib.php');
646
        $courseid = $zoomrecord->course;
647
        $context = context_course::instance($courseid);
648
        // Get grade list for items.
649
        $gradelist = grade_get_grades($courseid, 'mod', 'zoom', $zoomrecord->id);
650
 
651
        // Is this meeting is not gradable, return.
652
        if (empty($gradelist->items)) {
653
            return;
654
        }
655
 
656
        $gradeitem = $gradelist->items[0];
657
        $itemid = $gradeitem->id;
658
        $grademax = $gradeitem->grademax;
659
        $oldgrades = $gradeitem->grades;
660
 
661
        // After check and testing, these timings are the actual meeting timings returned from zoom
662
        // ... (i.e.when the host start and end the meeting).
663
        // Not like those on 'zoom' table which represent the settings from zoom activity.
664
        $meetingtime = $DB->get_record('zoom_meeting_details', ['id' => $detailsid], 'start_time, end_time');
665
        if (empty($zoomrecord->recurring)) {
666
            $end = min($meetingtime->end_time, $zoomrecord->start_time + $zoomrecord->duration);
667
            $start = max($meetingtime->start_time, $zoomrecord->start_time);
668
            $meetingduration = $end - $start;
669
        } else {
670
            $meetingduration = $meetingtime->end_time - $meetingtime->start_time;
671
        }
672
 
673
        // Get the required records again.
674
        $records = $DB->get_records('zoom_meeting_participants', ['detailsid' => $detailsid], 'join_time ASC');
675
        // Initialize the data arrays, indexing them later with userids.
676
        $durations = [];
677
        $join = [];
678
        $leave = [];
679
        // Looping the data to calculate the duration of each user.
680
        foreach ($records as $record) {
681
            $userid = $record->userid;
682
            if (empty($userid)) {
683
                if (is_numeric($record->name)) {
684
                    // In case the participant name looks like an integer, we need to avoid a conflict.
685
                    $userid = '~' . $record->name . '~';
686
                } else {
687
                    $userid = $record->name;
688
                }
689
            }
690
 
691
            // Check if there is old duration stored for this user.
692
            if (!empty($durations[$userid])) {
693
                $old = new stdClass();
694
                $old->duration = $durations[$userid];
695
                $old->join_time = $join[$userid];
696
                $old->leave_time = $leave[$userid];
697
                // Calculating the overlap time.
698
                $overlap = $this->get_participant_overlap_time($old, $record);
699
 
700
                // Set the new data for next use.
701
                $leave[$userid] = max($old->leave_time, $record->leave_time);
702
                $join[$userid] = min($old->join_time, $record->join_time);
703
                $durations[$userid] = $old->duration + $record->duration - $overlap;
704
            } else {
705
                $leave[$userid] = $record->leave_time;
706
                $join[$userid] = $record->join_time;
707
                $durations[$userid] = $record->duration;
708
            }
709
        }
710
 
711
        // Used to count the number of users being graded.
712
        $graded = 0;
713
        $alreadygraded = 0;
714
 
715
        // Array of unidentified users that need to be graded manually.
716
        $needgrade = [];
717
 
718
        // Array of found user ids.
719
        $found = [];
720
 
721
        // Array of non-enrolled users.
722
        $notenrolled = [];
723
 
724
        // Now check the duration for each user and grade them according to it.
725
        foreach ($durations as $userid => $userduration) {
726
            // Setup the grade according to the duration.
727
            $newgrade = min($userduration * $grademax / $meetingduration, $grademax);
728
 
729
            // Double check that this is a Moodle user.
730
            if (is_integer($userid) && (isset($found[$userid]) || $DB->record_exists('user', ['id' => $userid]))) {
731
                // Successfully found this user in Moodle.
732
                if (!isset($found[$userid])) {
733
                    $found[$userid] = true;
734
                }
735
 
736
                $oldgrade = null;
737
                if (isset($oldgrades[$userid])) {
738
                    $oldgrade = $oldgrades[$userid]->grade;
739
                }
740
 
741
                // Check if the user is enrolled before assign the grade.
742
                if (is_enrolled($context, $userid)) {
743
                    // Compare with the old grade and only update if the new grade is higher.
744
                    // Use number_format because the old stored grade only contains 5 decimals.
745
                    if (empty($oldgrade) || $oldgrade < number_format($newgrade, 5)) {
746
                        $gradegrade = [
747
                            'rawgrade' => $newgrade,
748
                            'userid' => $userid,
749
                            'usermodified' => $userid,
750
                            'dategraded' => '',
751
                            'feedbackformat' => '',
752
                            'feedback' => '',
753
                        ];
754
 
755
                        zoom_grade_item_update($zoomrecord, $gradegrade);
756
                        $graded++;
757
                        $this->debugmsg('grade updated for user with id: ' . $userid
758
                                        . ', duration =' . $userduration
759
                                        . ', maxgrade =' . $grademax
760
                                        . ', meeting duration =' . $meetingduration
761
                                        . ', User grade:' . $newgrade);
762
                    } else {
763
                        $alreadygraded++;
764
                        $this->debugmsg('User already has a higher grade. Old grade: ' . $oldgrade
765
                                        . ', New grade: ' . $newgrade);
766
                    }
767
                } else {
768
                    $notenrolled[$userid] = fullname(core_user::get_user($userid));
769
                }
770
            } else {
771
                // This means that this user was not identified.
772
                // Provide information about participants that need to be graded manually.
773
                $a = [
774
                    'userid' => $userid,
775
                    'grade' => $newgrade,
776
                ];
777
                $needgrade[] = get_string('nonrecognizedusergrade', 'mod_zoom', $a);
778
            }
779
        }
780
 
781
        // Get the list of users who clicked join meeting and were not recognized by the participant report.
782
        $allusers = $this->get_users_clicked_join($zoomrecord);
783
        $notfound = [];
784
        foreach ($allusers as $userid) {
785
            if (!isset($found[$userid])) {
786
                $notfound[$userid] = fullname(core_user::get_user($userid));
787
            }
788
        }
789
 
790
        // Try not to spam the instructors, only notify them when grades have changed.
791
        if ($graded > 0) {
792
            // Sending a notification to teachers in this course about grades, and users that need to be graded manually.
793
            $notifydata = [
794
                'graded' => $graded,
795
                'alreadygraded' => $alreadygraded,
796
                'needgrade' => $needgrade,
797
                'courseid' => $courseid,
798
                'zoomid' => $zoomrecord->id,
799
                'itemid' => $itemid,
800
                'name' => $zoomrecord->name,
801
                'notfound' => $notfound,
802
                'notenrolled' => $notenrolled,
803
            ];
804
            $this->notify_teachers($notifydata);
805
        }
806
    }
807
 
808
    /**
809
     * Calculate the overlap time for a participant.
810
     *
811
     * @param object $record1 Record data 1.
812
     * @param object $record2 Record data 2.
813
     * @return int the overlap time
814
     */
815
    public function get_participant_overlap_time($record1, $record2) {
816
        // Determine which record starts first.
817
        if ($record1->join_time < $record2->join_time) {
818
            $old = $record1;
819
            $new = $record2;
820
        } else {
821
            $old = $record2;
822
            $new = $record1;
823
        }
824
 
825
        $oldjoin = (int) $old->join_time;
826
        $oldleave = (int) $old->leave_time;
827
        $newjoin = (int) $new->join_time;
828
        $newleave = (int) $new->leave_time;
829
 
830
        // There are three possible cases.
831
        if ($newjoin >= $oldleave) {
832
            // First case - No overlap.
833
            // Example: old(join: 15:00 leave: 15:30), new(join: 15:35 leave: 15:50).
834
            // No overlap.
835
            $overlap = 0;
836
        } else if ($newleave > $oldleave) {
837
            // Second case - Partial overlap.
838
            // Example: new(join: 15:15 leave: 15:45), old(join: 15:00 leave: 15:30).
839
            // 15 min overlap.
840
            $overlap = $oldleave - $newjoin;
841
        } else {
842
            // Third case - Complete overlap.
843
            // Example:  new(join: 15:15 leave: 15:29), old(join: 15:00 leave: 15:30).
844
            // 14 min overlap (new duration).
845
            $overlap = $new->duration;
846
        }
847
 
848
        return $overlap;
849
    }
850
 
851
    /**
852
     * Sending a notification to all teachers in the course notify them about grading
853
     * also send the names of the users needing a manual grading.
854
     * return array of messages ids and false if there is no users in this course
855
     * with the capability of edit grades.
856
     *
857
     * @param array $data
858
     * @return array|bool
859
     */
860
    public function notify_teachers($data) {
861
        // Number of users graded automatically.
862
        $graded = $data['graded'];
863
        // Number of users already graded.
864
        $alreadygraded = $data['alreadygraded'];
865
        // Number of users need to be graded.
866
        $needgradenumber = count($data['needgrade']);
867
        // List of users need grading.
868
        $needstring = get_string('grading_needgrade', 'mod_zoom');
869
        $needgrade = (!empty($data['needgrade'])) ? $needstring . implode('<br>', $data['needgrade']) . "\n" : '';
870
 
871
        $zoomid = $data['zoomid'];
872
        $itemid = $data['itemid'];
873
        $name = $data['name'];
874
        $courseid = $data['courseid'];
875
        $context = context_course::instance($courseid);
876
        // Get teachers in the course (actually those with the ability to edit grades).
877
        $teachers = get_enrolled_users($context, 'moodle/grade:edit', 0, 'u.*', null, 0, 0, true);
878
 
879
        // Grading item url.
880
        $gurl = new moodle_url(
881
            '/grade/report/singleview/index.php',
882
            [
883
                'id' => $courseid,
884
                'item' => 'grade',
885
                'itemid' => $itemid,
886
            ]
887
        );
888
        $gradeurl = html_writer::link($gurl, get_string('gradinglink', 'mod_zoom'));
889
 
890
        // Zoom instance url.
891
        $zurl = new moodle_url('/mod/zoom/view.php', ['id' => $zoomid]);
892
        $zoomurl = html_writer::link($zurl, $name);
893
 
894
        // Data object used in lang strings.
895
        $a = (object) [
896
            'name' => $name,
897
            'graded' => $graded,
898
            'alreadygraded' => $alreadygraded,
899
            'needgrade' => $needgrade,
900
            'number' => $needgradenumber,
901
            'gradeurl' => $gradeurl,
902
            'zoomurl' => $zoomurl,
903
            'notfound' => '',
904
            'notenrolled' => '',
905
        ];
906
        // Get the list of users clicked join meeting but not graded or reconized.
907
        // This helps the teacher to grade them manually.
908
        $notfound = $data['notfound'];
909
        if (!empty($notfound)) {
910
            $a->notfound = get_string('grading_notfound', 'mod_zoom');
911
            foreach ($notfound as $userid => $fullname) {
912
                $params = ['item' => 'user', 'id' => $courseid, 'userid' => $userid];
913
                $url = new moodle_url('/grade/report/singleview/index.php', $params);
914
                $userurl = html_writer::link($url, $fullname . ' (' . $userid . ')');
915
                $a->notfound .= '<br> ' . $userurl;
916
            }
917
        }
918
 
919
        $notenrolled = $data['notenrolled'];
920
        if (!empty($notenrolled)) {
921
            $a->notenrolled = get_string('grading_notenrolled', 'mod_zoom');
922
            foreach ($notenrolled as $userid => $fullname) {
923
                $userurl = new moodle_url('/user/profile.php', ['id' => $userid]);
924
                $profile = html_writer::link($userurl, $fullname);
925
                $a->notenrolled .= '<br>' . $profile;
926
            }
927
        }
928
 
929
        // Prepare the message.
930
        $message = new message();
931
        $message->component = 'mod_zoom';
932
        $message->name = 'teacher_notification'; // The notification name from message.php.
933
        $message->userfrom = core_user::get_noreply_user();
934
 
935
        $message->subject = get_string('gradingmessagesubject', 'mod_zoom', $a);
936
 
937
        $messagebody = get_string('gradingmessagebody', 'mod_zoom', $a);
938
        $message->fullmessage = $messagebody;
939
 
940
        $message->fullmessageformat = FORMAT_MARKDOWN;
941
        $message->fullmessagehtml = "<p>$messagebody</p>";
942
        $message->smallmessage = get_string('gradingsmallmeassage', 'mod_zoom', $a);
943
        $message->notification = 1;
944
        $message->contexturl = $gurl; // This link redirect the teacher to the page of item's grades.
945
        $message->contexturlname = get_string('gradinglink', 'mod_zoom');
946
        // Email content.
947
        $content = ['*' => ['header' => $message->subject, 'footer' => '']];
948
        $message->set_additional_content('email', $content);
949
        $messageids = [];
950
        if (!empty($teachers)) {
951
            foreach ($teachers as $teacher) {
952
                $message->userto = $teacher;
953
                // Actually send the message for each teacher.
954
                $messageids[] = message_send($message);
955
            }
956
        } else {
957
            return false;
958
        }
959
 
960
        return $messageids;
961
    }
962
 
963
    /**
964
     * The meeting object from the Dashboard API differs from the Report API, so
965
     * normalize the meeting object to conform to what is expected it the
966
     * database.
967
     *
968
     * @param object $meeting
969
     * @return object   Normalized meeting object
970
     */
971
    public function normalize_meeting($meeting) {
972
        $normalizedmeeting = new stdClass();
973
 
974
        // Returned meeting object will not be using Zoom's id, because it is a
975
        // primary key in our own tables.
976
        $normalizedmeeting->meeting_id = $meeting->id;
977
 
978
        // Convert times to Unixtimestamps.
979
        $normalizedmeeting->start_time = strtotime($meeting->start_time);
980
        $normalizedmeeting->end_time = strtotime($meeting->end_time);
981
 
982
        // Copy values that are named the same.
983
        $normalizedmeeting->uuid = $meeting->uuid;
984
        $normalizedmeeting->topic = $meeting->topic;
985
 
986
        // Dashboard API has duration as H:M:S while report has it in minutes.
987
        $timeparts = explode(':', $meeting->duration);
988
 
989
        // Convert duration into minutes.
990
        if (count($timeparts) === 1) {
991
            // Time is already in minutes.
992
            $normalizedmeeting->duration = intval($meeting->duration);
993
        } else if (count($timeparts) === 2) {
994
            // Time is in MM:SS format.
995
            $normalizedmeeting->duration = $timeparts[0];
996
        } else {
997
            // Time is in HH:MM:SS format.
998
            $normalizedmeeting->duration = 60 * $timeparts[0] + $timeparts[1];
999
        }
1000
 
1001
        // Copy values that are named differently.
1002
        $normalizedmeeting->participants_count = $meeting->participants ?? $meeting->participants_count;
1003
 
1004
        // Dashboard API does not have total_minutes.
1005
        $normalizedmeeting->total_minutes = $meeting->total_minutes ?? null;
1006
 
1007
        return $normalizedmeeting;
1008
    }
1009
 
1010
    /**
1011
     * Get list of all users clicked (join meeting) in a given zoom instance.
1012
     * @param object $zoomrecord
1013
     * @return array<int>
1014
     */
1015
    public function get_users_clicked_join($zoomrecord) {
1016
        global $DB;
1017
        $logmanager = get_log_manager();
1018
        if (!$readers = $logmanager->get_readers('core\log\sql_reader')) {
1019
            // Should be using 2.8, use old class.
1020
            $readers = $logmanager->get_readers('core\log\sql_select_reader');
1021
        }
1022
 
1023
        $reader = array_pop($readers);
1024
        if ($reader === null) {
1025
            return [];
1026
        }
1027
 
1028
        $params = [
1029
            'courseid' => $zoomrecord->course,
1030
            'objectid' => $zoomrecord->id,
1031
        ];
1032
        $selectwhere = "eventname = '\\\\mod_zoom\\\\event\\\\join_meeting_button_clicked'
1033
            AND courseid = :courseid
1034
            AND objectid = :objectid";
1035
        $events = $reader->get_events_select($selectwhere, $params, 'userid ASC', 0, 0);
1036
 
1037
        $userids = [];
1038
        foreach ($events as $event) {
1039
            if (
1040
                $event->other['meetingid'] === $zoomrecord->meeting_id &&
1041
                !in_array($event->userid, $userids, true)
1042
            ) {
1043
                $userids[] = $event->userid;
1044
            }
1045
        }
1046
 
1047
        return $userids;
1048
    }
1049
}