Merge branch 'MDL-64336' of https://github.com/NeillM/moodle
authorSara Arjona <sara@moodle.com>
Wed, 21 Apr 2021 15:13:57 +0000 (17:13 +0200)
committerSara Arjona <sara@moodle.com>
Wed, 21 Apr 2021 15:13:57 +0000 (17:13 +0200)
66 files changed:
admin/settings/courses.php
admin/tool/oauth2/classes/form/issuer.php
admin/tool/oauth2/classes/output/renderer.php
admin/tool/oauth2/issuers.php
admin/tool/oauth2/lang/en/tool_oauth2.php
admin/tool/oauth2/pix/notconfigured.svg [new file with mode: 0644]
admin/tool/oauth2/tests/behat/basic_settings.feature
auth/oauth2/classes/auth.php
auth/oauth2/classes/output/renderer.php
auth/oauth2/linkedlogins.php
auth/oauth2/login.php
backup/moodle2/backup_root_task.class.php
backup/moodle2/backup_settingslib.php
backup/moodle2/restore_root_task.class.php
backup/moodle2/restore_settingslib.php
backup/moodle2/restore_stepslib.php
backup/tests/backup_restore_base_testcase.php [new file with mode: 0644]
backup/tests/backup_restore_permission_test.php [new file with mode: 0644]
backup/upgrade.txt
backup/util/dbops/backup_controller_dbops.class.php
backup/util/dbops/restore_controller_dbops.class.php
backup/util/ui/tests/behat/import_course.feature
backup/util/ui/tests/behat/restore_moodle2_courses.feature
lang/en/backup.php
lib/classes/oauth2/api.php
lib/classes/oauth2/issuer.php
lib/classes/oauth2/refresh_system_tokens_task.php
lib/classes/oauth2/service/facebook.php
lib/classes/oauth2/service/google.php
lib/classes/oauth2/service/microsoft.php
lib/db/install.xml
lib/db/upgrade.php
lib/plagiarismlib.php
lib/tests/oauth2_test.php
mod/label/view.php
mod/lti/OAuth.php
mod/lti/amd/build/tool_configure_controller.min.js
mod/lti/amd/build/tool_configure_controller.min.js.map
mod/lti/amd/src/tool_configure_controller.js
mod/lti/classes/local/ltiopenid/registration_helper.php
mod/lti/classes/output/registration_upgrade_choice_page.php [new file with mode: 0644]
mod/lti/classes/output/renderer.php
mod/lti/lang/en/lti.php
mod/lti/locallib.php
mod/lti/openid-configuration.php
mod/lti/openid-registration.php
mod/lti/startltiadvregistration.php
mod/lti/templates/registration_upgrade_choice_page.mustache [new file with mode: 0644]
mod/lti/tests/openidregistration_test.php
mod/quiz/attemptlib.php
mod/quiz/locallib.php
mod/quiz/review.php
mod/quiz/reviewquestion.php
mod/quiz/tests/behat/attempt_redo_questions.feature
question/engine/lib.php
question/engine/questionattemptstep.php
question/engine/questionusage.php
question/engine/renderer.php
question/engine/tests/questionattemptstep_test.php
question/engine/tests/questionusagebyactivity_test.php
question/engine/tests/walkthrough_test.php
question/type/numerical/edit_numerical_form.php
question/type/numerical/questiontype.php
question/type/numerical/tests/behat/add.feature
repository/googledocs/tests/generator/lib.php
version.php

