diff --git a/CHANGES.md b/CHANGES.md index cf084359..4d9d9c57 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,16 @@ ### Releases ### +#### v5.2.1 #### + +- Bugfix: Set icon size to something reasonable on Moodle 4.3 #581 (thanks @haietza) +- Bugfix: Save Zoom data (e.g. join_url) when updating instance #585 (thanks @selimmeziti) +- Bugfix: Form sections can now toggle independently #587 (thanks @kiratskitizing) +- Bugfix: Differentiate between multiple recording types #578 (thanks @welegionsr) +- Bugfix: Granular OAuth scopes work now #590 (thanks @amendezinserver, @jport500, @haietza, Kohei SHIRAHAMA) +- Code quality: Move function from view page to locallib #584 +- Code quality: Freshen GitHub Action to match moodle-plugin-ci #584 +- Code quality: Align with moodle-cs v3.4.6 #584 + #### v5.2.0 #### - Feature: Grading based on attendance duration #477 (thanks @fmido88) diff --git a/README.md b/README.md index 88959e27..91bad633 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,50 @@ permission is required. You should create a separate Server-to-Server OAuth app The Server-to-Server OAuth app will generate a client ID, client secret and account ID. -At a minimum, the following scopes are required by this plugin: +#### Granular scopes +At a minimum, the following scopes are required: + +- meeting:read:meeting:admin (Get meeting) +- meeting:read:invitation:admin (Get meeting invitation) +- meeting:delete:meeting:admin (Delete meeting) +- meeting:update:meeting:admin (Update meeting) +- meeting:write:meeting:admin (Create meeting) +- user:read:list_schedulers:admin (List schedulers) +- user:read:settings:admin (Get user settings) +- user:read:user:admin (Get user) + +Optional functionality can be enabled by granting additional scopes: + +- Meeting registrations + - meeting:read:list_registrants:admin (Get registrants) +- Reports for meetings / webinars (Licensed accounts and higher) + - report:read:list_meeting_participants:admin + - report:read:list_webinar_participants:admin + - report:read:list_users:admin + - report:read:user:admin +- Faster reports for meetings / webinars (Business accounts and higher) + - dashboard:read:list_meeting_participants:admin + - dashboard:read:list_meetings:admin + - dashboard:read:list_webinar_participants:admin + - dashboard:read:list_webinars:admin +- Allow recordings to be viewed (zoom | viewrecordings) + - cloud_recording:read:list_recording_files:admin + - cloud_recording:read:list_user_recordings:admin + - cloud_recording:read:recording_settings:admin +- Tracking fields (zoom | defaulttrackingfields) + - Not yet supported by Zoom +- Recycle licenses (zoom | utmost), (zoom | recycleonjoin) + - user:read:list_users:admin + - user:update:user:admin +- Webinars (zoom | showwebinars), (zoom | webinardefault) + - webinar:read:list_registrants:admin + - webinar:read:webinar:admin + - webinar:delete:webinar:admin + - webinar:update:webinar:admin + - webinar:write:webinar:admin + +#### Classic scopes +At a minimum, the following scopes are required: - meeting:read:admin (Read meeting details) - meeting:write:admin (Create/Update meetings) diff --git a/classes/task/get_meeting_reports.php b/classes/task/get_meeting_reports.php index 8686648d..178029bd 100644 --- a/classes/task/get_meeting_reports.php +++ b/classes/task/get_meeting_reports.php @@ -82,7 +82,6 @@ private function cmp($a, $b) { /** * Gets the meeting IDs from the queue, retrieve the information for each * meeting, then remove the meeting from the queue. - * @link https://zoom.github.io/api/#report-metric-apis * * @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. @@ -144,14 +143,24 @@ public function execute($paramstart = null, $paramend = null, $hostuuids = null) $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('dashboard_meetings:read:admin')) { + if (empty($hostuuids) && $this->service->has_scope($dashboardscopes)) { $allmeetings = $this->get_meetings_via_dashboard($start, $end); - } else if ($this->service->has_scope('report:read:admin')) { + } else if ($this->service->has_scope($reportscopes)) { $allmeetings = $this->get_meetings_via_reports($start, $end, $hostuuids); } else { - $requiredscope = !empty($hostuuids) ? 'report:read:admin' : 'dashboard_meetings:read:admin or report:read:admin'; - mtrace('Skipping task - missing required OAuth scope: ' . $requiredscope); + mtrace('Skipping task - missing OAuth scopes required for reports'); return; } @@ -406,13 +415,23 @@ public function get_meetings_via_reports($start, $end, $hostuuids) { 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('dashboard_meetings:read:admin')) { + if ($this->service->has_scope($meetingscopes)) { $meetings = $this->service->get_meetings($start, $end); } $webinars = []; - if ($this->service->has_scope('dashboard_webinars:read:admin')) { + if ($this->service->has_scope($webinarscopes)) { $webinars = $this->service->get_webinars($start, $end); } diff --git a/classes/webservice.php b/classes/webservice.php index 1f22008c..ffd9f43a 100644 --- a/classes/webservice.php +++ b/classes/webservice.php @@ -349,7 +349,6 @@ private function make_call($path, $data = [], $method = 'get') { * @param string $datatoget The name of the array of the data to get. * @return array The retrieved data. * @see make_call() - * @link https://zoom.github.io/api/#list-users */ private function make_paginated_call($url, $data, $datatoget) { $aggregatedata = []; @@ -379,9 +378,11 @@ private function make_paginated_call($url, $data, $datatoget) { * * @param stdClass $user The user to create. * @return bool Whether the user was succesfully created. - * @link https://zoom.github.io/api/#create-a-user + * @deprecated Has never been used by internal code. */ public function autocreate_user($user) { + // Classic: user:write:admin. + // Granular: user:write:user:admin. $url = 'users'; $data = ['action' => 'autocreate']; $data['user_info'] = [ @@ -410,10 +411,11 @@ public function autocreate_user($user) { * Get users list. * * @return array An array of users. - * @link https://zoom.github.io/api/#list-users */ public function list_users() { if (empty(self::$userslist)) { + // Classic: user:read:admin. + // Granular: user:read:list_users:admin. self::$userslist = $this->make_paginated_call('users', [], 'users'); } @@ -452,7 +454,11 @@ private function paid_user_limit_reached() { */ private function get_least_recently_active_paid_user_id() { $usertimes = []; + + // Classic: user:read:admin. + // Granular: user:read:list_users:admin. $userslist = $this->list_users(); + foreach ($userslist as $user) { if ($user->type != ZOOM_USER_TYPE_BASIC && isset($user->last_login_time)) { // Count the user if we're including all users or if the user is on this instance. @@ -474,9 +480,10 @@ private function get_least_recently_active_paid_user_id() { * * @param string $userid The user's ID. * @return stdClass The call's result in JSON format. - * @link https://marketplace.zoom.us/docs/api-reference/zoom-api/methods/#operation/userSettings */ public function get_user_settings($userid) { + // Classic: user:read:admin. + // Granular: user:read:settings:admin. return $this->make_call('users/' . $userid . '/settings'); } @@ -485,9 +492,10 @@ public function get_user_settings($userid) { * * @param string $userid The user's ID. * @return stdClass The call's result in JSON format. - * @link https://marketplace.zoom.us/docs/api-reference/zoom-api/methods/#operation/userSettings */ public function get_account_meeting_security_settings($userid) { + // Classic: user:read:admin. + // Granular: user:read:settings:admin. $url = 'users/' . $userid . '/settings?option=meeting_security'; try { $response = $this->make_call($url); @@ -519,11 +527,12 @@ public function get_account_meeting_security_settings($userid) { * * @param string|int $identifier The user's email or the user's ID per Zoom API. * @return stdClass|false If user is found, returns the User object. Otherwise, returns false. - * @link https://zoom.github.io/api/#users */ public function get_user($identifier) { $founduser = false; + // Classic: user:read:admin. + // Granular: user:read:user:admin. $url = 'users/' . $identifier; try { @@ -544,9 +553,10 @@ public function get_user($identifier) { * * @param string $identifier The user's email or the user's ID per Zoom API. * @return array|false If schedulers are returned array of {id,email} objects. Otherwise returns false. - * @link https://marketplace.zoom.us/docs/api-reference/zoom-api/users/userschedulers */ public function get_schedule_for_users($identifier) { + // Classic: user:read:admin. + // Granular: user:read:list_schedulers:admin. $url = "users/{$identifier}/schedulers"; $schedulerswithoutkey = []; @@ -734,6 +744,8 @@ private function database_to_api($zoom) { */ public function provide_license($zoomuserid) { // Checks whether we need to recycle licenses and acts accordingly. + // Classic: user:read:admin. + // Granular: user:read:user:admin. if ($this->recyclelicenses && $this->make_call("users/$zoomuserid")->type == ZOOM_USER_TYPE_BASIC) { if ($this->paid_user_limit_reached()) { $leastrecentlyactivepaiduserid = $this->get_least_recently_active_paid_user_id(); @@ -742,6 +754,8 @@ public function provide_license($zoomuserid) { } // Changes current user to pro so they can make a meeting. + // Classic: user:write:admin. + // Granular: user:update:user:admin. $this->make_call("users/$zoomuserid", ['type' => ZOOM_USER_TYPE_PRO], 'patch'); } } @@ -756,6 +770,11 @@ public function provide_license($zoomuserid) { public function create_meeting($zoom) { // Provide license if needed. $this->provide_license($zoom->host_id); + + // Classic: meeting:write:admin. + // Granular: meeting:write:meeting:admin. + // Classic: webinar:write:admin. + // Granular: webinar:write:webinar:admin. $url = "users/$zoom->host_id/" . (!empty($zoom->webinar) ? 'webinars' : 'meetings'); return $this->make_call($url, $this->database_to_api($zoom), 'post'); } @@ -767,6 +786,10 @@ public function create_meeting($zoom) { * @return void */ public function update_meeting($zoom) { + // Classic: meeting:write:admin. + // Granular: meeting:update:meeting:admin. + // Classic: webinar:write:admin. + // Granular: webinar:update:webinar:admin. $url = ($zoom->webinar ? 'webinars/' : 'meetings/') . $zoom->meeting_id; $this->make_call($url, $this->database_to_api($zoom), 'patch'); } @@ -779,6 +802,10 @@ public function update_meeting($zoom) { * @return void */ public function delete_meeting($id, $webinar) { + // Classic: meeting:write:admin. + // Granular: meeting:delete:meeting:admin. + // Classic: webinar:write:admin. + // Granular: webinar:delete:webinar:admin. $url = ($webinar ? 'webinars/' : 'meetings/') . $id . '?schedule_for_reminder=false'; $this->make_call($url, null, 'delete'); } @@ -791,6 +818,10 @@ public function delete_meeting($id, $webinar) { * @return stdClass The meeting's or webinar's information. */ public function get_meeting_webinar_info($id, $webinar) { + // Classic: meeting:read:admin. + // Granular: meeting:read:meeting:admin. + // Classic: webinar:read:admin. + // Granular: webinar:read:webinar:admin. $url = ($webinar ? 'webinars/' : 'meetings/') . $id; $response = $this->make_call($url); return $response; @@ -801,7 +832,6 @@ public function get_meeting_webinar_info($id, $webinar) { * * @param stdClass $zoom The zoom meeting * @return \mod_zoom\invitation The meeting's invitation. - * @link https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetinginvitation */ public function get_meeting_invitation($zoom) { global $CFG; @@ -812,7 +842,10 @@ public function get_meeting_invitation($zoom) { return new invitation(null); } + // Classic: meeting:read:admin. + // Granular: meeting:read:invitation:admin. $url = 'meetings/' . $zoom->meeting_id . '/invitation'; + try { $response = $this->make_call($url); } catch (moodle_exception $error) { @@ -830,9 +863,10 @@ public function get_meeting_invitation($zoom) { * @param string $from Start date of period in the form YYYY-MM-DD * @param string $to End date of period in the form YYYY-MM-DD * @return array The retrieved meetings. - * @link https://zoom.github.io/api/#retrieve-meetings-report */ public function get_user_report($userid, $from, $to) { + // Classic: report:read:admin. + // Granular: report:read:user:admin. $url = 'report/users/' . $userid . '/meetings'; $data = ['from' => $from, 'to' => $to]; return $this->make_paginated_call($url, $data, 'meetings'); @@ -844,10 +878,13 @@ public function get_user_report($userid, $from, $to) { * @param string $userid The user whose meetings or webinars to retrieve. * @param boolean $webinar Whether to list meetings or to list webinars. * @return array An array of meeting information. - * @link https://zoom.github.io/api/#list-webinars - * @link https://zoom.github.io/api/#list-meetings + * @deprecated Has never been used by internal code. */ public function list_meetings($userid, $webinar) { + // Classic: meeting:read:admin. + // Granular: meeting:read:list_meetings:admin. + // Classic: webinar:read:admin. + // Granular: webinar:read:list_webinars:admin. $url = 'users/' . $userid . ($webinar ? '/webinars' : '/meetings'); $instances = $this->make_paginated_call($url, [], ($webinar ? 'webinars' : 'meetings')); return $instances; @@ -863,13 +900,30 @@ public function get_meeting_participants($meetinguuid, $webinar) { $meetinguuid = $this->encode_uuid($meetinguuid); $meetingtype = ($webinar ? 'webinars' : 'meetings'); + $meetingtypesingular = ($webinar ? 'webinar' : 'meeting'); + + $reportscopes = [ + // Classic. + 'report:read:admin', + + // Granular. + 'report:read:list_' . $meetingtypesingular . '_participants:admin', + ]; + + $dashboardscopes = [ + // Classic. + 'dashboard_' . $meetingtype . ':read:admin', + + // Granular. + 'dashboard:read:list_' . $meetingtypesingular . '_participants:admin', + ]; - if ($this->has_scope('report:read:admin')) { + if ($this->has_scope($reportscopes)) { $apitype = 'report'; - } else if ($this->has_scope('dashboard_' . $meetingtype . ':read:admin')) { + } else if ($this->has_scope($dashboardscopes)) { $apitype = 'metrics'; } else { - mtrace('Missing required OAuth scope: report:read:admin or dashboard_' . $meetingtype . ':read:admin'); + mtrace('Missing OAuth scopes required for reports.'); return []; } @@ -885,6 +939,8 @@ public function get_meeting_participants($meetinguuid, $webinar) { * @return array An array of UUIDs. */ public function get_active_hosts_uuids($from, $to) { + // Classic: report:read:admin. + // Granular: report:read:list_users:admin. $users = $this->make_paginated_call('report/users', ['type' => 'active', 'from' => $from, 'to' => $to], 'users'); $uuids = []; foreach ($users as $user) { @@ -899,8 +955,6 @@ public function get_active_hosts_uuids($from, $to) { * * Ignores meetings that were attended only by one user. * - * See https://developers.zoom.us/docs/api/rest/reference/zoom-api/methods/#operation/dashboardMeetings - * * NOTE: Requires Business or a higher plan and have "Dashboard" feature * enabled. This query is rated "Resource-intensive" * @@ -909,6 +963,8 @@ public function get_active_hosts_uuids($from, $to) { * @return array An array of meeting objects. */ public function get_meetings($from, $to) { + // Classic: dashboard_meetings:read:admin. + // Granular: dashboard:read:list_meetings:admin. return $this->make_paginated_call( 'metrics/meetings', [ @@ -926,8 +982,6 @@ public function get_meetings($from, $to) { * * Ignores meetings that were attended only by one user. * - * See https://marketplace.zoom.us/docs/api-reference/zoom-api/dashboards/dashboardmeetings - * * NOTE: Requires Business or a higher plan and have "Dashboard" feature * enabled. This query is rated "Resource-intensive" * @@ -936,6 +990,8 @@ public function get_meetings($from, $to) { * @return array An array of meeting objects. */ public function get_webinars($from, $to) { + // Classic: dashboard_webinars:read:admin. + // Granular: dashboard:read:list_webinars:admin. return $this->make_paginated_call('metrics/webinars', ['type' => 'past', 'from' => $from, 'to' => $to], 'webinars'); } @@ -943,11 +999,12 @@ public function get_webinars($from, $to) { * Lists tracking fields configured on the account. * * @return ?stdClass The call's result in JSON format. - * @link https://marketplace.zoom.us/docs/api-reference/zoom-api/trackingfield/trackingfieldlist */ public function list_tracking_fields() { $response = null; try { + // Classic: tracking_fields:read:admin. + // Granular: Not yet implemented by Zoom. $response = $this->make_call('tracking_fields'); } catch (moodle_exception $error) { debugging($error->getMessage()); @@ -982,7 +1039,6 @@ public function encode_uuid($uuid) { * There can be more than one url for the same meeting if the host stops the recording in the middle * of the meeting and then starts recording again without ending the meeting. * - * @link https://marketplace.zoom.us/docs/api-reference/zoom-api/cloud-recording/recordingget * @param string $meetingid The string meeting UUID. * @return array Returns the list of recording URLs and the type of recording that is being sent back. */ @@ -1000,6 +1056,8 @@ public function get_recording_url_list($meetingid) { ]; try { + // Classic: recording:read:admin. + // Granular: cloud_recording:read:list_recording_files:admin. $url = 'meetings/' . $this->encode_uuid($meetingid) . '/recordings'; $response = $this->make_call($url); @@ -1035,7 +1093,6 @@ public function get_recording_url_list($meetingid) { * @param string $from Start date of period in the form YYYY-MM-DD * @param string $to End date of period in the form YYYY-MM-DD * @return array - * @link https://developers.zoom.us/docs/api/rest/reference/zoom-api/methods/#operation/recordingsList */ public function get_user_recordings($userid, $from, $to) { $recordings = []; @@ -1051,6 +1108,8 @@ public function get_user_recordings($userid, $from, $to) { ]; try { + // Classic: recording:read:admin. + // Granular: cloud_recording:read:list_user_recordings:admin. $url = 'users/' . $userid . '/recordings'; $data = ['from' => $from, 'to' => $to]; $response = $this->make_paginated_call($url, $data, 'meetings'); @@ -1100,19 +1159,21 @@ protected function get_access_token() { } /** - * Has the request OAuth scope been granted? + * Has one of the required OAuth scopes been granted? * - * @param string $scope OAuth scope. + * @param array $scopes OAuth scopes. * @throws moodle_exception * @return bool */ - public function has_scope($scope) { + public function has_scope($scopes) { if (!isset($this->scopes)) { $this->get_access_token(); } - mtrace('checking has_scope(' . $scope . ')'); - return \in_array($scope, $this->scopes, true); + mtrace('checking has_scope(' . implode(' || ', $scopes) . ')'); + + $matchingscopes = \array_intersect($scopes, $this->scopes); + return !empty($matchingscopes); } /** @@ -1149,12 +1210,29 @@ private function oauth($cache) { throw new moodle_exception('errorwebservice', 'mod_zoom', '', get_string('zoomerr_no_access_token', 'mod_zoom')); } + $scopes = explode(' ', $response->scope); + + // Assume that we are using granular scopes. $requiredscopes = [ - 'meeting:read:admin', - 'meeting:write:admin', - 'user:read:admin', + 'meeting:read:meeting:admin', + 'meeting:read:invitation:admin', + 'meeting:delete:meeting:admin', + 'meeting:update:meeting:admin', + 'meeting:write:meeting:admin', + 'user:read:list_schedulers:admin', + 'user:read:settings:admin', + 'user:read:user:admin', ]; - $scopes = explode(' ', $response->scope); + + // Check if we received classic scopes. + if (in_array('meeting:read:admin', $scopes, true)) { + $requiredscopes = [ + 'meeting:read:admin', + 'meeting:write:admin', + 'user:read:admin', + ]; + } + $missingscopes = array_diff($requiredscopes, $scopes); // Keep the scope information in memory. @@ -1190,6 +1268,10 @@ private function oauth($cache) { * @return stdClass The meeting's or webinar's information. */ public function get_meeting_registrants($id, $webinar) { + // Classic: meeting:read:admin. + // Granular: meeting:read:list_registrants:admin. + // Classic: webinar:read:admin. + // Granular: webinar:read:list_registrants:admin. $url = ($webinar ? 'webinars/' : 'meetings/') . $id . '/registrants'; $response = $this->make_call($url); return $response; @@ -1202,6 +1284,8 @@ public function get_meeting_registrants($id, $webinar) { * @return stdClass The meeting's recording settings. */ public function get_recording_settings($meetinguuid) { + // Classic: recording:read:admin. + // Granular: cloud_recording:read:recording_settings:admin. $url = 'meetings/' . $this->encode_uuid($meetinguuid) . '/recordings/settings'; $response = $this->make_call($url); return $response; diff --git a/recordings.php b/recordings.php index 98d1bacd..53f1c0cb 100644 --- a/recordings.php +++ b/recordings.php @@ -122,7 +122,7 @@ $recordingshowhtml = html_writer::div($recordingshowbuttonhtml); } - $recordingname = trim($recording->name) . ' (' . zoom_get_recording_type_string($recording->recordingtype). ')'; + $recordingname = trim($recording->name) . ' (' . zoom_get_recording_type_string($recording->recordingtype) . ')'; $params = ['id' => $cm->id, 'recordingid' => $recording->id]; $recordingurl = new moodle_url('/mod/zoom/loadrecording.php', $params); $recordinglink = html_writer::link($recordingurl, $recordingname); diff --git a/upgrade.txt b/upgrade.txt index 46aa00b8..a76b133e 100644 --- a/upgrade.txt +++ b/upgrade.txt @@ -1,3 +1,7 @@ +== v5.2.1 == + +- Document Zoom's new granular OAuth scopes. + == v5.2.0 == - New settings `zoom/gradingmethod`, `zoom/unamedisplay` diff --git a/version.php b/version.php index 4690ee79..a41110cc 100755 --- a/version.php +++ b/version.php @@ -26,7 +26,7 @@ $plugin->component = 'mod_zoom'; $plugin->version = 2024041900; -$plugin->release = 'v5.2.0'; +$plugin->release = 'v5.2.1'; $plugin->requires = 2019052000; $plugin->maturity = MATURITY_STABLE; $plugin->cron = 0;