index 86d198e..2037a63 100644 (file)
@@ -343,6 +343,11 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) {
 
     // Import defaults section.
     $temp->add(new admin_setting_heading('importsettings', new lang_string('importsettings', 'backup'), ''));
+    $temp->add(new admin_setting_configcheckbox_with_lock(
+            'backup/backup_import_permissions',
+            new lang_string('generalpermissions', 'backup'),
+            new lang_string('configgeneralpermissions', 'backup'),
+            array('value' => 0, 'locked' => 0)));
     $temp->add(new admin_setting_configcheckbox_with_lock('backup/backup_import_activities', new lang_string('generalactivities','backup'), new lang_string('configgeneralactivities','backup'), array('value'=>1, 'locked'=>0)));
     $temp->add(new admin_setting_configcheckbox_with_lock('backup/backup_import_blocks', new lang_string('generalblocks','backup'), new lang_string('configgeneralblocks','backup'), array('value'=>1, 'locked'=>0)));
     $temp->add(new admin_setting_configcheckbox_with_lock('backup/backup_import_filters', new lang_string('generalfilters','backup'), new lang_string('configgeneralfilters','backup'), array('value'=>1, 'locked'=>0)));
@@ -517,6 +522,9 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) {
     $temp->add(new admin_setting_configcheckbox_with_lock('restore/restore_general_role_assignments',
         new lang_string('generalroleassignments', 'backup'),
         new lang_string('configrestoreroleassignments', 'backup'), array('value' => 1, 'locked' => 0)));
+    $temp->add(new admin_setting_configcheckbox_with_lock('restore/restore_general_permissions',
+        new lang_string('generalpermissions', 'backup'),
+        new lang_string('configrestorepermissions', 'backup'), array('value' => 1, 'locked' => 0)));
     $temp->add(new admin_setting_configcheckbox_with_lock('restore/restore_general_activities',
         new lang_string('generalactivities', 'backup'),
         new lang_string('configrestoreactivities', 'backup'), array('value' => 1, 'locked' => 0)));
index a5d6c97..84b0669 100644 (file)
@@ -115,54 +115,69 @@ class issuer extends persistent {
         $mform->addElement('checkbox', 'basicauth', get_string('usebasicauth', 'tool_oauth2'));
         $mform->addHelpButton('basicauth', 'usebasicauth', 'tool_oauth2');
 
+        // Base Url.
+        $mform->addElement('text', 'baseurl', get_string('issuerbaseurl', 'tool_oauth2'));
+        $mform->addRule('baseurl', get_string('maximumchars', '', 1024), 'maxlength', 1024, 'client');
+        $mform->addHelpButton('baseurl', 'issuerbaseurl', 'tool_oauth2');
+        if ($this->type && $this->type == 'nextcloud') {
+            $mform->addRule('baseurl', null, 'required', null, 'client');
+        }
+
+        // Image.
+        $mform->addElement('text', 'image', get_string('issuerimage', 'tool_oauth2'), 'maxlength="1024"');
+        $mform->addRule('image', get_string('maximumchars', '', 1024), 'maxlength', 1024, 'client');
+        $mform->addHelpButton('image', 'issuername', 'tool_oauth2');
+
+        // Show on login page.
+        $options = [
+            \core\oauth2\issuer::EVERYWHERE => get_string('issueruseineverywhere', 'tool_oauth2'),
+            \core\oauth2\issuer::LOGINONLY => get_string('issueruseinloginonly', 'tool_oauth2'),
+            \core\oauth2\issuer::SERVICEONLY => get_string('issueruseininternalonly', 'tool_oauth2'),
+        ];
+        $mform->addElement('select', 'showonloginpage', get_string('issuerusein', 'tool_oauth2'), $options);
+        $mform->addHelpButton('showonloginpage', 'issuerusein', 'tool_oauth2');
+
+        // Name on login page.
+        $mform->addElement('text', 'loginpagename', get_string('issuerloginpagename', 'tool_oauth2'));
+        $mform->addRule('loginpagename', get_string('maximumchars', '', 255), 'maxlength', 255, 'client');
+        $mform->addHelpButton('loginpagename', 'issuerloginpagename', 'tool_oauth2');
+        $mform->hideIf('loginpagename', 'showonloginpage', 'eq', \core\oauth2\issuer::SERVICEONLY);
+
         // Login scopes.
         $mform->addElement('text', 'loginscopes', get_string('issuerloginscopes', 'tool_oauth2'));
-        $mform->addRule('loginscopes', null, 'required', null, 'client');
         $mform->addRule('loginscopes', get_string('maximumchars', '', 255), 'maxlength', 255, 'client');
         $mform->addHelpButton('loginscopes', 'issuerloginscopes', 'tool_oauth2');
+        $mform->hideIf('loginscopes', 'showonloginpage', 'eq', \core\oauth2\issuer::SERVICEONLY);
 
         // Login scopes offline.
         $mform->addElement('text', 'loginscopesoffline', get_string('issuerloginscopesoffline', 'tool_oauth2'));
-        $mform->addRule('loginscopesoffline', null, 'required', null, 'client');
         $mform->addRule('loginscopesoffline', get_string('maximumchars', '', 255), 'maxlength', 255, 'client');
         $mform->addHelpButton('loginscopesoffline', 'issuerloginscopesoffline', 'tool_oauth2');
+        $mform->hideIf('loginscopesoffline', 'showonloginpage', 'eq', \core\oauth2\issuer::SERVICEONLY);
 
         // Login params.
         $mform->addElement('text', 'loginparams', get_string('issuerloginparams', 'tool_oauth2'));
         $mform->addRule('loginparams', get_string('maximumchars', '', 255), 'maxlength', 255, 'client');
         $mform->addHelpButton('loginparams', 'issuerloginparams', 'tool_oauth2');
+        $mform->hideIf('loginparams', 'showonloginpage', 'eq', \core\oauth2\issuer::SERVICEONLY);
 
         // Login params offline.
         $mform->addElement('text', 'loginparamsoffline', get_string('issuerloginparamsoffline', 'tool_oauth2'));
         $mform->addRule('loginparamsoffline', get_string('maximumchars', '', 255), 'maxlength', 255, 'client');
         $mform->addHelpButton('loginparamsoffline', 'issuerloginparamsoffline', 'tool_oauth2');
-
-        // Base Url.
-        $mform->addElement('text', 'baseurl', get_string('issuerbaseurl', 'tool_oauth2'));
-        $mform->addRule('baseurl', get_string('maximumchars', '', 1024), 'maxlength', 1024, 'client');
-        $mform->addHelpButton('baseurl', 'issuerbaseurl', 'tool_oauth2');
-        if ($this->type && $this->type == 'nextcloud') {
-            $mform->addRule('baseurl', null, 'required', null, 'client');
-        }
+        $mform->hideIf('loginparamsoffline', 'showonloginpage', 'eq', \core\oauth2\issuer::SERVICEONLY);
 
         // Allowed Domains.
         $mform->addElement('text', 'alloweddomains', get_string('issueralloweddomains', 'tool_oauth2'));
         $mform->addRule('alloweddomains', get_string('maximumchars', '', 1024), 'maxlength', 1024, 'client');
         $mform->addHelpButton('alloweddomains', 'issueralloweddomains', 'tool_oauth2');
-
-        // Image.
-        $mform->addElement('text', 'image', get_string('issuerimage', 'tool_oauth2'), 'maxlength="1024"');
-        $mform->addRule('image', get_string('maximumchars', '', 1024), 'maxlength', 1024, 'client');
-        $mform->addHelpButton('image', 'issuername', 'tool_oauth2');
-
-        // Show on login page.
-        $mform->addElement('checkbox', 'showonloginpage', get_string('issuershowonloginpage', 'tool_oauth2'));
-        $mform->addHelpButton('showonloginpage', 'issuershowonloginpage', 'tool_oauth2');
+        $mform->hideIf('alloweddomains', 'showonloginpage', 'eq', \core\oauth2\issuer::SERVICEONLY);
 
         if ($this->showrequireconfirm) {
             // Require confirmation email for new accounts.
             $mform->addElement('advcheckbox', 'requireconfirmation', get_string('issuerrequireconfirmation', 'tool_oauth2'));
             $mform->addHelpButton('requireconfirmation', 'issuerrequireconfirmation', 'tool_oauth2');
+            $mform->hideIf('requireconfirmation', 'showonloginpage', 'eq', \core\oauth2\issuer::SERVICEONLY);
         }
 
         if ($this->type == 'imsobv2p1' || $issuer->get('servicetype') == 'imsobv2p1') {
@@ -209,4 +224,36 @@ class issuer extends persistent {
             $mform->getElement('servicetype')->setValue($this->type);
         }
     }
+
+    /**
+     * Define extra validation mechanims.
+     *
+     * The data here:
+     * - does not include {@see self::$fieldstoremove}.
+     * - does include {@see self::$foreignfields}.
+     * - was converted to map persistent-like data, e.g. array $description to string $description + int $descriptionformat.
+     *
+     * You can modify the $errors parameter in order to remove some validation errors should you
+     * need to. However, the best practice is to return new or overriden errors. Only modify the
+     * errors passed by reference when you have no other option.
+     *
+     * Do not add any logic here, it is only intended to be used by child classes.
+     *
+     * @param  stdClass $data Data to validate.
+     * @param  array $files Array of files.
+     * @param  array $errors Currently reported errors.
+     * @return array of additional errors, or overridden errors.
+     */
+    protected function extra_validation($data, $files, array &$errors) {
+        $errors = [];
+        if ($data->showonloginpage != \core\oauth2\issuer::SERVICEONLY) {
+            if (!strlen(trim($data->loginscopes))) {
+                $errors['loginscopes'] = get_string('required');
+            }
+            if (!strlen(trim($data->loginscopesoffline))) {
+                $errors['loginscopesoffline'] = get_string('required');
+            }
+        }
+        return $errors;
+    }
 }
index b838d0a..cb79978 100644 (file)
@@ -53,8 +53,9 @@ class renderer extends plugin_renderer_base {
         $table = new html_table();
         $table->head  = [
             get_string('name'),
-            get_string('configuredstatus', 'tool_oauth2'),
-            get_string('loginissuer', 'tool_oauth2'),
+            get_string('issuerusedforlogin', 'tool_oauth2'),
+            get_string('logindisplay', 'tool_oauth2'),
+            get_string('issuerusedforinternal', 'tool_oauth2'),
             get_string('discoverystatus', 'tool_oauth2') . ' ' . $this->help_icon('discovered', 'tool_oauth2'),
             get_string('systemauthstatus', 'tool_oauth2') . ' ' . $this->help_icon('systemaccountconnected', 'tool_oauth2'),
             get_string('edit'),
@@ -84,21 +85,29 @@ class renderer extends plugin_renderer_base {
             $namecell = new html_table_cell($name);
             $namecell->header = true;
 
-            // Configured.
-            if ($issuer->is_configured()) {
-                $configured = $this->pix_icon('yes', get_string('configured', 'tool_oauth2'), 'tool_oauth2');
+            // Login issuer.
+            if ((int)$issuer->get('showonloginpage') == issuer::SERVICEONLY) {
+                $loginissuer = $this->pix_icon('no', get_string('notloginissuer', 'tool_oauth2'), 'tool_oauth2');
+                $logindisplayas = '';
             } else {
-                $configured = $this->pix_icon('no', get_string('notconfigured', 'tool_oauth2'), 'tool_oauth2');
+                $logindisplayas = s($issuer->get_display_name());
+                if ($issuer->get('id') && $issuer->is_configured() && !empty($issuer->get_endpoint_url('userinfo'))) {
+                    $loginissuer = $this->pix_icon('yes', get_string('loginissuer', 'tool_oauth2'), 'tool_oauth2');
+                } else {
+                    $loginissuer = $this->pix_icon('notconfigured', get_string('notconfigured', 'tool_oauth2'), 'tool_oauth2');
+                }
             }
-            $configuredstatuscell = new html_table_cell($configured);
+            $loginissuerstatuscell = new html_table_cell($loginissuer);
 
-            // Login issuer.
-            if (!empty($issuer->get('showonloginpage'))) {
-                $loginissuer = $this->pix_icon('yes', get_string('loginissuer', 'tool_oauth2'), 'tool_oauth2');
+            // Internal services issuer.
+            if ((int)$issuer->get('showonloginpage') == issuer::LOGINONLY) {
+                $serviceissuer = $this->pix_icon('no', get_string('issuersservicesnotallow', 'tool_oauth2'), 'tool_oauth2');
+            } else if ($issuer->get('id') && $issuer->is_configured()) {
+                $serviceissuer = $this->pix_icon('yes', get_string('issuersservicesallow', 'tool_oauth2'), 'tool_oauth2');
             } else {
-                $loginissuer = $this->pix_icon('no', get_string('notloginissuer', 'tool_oauth2'), 'tool_oauth2');
+                $serviceissuer = $this->pix_icon('notconfigured', get_string('notconfigured', 'tool_oauth2'), 'tool_oauth2');
             }
-            $loginissuerstatuscell = new html_table_cell($loginissuer);
+            $internalissuerstatuscell = new html_table_cell($serviceissuer);
 
             // Discovered.
             if (!empty($issuer->get('scopessupported'))) {
@@ -186,13 +195,18 @@ class renderer extends plugin_renderer_base {
 
             $row = new html_table_row([
                 $namecell,
-                $configuredstatuscell,
                 $loginissuerstatuscell,
+                $logindisplayas,
+                $internalissuerstatuscell,
                 $discoverystatuscell,
                 $systemauthstatuscell,
                 $editcell,
             ]);
 
+            if (!$issuer->get('enabled')) {
+                $row->attributes['class'] = 'dimmed_text';
+            }
+
             $data[] = $row;
             $index++;
         }
index 6383ef2..ad720c4 100644 (file)
@@ -195,7 +195,7 @@ if ($mform && $mform->is_cancelled()) {
     echo $OUTPUT->header();
     echo $OUTPUT->heading(get_string('pluginname', 'tool_oauth2'));
     echo $OUTPUT->doc_link('OAuth2_Services', get_string('serviceshelp', 'tool_oauth2'));
-    $issuers = core\oauth2\api::get_all_issuers();
+    $issuers = core\oauth2\api::get_all_issuers(true);
     echo $renderer->issuers_table($issuers);
 
     echo $renderer->container_start();
index 9ff13af..b929911 100644 (file)
@@ -70,6 +70,8 @@ $string['issuerdisabled'] = 'Identity issuer disabled';
 $string['issuerenabled'] = 'Identity issuer enabled';
 $string['issuerimage_help'] = 'An image URL used to show a logo for this issuer. May be displayed on login page.';
 $string['issuerimage'] = 'Logo URL';
+$string['issuerloginpagename'] = 'Name displayed on the login page';
+$string['issuerloginpagename_help'] = 'If specified, this name will be used on the login page instead of the service name above';
 $string['issuerloginparams'] = 'Additional parameters included in a login request.';
 $string['issuerloginparams_help'] = 'Some systems require additional parameters for a login request in order to read the user\'s basic profile.';
 $string['issuerloginparamsoffline'] = 'Additional parameters included in a login request for offline access.';
@@ -85,6 +87,16 @@ $string['issuershowonloginpage'] = 'Show on login page';
 $string['issuerrequireconfirmation_help'] = 'Require that all users verify their email address before they can log in with OAuth. This applies to newly created accounts as part of the login process, or when an existing Moodle account is connected to an OAuth login via matching email addresses.';
 $string['issuerrequireconfirmation'] = 'Require email verification';
 $string['issuers'] = 'Issuers';
+$string['issuersservicesallow'] = 'Allow services';
+$string['issuersservicesnotallow'] = 'Do not allow services';
+$string['issuerusein'] = 'This service will be used';
+$string['issuerusein_help'] = 'OAuth 2 services can be used in some internal services, on the login page, or both, if needed';
+$string['issueruseineverywhere'] = 'Login page and internal services';
+$string['issueruseininternalonly'] = 'Internal services only';
+$string['issueruseinloginonly'] = 'Login page only';
+$string['issuerusedforlogin'] = 'Login';
+$string['issuerusedforinternal'] = 'Internal services';
+$string['logindisplay'] = 'Display on login page as';
 $string['loginissuer'] = 'Allow login';
 $string['microsoft_service'] = 'Microsoft';
 $string['nextcloud_service'] = 'Nextcloud';
diff --git a/admin/tool/oauth2/pix/notconfigured.svg b/admin/tool/oauth2/pix/notconfigured.svg
new file mode 100644 (file)
index 0000000..300198e
--- /dev/null
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 25.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+        viewBox="0 0 16 16" style="enable-background:new 0 0 16 16;" xml:space="preserve">
+<style type="text/css">
+       .st0{fill:#FFA500;}
+</style>
+<path class="st0" d="M9.1,12.9v-1.7c0-0.1,0-0.2-0.1-0.2s-0.1-0.1-0.2-0.1H7.2c-0.1,0-0.1,0-0.2,0.1s-0.1,0.1-0.1,0.2v1.7
+       c0,0.1,0,0.2,0.1,0.2s0.1,0.1,0.2,0.1h1.7c0.1,0,0.1,0,0.2-0.1S9.1,13,9.1,12.9z M9.1,9.5l0.2-4.1c0-0.1,0-0.1-0.1-0.2
+       C9.1,5.2,9,5.2,9,5.2H7c-0.1,0-0.1,0-0.2,0.1c-0.1,0-0.1,0.1-0.1,0.2l0.2,4.1c0,0.1,0,0.1,0.1,0.1c0.1,0,0.1,0.1,0.2,0.1h1.6
+       c0.1,0,0.2,0,0.2-0.1C9.1,9.7,9.1,9.6,9.1,9.5z M9,1.3l6.8,12.5c0.2,0.4,0.2,0.7,0,1.1c-0.1,0.2-0.2,0.3-0.4,0.4s-0.4,0.2-0.6,0.2
+       H1.2c-0.2,0-0.4-0.1-0.6-0.2S0.3,15,0.2,14.9c-0.2-0.4-0.2-0.7,0-1.1L7,1.3c0.1-0.2,0.2-0.3,0.4-0.4S7.8,0.7,8,0.7s0.4,0.1,0.6,0.2
+       C8.8,0.9,8.9,1.1,9,1.3z"/>
+</svg>
index 429d371..1a258b3 100644 (file)
@@ -18,8 +18,8 @@ Feature: Basic OAuth2 functionality
     When I press "Save changes"
     Then I should see "Changes saved"
     And I should see "Testing service"
-    And "Configured" "icon" should exist in the "Testing service" "table_row"
     And "Allow login" "icon" should exist in the "Testing service" "table_row"
+    And "Allow services" "icon" should exist in the "Testing service" "table_row"
     And "Service discovery successful" "icon" should exist in the "Testing service" "table_row"
     And I click on "Configure endpoints" "link" in the "Testing service" "table_row"
     And I should see "https://accounts.google.com/.well-known/openid-configuration" in the "discovery_endpoint" "table_row"
@@ -51,8 +51,8 @@ Feature: Basic OAuth2 functionality
     When I press "Save changes"
     Then I should see "Changes saved"
     And I should see "Testing service"
-    And "Configured" "icon" should exist in the "Testing service" "table_row"
     And "Allow login" "icon" should exist in the "Testing service" "table_row"
+    And "Allow services" "icon" should exist in the "Testing service" "table_row"
     And I should see "-" in the "Testing service" "table_row"
     And I click on "Configure endpoints" "link" in the "Testing service" "table_row"
     And I should see "authorization_endpoint"
@@ -83,8 +83,8 @@ Feature: Basic OAuth2 functionality
     When I press "Save changes"
     Then I should see "Changes saved"
     And I should see "Testing service"
-    And "Configured" "icon" should exist in the "Testing service" "table_row"
     And "Allow login" "icon" should exist in the "Testing service" "table_row"
+    And "Allow services" "icon" should exist in the "Testing service" "table_row"
     And I should see "-" in the "Testing service" "table_row"
     And I click on "Configure endpoints" "link" in the "Testing service" "table_row"
     And I should see "authorization_endpoint"
@@ -120,8 +120,8 @@ Feature: Basic OAuth2 functionality
     When I press "Save changes"
     Then I should see "Changes saved"
     And I should see "Testing service"
-    And "Configured" "icon" should exist in the "Testing service" "table_row"
     And "Do not allow login" "icon" should exist in the "Testing service" "table_row"
+    And "Allow services" "icon" should exist in the "Testing service" "table_row"
     And I should see "-" in the "Testing service" "table_row"
     And I click on "Configure endpoints" "link" in the "Testing service" "table_row"
     And I should see "authorization_endpoint"
@@ -152,7 +152,7 @@ Feature: Basic OAuth2 functionality
     When I press "Save changes"
     Then I should see "Changes saved"
     And I should see "OpenBadges"
-    And "Configured" "icon" should exist in the "OpenBadges" "table_row"
+    And "Allow services" "icon" should exist in the "OpenBadges" "table_row"
     And "Do not allow login" "icon" should exist in the "OpenBadges" "table_row"
     And "Service discovery successful" "icon" should exist in the "OpenBadges" "table_row"
     And the "src" attribute of "table.admintable th img" "css_element" should contain "IMS-Global-Logo.png"
@@ -187,8 +187,8 @@ Feature: Basic OAuth2 functionality
     When I press "Save changes"
     Then I should see "Changes saved"
     And I should see "Google custom"
-    And "Configured" "icon" should exist in the "Google custom" "table_row"
     And "Do not allow login" "icon" should exist in the "Google custom" "table_row"
+    And "Allow services" "icon" should exist in the "Google custom" "table_row"
     And "Service discovery successful" "icon" should exist in the "Google custom" "table_row"
     And the "src" attribute of "table.admintable th img" "css_element" should contain "favicon.ico"
     And I click on "Configure endpoints" "link" in the "Google custom" "table_row"
@@ -222,7 +222,7 @@ Feature: Basic OAuth2 functionality
     When I press "Save changes"
     Then I should see "Could not discover end points for identity issuer: Invalid custom service"
     And I should see "URL: https://dc.imsglobal.org/.well-known/openid-configuration"
-    And "Configured" "icon" should exist in the "Invalid custom service" "table_row"
+    And "Allow services" "icon" should exist in the "Invalid custom service" "table_row"
     And "Do not allow login" "icon" should exist in the "Invalid custom service" "table_row"
     And I should see "-" in the "Invalid custom service" "table_row"
     And I click on "Configure endpoints" "link" in the "Invalid custom service" "table_row"
@@ -237,8 +237,8 @@ Feature: Basic OAuth2 functionality
       | Name                       | Valid custom service                        |
       | Service base URL           | https://accounts.google.com/                |
     And I press "Save changes"
-    And "Configured" "icon" should exist in the "Valid custom" "table_row"
     And "Do not allow login" "icon" should exist in the "Valid custom" "table_row"
+    And "Allow services" "icon" should exist in the "Valid custom" "table_row"
     And "Service discovery successful" "icon" should exist in the "Valid custom" "table_row"
     And I click on "Edit" "link" in the "Valid custom service" "table_row"
     And I set the following fields to these values:
@@ -263,7 +263,7 @@ Feature: Basic OAuth2 functionality
     When I press "Save changes"
     And I should see "Changes saved"
     And I should see "Empty custom service"
-    And "Configured" "icon" should exist in the "Empty custom service" "table_row"
+    And "Allow services" "icon" should exist in the "Empty custom service" "table_row"
     And "Do not allow login" "icon" should exist in the "Empty custom service" "table_row"
     And I should see "-" in the "Empty custom service" "table_row"
     And I click on "Configure endpoints" "link" in the "Empty custom service" "table_row"
@@ -279,8 +279,8 @@ Feature: Basic OAuth2 functionality
       | Name                       | Valid custom service                      |
       | Service base URL           | https://accounts.google.com               |
     And I press "Save changes"
-    And "Configured" "icon" should exist in the "Valid custom" "table_row"
     And "Do not allow login" "icon" should exist in the "Valid custom" "table_row"
+    And "Allow services" "icon" should exist in the "Valid custom" "table_row"
     And "Service discovery successful" "icon" should exist in the "Valid custom" "table_row"
     And I click on "Edit" "link" in the "Valid custom service" "table_row"
     And I set the following fields to these values:
@@ -301,3 +301,59 @@ Feature: Basic OAuth2 functionality
     And I press "Continue"
     And I should see "Identity issuer deleted"
     And I should not see "Empty custom service"
+
+  Scenario: Create a standard service for Google and test form and UI for login only, services only and both
+    Given I press "Google"
+    And I should see "Create new service: Google"
+    # Create using 'Login page only' option.
+    And I set the following fields to these values:
+      | Name                       | Testing service                           |
+      | Client ID                  | thisistheclientid                         |
+      | Client secret              | supersecret                               |
+      | This service will be used  | Login page only                           |
+    When I press "Save changes"
+    Then I should see "Changes saved"
+    And I should see "Testing service"
+    And "Allow login" "icon" should exist in the "Testing service" "table_row"
+    And "Do not allow services" "icon" should exist in the "Testing service" "table_row"
+    And "Service discovery successful" "icon" should exist in the "Testing service" "table_row"
+    # Change to 'Internal services only'.
+    And I click on "Edit" "link" in the "Testing service" "table_row"
+    And I set the following fields to these values:
+      | This service will be used  | Internal services only                     |
+    And I press "Save changes"
+    And I should see "Changes saved"
+    And "Do not allow login" "icon" should exist in the "Testing service" "table_row"
+    And "Allow services" "icon" should exist in the "Testing service" "table_row"
+    # Change to 'Login page and internal services' and add a display name.
+    And I click on "Edit" "link" in the "Testing service" "table_row"
+    And I set the following fields to these values:
+      | This service will be used         | Login page and internal services     |
+      | Name displayed on the login page  | Google new display name              |
+    And I press "Save changes"
+    And I should see "Changes saved"
+    And "Allow login" "icon" should exist in the "Testing service" "table_row"
+    And "Allow services" "icon" should exist in the "Testing service" "table_row"
+    And I should see "Google new display name" in the "Testing service" "table_row"
+
+  Scenario: Create a login page only custom OIDC service
+    Given I press "Custom"
+    And I should see "Create new service: Custom"
+    And I set the following fields to these values:
+      | Name                              | Empty custom service                      |
+      | Client ID                         | thisistheclientid                         |
+      | Client secret                     | supersecret                               |
+      | This service will be used         | Login page only                           |
+      | Name displayed on the login page  | Custom display name                       |
+    When I press "Save changes"
+    And I should see "Changes saved"
+    And I should see "Empty custom service"
+    And I should see "Custom display name" in the "Empty custom service" "table_row"
+    And "Not configured" "icon" should exist in the "Empty custom service" "table_row"
+    And "Do not allow services" "icon" should exist in the "Empty custom service" "table_row"
+    And I click on "Edit" "link" in the "Empty custom service" "table_row"
+    And I set the following fields to these values:
+      | Service base URL           | https://accounts.google.com               |
+    And I press "Save changes"
+    And "Allow login" "icon" should exist in the "Empty custom service" "table_row"
+    And "Do not allow services" "icon" should exist in the "Empty custom service" "table_row"
index 4152729..251adbf 100644 (file)
@@ -166,17 +166,6 @@ class auth extends \auth_plugin_base {
         return false;
     }
 
-    /**
-     * Do some checks on the identity provider before showing it on the login page.
-     * @param core\oauth2\issuer $issuer
-     * @return boolean
-     */
-    private function is_ready_for_login_page(\core\oauth2\issuer $issuer) {
-        return $issuer->get('enabled') &&
-                $issuer->is_configured() &&
-                !empty($issuer->get('showonloginpage'));
-    }
-
     /**
      * Return a list of identity providers to display on the login page.
      *
@@ -184,17 +173,17 @@ class auth extends \auth_plugin_base {
      * @return array List of arrays with keys url, iconurl and name.
      */
     public function loginpage_idp_list($wantsurl) {
-        $providers = \core\oauth2\api::get_all_issuers();
+        $providers = \core\oauth2\api::get_all_issuers(true);
         $result = [];
         if (empty($wantsurl)) {
             $wantsurl = '/';
         }
         foreach ($providers as $idp) {
-            if ($this->is_ready_for_login_page($idp)) {
+            if ($idp->is_available_for_login()) {
                 $params = ['id' => $idp->get('id'), 'wantsurl' => $wantsurl, 'sesskey' => sesskey()];
                 $url = new moodle_url('/auth/oauth2/login.php', $params);
                 $icon = $idp->get('image');
-                $result[] = ['url' => $url, 'iconurl' => $icon, 'name' => $idp->get('name')];
+                $result[] = ['url' => $url, 'iconurl' => $icon, 'name' => $idp->get_display_name()];
             }
         }
         return $result;
index 1243560..07a0428 100644 (file)
@@ -28,7 +28,7 @@ use html_table;
 use html_table_cell;
 use html_table_row;
 use html_writer;
-use auth\oauth2\linked_login;
+use auth_oauth2\linked_login;
 use moodle_url;
 
 defined('MOODLE_INTERNAL') || die();
@@ -43,7 +43,7 @@ class renderer extends plugin_renderer_base {
     /**
      * This function will render one beautiful table with all the linked_logins.
      *
-     * @param \auth\oauth2\linked_login[] $linkedlogins - list of all linked logins.
+     * @param linked_login[] $linkedlogins - list of all linked logins.
      * @return string HTML to output.
      */
     public function linked_logins_table($linkedlogins) {
index 146e261..30df5d4 100644 (file)
@@ -45,7 +45,7 @@ if ($action == 'new') {
     $issuerid = required_param('issuerid', PARAM_INT);
     $issuer = \core\oauth2\api::get_issuer($issuerid);
 
-    if (!$issuer->is_authentication_supported() || !$issuer->get('showonloginpage') || !$issuer->get('enabled')) {
+    if (!$issuer->is_available_for_login()) {
         throw new \moodle_exception('issuernologin', 'auth_oauth2');
     }
 
@@ -89,19 +89,20 @@ $linkedlogin = null;
 
 auth_oauth2\api::clean_orphaned_linked_logins();
 
-$issuers = \core\oauth2\api::get_all_issuers();
+$issuers = \core\oauth2\api::get_all_issuers(true);
 
 $anyshowinloginpage = false;
 $issuerbuttons = array();
 foreach ($issuers as $issuer) {
-    if (!$issuer->is_authentication_supported() || !$issuer->get('showonloginpage') || !$issuer->get('enabled')) {
+    if (!$issuer->is_available_for_login()) {
         continue;
     }
     $anyshowinloginpage = true;
 
     $addparams = ['action' => 'new', 'issuerid' => $issuer->get('id'), 'sesskey' => sesskey(), 'logout' => true];
     $addurl = new moodle_url('/auth/oauth2/linkedlogins.php', $addparams);
-    $issuerbuttons[$issuer->get('id')] = $renderer->single_button($addurl, get_string('createnewlinkedlogin', 'auth_oauth2', s($issuer->get('name'))));
+    $issuerbuttons[$issuer->get('id')] = $renderer->single_button($addurl, get_string('createnewlinkedlogin', 'auth_oauth2',
+        s($issuer->get_display_name())));
 }
 
 if (!$anyshowinloginpage) {
index 9abb2a3..949b5a1 100644 (file)
@@ -37,6 +37,9 @@ if (!\auth_oauth2\api::is_enabled()) {
 }
 
 $issuer = new \core\oauth2\issuer($issuerid);
+if (!$issuer->is_available_for_login()) {
+    throw new \moodle_exception('issuernologin', 'auth_oauth2');
+}
 
 $returnparams = ['wantsurl' => $wantsurl, 'sesskey' => sesskey(), 'id' => $issuerid];
 $returnurl = new moodle_url('/auth/oauth2/login.php', $returnparams);
index 478ae2a..673b58b 100644 (file)
@@ -99,6 +99,13 @@ class backup_root_task extends backup_task {
         $this->add_setting($roleassignments);
         $users->add_dependency($roleassignments);
 
+        // Define permission.
+        if ($this->plan->get_mode() == backup::MODE_IMPORT) {
+            $permissions = new backup_permissions_setting('permissions', base_setting::IS_BOOLEAN, false);
+            $permissions->set_ui(new backup_setting_ui_checkbox($permissions, get_string('rootsettingpermissions', 'backup')));
+            $this->add_setting($permissions);
+        }
+
         // Define activities
         $activities = new backup_activities_setting('activities', base_setting::IS_BOOLEAN, true);
         $activities->set_ui(new backup_setting_ui_checkbox($activities, get_string('rootsettingactivities', 'backup')));
index 59ea252..ca9224f 100644 (file)
@@ -65,6 +65,12 @@ class backup_filename_setting extends backup_generic_setting {
  */
 class backup_users_setting extends backup_generic_setting {}
 
+/**
+ * root setting to control if backup will include permission information by roles
+ */
+class backup_permissions_setting extends backup_generic_setting {
+}
+
 /**
  * root setting to control if backup will include group information depends on @backup_users_setting
  *
index 3b37acc..40345c1 100644 (file)
@@ -146,6 +146,19 @@ class restore_root_task extends restore_task {
         $this->add_setting($roleassignments);
         $users->add_dependency($roleassignments);
 
+        // Define permissions.
+        $defaultvalue = false;                      // Safer default.
+        $changeable = false;
+        // Enable when available, or key doesn't exist (backward compatibility).
+        if (!array_key_exists('permissions', $rootsettings) || !empty($rootsettings['permissions'])) {
+            $defaultvalue = true;
+            $changeable = true;
+        }
+        $permissions = new restore_permissions_setting('permissions', base_setting::IS_BOOLEAN, $defaultvalue);
+        $permissions->set_ui(new backup_setting_ui_checkbox($permissions, get_string('rootsettingpermissions', 'backup')));
+        $permissions->get_ui()->set_changeable($changeable);
+        $this->add_setting($permissions);
+
         // Define activitites
         $defaultvalue = false;                      // Safer default
         $changeable = false;
index 61124de..fb9e064 100644 (file)
@@ -43,6 +43,12 @@ class restore_generic_setting extends root_backup_setting {}
  */
 class restore_users_setting extends restore_generic_setting {}
 
+/**
+ * root setting to control if restore will create override permission information by roles
+ */
+class restore_permissions_setting extends restore_generic_setting {
+}
+
 /**
  * root setting to control if restore will create groups/grouping information. Depends on @restore_users_setting
  *
index 8c63c49..7e51a1f 100644 (file)
@@ -2058,7 +2058,9 @@ class restore_ras_and_caps_structure_step extends restore_structure_step {
         if ($this->get_setting_value('role_assignments')) {
             $paths[] = new restore_path_element('assignment', '/roles/role_assignments/assignment');
         }
-        $paths[] = new restore_path_element('override', '/roles/role_overrides/override');
+        if ($this->get_setting_value('permissions')) {
+            $paths[] = new restore_path_element('override', '/roles/role_overrides/override');
+        }
 
         return $paths;
     }
diff --git a/backup/tests/backup_restore_base_testcase.php b/backup/tests/backup_restore_base_testcase.php
new file mode 100644 (file)
index 0000000..8b12296
--- /dev/null
@@ -0,0 +1,123 @@
+<?php
+// This file is part of 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/>.
+
+/**
+ * Backup restore base tests.
+ *
+ * @package   core_backup
+ * @copyright Tomo Tsuyuki <tomotsuyuki@catalyst-au.net>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php');
+require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php');
+
+/**
+ * Basic testcase class for backup / restore functionality.
+ */
+abstract class core_backup_backup_restore_base_testcase extends advanced_testcase {
+
+    /**
+     * Setup test data.
+     */
+    protected function setUp(): void {
+        $this->resetAfterTest();
+        $this->setAdminUser();
+    }
+
+    /**
+     * Backup the course by general mode.
+     *
+     * @param  stdClass $course Course for backup.
+     * @return string Hash string ID from the backup.
+     * @throws coding_exception
+     * @throws moodle_exception
+     */
+    protected function perform_backup($course): string {
+        global $CFG, $USER;
+
+        $coursecontext = context_course::instance($course->id);
+
+        // Start backup process.
+        $bc = new backup_controller(backup::TYPE_1COURSE, $course->id, backup::FORMAT_MOODLE,
+                backup::INTERACTIVE_NO, backup::MODE_GENERAL, $USER->id);
+        $bc->execute_plan();
+        $backupid = $bc->get_backupid();
+        $bc->destroy();
+
+        // Get the backup file.
+        $fs = get_file_storage();
+        $files = $fs->get_area_files($coursecontext->id, 'backup', 'course', false, 'id ASC');
+        $backupfile = reset($files);
+
+        // Extract backup file.
+        $path = $CFG->tempdir . DIRECTORY_SEPARATOR . "backup" . DIRECTORY_SEPARATOR . $backupid;
+
+        $fp = get_file_packer('application/vnd.moodle.backup');
+        $fp->extract_to_pathname($backupfile, $path);
+
+        return $backupid;
+    }
+
+    /**
+     * Restore from backupid to course.
+     *
+     * @param  string   $backupid Hash string ID from backup.
+     * @param  stdClass $course Course which is restored for.
+     * @throws restore_controller_exception
+     */
+    protected function perform_restore($backupid, $course): void {
+        global $USER;
+
+        // Set up restore.
+        $rc = new restore_controller($backupid, $course->id,
+                backup::INTERACTIVE_NO, backup::MODE_GENERAL, $USER->id, backup::TARGET_EXISTING_ADDING);
+        // Execute restore.
+        $rc->execute_precheck();
+        $rc->execute_plan();
+        $rc->destroy();
+    }
+
+    /**
+     * Import course from course1 to course2.
+     *
+     * @param stdClass $course1 Course to be backuped up.
+     * @param stdClass $course2 Course to be restored.
+     * @throws restore_controller_exception
+     */
+    protected function perform_import($course1, $course2): void {
+        global $USER;
+
+        // Start backup process.
+        $bc = new backup_controller(backup::TYPE_1COURSE, $course1->id, backup::FORMAT_MOODLE,
+                backup::INTERACTIVE_NO, backup::MODE_IMPORT, $USER->id);
+        $backupid = $bc->get_backupid();
+        $bc->execute_plan();
+        $bc->destroy();
+
+        // Set up restore.
+        $rc = new restore_controller($backupid, $course2->id,
+                backup::INTERACTIVE_NO, backup::MODE_SAMESITE, $USER->id, backup::TARGET_EXISTING_ADDING);
+        // Execute restore.
+        $rc->execute_precheck();
+        $rc->execute_plan();
+        $rc->destroy();
+    }
+
+}
diff --git a/backup/tests/backup_restore_permission_test.php b/backup/tests/backup_restore_permission_test.php
new file mode 100644 (file)
index 0000000..42929a4
--- /dev/null
@@ -0,0 +1,156 @@
+<?php
+// This file is part of 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/>.
+
+/**
+ * Backup restore permission tests.
+ *
+ * @package   core_backup
+ * @copyright Tomo Tsuyuki <tomotsuyuki@catalyst-au.net>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once('backup_restore_base_testcase.php');
+require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php');
+require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php');
+
+/**
+ * Testcase class for permission backup / restore functionality.
+ */
+class core_backup_backup_restore_permission_testcase extends core_backup_backup_restore_base_testcase {
+
+    /** @var stdClass A test course which is restored/imported from. */
+    protected $course1;
+
+    /** @var stdClass A test course which is restored/imported to. */
+    protected $course2;
+
+    /** @var stdClass A user for using in this test. */
+    protected $user;
+
+    /** @var string Capability name for using in this test. */
+    protected $capabilityname;
+
+    /** @var context_course Context instance for course1. */
+    protected $course1context;
+
+    /** @var context_course Context instance for course2. */
+    protected $course2context;
+
+    /**
+     * Setup test data.
+     */
+    protected function setUp(): void {
+        global $DB;
+
+        parent::setUp();
+        // Create a course with some availability data set.
+        $generator = $this->getDataGenerator();
+        $this->course1 = $generator->create_course();
+        $this->course1context = context_course::instance($this->course1->id);
+        $this->course2 = $generator->create_course();
+        $this->course2context = context_course::instance($this->course2->id);
+        $this->capabilityname = 'enrol/manual:enrol';
+        $this->user = $generator->create_user();
+
+        // Set additional permission for course 1.
+        $teacherrole = $DB->get_record('role', ['shortname' => 'teacher'], '*', MUST_EXIST);
+        role_change_permission($teacherrole->id, $this->course1context, $this->capabilityname, CAP_ALLOW);
+
+        // Enrol to the courses.
+        $generator->enrol_user($this->user->id, $this->course1->id, $teacherrole->id);
+        $generator->enrol_user($this->user->id, $this->course2->id, $teacherrole->id);
+    }
+
+    /**
+     * Test having settings.
+     */
+    public function test_having_settings(): void {
+        $this->assertEquals(0, get_config('backup', 'backup_import_permissions'));
+        $this->assertEquals(1, get_config('restore', 'restore_general_permissions'));
+    }
+
+    /**
+     * Test for restore with permission.
+     */
+    public function test_backup_restore_with_permission(): void {
+
+        // Set default setting to restore with permission.
+        set_config('restore_general_permissions', 1, 'restore');
+
+        // Confirm course1 has the capability for the user.
+        $this->assertTrue(has_capability($this->capabilityname, $this->course1context, $this->user));
+
+        // Confirm course2 does not have the capability for the user.
+        $this->assertFalse(has_capability($this->capabilityname, $this->course2context, $this->user));
+
+        // Perform backup and restore.
+        $backupid = $this->perform_backup($this->course1);
+        $this->perform_restore($backupid, $this->course2);
+
+        // Confirm course2 has the capability for the user.
+        $this->assertTrue(has_capability($this->capabilityname, $this->course2context, $this->user));
+    }
+
+    /**
+     * Test for backup / restore without restore permission.
+     */
+    public function test_backup_restore_without_permission(): void {
+
+        // Set default setting to restore without permission.
+        set_config('restore_general_permissions', 0, 'restore');
+
+        // Perform backup and restore.
+        $backupid = $this->perform_backup($this->course1);
+        $this->perform_restore($backupid, $this->course2);
+
+        // Confirm course2 does not have the capability for the user.
+        $this->assertFalse(has_capability($this->capabilityname, $this->course2context, $this->user));
+    }
+
+    /**
+     * Test for import with permission.
+     */
+    public function test_backup_import_with_permission(): void {
+
+        // Set default setting to restore with permission.
+        set_config('backup_import_permissions', 1, 'backup');
+
+        // Perform import.
+        $this->perform_import($this->course1, $this->course2);
+
+        // Confirm course2 does not have the capability for the user.
+        $this->assertTrue(has_capability($this->capabilityname, $this->course2context, $this->user));
+    }
+
+    /**
+     * Test for import without permission.
+     */
+    public function test_backup_import_without_permission(): void {
+
+        // Set default setting to restore without permission.
+        set_config('backup_import_permissions', 0, 'backup');
+
+        // Perform import.
+        $this->perform_import($this->course1, $this->course2);
+
+        // Confirm course2 does not have the capability for the user.
+        $this->assertFalse(has_capability($this->capabilityname, $this->course2context, $this->user));
+    }
+
+}
index a07e0b4..825f55b 100644 (file)
@@ -1,7 +1,13 @@
 This files describes API changes in /backup/*,
 information provided here is intended especially for developers.
 
+=== 3.11 ===
+
+ * New setting called "Include override permissions" has been implemented. The default
+   settings is OFF for import, and ON for restore.
+
 === 3.10 ===
+
  * Local plugins can now hook into a backup and restore process of grade items by
    using define_grade_item_plugin_structure method (See MDL-69418).
 
index e5c0163..7b5cbd4 100644 (file)
@@ -577,6 +577,7 @@ abstract class backup_controller_dbops extends backup_dbops {
                         'backup_import_blocks'             => 'blocks',
                         'backup_import_filters'            => 'filters',
                         'backup_import_calendarevents'     => 'calendarevents',
+                        'backup_import_permissions'        => 'permissions',
                         'backup_import_questionbank'       => 'questionbank',
                         'backup_import_groups'             => 'groups',
                         'backup_import_competencies'       => 'competencies',
index 216da03..4cc7ee2 100644 (file)
@@ -146,6 +146,7 @@ abstract class restore_controller_dbops extends restore_dbops {
             'restore_general_users'              => 'users',
             'restore_general_enrolments'         => 'enrolments',
             'restore_general_role_assignments'   => 'role_assignments',
+            'restore_general_permissions'        => 'permissions',
             'restore_general_activities'         => 'activities',
             'restore_general_blocks'             => 'blocks',
             'restore_general_filters'            => 'filters',
index 5adde7f..52c1452 100644 (file)
@@ -4,19 +4,21 @@ Feature: Import course's contents into another course
   As a teacher
   I need to import a course contents into another course selecting what I want to import
 
-  Scenario: Import course's contents to another course
+  Background:
     Given the following "courses" exist:
       | fullname | shortname | category |
-      | Course 1 | C1 | 0 |
-      | Course 2 | C2 | 0 |
+      | Course 1 | C1        | 0        |
+      | Course 2 | C2        | 0        |
     And the following "users" exist:
-      | username | firstname | lastname | email |
-      | teacher1 | Teacher | 1 | teacher1@example.com |
+      | username | firstname | lastname | email                |
+      | teacher1 | Teacher   | 1        | teacher1@example.com |
     And the following "course enrolments" exist:
-      | user | course | role |
-      | teacher1 | C1 | editingteacher |
-      | teacher1 | C2 | editingteacher |
-    And I log in as "teacher1"
+      | user     | course | role           |
+      | teacher1 | C1     | editingteacher |
+      | teacher1 | C2     | editingteacher |
+
+  Scenario: Import course's contents to another course
+    Given I log in as "teacher1"
     And I am on "Course 1" course homepage with editing mode on
     And I add a "Database" to section "1" and I fill the form with:
       | Name | Test database name |
@@ -32,3 +34,26 @@ Feature: Import course's contents into another course
     And I should see "Test forum name"
     And I should see "Comments" in the "Comments" "block"
     And I should see "Recent blog entries"
+
+  Scenario: Import process with permission option
+    Given the following "permission overrides" exist:
+      | capability         | permission | role    | contextlevel | reference |
+      | enrol/manual:enrol | Allow      | teacher | Course       | C1        |
+    And I log in as "teacher1"
+    When I import "Course 1" course into "Course 2" course using this options:
+      | Initial | Include override permissions | 1 |
+    And I navigate to "Users > Permissions" in current page administration
+    Then I should see "Non-editing teacher (1)"
+    And I set the field "Advanced role override" to "Non-editing teacher (1)"
+    And I press "Go"
+    And "enrol/manual:enrol" capability has "Allow" permission
+
+  Scenario: Import process without permission option
+    Given the following "permission overrides" exist:
+      | capability         | permission | role    | contextlevel | reference |
+      | enrol/manual:enrol | Allow      | teacher | Course       | C1        |
+    And I log in as "teacher1"
+    When I import "Course 1" course into "Course 2" course using this options:
+      | Initial | Include override permissions | 0 |
+    And I navigate to "Users > Permissions" in current page administration
+    Then I should see "Non-editing teacher (0)"
index d1bae45..fb32d18 100644 (file)
@@ -244,3 +244,29 @@ Feature: Restore Moodle 2 course backups
     And I should not see "Topic 16"
     And I should see "Test URL name" in the "Topic 3" "section"
     And I should see "Test forum name" in the "Topic 1" "section"
+
+  @javascript
+  Scenario: Restore a backup with override permission
+    Given the following "permission overrides" exist:
+      | capability         | permission | role           | contextlevel | reference |
+      | enrol/manual:enrol | Allow      | teacher        | Course       | C1        |
+    And I backup "Course 1" course using this options:
+      | Confirmation | Filename | test_backup.mbz |
+    When I restore "test_backup.mbz" backup into a new course using this options:
+      | Settings | Include override permissions | 1 |
+    Then I navigate to "Users > Permissions" in current page administration
+    And I should see "Non-editing teacher (1)"
+    And I set the field "Advanced role override" to "Non-editing teacher (1)"
+    And "enrol/manual:enrol" capability has "Allow" permission
+
+  @javascript
+  Scenario: Restore a backup without override permission
+    Given the following "permission overrides" exist:
+      | capability         | permission | role           | contextlevel | reference |
+      | enrol/manual:enrol | Allow      | teacher        | Course       | C1        |
+    And I backup "Course 1" course using this options:
+      | Confirmation | Filename | test_backup.mbz |
+    When I restore "test_backup.mbz" backup into a new course using this options:
+      | Settings | Include override permissions | 0 |
+    Then I navigate to "Users > Permissions" in current page administration
+    And I should see "Non-editing teacher (0)"
index 4eea412..c200a72 100644 (file)
@@ -135,6 +135,7 @@ $string['configgenerallogs'] = 'If enabled logs will be included in backups by d
 $string['configgeneralquestionbank'] = 'If enabled the question bank will be included in backups by default. PLEASE NOTE: Disabling this setting will disable the backup of activities which use the question bank, such as the quiz.';
 $string['configgeneralgroups'] = 'Sets the default for including groups and groupings in a backup.';
 $string['configgeneralroleassignments'] = 'If enabled by default roles assignments will also be backed up.';
+$string['configgeneralpermissions'] = 'If enabled the role permissions will be imported. This may override existing permissions for enrolled users.';
 $string['configgeneraluserscompletion'] = 'If enabled user completion information will be included in backups by default.';
 $string['configgeneralusers'] = 'Sets the default for whether to include users in backups.';
 $string['configlegacyfiles'] = 'Sets the default for including legacy course files in a backup. Legacy course files are from versions of Moodle prior to 2.0.';
@@ -152,6 +153,7 @@ $string['configrestorehistories'] = 'Sets the default for restoring user history
 $string['configrestorelogs'] = 'If enabled logs will be restored by default if they were included in the backup.';
 $string['configrestoregroups'] = 'Sets the default for restoring groups and groupings if they were included in the backup.';
 $string['configrestoreroleassignments'] = 'If enabled by default roles assignments will be restored if they were included in the backup.';
+$string['configrestorepermissions'] = 'If enabled the role permissions will be restored. This may override existing permissions for enrolled users.';
 $string['configrestoreuserscompletion'] = 'If enabled user completion information will be restored by default if it was included in the backup.';
 $string['configrestoreusers'] = 'Sets the default for whether to restore users if they were included in the backup.';
 $string['confirmcancel'] = 'Cancel backup';
@@ -237,6 +239,7 @@ $string['mergerestoredefaults'] = 'Restore defaults when merging into another co
 $string['replacerestoredefaults'] = 'Restore defaults when restoring into another course deleting contents';
 $string['generalrestoresettings'] = 'General restore settings';
 $string['generalroleassignments'] = 'Include role assignments';
+$string['generalpermissions'] = 'Include override permissions';
 $string['generalsettings'] = 'General backup settings';
 $string['generaluserscompletion'] = 'Include user completion information';
 $string['generalusers'] = 'Include users';
@@ -350,6 +353,7 @@ $string['rootsettings'] = 'Backup settings';
 $string['rootsettingusers'] = 'Include enrolled users';
 $string['rootsettinganonymize'] = 'Anonymize user information';
 $string['rootsettingroleassignments'] = 'Include user role assignments';
+$string['rootsettingpermissions'] = 'Include override permissions';
 $string['rootsettingactivities'] = 'Include activities and resources';
 $string['rootsettingbadges'] = 'Include badges';
 $string['rootsettingblocks'] = 'Include blocks';
index 6219c8d..c083def 100644 (file)
@@ -110,10 +110,17 @@ class api {
 
     /**
      * List all the issuers, ordered by the sortorder field
+     *
+     * @param bool $includeloginonly also include issuers that are configured to be shown only on login page,
+     *     By default false, in this case the method returns all issuers that can be used in services
      * @return \core\oauth2\issuer[]
      */
-    public static function get_all_issuers() {
-        return issuer::get_records([], 'sortorder');
+    public static function get_all_issuers(bool $includeloginonly = false) {
+        if ($includeloginonly) {
+            return issuer::get_records([], 'sortorder');
+        } else {
+            return array_values(issuer::get_records_select('showonloginpage<>?', [issuer::LOGINONLY], 'sortorder'));
+        }
     }
 
     /**
index ce4d32f..89beafe 100644 (file)
@@ -36,6 +36,13 @@ use lang_string;
  */
 class issuer extends persistent {
 
+    /** @var int Issuer is displayed on both login page and in the services lists */
+    const EVERYWHERE = 1;
+    /** @var int Issuer is displayed on the login page only */
+    const LOGINONLY = 2;
+    /** @var int Issuer is displayed only in the services lists and can not be used for login */
+    const SERVICEONLY = 0;
+
     const TABLE = 'oauth2_issuer';
 
     /**
@@ -70,8 +77,8 @@ class issuer extends persistent {
                 'default' => true
             ),
             'showonloginpage' => array(
-                'type' => PARAM_BOOL,
-                'default' => false
+                'type' => PARAM_INT,
+                'default' => self::SERVICEONLY,
             ),
             'basicauth' => array(
                 'type' => PARAM_BOOL,
@@ -115,6 +122,11 @@ class issuer extends persistent {
                 'null' => NULL_ALLOWED,
                 'default' => null,
             ),
+            'loginpagename' => array(
+                'type' => PARAM_TEXT,
+                'null' => NULL_ALLOWED,
+                'default' => null,
+            ),
         );
     }
 
@@ -173,9 +185,25 @@ class issuer extends persistent {
      * @return boolean
      */
     public function is_authentication_supported() {
+        debugging('Method is_authentication_supported() is deprecated, please use is_available_for_login()',
+            DEBUG_DEVELOPER);
         return (!empty($this->get_endpoint_url('userinfo')));
     }
 
+    /**
+     * Is this issue fully configured and enabled and can be used for login/signup
+     *
+     * @return bool
+     * @throws \coding_exception
+     */
+    public function is_available_for_login(): bool {
+        return $this->get('id') &&
+            $this->is_configured() &&
+            $this->get('showonloginpage') != self::SERVICEONLY &&
+            $this->get('enabled') &&
+            !empty($this->get_endpoint_url('userinfo'));
+    }
+
     /**
      * Return true if this issuer looks like it has been configured.
      *
@@ -231,4 +259,13 @@ class issuer extends persistent {
         }
         return true;
     }
+
+    /**
+     * Display name for the issuers used on the login page
+     *
+     * @return string
+     */
+    public function get_display_name(): string {
+        return $this->get('loginpagename') ? $this->get('loginpagename') : $this->get('name');
+    }
 }
index 1249cdf..00f247a 100644 (file)
@@ -84,7 +84,7 @@ class refresh_system_tokens_task extends scheduled_task {
      * Throw exceptions on errors (the job will be retried).
      */
     public function execute() {
-        $issuers = \core\oauth2\api::get_all_issuers();
+        $issuers = \core\oauth2\api::get_all_issuers(true);
         $tasksuccess = true;
         foreach ($issuers as $issuer) {
             if ($issuer->is_system_account_connected()) {
index 92ec1f5..29af16e 100644 (file)
@@ -42,7 +42,7 @@ class facebook extends openidconnect implements issuer_interface {
             'baseurl' => '',
             'loginscopes' => 'public_profile email',
             'loginscopesoffline' => 'public_profile email',
-            'showonloginpage' => true,
+            'showonloginpage' => issuer::EVERYWHERE,
             'servicetype' => 'facebook',
         ];
 
index 77d27cc..253c6d3 100644 (file)
@@ -39,7 +39,7 @@ class google extends openidconnect implements issuer_interface {
             'image' => 'https://accounts.google.com/favicon.ico',
             'baseurl' => 'https://accounts.google.com/',
             'loginparamsoffline' => 'access_type=offline&prompt=consent',
-            'showonloginpage' => true,
+            'showonloginpage' => issuer::EVERYWHERE,
             'servicetype' => 'google',
         ];
         $issuer = new issuer(0, $record);
index d5d6e7f..3a891a3 100644 (file)
@@ -42,7 +42,7 @@ class microsoft extends openidconnect implements issuer_interface {
             'baseurl' => '',
             'loginscopes' => 'openid profile email user.read',
             'loginscopesoffline' => 'openid profile email user.read offline_access',
-            'showonloginpage' => true,
+            'showonloginpage' => issuer::EVERYWHERE,
             'servicetype' => 'microsoft',
         ];
 
index fdeba85..3d5113f 100644 (file)
         <FIELD NAME="sortorder" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="The defined sort order."/>
         <FIELD NAME="requireconfirmation" TYPE="int" LENGTH="2" NOTNULL="true" DEFAULT="1" SEQUENCE="false"/>
         <FIELD NAME="servicetype" TYPE="char" LENGTH="255" NOTNULL="false" SEQUENCE="false" COMMENT="Issuer service type, such as 'google' or 'facebook'."/>
+        <FIELD NAME="loginpagename" TYPE="char" LENGTH="255" NOTNULL="false" SEQUENCE="false"/>
       </FIELDS>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
index cb43328..1b6f97b 100644 (file)
@@ -2560,5 +2560,20 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2021052500.78);
     }
 
+    if ($oldversion < 2021052500.83) {
+
+        // Define field loginpagename to be added to oauth2_issuer.
+        $table = new xmldb_table('oauth2_issuer');
+        $field = new xmldb_field('loginpagename', XMLDB_TYPE_CHAR, '255', null, null, null, null, 'servicetype');
+
+        // Conditionally launch add field loginpagename.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2021052500.83);
+    }
+
     return true;
 }
index bfdb676..d8e246e 100644 (file)
@@ -138,7 +138,7 @@ function plagiarism_get_form_elements_module($mform, $context, $modulename = "")
     }
 }
 /**
- * updates the status of all files within a module
+ * Allows a plagiarism plugin to print a button/link at the top of activity overview report pages.
  *
  * @param object $course - full Course object
  * @param object $cm - full cm object
index 09595ad..4da5d1e 100644 (file)
@@ -366,4 +366,81 @@ class core_oauth2_testcase extends advanced_testcase {
         ];
     }
 
+    /**
+     * Test for get all issuers.
+     */
+    public function test_get_all_issuers() {
+        $this->resetAfterTest();
+        $this->setAdminUser();
+        $googleissuer = core\oauth2\api::create_standard_issuer('google');
+        core\oauth2\api::create_standard_issuer('facebook');
+        core\oauth2\api::create_standard_issuer('microsoft');
+
+        // Set Google issuer to be shown only on login page.
+        $record = $googleissuer->to_record();
+        $record->showonloginpage = $googleissuer::LOGINONLY;
+        core\oauth2\api::update_issuer($record);
+
+        $issuers = \core\oauth2\api::get_all_issuers();
+        $this->assertCount(2, $issuers);
+        $expected = ['Microsoft', 'Facebook'];
+        $this->assertEqualsCanonicalizing($expected, [$issuers[0]->get_display_name(), $issuers[1]->get_display_name()]);
+
+        $issuers = \core\oauth2\api::get_all_issuers(true);
+        $this->assertCount(3, $issuers);
+        $expected = ['Google', 'Microsoft', 'Facebook'];
+        $this->assertEqualsCanonicalizing($expected,
+            [$issuers[0]->get_display_name(), $issuers[1]->get_display_name(), $issuers[2]->get_display_name()]);
+    }
+
+    /**
+     * Test for is available for login.
+     */
+    public function test_is_available_for_login() {
+        $this->resetAfterTest();
+        $this->setAdminUser();
+        $googleissuer = core\oauth2\api::create_standard_issuer('google');
+
+        // Set Google issuer to be shown only on login page.
+        $record = $googleissuer->to_record();
+        $record->showonloginpage = $googleissuer::LOGINONLY;
+        core\oauth2\api::update_issuer($record);
+
+        $this->assertFalse($googleissuer->is_available_for_login());
+
+        // Set a clientid and clientsecret.
+        $googleissuer->set('clientid', 'clientid');
+        $googleissuer->set('clientsecret', 'secret');
+        $googleissuer->update();
+
+        $this->assertTrue($googleissuer->is_available_for_login());
+
+        // Set showonloginpage to service only.
+        $googleissuer->set('showonloginpage', \core\oauth2\issuer::SERVICEONLY);
+        $googleissuer->update();
+
+        $this->assertFalse($googleissuer->is_available_for_login());
+
+        // Set showonloginpage to everywhere (service and login) and disable issuer.
+        $googleissuer->set('showonloginpage', \core\oauth2\issuer::EVERYWHERE);
+        $googleissuer->set('enabled', 0);
+        $googleissuer->update();
+
+        $this->assertFalse($googleissuer->is_available_for_login());
+
+        // Enable issuer.
+        $googleissuer->set('enabled', 1);
+        $googleissuer->update();
+
+        $this->assertTrue($googleissuer->is_available_for_login());
+
+        // Remove userinfo endpoint from issuer.
+        $endpoint = core\oauth2\endpoint::get_record([
+            'issuerid' => $googleissuer->get('id'),
+            'name' => 'userinfo_endpoint'
+        ]);
+        \core\oauth2\api::delete_endpoint($endpoint->get('id'));
+
+        $this->assertFalse($googleissuer->is_available_for_login());
+    }
 }
index b00cd6c..3ef5660 100644 (file)
@@ -30,7 +30,7 @@ $l = optional_param('l',0,PARAM_INT);     // Label ID
 
 if ($id) {
     $PAGE->set_url('/mod/label/index.php', array('id'=>$id));
-    if (! $cm = get_coursemodule_from_id('label', $id)) {
+    if (! $cm = get_coursemodule_from_id('label', $id, 0, true)) {
         print_error('invalidcoursemodule');
     }
 
@@ -50,13 +50,15 @@ if ($id) {
     if (! $course = $DB->get_record("course", array("id"=>$label->course)) ){
         print_error('coursemisconf');
     }
-    if (! $cm = get_coursemodule_from_instance("label", $label->id, $course->id)) {
+    if (! $cm = get_coursemodule_from_instance("label", $label->id, $course->id, true)) {
         print_error('invalidcoursemodule');
     }
 }
 
 require_login($course, true, $cm);
 
-redirect("$CFG->wwwroot/course/view.php?id=$course->id");
+$url = course_get_url($course, $cm->sectionnum, []);
+$url->set_anchor('module-' . $id);
+redirect($url);
 
 
index 0ba4e85..6dd60f0 100644 (file)
 // You should have received a copy of the GNU General Public License
 // along with Moodle. If not, see <http://www.gnu.org/licenses/>.
 
+/**
+ * This file contains the OAuth 1.0a implementation used for support for LTI 1.1.
+ *
+ * @package    mod_lti
+ * @copyright moodle
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
 namespace moodle\mod\lti;//Using a namespace as the basicLTI module imports classes with the same names
 
 defined('MOODLE_INTERNAL') || die;
 
-$oauth_last_computed_signature = false;
+$lastcomputedsignature = false;
 
-/* Generic exception class
+/**
+ * Generic exception class
  */
 class OAuthException extends \Exception {
     // pass
 }
 
+/**
+ * OAuth 1.0 Consumer class
+ */
 class OAuthConsumer {
     public $key;
     public $secret;
@@ -118,17 +129,25 @@ class OAuthSignatureMethod {
     }
 }
 
-class OAuthSignatureMethod_HMAC_SHA1 extends OAuthSignatureMethod {
-    function get_name() {
-        return "HMAC-SHA1";
-    }
+
+/**
+ * Base class for the HMac based signature methods.
+ */
+abstract class OAuthSignatureMethod_HMAC extends OAuthSignatureMethod {
+
+    /**
+     * Name of the Algorithm used.
+     *
+     * @return string algorithm name.
+     */
+    abstract public function get_name(): string;
 
     public function build_signature($request, $consumer, $token) {
-        global $oauth_last_computed_signature;
-        $oauth_last_computed_signature = false;
+        global $lastcomputedsignature;
+        $lastcomputedsignature = false;
 
-        $base_string = $request->get_signature_base_string();
-        $request->base_string = $base_string;
+        $basestring = $request->get_signature_base_string();
+        $request->base_string = $basestring;
 
         $key_parts = array(
             $consumer->secret,
@@ -138,15 +157,48 @@ class OAuthSignatureMethod_HMAC_SHA1 extends OAuthSignatureMethod {
         $key_parts = OAuthUtil::urlencode_rfc3986($key_parts);
         $key = implode('&', $key_parts);
 
-        $computed_signature = base64_encode(hash_hmac('sha1', $base_string, $key, true));
-        $oauth_last_computed_signature = $computed_signature;
-        return $computed_signature;
+        $computedsignature = base64_encode(hash_hmac(strtolower(substr($this->get_name(), 5)), $basestring, $key, true));
+        $lastcomputedsignature = $computedsignature;
+        return $computedsignature;
     }
 
 }
 
+/**
+ * Implementation for SHA 1.
+ */
+class OAuthSignatureMethod_HMAC_SHA1 extends OAuthSignatureMethod_HMAC {
+    /**
+     * Name of the Algorithm used.
+     *
+     * @return string algorithm name.
+     */
+    public function get_name(): string {
+        return "HMAC-SHA1";
+    }
+}
+
+/**
+ * Implementation for SHA 256.
+ */
+class OAuthSignatureMethod_HMAC_SHA256 extends OAuthSignatureMethod_HMAC {
+    /**
+     * Name of the Algorithm used.
+     *
+     * @return string algorithm name.
+     */
+    public function get_name(): string {
+        return "HMAC-SHA256";
+    }
+}
+
 class OAuthSignatureMethod_PLAINTEXT extends OAuthSignatureMethod {
-    public function get_name() {
+    /**
+     * Name of the Algorithm used.
+     *
+     * @return string algorithm name.
+     */
+    public function get_name(): string {
         return "PLAINTEXT";
     }
 
@@ -170,7 +222,12 @@ class OAuthSignatureMethod_PLAINTEXT extends OAuthSignatureMethod {
 }
 
 class OAuthSignatureMethod_RSA_SHA1 extends OAuthSignatureMethod {
-    public function get_name() {
+    /**
+     * Name of the Algorithm used.
+     *
+     * @return string algorithm name.
+     */
+    public function get_name(): string {
         return "RSA-SHA1";
     }
 
@@ -539,8 +596,8 @@ class OAuthServer {
      * verify an api call, checks all the parameters
      */
     public function verify_request(&$request) {
-        global $oauth_last_computed_signature;
-        $oauth_last_computed_signature = false;
+        global $lastcomputedsignature;
+        $lastcomputedsignature = false;
         $this->get_version($request);
         $consumer = $this->get_consumer($request);
         $token = $this->get_token($request, $consumer, "access");
@@ -620,8 +677,8 @@ class OAuthServer {
      */
     private function check_signature(&$request, $consumer, $token) {
         // this should probably be in a different method
-        global $oauth_last_computed_signature;
-        $oauth_last_computed_signature = false;
+        global $lastcomputedsignature;
+        $lastcomputedsignature = false;
 
         $timestamp = @ $request->get_parameter('oauth_timestamp');
         $nonce = @ $request->get_parameter('oauth_nonce');
@@ -636,8 +693,8 @@ class OAuthServer {
 
         if (!$valid_sig) {
             $ex_text = "Invalid signature";
-            if ($oauth_last_computed_signature) {
-                $ex_text = $ex_text . " ours= $oauth_last_computed_signature yours=$signature";
+            if ($lastcomputedsignature) {
+                $ex_text = $ex_text . " ours= $lastcomputedsignature yours=$signature";
             }
             throw new OAuthException($ex_text);
         }
index cf707f9..42c7ef5 100644 (file)
Binary files a/mod/lti/amd/build/tool_configure_controller.min.js and b/mod/lti/amd/build/tool_configure_controller.min.js differ
index d2b2ba4..f197ea7 100644 (file)
Binary files a/mod/lti/amd/build/tool_configure_controller.min.js.map and b/mod/lti/amd/build/tool_configure_controller.min.js.map differ
index 1ca2fd3..072218e 100644 (file)
@@ -26,8 +26,8 @@
  * @since      3.1
  */
 define(['jquery', 'core/ajax', 'core/notification', 'core/templates', 'mod_lti/events', 'mod_lti/keys', 'mod_lti/tool_type',
-        'mod_lti/tool_proxy', 'core/str'],
-        function($, ajax, notification, templates, ltiEvents, KEYS, toolType, toolProxy, str) {
+        'mod_lti/tool_proxy', 'core/str', 'core/config'],
+        function($, ajax, notification, templates, ltiEvents, KEYS, toolType, toolProxy, str, config) {
 
     var SELECTORS = {
         EXTERNAL_REGISTRATION_CONTAINER: '#external-registration-container',
@@ -116,7 +116,7 @@ define(['jquery', 'core/ajax', 'core/notification', 'core/templates', 'mod_lti/e
         $(SELECTORS.EXTERNAL_REGISTRATION_PAGE_CONTAINER).removeClass('hidden');
         var container = $(SELECTORS.EXTERNAL_REGISTRATION_TEMPLATE_CONTAINER);
         container.append($("<iframe src='startltiadvregistration.php?url="
-                         + encodeURIComponent(url) + "'></iframe>"));
+                         + encodeURIComponent(url) + "&sesskey=" + config.sesskey + "'></iframe>"));
         showExternalRegistration();
         window.addEventListener("message", closeLTIAdvRegistration, false);
     };
index ffedbbf..bbbe9a3 100644 (file)
@@ -51,6 +51,19 @@ class registration_helper {
     /** Tool Settings scope */
     const SCOPE_TOOL_SETTING = 'https://purl.imsglobal.org/spec/lti-ts/scope/toolsetting';
 
+    /** Indicates the token is to create a new registration */
+    const REG_TOKEN_OP_NEW_REG = 'reg';
+    /** Indicates the token is to update an existing registration */
+    const REG_TOKEN_OP_UPDATE_REG = 'reg-update';
+
+    /**
+     * Get an instance of this helper
+     *
+     * @return object
+     */
+    public static function get() {
+        return new registration_helper();
+    }
 
     /**
      * Function used to validate parameters.
@@ -64,7 +77,7 @@ class registration_helper {
      *
      * @return mixed
      */
-    private static function get_parameter(array $payload, string $key, bool $required) {
+    private function get_parameter(array $payload, string $key, bool $required) {
         if (!isset($payload[$key]) || empty($payload[$key])) {
             if ($required) {
                 throw new registration_exception('missing required attribute '.$key, 400);
@@ -87,33 +100,36 @@ class registration_helper {
      *
      * @return object the Moodle LTI config.
      */
-    public static function registration_to_config(array $registrationpayload, string $clientid): object {
-        $responsetypes = self::get_parameter($registrationpayload, 'response_types', true);
-        $initiateloginuri = self::get_parameter($registrationpayload, 'initiate_login_uri', true);
-        $redirecturis = self::get_parameter($registrationpayload, 'redirect_uris', true);
-        $clientname = self::get_parameter($registrationpayload, 'client_name', true);
-        $jwksuri = self::get_parameter($registrationpayload, 'jwks_uri', true);
-        $tokenendpointauthmethod = self::get_parameter($registrationpayload, 'token_endpoint_auth_method', true);
-
-        $applicationtype = self::get_parameter($registrationpayload, 'application_type', false);
-        $logouri = self::get_parameter($registrationpayload, 'logo_uri', false);
-
-        $ltitoolconfiguration = self::get_parameter($registrationpayload,
+    public function registration_to_config(array $registrationpayload, string $clientid): object {
+        $responsetypes = $this->get_parameter($registrationpayload, 'response_types', true);
+        $initiateloginuri = $this->get_parameter($registrationpayload, 'initiate_login_uri', true);
+        $redirecturis = $this->get_parameter($registrationpayload, 'redirect_uris', true);
+        $clientname = $this->get_parameter($registrationpayload, 'client_name', true);
+        $jwksuri = $this->get_parameter($registrationpayload, 'jwks_uri', true);
+        $tokenendpointauthmethod = $this->get_parameter($registrationpayload, 'token_endpoint_auth_method', true);
+
+        $applicationtype = $this->get_parameter($registrationpayload, 'application_type', false);
+        $logouri = $this->get_parameter($registrationpayload, 'logo_uri', false);
+
+        $ltitoolconfiguration = $this->get_parameter($registrationpayload,
             'https://purl.imsglobal.org/spec/lti-tool-configuration', true);
 
-        $domain = self::get_parameter($ltitoolconfiguration, 'domain', false);
-        $targetlinkuri = self::get_parameter($ltitoolconfiguration, 'target_link_uri', false);
-        $customparameters = self::get_parameter($ltitoolconfiguration, 'custom_parameters', false);
-        $scopes = explode(" ", self::get_parameter($registrationpayload, 'scope', false) ?? '');
-        $claims = self::get_parameter($ltitoolconfiguration, 'claims', false);
+        $domain = $this->get_parameter($ltitoolconfiguration, 'domain', false);
+        $targetlinkuri = $this->get_parameter($ltitoolconfiguration, 'target_link_uri', false);
+        $customparameters = $this->get_parameter($ltitoolconfiguration, 'custom_parameters', false);
+        $scopes = explode(" ", $this->get_parameter($registrationpayload, 'scope', false) ?? '');
+        $claims = $this->get_parameter($ltitoolconfiguration, 'claims', false);
         $messages = $ltitoolconfiguration['messages'] ?? [];
-        $description = self::get_parameter($ltitoolconfiguration, 'description', false);
+        $description = $this->get_parameter($ltitoolconfiguration, 'description', false);
 
         // Validate domain and target link.
         if (empty($domain)) {
             throw new registration_exception('missing_domain', 400);
         }
+
         $targetlinkuri = $targetlinkuri ?: 'https://'.$domain;
+        // Stripping www as this is ignored for domain matching.
+        $domain = lti_get_domain_from_url($domain);
         if ($domain !== lti_get_domain_from_url($targetlinkuri)) {
             throw new registration_exception('domain_targetlinkuri_mismatch', 400);
         }
@@ -237,72 +253,107 @@ class registration_helper {
         return $config;
     }
 
+    /**
+     * Adds to the config the LTI 1.1 key and sign it with the 1.1 secret.
+     *
+     * @param array $lticonfig reference to lticonfig to which to add the 1.1 OAuth info.
+     * @param string $key - LTI 1.1 OAuth Key
+     * @param string $secret - LTI 1.1 OAuth Secret
+     *
+     */
+    private function add_previous_key_claim(array &$lticonfig, string $key, string $secret) {
+        if ($key) {
+            $oauthconsumer = [];
+            $oauthconsumer['key'] = $key;
+            $oauthconsumer['nonce'] = random_string(random_int(10, 20));
+            $oauthconsumer['sign'] = hash('sha256', $key.$secret.$oauthconsumer['nonce']);
+            $lticonfig['oauth_consumer'] = $oauthconsumer;
+        }
+    }
+
     /**
      * Transforms a moodle LTI 1.3 Config to an OAuth/LTI Client Registration.
      *
      * @param object $config Moodle LTI Config.
      * @param int $typeid which is the LTI deployment id.
+     * @param object $type tool instance in case the tool already exists.
      *
      * @return array the Client Registration as an associative array.
      */
-    public static function config_to_registration(object $config, int $typeid): array {
+    public function config_to_registration(object $config, int $typeid, object $type = null): array {
+        $configarray = [];
+        foreach ((array)$config as $k => $v) {
+            if (substr($k, 0, 4) == 'lti_') {
+                $k = substr($k, 4);
+            }
+            $configarray[$k] = $v;
+        }
+        $config = (object) $configarray;
         $registrationresponse = [];
-        $registrationresponse['client_id'] = $config->lti_clientid;
-        $registrationresponse['token_endpoint_auth_method'] = ['private_key_jwt'];
-        $registrationresponse['response_types'] = ['id_token'];
-        $registrationresponse['jwks_uri'] = $config->lti_publickeyset;
-        $registrationresponse['initiate_login_uri'] = $config->lti_initiatelogin;
-        $registrationresponse['grant_types'] = ['client_credentials', 'implicit'];
-        $registrationresponse['redirect_uris'] = explode(PHP_EOL, $config->lti_redirectionuris);
-        $registrationresponse['application_type'] = 'web';
-        $registrationresponse['token_endpoint_auth_method'] = 'private_key_jwt';
-        $registrationresponse['client_name'] = $config->lti_typename;
-        $registrationresponse['logo_uri'] = $config->lti_icon ?? '';
         $lticonfigurationresponse = [];
+        $ltiversion = $type ? $type->ltiversion : $config->ltiversion;
+        $lticonfigurationresponse['version'] = $ltiversion;
+        if ($ltiversion === LTI_VERSION_1P3) {
+            $registrationresponse['client_id'] = $type ? $type->clientid : $config->clientid;
+            $registrationresponse['response_types'] = ['id_token'];
+            $registrationresponse['jwks_uri'] = $config->publickeyset;
+            $registrationresponse['initiate_login_uri'] = $config->initiatelogin;
+            $registrationresponse['grant_types'] = ['client_credentials', 'implicit'];
+            $registrationresponse['redirect_uris'] = explode(PHP_EOL, $config->redirectionuris);
+            $registrationresponse['application_type'] = 'web';
+            $registrationresponse['token_endpoint_auth_method'] = 'private_key_jwt';
+        } else if ($ltiversion === LTI_VERSION_1 && $type) {
+            $this->add_previous_key_claim($lticonfigurationresponse, $config->resourcekey, $config->password);
+        } else if ($ltiversion === LTI_VERSION_2 && $type) {
+            $toolproxy = $this->get_tool_proxy($type->toolproxyid);
+            $this->add_previous_key_claim($lticonfigurationresponse, $toolproxy['guid'], $toolproxy['secret']);
+        }
+        $registrationresponse['client_name'] = $type ? $type->name : $config->typename;
+        $registrationresponse['logo_uri'] = $type ? ($type->secureicon ?? $type->icon ?? '') : $config->icon ?? '';
         $lticonfigurationresponse['deployment_id'] = strval($typeid);
-        $lticonfigurationresponse['target_link_uri'] = $config->lti_toolurl;
-        $lticonfigurationresponse['domain'] = $config->lti_tooldomain ?? '';
-        $lticonfigurationresponse['description'] = $config->lti_description ?? '';
-        if ($config->lti_contentitem == 1) {
+        $lticonfigurationresponse['target_link_uri'] = $type ? $type->baseurl : $config->toolurl ?? '';
+        $lticonfigurationresponse['domain'] = $type ? $type->tooldomain : $config->tooldomain ?? '';
+        $lticonfigurationresponse['description'] = $type ? $type->description ?? '' : $config->description ?? '';
+        if ($config->contentitem ?? 0 == 1) {
             $contentitemmessage = [];
             $contentitemmessage['type'] = 'LtiDeepLinkingRequest';
-            if (isset($config->lti_toolurl_ContentItemSelectionRequest)) {
-                $contentitemmessage['target_link_uri'] = $config->lti_toolurl_ContentItemSelectionRequest;
+            if (isset($config->toolurl_ContentItemSelectionRequest)) {
+                $contentitemmessage['target_link_uri'] = $config->toolurl_ContentItemSelectionRequest;
             }
             $lticonfigurationresponse['messages'] = [$contentitemmessage];
         }
-        if (isset($config->lti_customparameters) && !empty($config->lti_customparameters)) {
+        if (isset($config->customparameters) && !empty($config->customparameters)) {
             $params = [];
-            foreach (explode(PHP_EOL, $config->lti_customparameters) as $param) {
+            foreach (explode(PHP_EOL, $config->customparameters) as $param) {
                 $split = explode('=', $param);
                 $params[$split[0]] = $split[1];
             }
             $lticonfigurationresponse['custom_parameters'] = $params;
         }
         $scopesresponse = [];
-        if ($config->ltiservice_gradesynchronization > 0) {
+        if ($config->ltiservice_gradesynchronization ?? 0 > 0) {
             $scopesresponse[] = self::SCOPE_SCORE;
             $scopesresponse[] = self::SCOPE_RESULT;
             $scopesresponse[] = self::SCOPE_LINEITEM_RO;
         }
-        if ($config->ltiservice_gradesynchronization == 2) {
+        if ($config->ltiservice_gradesynchronization ?? 0 == 2) {
             $scopesresponse[] = self::SCOPE_LINEITEM;
         }
-        if ($config->ltiservice_memberships == 1) {
+        if ($config->ltiservice_memberships ?? 0 == 1) {
             $scopesresponse[] = self::SCOPE_NRPS;
         }
-        if ($config->ltiservice_toolsettings == 1) {
+        if ($config->ltiservice_toolsettings ?? 0 == 1) {
             $scopesresponse[] = self::SCOPE_TOOL_SETTING;
         }
         $registrationresponse['scope'] = implode(' ', $scopesresponse);
 
         $claimsresponse = ['sub', 'iss'];
-        if ($config->lti_sendname == LTI_SETTING_ALWAYS) {
+        if ($config->sendname ?? '' == LTI_SETTING_ALWAYS) {
             $claimsresponse[] = 'name';
             $claimsresponse[] = 'family_name';
             $claimsresponse[] = 'given_name';
         }
-        if ($config->lti_sendemailaddr == LTI_SETTING_ALWAYS) {
+        if ($config->sendemailaddr ?? '' == LTI_SETTING_ALWAYS) {
             $claimsresponse[] = 'email';
         }
         $lticonfigurationresponse['claims'] = $claimsresponse;
@@ -316,21 +367,32 @@ class registration_helper {
      *
      * @param string $registrationtokenjwt registration token
      *
-     * @return string client id for the registration
+     * @return array with 2 keys: clientid for the registration, type but only if it's an update
      */
-    public static function validate_registration_token(string $registrationtokenjwt): string {
+    public function validate_registration_token(string $registrationtokenjwt): array {
         global $DB;
         $keys = JWK::parseKeySet(jwks_helper::get_jwks());
         $registrationtoken = JWT::decode($registrationtokenjwt, $keys, ['RS256']);
-
+        $response = [];
         // Get clientid from registrationtoken.
         $clientid = $registrationtoken->sub;
-
-        // Checks if clientid is already registered.
-        if (!empty($DB->get_record('lti_types', array('clientid' => $clientid)))) {
-            throw new registration_exception("token_already_used", 401);
+        if ($registrationtoken->scope == self::REG_TOKEN_OP_NEW_REG) {
+            // Checks if clientid is already registered.
+            if (!empty($DB->get_record('lti_types', array('clientid' => $clientid)))) {
+                throw new registration_exception("token_already_used", 401);
+            }
+            $response['clientid'] = $clientid;
+        } else if ($registrationtoken->scope == self::REG_TOKEN_OP_UPDATE_REG) {
+            $tool = lti_get_type($registrationtoken->sub);
+            if (!$tool) {
+                throw new registration_exception("Unknown client", 400);
+            }
+            $response['clientid'] = $tool->clientid ?? $this->new_clientid();
+            $response['type'] = $tool;
+        } else {
+            throw new registration_exception("Incorrect scope", 403);
         }
-        return $clientid;
+        return $response;
     }
 
     /**
@@ -338,7 +400,7 @@ class registration_helper {
      *
      * @return array List of scopes
      */
-    public static function lti_get_service_scopes() {
+    public function lti_get_service_scopes() {
 
         $services = lti_get_services();
         $scopes = array();
@@ -351,4 +413,35 @@ class registration_helper {
         return $scopes;
     }
 
+    /**
+     * Generates a new client id string.
+     *
+     * @return string generated client id
+     */
+    public function new_clientid(): string {
+        return random_string(15);
+    }
+
+    /**
+     * Base64 encoded signature for LTI 1.1 migration.
+     * @param string $key LTI 1.1 key
+     * @param string $salt Salt value
+     * @param string $secret LTI 1.1 secret
+     *
+     * @return string base64encoded hash
+     */
+    public function sign(string $key, string $salt, string $secret): string {
+        return base64_encode(hash_hmac('sha-256', $key.$salt, $secret, true));
+    }
+
+    /**
+     * Returns a tool proxy
+     *
+     * @param int $proxyid
+     *
+     * @return mixed Tool Proxy details
+     */
+    public function get_tool_proxy(int $proxyid) : array {
+        return lti_get_tool_proxy($proxyid);
+    }
 }
diff --git a/mod/lti/classes/output/registration_upgrade_choice_page.php b/mod/lti/classes/output/registration_upgrade_choice_page.php
new file mode 100644 (file)
index 0000000..1e7e3a0
--- /dev/null
@@ -0,0 +1,69 @@
+<?php
+// This file is part of 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/>.
+
+/**
+ * Class containing data for rendering LTI upgrade choices page.
+ *
+ * @copyright  2021 Cengage
+ * @package    mod_lti
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace mod_lti\output;
+
+defined('MOODLE_INTERNAL') || die;
+
+require_once($CFG->dirroot.'/mod/lti/locallib.php');
+
+use renderable;
+use templatable;
+use renderer_base;
+use stdClass;
+
+/**
+ * Class containing data for rendering LTI upgrade choices page.
+ *
+ * @copyright  2021 Cengage
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class registration_upgrade_choice_page implements renderable, templatable {
+
+    /**
+     * Constructor
+     *
+     * @param array $tools array of tools that can be upgraded
+     * @param string $startregurl tool URL to start the registration process
+     */
+    public function __construct(array $tools, string $startregurl) {
+        $this->tools = $tools;
+        $this->startregurl = $startregurl;
+    }
+    /**
+     * Export this data so it can be used as the context for a mustache template.
+     *
+     * @param renderer_base $output The renderer
+     * @return stdClass Data to be used by the template
+     */
+    public function export_for_template(renderer_base $output) {
+        $renderdata = new stdClass();
+        $renderdata->startregurlenc = urlencode($this->startregurl);
+        $renderdata->sesskey = sesskey();
+        $renderdata->tools = [];
+        foreach ($this->tools as $tool) {
+            $renderdata->tools[] = (object)$tool;
+        }
+        return $renderdata;
+    }
+}
index b94178d..e0bdb59 100644 (file)
@@ -59,4 +59,16 @@ class renderer extends plugin_renderer_base {
         $data = $page->export_for_template($this);
         return parent::render_from_template('mod_lti/external_registration_return', $data);
     }
+
+    /**
+     * Render the external registration return page
+     *
+     * @param tool_configure_page $page
+     *
+     * @return string html for the page
+     */
+    public function render_registration_upgrade_choice_page($page) {
+        $data = $page->export_for_template($this);
+        return parent::render_from_template('mod_lti/registration_upgrade_choice_page', $data);
+    }
 }
index 3746760..076914d 100644 (file)
@@ -166,6 +166,15 @@ $string['domain_mismatch'] = 'Tool URL\'s domain does not match tool configurati
 $string['donot'] = 'Do not send';
 $string['donotaccept'] = 'Do not accept';
 $string['donotallow'] = 'Do not allow';
+$string['dynreg_update_text'] = 'There are existing tools attached to the registration\'s domain. Do you want to update an already installed
+external tool or create a new external tool?';
+$string['dynreg_update_warn_dupdomain'] = 'It is not recommended to have multiple external tools under the same domain.';
+$string['dynreg_update_name'] = 'Tool name';
+$string['dynreg_update_url'] = 'Base URL';
+$string['dynreg_update_version'] = 'LTI Version';
+$string['dynreg_update_notools'] = 'No tools in context.';
+$string['dynreg_update_btn_update'] = 'Update';
+$string['dynreg_update_btn_new'] = 'Register as a new external tool';
 $string['duplicateregurl'] = 'This registration URL is already in use';
 $string['editdescription'] = 'Click here to give this tool a description';
 $string['edittype'] = 'Edit preconfigured tool';
index 8182d98..ae27f4a 100644 (file)
@@ -55,6 +55,7 @@ use moodle\mod\lti as lti;
 use Firebase\JWT\JWT;
 use Firebase\JWT\JWK;
 use mod_lti\local\ltiopenid\jwks_helper;
+use mod_lti\local\ltiopenid\registration_helper;
 
 global $CFG;
 require_once($CFG->dirroot.'/mod/lti/OAuth.php');
@@ -2752,7 +2753,7 @@ function lti_prepare_type_for_save($type, $config) {
         $type->clientid = $config->lti_clientid;
     }
     if ((!empty($type->ltiversion) && $type->ltiversion === LTI_VERSION_1P3) && empty($type->clientid)) {
-        $type->clientid = random_string(15);
+        $type->clientid = registration_helper::get()->new_clientid();
     } else if (empty($type->clientid)) {
         $type->clientid = null;
     }
@@ -2822,6 +2823,14 @@ function lti_update_type($type, $config) {
                 lti_update_config($record);
             }
         }
+        if (isset($type->toolproxyid) && $type->ltiversion === LTI_VERSION_1P3) {
+            // We need to remove the tool proxy for this tool to function under 1.3.
+            $toolproxyid = $type->toolproxyid;
+            $DB->delete_records('lti_tool_settings', array('toolproxyid' => $toolproxyid));
+            $DB->delete_records('lti_tool_proxies', array('id' => $toolproxyid));
+            $type->toolproxyid = null;
+            $DB->update_record('lti_types', $type);
+        }
         require_once($CFG->libdir.'/modinfolib.php');
         if ($clearcache) {
             $sql = "SELECT DISTINCT course
index 7d616c3..0e911da 100644 (file)
@@ -33,7 +33,7 @@ require_once(__DIR__ . '/../../config.php');
 require_once($CFG->dirroot . '/mod/lti/locallib.php');
 require_once($CFG->libdir.'/weblib.php');
 
-$scopes = registration_helper::lti_get_service_scopes();
+$scopes = registration_helper::get()->lti_get_service_scopes();
 $scopes[] = 'openid';
 $conf = [
     'issuer' => $CFG->wwwroot,
index c06e531..a9d748b 100644 (file)
@@ -32,27 +32,50 @@ require_once($CFG->dirroot . '/mod/lti/locallib.php');
 
 $code = 200;
 $message = '';
-// Retrieve registration token from Bearer Authorization header.
-$authheader = moodle\mod\lti\OAuthUtil::get_headers() ['Authorization'] ?? '';
-if (!($authheader && substr($authheader, 0, 7) == 'Bearer ')) {
-    $message = 'missing_registration_token';
-    $code = 401;
-} else {
-    $registrationpayload = json_decode(file_get_contents('php://input'), true);
+if ($_SERVER['REQUEST_METHOD'] === 'POST' or ($_SERVER['REQUEST_METHOD'] === 'GET')) {
+    $doregister = $_SERVER['REQUEST_METHOD'] === 'POST';
+    // Retrieve registration token from Bearer Authorization header.
+    $authheader = moodle\mod\lti\OAuthUtil::get_headers()['Authorization'] ?? '';
+    if (!($authheader && substr($authheader, 0, 7) == 'Bearer ')) {
+        $message = 'missing_registration_token';
+        $code = 401;
+    } else {
 
-    // Registers tool.
-    $type = new stdClass();
-    $type->state = LTI_TOOL_STATE_PENDING;
-    try {
-        $clientid = registration_helper::validate_registration_token(trim(substr($authheader, 7)));
-        $config = registration_helper::registration_to_config($registrationpayload, $clientid);
-        $typeid = lti_add_type($type, clone $config);
-        $message = json_encode(registration_helper::config_to_registration($config, $typeid));
-        header('Content-Type: application/json; charset=utf-8');
-    } catch (registration_exception $e) {
-        $code = $e->getCode();
-        $message = $e->getMessage();
+        // Registers tool.
+        try {
+            $tokenres = registration_helper::get()->validate_registration_token(trim(substr($authheader, 7)));
+            $type = new stdClass();
+            $type->state = LTI_TOOL_STATE_PENDING;
+            if (array_key_exists('type', $tokenres)) {
+                $type = $tokenres['type'];
+            }
+            if ($doregister) {
+                $registrationpayload = json_decode(file_get_contents('php://input'), true);
+                $config = registration_helper::get()->registration_to_config($registrationpayload, $tokenres['clientid']);
+                if ($type->id) {
+                    lti_update_type($type, clone $config);
+                    $typeid = $type->id;
+                } else {
+                    $typeid = lti_add_type($type, clone $config);
+                }
+                header('Content-Type: application/json; charset=utf-8');
+                $message = json_encode(registration_helper::get()->config_to_registration((object)$config, $typeid));
+            } else if ($type) {
+                $config = lti_get_type_config($type->id);
+                header('Content-Type: application/json; charset=utf-8');
+                $message = json_encode(registration_helper::get()->config_to_registration((object)$config, $type->id, $type));
+            } else {
+                $code = 404;
+                $message = "No registration found.";
+            }
+        } catch (registration_exception $e) {
+            $code = $e->getCode();
+            $message = $e->getMessage();
+        }
     }
+} else {
+    $code = 400;
+    $message = 'Unsupported operation';
 }
 $response = new \mod_lti\local\ltiservice\response();
 // Set code.
index 9644a94..ddf993a 100644 (file)
 use Firebase\JWT\JWT;
 
 use mod_lti\local\ltiopenid\jwks_helper;
+use mod_lti\local\ltiopenid\registration_helper;
 
 require_once(__DIR__ . '/../../config.php');
 require_once($CFG->libdir.'/weblib.php');
+require_once($CFG->dirroot . '/mod/lti/locallib.php');
 
 require_login();
 $context = context_system::instance();
 require_capability('moodle/site:config', $context);
 
 $starturl = required_param('url', PARAM_URL);
-$now = time();
-$token = [
-    "sub" => random_string(15),
-    "scope" => "reg",
-    "iat" => $now,
-    "exp" => $now + HOURSECS
-];
-$privatekey = jwks_helper::get_private_key();
-$regtoken = JWT::encode($token, $privatekey['key'], 'RS256', $privatekey['kid']);
-$confurl = new moodle_url('/mod/lti/openid-configuration.php');
-$url = new moodle_url($starturl);
-$url->param('openid_configuration', $confurl->out(false));
-$url->param('registration_token', $regtoken);
-header("Location: ".$url->out(false));
+$typeid = optional_param('type', -1, PARAM_INT);
+
+$types = lti_get_tools_by_url($starturl, null);
+
+if (!empty($types) && $typeid == -1) {
+    // There are matching types for the registration domain, let's prompt the user to upgrade.
+    $pageurl = new moodle_url('/mod/lti/startltiadvregistration.php');
+    $PAGE->set_context($context);
+    $PAGE->set_url($pageurl);
+    $PAGE->set_pagelayout('maintenance');
+    $output = $PAGE->get_renderer('mod_lti');
+    $page = new \mod_lti\output\registration_upgrade_choice_page($types, $starturl);
+    echo $output->header();
+    echo $output->render($page);
+    echo $output->footer();
+} else {
+    // Let's actually start the registration process by launching the tool registration
+    // endpoint with the registration token and the site config url.
+    require_sesskey();
+    $sub = registration_helper::get()->new_clientid();
+    $scope = registration_helper::REG_TOKEN_OP_NEW_REG;
+    if ($typeid > 0) {
+        // In the context of an update, the sub is the id of the type.
+        $sub = strval($typeid);
+        $scope = registration_helper::REG_TOKEN_OP_UPDATE_REG;
+    }
+    $now = time();
+    $token = [
+        "sub" => $sub,
+        "scope" => $scope,
+        "iat" => $now,
+        "exp" => $now + HOURSECS
+    ];
+    $privatekey = jwks_helper::get_private_key();
+    $regtoken = JWT::encode($token, $privatekey['key'], 'RS256', $privatekey['kid']);
+    $confurl = new moodle_url('/mod/lti/openid-configuration.php');
+    $url = new moodle_url($starturl);
+    $url->param('openid_configuration', $confurl->out(false));
+    $url->param('registration_token', $regtoken);
+    header("Location: ".$url->out(false));
+}
diff --git a/mod/lti/templates/registration_upgrade_choice_page.mustache b/mod/lti/templates/registration_upgrade_choice_page.mustache
new file mode 100644 (file)
index 0000000..57ea046
--- /dev/null
@@ -0,0 +1,94 @@
+{{!
+    This file is part of 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/>.
+}}
+{{!
+    @template mod_lti/registration_upgrade_choice_page.mustache
+
+    This template provides the layout to display the upgrade choices
+    available when registering a tool for which there exists at least
+    one other tool with the same domain.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * none
+
+    Context variables required for this template:
+    * startregurlenc: the URL provided by the tool to start the registration, url encoded
+    * tools: the list of tools matching the domain
+
+    Example context (json):
+    {
+        "startregurlenc":"https%3A%2F%2Fmytool.example%2Fregister",
+        "tools": [
+          {
+            "id": 222,
+            "name": "This is My LTI 1.1 tool",
+            "baseurl": "https://mytool.example",
+            "version": "LTI-1p0",
+            "sesskey": "785t9302"
+          }
+        ]
+    }
+
+}}
+<p>{{#str}}dynreg_update_text, mod_lti {{/str}}</p>
+<div class="alert alert-warning" role="alert">
+{{#str}}dynreg_update_warn_dupdomain, mod_lti {{/str}}
+</div>
+<table class="table table-striped">
+<tr>
+<th>{{#str}}dynreg_update_name, mod_lti {{/str}}</th>
+<th>{{#str}}dynreg_update_url, mod_lti {{/str}}</th>
+<th>{{#str}}dynreg_update_version, mod_lti {{/str}}</th>
+<th></th>
+</tr>
+{{#tools}}
+  <tr>
+  <td>{{name}}</td>
+  <td>{{baseurl}}</td>
+  <td>{{ltiversion}}</td>
+  <td><button data-href="startltiadvregistration.php?url={{startregurlenc}}&type={{id}}" class="btn btn-outline-primary">
+      <span class="btn-loader">{{> mod_lti/loader }}</span>
+      {{#str}}dynreg_update_btn_update, mod_lti {{/str}}
+      </button>
+  </td>
+  </tr>
+{{/tools}}
+</table>
+{{^tools}}
+{{#str}}dynreg_update_notools, mod_lti {{/str}}
+{{/tools}}
+<div>
+  <button data-href="startltiadvregistration.php?url={{startregurlenc}}&type=0" class="btn btn-primary">
+    <span class="btn-loader">{{> mod_lti/loader }}</span>
+    {{#str}}dynreg_update_btn_new, mod_lti {{/str}}
+  </button>
+</div>
+<form method="POST" id="startregform">
+  <input type="hidden" name="sesskey" value="{{sesskey}}"/>
+</form>
+{{#js}}
+    disableAndGo = e => {
+      document.querySelectorAll("button").forEach(e=>e.setAttribute("disabled", true));
+      e.target.querySelector("span.btn-loader").style.display="inline";
+      let form = document.getElementById("startregform");
+      form.action = e.target.getAttribute("data-href");
+      form.submit();
+    };
+    document.querySelectorAll("button").forEach(b=>b.onclick=disableAndGo);
+{{/js}}
index 4f789f5..6e1a89a 100644 (file)
  * @author     Claude Vervoort
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-
+namespace mod_lti\local\ltiopenid;
 use mod_lti\local\ltiopenid\registration_exception;
 use mod_lti\local\ltiopenid\registration_helper;
 
 /**
  * OpenId LTI Registration library tests
  */
-class mod_lti_openidregistrationlib_testcase extends advanced_testcase {
+class mod_lti_openidregistrationlib_testcase extends \advanced_testcase {
 
     /**
      * @var string A has-it-all client registration.
@@ -108,8 +108,8 @@ EOD;
         "jwks_uri": "https://client.example.org/.well-known/jwks.json",
         "token_endpoint_auth_method": "private_key_jwt",
         "https://purl.imsglobal.org/spec/lti-tool-configuration": {
-            "domain": "client.example.org",
-            "target_link_uri": "https://client.example.org/lti"
+            "domain": "www.example.org",
+            "target_link_uri": "https://www.example.org/lti"
         }
     }
 EOD;
@@ -146,7 +146,7 @@ EOD;
     public function test_to_config_full() {
         $registration = json_decode($this->registrationfulljson, true);
         $registration['scope'] .= ' https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly';
-        $config = registration_helper::registration_to_config($registration, 'TheClientId');
+        $config = registration_helper::get()->registration_to_config($registration, 'TheClientId');
         $this->assertEquals('JWK_KEYSET', $config->lti_keytype);
         $this->assertEquals(LTI_VERSION_1P3, $config->lti_ltiversion);
         $this->assertEquals('TheClientId', $config->lti_clientid);
@@ -175,12 +175,15 @@ EOD;
      */
     public function test_to_config_minimal() {
         $registration = json_decode($this->registrationminimaljson, true);
-        $config = registration_helper::registration_to_config($registration, 'TheClientId');
+        $config = registration_helper::get()->registration_to_config($registration, 'TheClientId');
         $this->assertEquals('JWK_KEYSET', $config->lti_keytype);
         $this->assertEquals(LTI_VERSION_1P3, $config->lti_ltiversion);
         $this->assertEquals('TheClientId', $config->lti_clientid);
         $this->assertEquals('Virtual Garden', $config->lti_typename);
         $this->assertEmpty($config->lti_description);
+        // Special case here where Moodle ignores www for domains.
+        $this->assertEquals('example.org', $config->lti_tooldomain);
+        $this->assertEquals('https://www.example.org/lti', $config->lti_toolurl);
         $this->assertEquals('https://client.example.org/lti/init', $config->lti_initiatelogin);
         $this->assertEquals('https://client.example.org/callback', $config->lti_redirectionuris);
         $this->assertEmpty($config->lti_customparameters);
@@ -200,7 +203,7 @@ EOD;
      */
     public function test_to_config_minimal_with_deeplinking() {
         $registration = json_decode($this->registrationminimaldljson, true);
-        $config = registration_helper::registration_to_config($registration, 'TheClientId');
+        $config = registration_helper::get()->registration_to_config($registration, 'TheClientId');
         $this->assertEquals(1, $config->lti_contentitem);
         $this->assertEmpty($config->lti_toolurl_ContentItemSelectionRequest);
     }
@@ -213,7 +216,7 @@ EOD;
         $this->expectException(registration_exception::class);
         $this->expectExceptionCode(400);
         unset($registration['initiate_login_uri']);
-        registration_helper::registration_to_config($registration, 'TheClientId');
+        registration_helper::get()->registration_to_config($registration, 'TheClientId');
     }
 
     /**
@@ -224,7 +227,7 @@ EOD;
         $this->expectException(registration_exception::class);
         $this->expectExceptionCode(400);
         unset($registration['redirect_uris']);
-        registration_helper::registration_to_config($registration, 'TheClientId');
+        registration_helper::get()->registration_to_config($registration, 'TheClientId');
     }
 
     /**
@@ -235,7 +238,7 @@ EOD;
         $this->expectException(registration_exception::class);
         $this->expectExceptionCode(400);
         $registration['jwks_uri'] = '';
-        registration_helper::registration_to_config($registration, 'TheClientId');
+        registration_helper::get()->registration_to_config($registration, 'TheClientId');
     }
 
     /**
@@ -247,7 +250,7 @@ EOD;
         $this->expectExceptionCode(400);
         unset($registration['https://purl.imsglobal.org/spec/lti-tool-configuration']['domain']);
         unset($registration['https://purl.imsglobal.org/spec/lti-tool-configuration']['target_link_uri']);
-        registration_helper::registration_to_config($registration, 'TheClientId');
+        registration_helper::get()->registration_to_config($registration, 'TheClientId');
     }
 
     /**
@@ -258,7 +261,7 @@ EOD;
         $this->expectException(registration_exception::class);
         $this->expectExceptionCode(400);
         $registration['https://purl.imsglobal.org/spec/lti-tool-configuration']['domain'] = 'not.the.right.domain';
-        registration_helper::registration_to_config($registration, 'TheClientId');
+        registration_helper::get()->registration_to_config($registration, 'TheClientId');
     }
 
     /**
@@ -269,7 +272,7 @@ EOD;
         unset($registration['https://purl.imsglobal.org/spec/lti-tool-configuration']['domain']);
         $this->expectException(registration_exception::class);
         $this->expectExceptionCode(400);
-        $config = registration_helper::registration_to_config($registration, 'TheClientId');
+        $config = registration_helper::get()->registration_to_config($registration, 'TheClientId');
     }
 
     /**
@@ -278,9 +281,9 @@ EOD;
     public function test_validation_domain_targetlinkuri_onlydomain() {
         $registration = json_decode($this->registrationminimaljson, true);
         unset($registration['https://purl.imsglobal.org/spec/lti-tool-configuration']['target_link_uri']);
-        $config = registration_helper::registration_to_config($registration, 'TheClientId');
-        $this->assertEquals('client.example.org', $config->lti_tooldomain);
-        $this->assertEquals('https://client.example.org', $config->lti_toolurl);
+        $config = registration_helper::get()->registration_to_config($registration, 'TheClientId');
+        $this->assertEquals('example.org', $config->lti_tooldomain);
+        $this->assertEquals('https://www.example.org', $config->lti_toolurl);
     }
 
     /**
@@ -289,7 +292,8 @@ EOD;
     public function test_config_to_registration() {
         $orig = json_decode($this->registrationfulljson, true);
         $orig['scope'] .= ' https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly';
-        $reg = registration_helper::config_to_registration(registration_helper::registration_to_config($orig, 'clid'), 12);
+        $reghelper = registration_helper::get();
+        $reg = $reghelper->config_to_registration($reghelper->registration_to_config($orig, 'clid'), 12);
         $this->assertEquals('clid', $reg['client_id']);
         $this->assertEquals($orig['response_types'], $reg['response_types']);
         $this->assertEquals($orig['initiate_login_uri'], $reg['initiate_login_uri']);
@@ -325,7 +329,8 @@ EOD;
      */
     public function test_config_to_registration_minimal() {
         $orig = json_decode($this->registrationminimaljson, true);
-        $reg = registration_helper::config_to_registration(registration_helper::registration_to_config($orig, 'clid'), 12);
+        $reghelper = registration_helper::get();
+        $reg = $reghelper->config_to_registration($reghelper->registration_to_config($orig, 'clid'), 12);
         $this->assertEquals('clid', $reg['client_id']);
         $this->assertEquals($orig['response_types'], $reg['response_types']);
         $this->assertEquals($orig['initiate_login_uri'], $reg['initiate_login_uri']);
@@ -342,4 +347,94 @@ EOD;
         $this->assertFalse(in_array('name', $lti['claims']));
     }
 
+    /**
+     * Test the transformation from lti config 1.1 to Registration Response.
+     */
+    public function test_config_to_registration_lti11() {
+        $config = [];
+        $config['contentitem'] = 1;
+        $config['toolurl_ContentItemSelectionRequest'] = '';
+        $config['sendname'] = 0;
+        $config['sendemailaddr'] = 1;
+        $config['acceptgrades'] = 2;
+        $config['resourcekey'] = 'testkey';
+        $config['password'] = 'testp@ssw0rd';
+        $config['customparameters'] = 'a1=b1';
+        $type = [];
+        $type['id'] = 130;
+        $type['name'] = 'LTI Test 1.1';
+        $type['baseurl'] = 'https://base.test.url/test';
+        $type['tooldomain'] = 'base.test.url';
+        $type['ltiversion'] = 'LTI-1p0';
+        $type['icon'] = 'https://base.test.url/icon.png';
+
+        $reg = registration_helper::get()->config_to_registration((object)$config, $type['id'], (object)$type);
+        $this->assertFalse(isset($reg['client_id']));
+        $this->assertFalse(isset($reg['initiate_login_uri']));
+        $this->assertEquals($type['name'], $reg['client_name']);
+        $lti = $reg['https://purl.imsglobal.org/spec/lti-tool-configuration'];
+        $this->assertEquals(LTI_VERSION_1, $lti['version']);
+        $this->assertEquals('b1', $lti['custom_parameters']['a1']);
+        $this->assertEquals('LtiDeepLinkingRequest', $lti['messages'][0]['type']);
+        $this->assertEquals('base.test.url', $lti['domain']);
+        $this->assertEquals($type['baseurl'], $lti['target_link_uri']);
+        $oauth = $lti['oauth_consumer'];
+        $this->assertEquals('testkey', $oauth['key']);
+        $this->assertFalse(empty($oauth['nonce']));
+        $this->assertEquals(hash('sha256', 'testkeytestp@ssw0rd'.$oauth['nonce']), $oauth['sign']);
+        $this->assertTrue(in_array('iss', $lti['claims']));
+        $this->assertTrue(in_array('sub', $lti['claims']));
+        $this->assertTrue(in_array('email', $lti['claims']));
+        $this->assertFalse(in_array('family_name', $lti['claims']));
+        $this->assertFalse(in_array('given_name', $lti['claims']));
+        $this->assertFalse(in_array('name', $lti['claims']));
+    }
+
+    /**
+     * Test the transformation from lti config 2.0 to Registration Response.
+     * For LTI 2.0 we limit to just passing the previous key/secret.
+     */
+    public function test_config_to_registration_lti20() {
+        $config = [];
+        $config['contentitem'] = 1;
+        $config['toolurl_ContentItemSelectionRequest'] = '';
+        $type = [];
+        $type['id'] = 131;
+        $type['name'] = 'LTI Test 1.2';
+        $type['baseurl'] = 'https://base.test.url/test';
+        $type['tooldomain'] = 'base.test.url';
+        $type['ltiversion'] = 'LTI-2p0';
+        $type['icon'] = 'https://base.test.url/icon.png';
+        $type['toolproxyid'] = 9;
+        $toolproxy = [];
+        $toolproxy['id'] = 9;
+        $toolproxy['guid'] = 'lti2guidtest';
+        $toolproxy['secret'] = 'peM7YDx420bo';
+
+        $reghelper = $this->getMockBuilder(registration_helper::class)
+            ->setMethods(['get_tool_proxy'])
+            ->getMock();
+        $map = [[$toolproxy['id'], $toolproxy]];
+        $reghelper->method('get_tool_proxy')
+            ->will($this->returnValueMap($map));
+        $reg = $reghelper->config_to_registration((object)$config, $type['id'], (object)$type);
+        $this->assertFalse(isset($reg['client_id']));
+        $this->assertFalse(isset($reg['initiate_login_uri']));
+        $this->assertEquals($type['name'], $reg['client_name']);
+        $lti = $reg['https://purl.imsglobal.org/spec/lti-tool-configuration'];
+        $this->assertEquals(LTI_VERSION_2, $lti['version']);
+        $this->assertEquals('LtiDeepLinkingRequest', $lti['messages'][0]['type']);
+        $this->assertEquals('base.test.url', $lti['domain']);
+        $this->assertEquals($type['baseurl'], $lti['target_link_uri']);
+        $oauth = $lti['oauth_consumer'];
+        $this->assertEquals('lti2guidtest', $toolproxy['guid']);
+        $this->assertFalse(empty($oauth['nonce']));
+        $this->assertEquals(hash('sha256', 'lti2guidtestpeM7YDx420bo'.$oauth['nonce']), $oauth['sign']);
+        $this->assertTrue(in_array('iss', $lti['claims']));
+        $this->assertTrue(in_array('sub', $lti['claims']));
+        $this->assertFalse(in_array('email', $lti['claims']));
+        $this->assertFalse(in_array('family_name', $lti['claims']));
+        $this->assertFalse(in_array('given_name', $lti['claims']));
+        $this->assertFalse(in_array('name', $lti['claims']));
+    }
 }
index 51fe4c0..d20cf0d 100644 (file)
@@ -719,6 +719,15 @@ class quiz_attempt {
         $this->number_questions();
     }
 
+    /**
+     * Preload all attempt step users to show in Response history.
+     *
+     * @throws dml_exception
+     */
+    public function preload_all_attempt_step_users(): void {
+        $this->quba->preload_all_step_users();
+    }
+
     /**
      * Let each slot know which section it is part of.
      */
index 46298e4..c78df82 100644 (file)
@@ -1528,6 +1528,7 @@ function quiz_get_review_options($quiz, $attempt, $context) {
         $options->rightanswer = question_display_options::VISIBLE;
         $options->overallfeedback = question_display_options::VISIBLE;
         $options->history = question_display_options::VISIBLE;
+        $options->userinfoinhistory = $attempt->userid;
 
     }
 
index 6773b91..1c1d42f 100644 (file)
@@ -44,6 +44,7 @@ if ($page !== 0) {
 $PAGE->set_url($url);
 
 $attemptobj = quiz_create_attempt_handling_errors($attemptid, $cmid);
+$attemptobj->preload_all_attempt_step_users();
 $page = $attemptobj->force_page_number_into_range($page);
 
 // Now we can validate the params better, re-genrate the page URL.
index 9131389..f49306b 100644 (file)
@@ -41,6 +41,7 @@ if (!is_null($seq)) {
 $PAGE->set_url($currenturl);
 
 $attemptobj = quiz_create_attempt_handling_errors($attemptid, $cmid);
+$attemptobj->preload_all_attempt_step_users();
 
 // Check login.
 require_login($attemptobj->get_course(), false, $attemptobj->get_cm());
index 2e63a89..ffc0697 100644 (file)
@@ -13,19 +13,19 @@ Feature: Allow students to redo questions in a practice quiz, without starting a
       | fullname | shortname | category |
       | Course 1 | C1        | 0        |
     And the following "course enrolments" exist:
-      | user     | course | role    |
-      | student  | C1     | student |
-      | teacher  | C1     | teacher |
+      | user    | course | role    |
+      | student | C1     | student |
+      | teacher | C1     | teacher |
     And the following "question categories" exist:
       | contextlevel | reference | name           |
       | Course       | C1        | Test questions |
     And the following "questions" exist:
-      | questioncategory | qtype       | name  | questiontext    |
-      | Test questions   | truefalse   | TF1   | First question  |
-      | Test questions   | truefalse   | TF2   | Second question |
+      | questioncategory | qtype     | name | questiontext    |
+      | Test questions   | truefalse | TF1  | First question  |
+      | Test questions   | truefalse | TF2  | Second question |
     And the following "activities" exist:
-      | activity   | name   | intro              | course | idnumber | preferredbehaviour | canredoquestions |
-      | quiz       | Quiz 1 | Quiz 1 description | C1     | quiz1    | immediatefeedback  | 1                |
+      | activity | name   | intro              | course | idnumber | preferredbehaviour | canredoquestions |
+      | quiz     | Quiz 1 | Quiz 1 description | C1     | quiz1    | immediatefeedback  | 1                |
     And quiz "Quiz 1" contains the following questions:
       | question | page | maxmark |
       | TF1      | 1    | 2       |
@@ -106,11 +106,11 @@ Feature: Allow students to redo questions in a practice quiz, without starting a
   @javascript
   Scenario: Redoing questions should work with random questions as well
     Given the following "questions" exist:
-      | questioncategory | qtype       | name                    | questiontext |
-      | Test questions   | random      | Random (Test questions) | 0            |
+      | questioncategory | qtype  | name                    | questiontext |
+      | Test questions   | random | Random (Test questions) | 0            |
     And the following "activities" exist:
-      | activity   | name   | intro              | course | idnumber | preferredbehaviour | canredoquestions |
-      | quiz       | Quiz 2 | Quiz 2 description | C1     | quiz2    | immediatefeedback  | 1                |
+      | activity | name   | intro              | course | idnumber | preferredbehaviour | canredoquestions |
+      | quiz     | Quiz 2 | Quiz 2 description | C1     | quiz2    | immediatefeedback  | 1                |
     And quiz "Quiz 2" contains the following questions:
       | question                | page |
       | Random (Test questions) | 1    |
@@ -125,3 +125,27 @@ Feature: Allow students to redo questions in a practice quiz, without starting a
     And I press "Try another question like this one"
     Then I should see "Second question"
     And "Check" "button" should exist
+
+  Scenario: Teachers reviewing can see author of action in review attempt
+    Given the following "questions" exist:
+      | questioncategory | qtype       | name | questiontext                   | answer 1    | grade |
+      | Test questions   | shortanswer | SA1  | Who is author of Harry Potter? | J.K.Rowling | 100%  |
+    And the following "activities" exist:
+      | activity | name   | intro              | course | idnumber |
+      | quiz     | Quiz 2 | Quiz 2 description | C1     | quiz2    |
+    And quiz "Quiz 2" contains the following questions:
+      | question | page |
+      | SA1      | 1    |
+    And user "student" has attempted "Quiz 2" with responses:
+      | slot | response    |
+      | 1    | J.K.Rowling |
+    And I am on the "Quiz 2" "mod_quiz > View" page logged in as "teacher"
+    And I navigate to "Results > Manual grading" in current page administration
+    And I follow "Also show questions that have been graded automatically"
+    When I click on "update grades" "link" in the "SA1" "table_row"
+    Then I set the field "Comment" to "I have adjusted your mark to 1.0"
+    And I set the field "Mark" to "1.0"
+    And I press "Save and go to next page"
+    And I follow "Results"
+    And I follow "Review attempt"
+    And I should see "Teacher One" in the "I have adjusted your mark to 1.0" "table_row"
index b1f21d9..76d7707 100644 (file)
@@ -476,7 +476,10 @@ abstract class question_engine {
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class question_display_options {
-    /**#@+ @var integer named constants for the values that most of the options take. */
+    /**#@+
+     * @var integer named constants for the values that most of the options take.
+     */
+    const SHOW_ALL = -1;
     const HIDDEN = 0;
     const VISIBLE = 1;
     const EDITABLE = 2;
@@ -631,6 +634,11 @@ class question_display_options {
      */
     public $context;
 
+    /**
+     * @var int The option to show the action author in the response history.
+     */
+    public $userinfoinhistory = self::HIDDEN;
+
     /**
      * Set all the feedback-related fields {@link $feedback}, {@link generalfeedback},
      * {@link rightanswer} and {@link manualcomment} to
index 3781806..d16c88b 100644 (file)
@@ -94,6 +94,9 @@ class question_attempt_step {
     /** @var array name => array of {@link stored_file}s. Caches the contents of file areas. */
     private $files = array();
 
+    /** @var stdClass User information. */
+    private $user = null;
+
     /**
      * You should not need to call this constructor in your own code. Steps are
      * normally created by {@link question_attempt} methods like
@@ -172,6 +175,37 @@ class question_attempt_step {
         return $this->userid;
     }
 
+    /**
+     * Update full user information for step.
+     *
+     * @param stdClass $user Full user object.
+     * @throws coding_exception
+     */
+    public function add_full_user_object(stdClass $user): void {
+        if ($user->id != $this->userid) {
+            throw new coding_exception('Wrong user passed to add_full_user_object');
+        }
+        $this->user = $user;
+    }
+
+    /**
+     * Return the full user object.
+     *
+     * @return stdClass Get full user object.
+     */
+    public function get_user(): stdClass {
+        return $this->user;
+    }
+
+    /**
+     * Get full name of user who did action.
+     *
+     * @return string full name of user.
+     */
+    public function get_user_fullname(): string {
+        return fullname($this->user);
+    }
+
     /** @return int the timestamp when this step was created. */
     public function get_timecreated() {
         return $this->timecreated;
index 7991c91..9c5effb 100644 (file)
@@ -967,6 +967,35 @@ class question_usage_by_activity {
 
         return $quba;
     }
+
+    /**
+     * Preload users of all question attempt steps.
+     *
+     * @throws dml_exception
+     */
+    public function preload_all_step_users(): void {
+        global $DB;
+
+        // Get all user ids.
+        $userids = [];
+        foreach ($this->questionattempts as $qa) {
+            foreach ($qa->get_full_step_iterator() as $step) {
+                $userids[$step->get_user_id()] = 1;
+            }
+        }
+
+        // Load user information.
+        $users = $DB->get_records_list('user', 'id', array_keys($userids), '', '*');
+        // Update user information for steps.
+        foreach ($this->questionattempts as $qa) {
+            foreach ($qa->get_full_step_iterator() as $step) {
+                $user = $users[$step->get_user_id()];
+                if (isset($user)) {
+                    $step->add_full_user_object($user);
+                }
+            }
+        }
+    }
 }
 
 
index 3e59ce9..0a3c836 100644 (file)
@@ -471,14 +471,10 @@ class core_question_renderer extends plugin_renderer_base {
 
             $restrictedqa = new question_attempt_with_restricted_history($qa, $i, null);
 
-            $user = new stdClass();
-            $user->id = $step->get_user_id();
-            $row = array(
-                $stepno,
-                userdate($step->get_timecreated(), get_string('strftimedatetimeshort')),
-                s($qa->summarise_action($step)),
-                $restrictedqa->get_state_string($options->correctness),
-            );
+            $row = [$stepno,
+                    userdate($step->get_timecreated(), get_string('strftimedatetimeshort')),
+                    s($qa->summarise_action($step)) . $this->action_author($step, $options),
+                    $restrictedqa->get_state_string($options->correctness)];
 
             if ($options->marks >= question_display_options::MARK_AND_MAX) {
                 $row[] = $qa->format_fraction_as_mark($step->get_fraction(), $options->markdp);
@@ -495,4 +491,20 @@ class core_question_renderer extends plugin_renderer_base {
                         array('class' => 'responsehistoryheader'));
     }
 
+    /**
+     * Action author's profile link.
+     *
+     * @param question_attempt_step $step The step.
+     * @param question_display_options $options The display options.
+     * @return string The link to user's profile.
+     */
+    protected function action_author(question_attempt_step $step, question_display_options $options): string {
+        if ($options->userinfoinhistory && $step->get_user_id() != $options->userinfoinhistory) {
+            return html_writer::link(
+                    new moodle_url('/user/view.php', ['id' => $step->get_user_id(), 'course' => $this->page->course->id]),
+                    $step->get_user_fullname(), ['class' => 'd-table-cell']);
+        } else {
+            return '';
+        }
+    }
 }
index b7a73ab..59139df 100644 (file)
@@ -128,4 +128,51 @@ class question_attempt_step_test extends advanced_testcase {
         $this->assertEquals(array(), $step->get_behaviour_data());
 
     }
+
+
+    /**
+     * Test get_user function.
+     */
+    public function test_get_user() {
+        $this->resetAfterTest(true);
+        $student = $this->getDataGenerator()->create_user();
+
+        $step = new question_attempt_step(array(), 123, $student->id);
+        $step->add_full_user_object($student);
+
+        $this->assertEquals($student, $step->get_user());
+    }
+
+    /**
+     * Test get_user_fullname function.
+     */
+    public function test_get_user_fullname() {
+        $this->resetAfterTest(true);
+        $student = $this->getDataGenerator()->create_user();
+
+        $step = new question_attempt_step(array(), 123, $student->id);
+        $step->add_full_user_object($student);
+
+        $this->assertEquals(fullname($student), $step->get_user_fullname());
+    }
+
+    /**
+     * Test add_full_user_object function.
+     */
+    public function test_add_full_user_object() {
+        $this->resetAfterTest(true);
+        $student1 = $this->getDataGenerator()->create_user();
+        $student2 = $this->getDataGenerator()->create_user();
+
+        $step = new question_attempt_step(array(), 123, $student1->id);
+
+        // Add full user with the valid user.
+        $step->add_full_user_object($student1);
+        $this->assertEquals($student1, $step->get_user());
+
+        // Throw exception with the invalid user.
+        $this->expectException('coding_exception');
+        $this->expectExceptionMessage('Wrong user passed to add_full_user_object');
+        $step->add_full_user_object($student2);
+    }
 }
index 2e9dbe3..f69198a 100644 (file)
@@ -158,4 +158,36 @@ class question_usage_by_activity_test extends advanced_testcase {
         $this->expectException('question_out_of_sequence_exception');
         $quba->process_all_actions($slot, $postdata);
     }
+
+    /**
+     * Test function preload all step users.
+     */
+    public function test_preload_all_step_users() {
+        $this->resetAfterTest();
+        $this->setAdminUser();
+        // Set up.
+        $quba = question_engine::make_questions_usage_by_activity('unit_test',
+                context_system::instance());
+
+        // Create an essay question in the DB.
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
+        $cat = $generator->create_question_category();
+        $essay = $generator->create_question('essay', 'editorfilepicker', ['category' => $cat->id]);
+
+        // Start attempt at the question.
+        $q = question_bank::load_question($essay->id);
+        $quba->set_preferred_behaviour('deferredfeedback');
+        $slot = $quba->add_question($q, 10);
+        $quba->start_question($slot, 1);
+
+        // Finish the attempt.
+        $quba->finish_all_questions();
+        question_engine::save_questions_usage_by_activity($quba);
+
+        // The user information of question attempt step should be loaded.
+        $quba->preload_all_step_users();
+        $qa = $quba->get_attempt_iterator()->current();
+        $steps = $qa->get_full_step_iterator();
+        $this->assertEquals('Admin User', $steps[0]->get_user_fullname());
+    }
 }
index 79fd021..2b63144 100644 (file)
@@ -60,4 +60,72 @@ class core_question_walkthrough_testcase extends qbehaviour_walkthrough_test_bas
         // Verify the flag has not been lost.
         $this->assertTrue($this->get_question_attempt()->is_flagged());
     }
+
+    /**
+     * Test action_author function.
+     */
+    public function test_action_author_with_display_options_testcase() {
+        $this->resetAfterTest(true);
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
+        $teacher = $this->getDataGenerator()->create_user();
+        $student = $this->getDataGenerator()->create_user();
+
+        // Create an essay question in the DB.
+        $cat = $generator->create_question_category();
+        $essay = $generator->create_question('essay', 'editorfilepicker', ['category' => $cat->id]);
+
+        // Start attempt at the question.
+        $q = question_bank::load_question($essay->id);
+
+        // Student attempt the question.
+        $this->setUser($student);
+        $this->start_attempt_at_question($q, 'deferredfeedback', 10, 1);
+
+        // Simulate some data submitted by the student.
+        $this->process_submission(['answer' => 'This is my wonderful essay!', 'answerformat' => FORMAT_HTML]);
+        $this->finish();
+
+        // Process a manual comment.
+        $this->setUser($teacher);
+        $this->manual_grade('Not good enough!', 10, FORMAT_HTML);
+        $this->render();
+        $this->save_quba();
+
+        // Set display option userinfoinhistory to HIDDEN.
+        $displayoptions = new question_display_options();
+        $displayoptions->history = question_display_options::VISIBLE;
+        $displayoptions->userinfoinhistory = question_display_options::HIDDEN;
+
+        $this->load_quba();
+        $result = $this->quba->render_question($this->slot, $displayoptions);
+
+        // The profile user link should not display.
+        preg_match("/<a ?.*>(.*)<\/a>/", $result, $matches);
+        $this->assertEquals(false, isset($matches[0]));
+
+        // Set display option userinfoinhistory to SHOW_ALL.
+        $displayoptions = new question_display_options();
+        $displayoptions->history = question_display_options::VISIBLE;
+        $displayoptions->userinfoinhistory = question_display_options::SHOW_ALL;
+
+        $this->load_quba();
+        $result = $this->quba->render_question($this->slot, $displayoptions);
+        $numsteps = $this->quba->get_question_attempt($this->slot)->get_num_steps();
+
+        // All steps in the result should contain user profile link.
+        preg_match_all("/<a ?.*>(.*)<\/a>/", $result, $matches);
+        $this->assertEquals($numsteps, count($matches[0]));
+
+        // Set the userinfoinhistory to student id.
+        $displayoptions = new question_display_options();
+        $displayoptions->history = question_display_options::VISIBLE;
+        $displayoptions->userinfoinhistory = $student->id;
+
+        $this->load_quba();
+        $result = $this->quba->render_question($this->slot, $displayoptions);
+
+        // The step just show the user profile link if the step's userid is different with student id.
+        preg_match_all("/<a ?.*>(.*)<\/a>/", $result, $matches);
+        $this->assertEquals(1, count($matches[0]));
+    }
 }
index c54e06d..fa93e6e 100644 (file)
@@ -88,11 +88,12 @@ class qtype_numerical_edit_form extends question_edit_form {
         );
         $mform->addElement('select', 'unitrole',
                 get_string('unithandling', 'qtype_numerical'), $unitoptions);
+        $mform->setDefault('unitrole', $this->get_default_value('unitrole', qtype_numerical::UNITNONE));
 
         $penaltygrp = array();
         $penaltygrp[] = $mform->createElement('float', 'unitpenalty',
                 get_string('unitpenalty', 'qtype_numerical'), array('size' => 6));
-        $mform->setDefault('unitpenalty', 0.1000000);
+        $mform->setDefault('unitpenalty', $this->get_default_value('unitpenalty', 0.1000000));
 
         $unitgradingtypes = array(
             qtype_numerical::UNITGRADEDOUTOFMARK =>
@@ -101,7 +102,8 @@ class qtype_numerical_edit_form extends question_edit_form {
                     get_string('decfractionofquestiongrade', 'qtype_numerical'),
         );
         $penaltygrp[] = $mform->createElement('select', 'unitgradingtypes', '', $unitgradingtypes);
-        $mform->setDefault('unitgradingtypes', 1);
+        $mform->setDefault('unitgradingtypes',
+                $this->get_default_value('unitgradingtypes', qtype_numerical::UNITGRADEDOUTOFMARK));
 
         $mform->addGroup($penaltygrp, 'penaltygrp',
                 get_string('unitpenalty', 'qtype_numerical'), ' ', false);
@@ -114,6 +116,8 @@ class qtype_numerical_edit_form extends question_edit_form {
         );
         $mform->addElement('select', 'multichoicedisplay',
                 get_string('studentunitanswer', 'qtype_numerical'), $unitinputoptions);
+        $mform->setDefault('multichoicedisplay',
+                $this->get_default_value('multichoicedisplay', qtype_numerical::UNITINPUT));
 
         $unitsleftoptions = array(
             0 => get_string('rightexample', 'qtype_numerical'),
@@ -121,7 +125,7 @@ class qtype_numerical_edit_form extends question_edit_form {
         );
         $mform->addElement('select', 'unitsleft',
                 get_string('unitposition', 'qtype_numerical'), $unitsleftoptions);
-        $mform->setDefault('unitsleft', 0);
+        $mform->setDefault('unitsleft', $this->get_default_value('unitsleft', 0));
 
         $mform->disabledIf('penaltygrp', 'unitrole', 'eq', qtype_numerical::UNITNONE);
         $mform->disabledIf('penaltygrp', 'unitrole', 'eq', qtype_numerical::UNITOPTIONAL);
index 25754a9..ccf62b1 100644 (file)
@@ -157,6 +157,15 @@ class qtype_numerical extends question_type {
         return true;
     }
 
+    public function save_defaults_for_new_questions(stdClass $fromform): void {
+        parent::save_defaults_for_new_questions($fromform);
+        $this->set_default_value('unitrole', $fromform->unitrole);
+        $this->set_default_value('unitpenalty', $fromform->unitpenalty);
+        $this->set_default_value('unitgradingtypes', $fromform->unitgradingtypes);
+        $this->set_default_value('multichoicedisplay', $fromform->multichoicedisplay);
+        $this->set_default_value('unitsleft', $fromform->unitsleft);
+    }
+
     /**
      * Save the units and the answers associated with this question.
      */
index 8133a89..83da6e3 100644 (file)
@@ -37,3 +37,37 @@ Feature: Test creating a Numerical question
       | id_tolerance_2                     | 0                                      |
       | id_fraction_2                      | 100%                                   |
     Then I should see "Numerical-001"
+
+  @javascript
+  Scenario: Create a Numerical question with units
+    Given I add a "Numerical" question filling the form with:
+      | Question name                      | Numerical-002                               |
+      | Question text                      | How many meter is 1m + 20cm + 50mm?         |
+      | Default mark                       | 1                                           |
+      | General feedback                   | The correct answer is 1.25m                 |
+      | id_answer_0                        | 1.25                                        |
+      | id_tolerance_0                     | 0                                           |
+      | id_fraction_0                      | 100%                                        |
+      | id_answer_1                        | 125                                         |
+      | id_tolerance_1                     | 0                                           |
+      | id_fraction_1                      | 0%                                          |
+      | id_answer_2                        | 1250                                        |
+      | id_tolerance_2                     | 0                                           |
+      | id_fraction_2                      | 0%                                          |
+      | id_unitrole                        | The unit must be given, and will be graded. |
+      | id_unitpenalty                     | 0.15                                        |
+      | id_unitgradingtypes                | as a fraction (0-1) of the question grade   |
+      | id_unitsleft                       | on the right, for example 1.00cm or 1.00km  |
+      | id_multichoicedisplay              | a drop-down menu                            |
+      | id_unit_0                          | m                                           |
+    And I should see "Numerical-002"
+    # Checking that the next new question form displays user preferences settings.
+    When I press "Create a new question ..."
+    And I set the field "item_qtype_numerical" to "1"
+    And I click on "Add" "button" in the "Choose a question type to add" "dialogue"
+    Then the following fields match these values:
+      | id_unitrole                        | The unit must be given, and will be graded. |
+      | id_unitpenalty                     | 0#15                                        |
+      | id_unitgradingtypes                | as a fraction (0-1) of the question grade   |
+      | id_multichoicedisplay              | a drop-down menu                            |
+      | id_unitsleft                       | on the right, for example 1.00cm or 1.00km  |
index 294fcbd..cc875a4 100644 (file)
@@ -49,7 +49,7 @@ class repository_googledocs_generator extends testing_repository_generator {
             'image' => 'https://accounts.google.com/favicon.ico',
             'baseurl' => 'https://accounts.google.com/',
             'loginparamsoffline' => 'access_type=offline&prompt=consent',
-            'showonloginpage' => true
+            'showonloginpage' => issuer::EVERYWHERE
         ];
 
         $issuer = new issuer(0, $issuerrecord);
index 4da6d10..5dd5232 100644 (file)
@@ -29,7 +29,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$version  = 2021052500.81;              // YYYYMMDD      = weekly release date of this DEV branch.
+$version  = 2021052500.83;              // YYYYMMDD      = weekly release date of this DEV branch.
                                         //         RR    = release increments - 00 in DEV branches.
                                         //           .XX = incremental changes.
 $release  = '4.0dev (Build: 20210420)'; // Human-friendly version name