Merge branch 'MDL-69166-310-4' of git://github.com/rezaies/moodle into MOODLE_310_STABLE
authorAndrew Nicols <andrew@nicols.co.uk>
Tue, 27 Oct 2020 04:59:25 +0000 (12:59 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Tue, 27 Oct 2020 04:59:25 +0000 (12:59 +0800)
72 files changed:
admin/settings/courses.php
backup/moodle2/backup_activity_task.class.php
backup/moodle2/restore_activity_task.class.php
backup/util/ui/base_moodleform.class.php
badges/backpack-add.php
badges/backpack-connect.php
badges/backpack-export.php
badges/backpackemailverify.php
badges/backpacks.php
badges/classes/backpack_api.php
badges/classes/backpack_api_mapping.php
badges/classes/form/backpack.php
badges/classes/form/external_backpack.php
badges/mybackpack.php
badges/renderer.php
badges/tests/badgeslib_test.php
badges/tests/behat/backpack.feature
badges/upgrade.txt
course/amd/build/downloadcontent.min.js [new file with mode: 0644]
course/amd/build/downloadcontent.min.js.map [new file with mode: 0644]
course/amd/src/downloadcontent.js [new file with mode: 0644]
course/classes/output/content_export_link.php [new file with mode: 0644]
course/downloadcontent.php [new file with mode: 0644]
course/edit_form.php
course/lib.php
course/tests/behat/course_download_content.feature [new file with mode: 0644]
course/tests/behat/course_download_content_permissions.feature [new file with mode: 0644]
course/view.php
lang/en/admin.php
lang/en/badges.php
lang/en/course.php
lang/en/deprecated.txt
lang/en/moodle.php
lang/en/role.php
lib/amd/build/tree.min.js
lib/amd/build/tree.min.js.map
lib/amd/src/tree.js
lib/badgeslib.php
lib/classes/content.php
lib/db/access.php
lib/db/install.xml
lib/db/upgrade.php
lib/navigationlib.php
lib/pagelib.php
lib/tests/content_test.php [new file with mode: 0644]
mod/lti/amd/build/tool_card_controller.min.js
mod/lti/amd/build/tool_card_controller.min.js.map
mod/lti/amd/build/tool_configure_controller.min.js
mod/lti/amd/build/tool_configure_controller.min.js.map
mod/lti/amd/src/tool_card_controller.js
mod/lti/amd/src/tool_configure_controller.js
mod/lti/certs.php
mod/lti/classes/local/ltiopenid/jwks_helper.php [new file with mode: 0644]
mod/lti/classes/local/ltiopenid/registration_exception.php [new file with mode: 0644]
mod/lti/classes/local/ltiopenid/registration_helper.php [new file with mode: 0644]
mod/lti/classes/local/ltiservice/service_base.php
mod/lti/lang/en/lti.php
mod/lti/locallib.php
mod/lti/openid-configuration.php [new file with mode: 0644]
mod/lti/openid-registration.php [new file with mode: 0644]
mod/lti/service/basicoutcomes/classes/local/service/basicoutcomes.php
mod/lti/service/gradebookservices/classes/local/service/gradebookservices.php
mod/lti/service/memberships/classes/local/service/memberships.php
mod/lti/service/toolsettings/classes/local/service/toolsettings.php
mod/lti/startltiadvregistration.php [new file with mode: 0644]
mod/lti/templates/cartridge_registration_form.mustache
mod/lti/templates/external_registration.mustache
mod/lti/templates/loader.mustache
mod/lti/templates/tool_configure.mustache
mod/lti/tests/behat/toolconfigure.feature
mod/lti/tests/openidregistration_test.php [new file with mode: 0644]
version.php

index 6b53eb0..c0da1aa 100644 (file)
@@ -22,6 +22,8 @@
  * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
+use core_admin\local\settings\filesize;
+
 $capabilities = array(
     'moodle/backup:backupcourse',
     'moodle/category:manage',
@@ -75,6 +77,17 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) {
     $temp->add(new admin_setting_configselect('moodlecourse/visible', new lang_string('visible'), new lang_string('visible_help'),
         1, $choices));
 
+    // Enable/disable download course content.
+    $choices = [
+        DOWNLOAD_COURSE_CONTENT_DISABLED => new lang_string('no'),
+        DOWNLOAD_COURSE_CONTENT_ENABLED => new lang_string('yes'),
+    ];
+    $downloadcontentsitedefault = new admin_setting_configselect('moodlecourse/downloadcontentsitedefault',
+            new lang_string('enabledownloadcoursecontent', 'course'),
+            new lang_string('downloadcoursecontent_help', 'course'), 0, $choices);
+    $downloadcontentsitedefault->add_dependent_on('downloadcoursecontentallowed');
+    $temp->add($downloadcontentsitedefault);
+
     // Course format.
     $temp->add(new admin_setting_heading('courseformathdr', new lang_string('type_format', 'plugin'), ''));
 
@@ -161,6 +174,21 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) {
 
     $ADMIN->add('courses', $temp);
 
+    // Download course content.
+    $downloadcoursedefaulturl = new moodle_url('/admin/settings.php', ['section' => 'coursesettings']);
+    $temp = new admin_settingpage('downloadcoursecontent', new lang_string('downloadcoursecontent', 'course'));
+    $temp->add(new admin_setting_configcheckbox('downloadcoursecontentallowed',
+            new lang_string('downloadcoursecontentallowed', 'admin'),
+            new lang_string('downloadcoursecontentallowed_desc', 'admin', $downloadcoursedefaulturl->out()), 0));
+
+    // 50MB default maximum size per file when downloading course content.
+    $defaultmaxdownloadsize = 50 * filesize::UNIT_MB;
+    $temp->add(new filesize('maxsizeperdownloadcoursefile', new lang_string('maxsizeperdownloadcoursefile', 'admin'),
+            new lang_string('maxsizeperdownloadcoursefile_desc', 'admin'), $defaultmaxdownloadsize, filesize::UNIT_MB));
+    $temp->hide_if('maxsizeperdownloadcoursefile', 'downloadcoursecontentallowed');
+
+    $ADMIN->add('courses', $temp);
+
     // "courserequests" settingpage.
     $temp = new admin_settingpage('courserequest', new lang_string('courserequest'));
     $temp->add(new admin_setting_configcheckbox('enablecourserequests',
index b1da9bb..5567d91 100644 (file)
@@ -272,7 +272,7 @@ abstract class backup_activity_task extends backup_task {
         $settingname = $settingprefix . 'included';
         $activity_included = new backup_activity_generic_setting($settingname, base_setting::IS_BOOLEAN, true);
         $activity_included->get_ui()->set_icon(new image_icon('icon', get_string('pluginname', $this->modulename),
-            $this->modulename, array('class' => 'iconlarge icon-post')));
+            $this->modulename, array('class' => 'iconlarge icon-post ml-1')));
         $this->add_setting($activity_included);
         // Look for "activities" root setting
         $activities = $this->plan->get_setting('activities');
index 6d5e6a5..9e0cd35 100644 (file)
@@ -297,7 +297,7 @@ abstract class restore_activity_task extends restore_task {
         $settingname = $settingprefix . 'included';
         $activity_included = new restore_activity_generic_setting($settingname, base_setting::IS_BOOLEAN, true);
         $activity_included->get_ui()->set_icon(new image_icon('icon', get_string('pluginname', $this->modulename),
-            $this->modulename, array('class' => 'iconlarge icon-post')));
+            $this->modulename, array('class' => 'iconlarge icon-post ml-1')));
         $this->add_setting($activity_included);
         // Look for "activities" root setting
         $activities = $this->plan->get_setting('activities');
index b37aff2..3915516 100644 (file)
@@ -317,7 +317,7 @@ abstract class base_moodleform extends moodleform {
             $label = format_string($settingui->get_label($task), true, array('context' => $context));
             $labelicon = $settingui->get_icon();
             if (!empty($labelicon)) {
-                $label .= '&nbsp;'.$OUTPUT->render($labelicon);
+                $label .= $OUTPUT->render($labelicon);
             }
             $this->_form->addElement('static', 'static_'.$settingui->get_name(), $label, $settingui->get_static_value().$icon);
             $this->_form->addElement('html', html_writer::end_tag('div'));
index 9c57520..2e2478b 100644 (file)
 require_once(__DIR__ . '/../config.php');
 require_once($CFG->libdir . '/badgeslib.php');
 
-if (badges_open_badges_backpack_api() != OPEN_BADGES_V2) {
+require_login();
+
+$userbackpack = badges_get_user_backpack();
+if (badges_open_badges_backpack_api($userbackpack->id) != OPEN_BADGES_V2) {
     throw new coding_exception('No backpacks support Open Badges V2.');
 }
 
-require_login();
-
 $id = required_param('hash', PARAM_ALPHANUM);
 
 $PAGE->set_url('/badges/backpack-add.php', array('hash' => $id));
@@ -44,53 +45,111 @@ if (!empty($issuedbadge->recipient->id)) {
     // * Create badge
     // * Create assertion (Award the badge!)
 
+    // With the introduction OBv2.1 and MDL-65959 to allow cross region Badgr imports the above (old) procedure will
+    // only be completely performed if both the site and user backpacks conform to the same apiversion.
+    // Else we will attempt at pushing the assertion to the user's backpack. In this case, the id set against the assertion
+    // has to be a publicly accessible resource.
+
     // Get the backpack.
     $badgeid = $issuedbadge->badgeid;
     $badge = new badge($badgeid);
     $backpack = $DB->get_record('badge_backpack', array('userid' => $USER->id));
-    $sitebackpack = badges_get_site_backpack($backpack->externalbackpackid);
-    $assertion = new core_badges_assertion($id, $sitebackpack->apiversion);
-    $api = new \core_badges\backpack_api($sitebackpack);
-    $api->authenticate();
+    $userbackpack = badges_get_site_backpack($backpack->externalbackpackid, $USER->id);
+    $assertion = new core_badges_assertion($id, OPEN_BADGES_V2);
+    $assertiondata = $assertion->get_badge_assertion(false, false);
+    $assertionid = $assertion->get_assertion_hash();
+    $assertionentityid = $assertiondata['id'];
+    $badgeadded = false;
+    if (badges_open_badges_backpack_api() == OPEN_BADGES_V2) {
+        $sitebackpack = badges_get_site_primary_backpack();
+        $api = new \core_badges\backpack_api($sitebackpack);
+        $response = $api->authenticate();
 
-    // Create issuer.
-    $issuer = $assertion->get_issuer();
-    if (!($issuerentityid = badges_external_get_mapping($sitebackpack->id, OPEN_BADGES_V2_TYPE_ISSUER, $issuer['email']))) {
-        $response = $api->put_issuer($issuer);
-        if (!$response) {
-            throw new moodle_exception('invalidrequest', 'error');
-        }
-        $issuerentityid = $response->id;
-        badges_external_create_mapping($sitebackpack->id, OPEN_BADGES_V2_TYPE_ISSUER, $issuer['email'], $issuerentityid);
-    }
-    // Create badge.
-    $badge = $assertion->get_badge_class(false);
-    $badgeid = $assertion->get_badge_id();
-    if (!($badgeentityid = badges_external_get_mapping($sitebackpack->id, OPEN_BADGES_V2_TYPE_BADGE, $badgeid))) {
-        $response = $api->put_badgeclass($issuerentityid, $badge);
-        if (!$response) {
-            throw new moodle_exception('invalidrequest', 'error');
-        }
-        $badgeentityid = $response->id;
-        badges_external_create_mapping($sitebackpack->id, OPEN_BADGES_V2_TYPE_BADGE, $badgeid, $badgeentityid);
-    }
+        // A numeric response indicates a valid successful authentication. Else an error object will be returned.
+        if (is_numeric($response)) {
+            // Create issuer.
+            $issuer = $assertion->get_issuer();
+            if (!($issuerentityid = badges_external_get_mapping($sitebackpack->id, OPEN_BADGES_V2_TYPE_ISSUER, $issuer['email']))) {
+                $response = $api->put_issuer($issuer);
+                if (!$response) {
+                    throw new moodle_exception('invalidrequest', 'error');
+                }
+                $issuerentityid = $response->id;
+                badges_external_create_mapping($sitebackpack->id, OPEN_BADGES_V2_TYPE_ISSUER, $issuer['email'],
+                    $issuerentityid);
+            }
+            // Create badge.
+            $badge = $assertion->get_badge_class(false);
+            $badgeid = $assertion->get_badge_id();
+            if (!($badgeentityid = badges_external_get_mapping($sitebackpack->id, OPEN_BADGES_V2_TYPE_BADGE, $badgeid))) {
+                $response = $api->put_badgeclass($issuerentityid, $badge);
+                if (!$response) {
+                    throw new moodle_exception('invalidrequest', 'error');
+                }
+                $badgeentityid = $response->id;
+                badges_external_create_mapping($sitebackpack->id, OPEN_BADGES_V2_TYPE_BADGE, $badgeid,
+                    $badgeentityid);
+            }
 
-    // Create assertion (Award the badge!).
-    $assertiondata = $assertion->get_badge_assertion(false, false);
+            // Create assertion (Award the badge!).
+            $assertionentityid = badges_external_get_mapping(
+                $sitebackpack->id,
+                OPEN_BADGES_V2_TYPE_ASSERTION,
+                $assertionid
+            );
 
-    $assertionid = $assertion->get_assertion_hash();
+            if (!$assertionentityid && strpos($sitebackpack->backpackapiurl, 'badgr')) {
+                $assertionentityid = badges_generate_badgr_open_url(
+                    $sitebackpack,
+                    OPEN_BADGES_V2_TYPE_ASSERTION,
+                    $assertionentityid
+                );
+            }
 
-    if (!($assertionentityid = badges_external_get_mapping($sitebackpack->id, OPEN_BADGES_V2_TYPE_ASSERTION, $assertionid))) {
-        $response = $api->put_badgeclass_assertion($badgeentityid, $assertiondata);
+            // Create an assertion for the recipient in the issuer's account.
+            if (!$assertionentityid) {
+                $response = $api->put_badgeclass_assertion($badgeentityid, $assertiondata);
+                if (!$response) {
+                    throw new moodle_exception('invalidrequest', 'error');
+                }
+                $assertionentityid = badges_generate_badgr_open_url($sitebackpack, OPEN_BADGES_V2_TYPE_ASSERTION, $response->id);
+                $badgeadded = true;
+                badges_external_create_mapping($sitebackpack->id, OPEN_BADGES_V2_TYPE_ASSERTION, $assertionid,
+                    $response->id);
+            } else {
+                // An assertion already exists. Make sure it's up to date.
+                $internalid = badges_external_get_mapping(
+                    $sitebackpack->id,
+                    OPEN_BADGES_V2_TYPE_ASSERTION,
+                    $assertionid,
+                    'externalid'
+                );
+                $response = $api->update_assertion($internalid, $assertiondata);
+                if (!$response) {
+                    throw new moodle_exception('invalidrequest', 'error');
+                }
+            }
+        }
+    }
+
+    // Now award/upload the badge to the user's account.
+    // - If a user and site backpack have the same provider we can skip this as Badgr automatically maps recipients
+    // based on email address.
+    // - This is only needed when the backpacks are from different regions.
+    if ($assertionentityid && !badges_external_get_mapping($userbackpack->id, OPEN_BADGES_V2_TYPE_ASSERTION, $assertionid)) {
+        $userapi = new \core_badges\backpack_api($userbackpack, $backpack);
+        $userapi->authenticate();
+        $response = $userapi->import_badge_assertion($assertionentityid);
         if (!$response) {
             throw new moodle_exception('invalidrequest', 'error');
         }
         $assertionentityid = $response->id;
-        badges_external_create_mapping($sitebackpack->id, OPEN_BADGES_V2_TYPE_ASSERTION, $assertionid, $assertionentityid);
-        $response = ['success' => 'addedtobackpack'];
-    } else {
-        $response = ['warning' => 'existsinbackpack'];
+        $badgeadded = true;
+        badges_external_create_mapping($userbackpack->id, OPEN_BADGES_V2_TYPE_ASSERTION, $assertionid,
+            $assertionentityid);
     }
+
+    $response = $badgeadded ? ['success' => 'addedtobackpack'] : ['warning' => 'existsinbackpack'];
     redirect(new moodle_url('/badges/mybadges.php', $response));
 } else {
     redirect(new moodle_url('/badges/mybadges.php'));
index cece808..ff77f49 100644 (file)
 require_once(__DIR__ . '/../config.php');
 require_once($CFG->libdir . '/badgeslib.php');
 
+$backpackid = required_param('backpackid', PARAM_INT);
 $scope = optional_param('scope', '', PARAM_RAW);
 $action = optional_param('action', null, PARAM_RAW);
 
-if (badges_open_badges_backpack_api() != OPEN_BADGES_V2P1) {
+if (badges_open_badges_backpack_api($backpackid) != OPEN_BADGES_V2P1) {
     throw new coding_exception('backpacks only support Open Badges V2.1');
 }
 
 require_login();
 
-$externalbackpack = badges_get_site_backpack($CFG->badges_site_backpack);
+$externalbackpack = badges_get_site_backpack($backpackid);
 $persistedissuer = \core\oauth2\issuer::get_record(['id' => $externalbackpack->oauth2_issuerid]);
 if ($persistedissuer) {
     $issuer = new \core\oauth2\issuer($externalbackpack->oauth2_issuerid);
     $returnurl = new moodle_url('/badges/backpack-connect.php',
-        ['action' => 'authorization', 'sesskey' => sesskey()]);
+        ['action' => 'authorization', 'sesskey' => sesskey(), 'backpackid' => $backpackid]);
 
     $client = new core_badges\oauth2\client($issuer, $returnurl, $scope, $externalbackpack);
     if ($client) {
index e1015b7..60a0ace 100644 (file)
@@ -25,9 +25,6 @@
 require_once(__DIR__ . '/../config.php');
 require_once($CFG->libdir . '/badgeslib.php');
 
-if (badges_open_badges_backpack_api() != OPEN_BADGES_V2P1) {
-    throw new coding_exception('backpacks only support Open Badges V2.1');
-}
 $hash = optional_param('hash', null, PARAM_RAW);
 
 $PAGE->set_pagelayout('admin');
@@ -37,7 +34,12 @@ require_login();
 if (empty($CFG->badges_allowexternalbackpack) || empty($CFG->enablebadges)) {
     redirect($CFG->wwwroot);
 }
-$backpack = badges_get_site_backpack($CFG->badges_site_backpack);
+
+$backpack = badges_get_user_backpack();
+if (badges_open_badges_backpack_api($backpack->id) != OPEN_BADGES_V2P1) {
+    throw new coding_exception('backpacks only support Open Badges V2.1');
+}
+
 $userbadges = badges_get_user_badges($USER->id);
 $context = context_user::instance($USER->id);
 
@@ -50,7 +52,6 @@ $PAGE->set_pagelayout('standard');
 
 $redirecturl = new moodle_url('/badges/mybadges.php');
 if ($hash) {
-    $backpack = badges_get_site_backpack($CFG->badges_site_backpack);
     $api = new core_badges\backpack_api2p1($backpack);
     $notify = $api->put_assertions($hash);
     if (!empty($notify['status']) && $notify['status'] == \core\output\notification::NOTIFY_SUCCESS) {
index 9b563ff..73edaa4 100644 (file)
@@ -54,19 +54,21 @@ if (!is_null($storedsecret)) {
                 null, \core\output\notification::NOTIFY_ERROR);
         }
 
-        $obj = new stdClass();
-        $obj->userid = $USER->id;
-        $obj->email = $data->email;
-        $obj->externalbackpackid = $backpackid;
-        $obj->backpackuid = $backpackuid;
-        $obj->autosync = 0;
-        $obj->password = $password;
-
-        $DB->insert_record('badge_backpack', $obj);
+        $values = [
+            'userid' => $USER->id,
+            'backpackemail' => $data->email,
+            'externalbackpackid' => $backpackid,
+            'backpackuid' => $backpackuid,
+            'autosync' => 0,
+            'password' => $password
+        ];
+        badges_save_backpack_credentials((object) $values);
 
         // Remove the verification vars and redirect to the mypackpack page.
         unset_user_preference('badges_email_verify_secret');
         unset_user_preference('badges_email_verify_address');
+        unset_user_preference('badges_email_verify_backpackid');
+        unset_user_preference('badges_email_verify_password');
         redirect(new moodle_url($redirect), get_string('backpackemailverifysuccess', 'badges'),
             null, \core\output\notification::NOTIFY_SUCCESS);
     } else {
index 74beadd..635a164 100644 (file)
@@ -69,7 +69,11 @@ if ($action == 'edit') {
     } else if ($data = $form->get_data()) {
         require_sesskey();
         if (!empty($data->id)) {
-            badges_update_site_backpack($data->id, $data);
+            $id = $data->id;
+            badges_update_site_backpack($id, $data);
+            // Apart from the password, any change here would result in an error in other parts of the badge systems.
+            // In order to negate this, we restart any further mapping from scratch.
+            badges_external_delete_mappings($id);
         } else {
             badges_create_site_backpack($data);
         }
index 0916dea..e0db003 100644 (file)
@@ -87,17 +87,13 @@ class backpack_api {
         global $CFG;
         $admin = get_admin();
 
-        $this->backpackapiurl = $sitebackpack->backpackapiurl;
         $this->backpackapiurl = $sitebackpack->backpackapiurl;
         $this->backpackapiversion = $sitebackpack->apiversion;
         $this->password = $sitebackpack->password;
-        $this->email = !empty($CFG->badges_defaultissuercontact) ? $CFG->badges_defaultissuercontact : '';
+        $this->email = $sitebackpack->backpackemail;
         $this->isuserbackpack = false;
         $this->backpackid = $sitebackpack->id;
         if (!empty($userbackpack)) {
-            if ($userbackpack->externalbackpackid != $sitebackpack->id) {
-                throw new coding_exception('Incorrect backpack');
-            }
             $this->isuserbackpack = true;
             $this->password = $userbackpack->password;
             $this->email = $userbackpack->email;
@@ -152,6 +148,21 @@ class backpack_api {
                     true,                                       // JSON Encoded.
                     true                                        // Auth required.
                 ];
+                $mapping[] = [
+                    'importbadge',                                // Action.
+                    // Badgr.io does not return the public information about a badge
+                    // if the issuer is associated with another user. We need to pass
+                    // the expand parameters which are not in any specification to get
+                    // additional information about the assertion in a single request.
+                    '[URL]/backpack/import',
+                    ['url' => '[PARAM]'],  // Post params.
+                    '',                                             // Request exporter.
+                    'core_badges\external\assertion_exporter',      // Response exporter.
+                    false,                                          // Multiple.
+                    'post',                                         // Method.
+                    true,                                           // JSON Encoded.
+                    true                                            // Auth required.
+                ];
                 $mapping[] = [
                     'badges',                                   // Action.
                     '[URL]/backpack/collections/[PARAM1]',      // URL
@@ -214,6 +225,17 @@ class backpack_api {
                     true,                                       // JSON Encoded.
                     true                                        // Auth required.
                 ];
+                $mapping[] = [
+                    'updateassertion',                                // Action.
+                    '[URL]/assertions/[PARAM2]?expand=badgeclass&expand=issuer',
+                    '[PARAM]',                                  // Post params.
+                    'core_badges\external\assertion_exporter', // Request exporter.
+                    'core_badges\external\assertion_exporter', // Response exporter.
+                    false,                                      // Multiple.
+                    'put',                                     // Method.
+                    true,                                       // JSON Encoded.
+                    true                                        // Auth required.
+                ];
                 foreach ($mapping as $map) {
                     $map[] = false; // Site api function.
                     $map[] = OPEN_BADGES_V2; // V2 function.
@@ -408,6 +430,38 @@ class backpack_api {
         return $this->curl_request('assertions', null, $entityid, $data);
     }
 
+    /**
+     * Update a badgeclass assertion.
+     *
+     * @param string $entityid The id of the badge class.
+     * @param array $data The structure of the badge class assertion.
+     * @return mixed
+     */
+    public function update_assertion(string $entityid, array $data) {
+        // V2 Only.
+        if ($this->backpackapiversion == OPEN_BADGES_V1) {
+            throw new coding_exception('Not supported in this backpack API');
+        }
+
+        return $this->curl_request('updateassertion', null, $entityid, $data);
+    }
+
+    /**
+     * Import a badge assertion into a backpack. This is used to handle cross domain backpacks.
+     *
+     * @param string $data The structure of the badge class assertion.
+     * @return mixed
+     * @throws coding_exception
+     */
+    public function import_badge_assertion(string $data) {
+        // V2 Only.
+        if ($this->backpackapiversion == OPEN_BADGES_V1) {
+            throw new coding_exception('Not supported in this backpack API');
+        }
+
+        return $this->curl_request('importbadge', null, null, $data);
+    }
+
     /**
      * Select collections from a backpack.
      *
index efd57c4..aa9155c 100644 (file)
@@ -209,6 +209,8 @@ class backpack_api_mapping {
                 } else if ($value == '[PASSWORD]') {
                     $value = $password;
                     $request[$key] = $value;
+                } else if ($value == '[PARAM]') {
+                    $request[$key] = is_array($param) ? $param[0] : $param;
                 }
             }
         }
@@ -312,6 +314,7 @@ class backpack_api_mapping {
         return array(
             'FRESH_CONNECT'     => true,
             'RETURNTRANSFER'    => true,
+            'FOLLOWLOCATION'    => true,
             'FORBID_REUSE'      => true,
             'HEADER'            => 0,
             'CONNECTTIMEOUT'    => 3,
@@ -359,6 +362,8 @@ class backpack_api_mapping {
             $response = $curl->get($url, $post, $options);
         } else if ($this->method == 'post') {
             $response = $curl->post($url, $post, $options);
+        } else if ($this->method == 'put') {
+            $response = $curl->put($url, $post, $options);
         }
         $response = json_decode($response);
         if (isset($response->result)) {
index 6c7370c..6310b4c 100644 (file)
@@ -39,7 +39,7 @@ use stdClass;
  * Form to edit backpack initial details.
  *
  */
-class backpack extends moodleform {
+class backpack extends external_backpack {
 
     /**
      * Defines the form
@@ -47,30 +47,66 @@ class backpack extends moodleform {
     public function definition() {
         global $USER, $PAGE, $OUTPUT, $CFG;
         $mform = $this->_form;
+        $this->_customdata['userbackpack'] = 1;
 
         $mform->addElement('html', html_writer::tag('span', '', array('class' => 'notconnected', 'id' => 'connection-error')));
         $mform->addElement('header', 'backpackheader', get_string('backpackconnection', 'badges'));
         $mform->addHelpButton('backpackheader', 'backpackconnection', 'badges');
         $mform->addElement('hidden', 'userid', $USER->id);
         $mform->setType('userid', PARAM_INT);
-        $sitebackpack = badges_get_site_backpack($CFG->badges_site_backpack);
-
+        $freeze = [];
         if (isset($this->_customdata['email'])) {
             // Email will be passed in when we're in the process of verifying the user's email address,
             // so set the connection status, lock the email field, and provide options to resend the verification
             // email or cancel the verification process entirely and start over.
-            $mform->addElement('hidden', 'backpackid', $sitebackpack->id);
-            $mform->setType('backpackid', PARAM_INT);
+            $freeze = ['backpackemail'];
+            $mform->addElement('hidden', 'password', $this->_customdata['backpackpassword']);
+            $mform->setType('password', PARAM_RAW);
+            $mform->addElement('hidden', 'externalbackpackid', $this->_customdata['backpackid']);
+            $mform->setType('externalbackpackid', PARAM_INT);
             $status = html_writer::tag('span', get_string('backpackemailverificationpending', 'badges'),
                 array('class' => 'notconnected', 'id' => 'connection-status'));
-            $mform->addElement('static', 'status', get_string('status'), $status);
-            $mform->addElement('hidden', 'email', $this->_customdata['email']);
-            $mform->setType('email', PARAM_EMAIL);
-            $mform->hardFreeze(['email']);
-            $emailverify = html_writer::tag('span', s($this->_customdata['email']), []);
-            $mform->addElement('static', 'emailverify', get_string('email'), $emailverify);
-            $mform->addElement('hidden', 'backpackpassword', $this->_customdata['backpackpassword']);
-            $mform->setType('backpackpassword', PARAM_RAW);
+        } else {
+            $sitebackpacks = badges_get_site_backpacks();
+            $choices = [];
+            $restrictedoptions = [];
+            foreach ($sitebackpacks as $backpack) {
+                $choices[$backpack->id] = $backpack->backpackweburl;
+                if ($backpack->apiversion == OPEN_BADGES_V2P1) {
+                    $restrictedoptions[] = $backpack->id;
+                }
+            }
+            $mform->addElement('select', 'externalbackpackid', get_string('backpackprovider', 'badges'), $choices);
+            $mform->setType('externalbackpackid', PARAM_INT);
+            $mform->setDefault('externalbackpackid', $CFG->badges_site_backpack);
+            $mform->hideIf('password', 'externalbackpackid', 'in', $restrictedoptions);
+            $mform->hideIf('backpackemail', 'externalbackpackid', 'in', $restrictedoptions);
+
+            $status = html_writer::tag('span', get_string('notconnected', 'badges'),
+                array('class' => 'notconnected', 'id' => 'connection-status'));
+        }
+        $mform->addElement('static', 'status', get_string('status'), $status);
+
+        $this->add_auth_fields($this->_customdata['email'] ?? $USER->email, !isset($this->_customdata['email']));
+
+        $mform->setDisableShortforms(false);
+
+        // Freeze any elemnts after definition.
+        if ($freeze) {
+            $mform->freeze($freeze);
+        }
+        $this->add_action_buttons();
+    }
+
+    /**
+     * Override add_action_buttons
+     *
+     * @param bool $cancel
+     * @param null|text $submitlabel
+     */
+    public function add_action_buttons($cancel = true, $submitlabel = null) {
+        $mform = $this->_form;
+        if (isset($this->_customdata['email'])) {
             $buttonarray = [];
             $buttonarray[] = &$mform->createElement('submit', 'submitbutton',
                                                     get_string('backpackconnectionresendemail', 'badges'));
@@ -80,28 +116,7 @@ class backpack extends moodleform {
             $mform->closeHeaderBefore('buttonar');
         } else {
             // Email isn't present, so provide an input element to get it and a button to start the verification process.
-
-            $mform->addElement('static', 'info', get_string('backpackweburl', 'badges'), $sitebackpack->backpackweburl);
-            $mform->addElement('hidden', 'backpackid', $sitebackpack->id);
-            $mform->setType('backpackid', PARAM_INT);
-
-            $status = html_writer::tag('span', get_string('notconnected', 'badges'),
-                array('class' => 'notconnected', 'id' => 'connection-status'));
-            $mform->addElement('static', 'status', get_string('status'), $status);
-            if (badges_open_badges_backpack_api() != OPEN_BADGES_V2P1) {
-                $mform->addElement('text', 'email', get_string('email'), 'maxlength="100" size="30"');
-                $mform->addHelpButton('email', 'backpackemail', 'badges');
-                $mform->addRule('email', get_string('required'), 'required', null, 'client');
-                $mform->setType('email', PARAM_EMAIL);
-                if (badges_open_badges_backpack_api() == OPEN_BADGES_V2) {
-                    $mform->addElement('passwordunmask', 'backpackpassword', get_string('password'));
-                    $mform->setType('backpackpassword', PARAM_RAW);
-                } else {
-                    $mform->addElement('hidden', 'backpackpassword', '');
-                    $mform->setType('backpackpassword', PARAM_RAW);
-                }
-            }
-            $this->add_action_buttons(false, get_string('backpackconnectionconnect', 'badges'));
+            parent::add_action_buttons(false, get_string('backpackconnectionconnect', 'badges'));
         }
     }
 
@@ -118,19 +133,18 @@ class backpack extends moodleform {
         // We don't need to verify the email address if we're clearing a pending email verification attempt.
         if (!isset($data['revertbutton'])) {
             $check = new stdClass();
-            $backpack = badges_get_site_backpack($data['backpackid']);
-            $check->email = $data['email'];
-            $check->password = $data['backpackpassword'];
-            $check->externalbackpackid = $backpack->id;
+            $check->email = $data['backpackemail'];
+            $check->password = $data['password'];
+            $sitebackpack = badges_get_site_backpack($data['externalbackpackid']);
+            $bp = new \core_badges\backpack_api($sitebackpack, $check);
 
-            $bp = new \core_badges\backpack_api($backpack, $check);
             $result = $bp->authenticate();
             if ($result === false || !empty($result->error)) {
-                $errors['email'] = get_string('backpackconnectionunexpectedresult', 'badges');
+                $errors['backpackemail'] = get_string('backpackconnectionunexpectedresult', 'badges');
                 $msg = $bp->get_authentication_error();
                 if (!empty($msg)) {
-                    $errors['email'] .= '<br/><br/>';
-                    $errors['email'] .= get_string('backpackconnectionunexpectedmessage', 'badges', $msg);
+                    $errors['backpackemail'] .= '<br/><br/>';
+                    $errors['backpackemail'] .= get_string('backpackconnectionunexpectedmessage', 'badges', $msg);
                 }
             }
         }
index e5c495c..b5d10e0 100644 (file)
@@ -53,11 +53,6 @@ class external_backpack extends \moodleform {
         $mform->addElement('hidden', 'action', 'edit');
         $mform->setType('action', PARAM_ALPHA);
 
-        if ($backpack) {
-            $mform->addElement('hidden', 'id', $backpack->id);
-            $mform->setType('id', PARAM_INTEGER);
-        }
-
         $mform->addElement('text', 'backpackapiurl',  get_string('backpackapiurl', 'core_badges'));
         $mform->setType('backpackapiurl', PARAM_URL);
         $mform->addRule('backpackapiurl', null, 'required', null, 'client');
@@ -74,16 +69,22 @@ class external_backpack extends \moodleform {
         $mform->setDefault('apiversion', OPEN_BADGES_V2P1);
         $mform->addRule('apiversion', null, 'required', null, 'client');
 
-        $issuername = $CFG->badges_defaultissuername;
-        $mform->addElement('static', 'issuerinfo', get_string('defaultissuername', 'core_badges'), $issuername);
+        $mform->addElement('hidden', 'id', ($backpack->id ?? null));
+        $mform->setType('id', PARAM_INT);
+        $mform->addElement('hidden', 'badgebackpack', 0);
+        $mform->setType('badgebackpack', PARAM_INT);
+        $mform->addElement('hidden', 'userid', 0);
+        $mform->setType('userid', PARAM_INT);
+        $mform->addElement('hidden', 'backpackuid', 0);
+        $mform->setType('backpackuid', PARAM_INT);
+
+        $mform->addElement('advcheckbox', 'includeauthdetails', null, get_string('includeauthdetails', 'core_badges'));
+        if (!empty($backpack->backpackemail) || !empty($backpack->password)) {
+            $mform->setDefault('includeauthdetails', 1);
+        }
 
         $issuercontact = $CFG->badges_defaultissuercontact;
-        $mform->addElement('static', 'issuerinfo', get_string('defaultissuercontact', 'core_badges'), $issuercontact);
-
-        $mform->addElement('passwordunmask', 'password', get_string('defaultissuerpassword', 'core_badges'));
-        $mform->setType('password', PARAM_RAW);
-        $mform->addHelpButton('password', 'defaultissuerpassword', 'badges');
-        $mform->hideIf('password', 'apiversion', 'neq', 2);
+        $this->add_auth_fields($issuercontact);
 
         $oauth2options = badges_get_oauth2_service_options();
         $mform->addElement('select', 'oauth2_issuerid', get_string('oauth2issuer', 'core_badges'), $oauth2options);
@@ -94,6 +95,12 @@ class external_backpack extends \moodleform {
             $this->set_data($backpack);
         }
 
+        $mform->hideIf('includeauthdetails', 'apiversion', 'in', [OPEN_BADGES_V2P1]);
+        $mform->hideIf('backpackemail', 'includeauthdetails');
+        $mform->hideIf('backpackemail', 'apiversion', 'in', [OPEN_BADGES_V2P1]);
+        $mform->hideIf('password', 'includeauthdetails');
+        $mform->hideIf('password', 'apiversion', 'in', [1, OPEN_BADGES_V2P1]);
+
         // Disable short forms.
         $mform->setDisableShortforms();
 
@@ -120,4 +127,54 @@ class external_backpack extends \moodleform {
 
         return $errors;
     }
+
+    /**
+     * Return submitted data if properly submitted or returns NULL if validation fails or
+     * if there is no submitted data.
+     *
+     * @return object|void
+     */
+    public function get_data() {
+        $data = parent::get_data();
+        if ($data ) {
+            if ((isset($data->includeauthdetails) && !$data->includeauthdetails)
+                || (isset($data->apiversion) && $data->apiversion == 2.1)) {
+                $data->backpackemail = "";
+                $data->password = "";
+            }
+
+            if ((isset($data->apiversion) && $data->apiversion == 1)) {
+                $data->password = "";
+            }
+        }
+
+        return $data;
+    }
+
+    /**
+     * Add backpack specific auth details.
+     *
+     * @param string|null $email The email addressed provided or null if it's new.
+     * @param bool $includepassword Include the password field. Defaults to true
+     * @throws \coding_exception
+     */
+    protected function add_auth_fields(?string $email, bool $includepassword = true) {
+        $mform = $this->_form;
+        $emailstring = get_string('email');
+        $passwordstring = get_string('password');
+        if (!isset($this->_customdata['userbackpack'])) {
+            $emailstring = get_string('defaultissuercontact', 'core_badges');
+            $passwordstring = get_string('defaultissuerpassword', 'core_badges');
+        }
+
+        $mform->addElement('text', 'backpackemail', $emailstring);
+        $mform->setType('backpackemail', PARAM_EMAIL);
+        $mform->setDefault('backpackemail', $email);
+
+        if ($includepassword) {
+            $mform->addElement('passwordunmask', 'password', $passwordstring);
+            $mform->setType('password', PARAM_RAW);
+            $mform->addHelpButton('password', 'defaultissuerpassword', 'badges');
+        }
+    }
 }
index 61dd374..89c3ffd 100644 (file)
@@ -55,7 +55,7 @@ $badgescache = cache::make('core', 'externalbadges');
 
 if ($disconnect && $backpack) {
     require_sesskey();
-    $sitebackpack = badges_get_site_backpack($backpack->externalbackpackid);
+    $sitebackpack = badges_get_user_backpack();
     if ($sitebackpack->apiversion == OPEN_BADGES_V2P1) {
         $bp = new \core_badges\backpack_api2p1($sitebackpack);
         $bp->disconnect_backpack($backpack);
@@ -71,11 +71,7 @@ if ($disconnect && $backpack) {
 $warning = '';
 if ($backpack) {
 
-    $sitebackpack = badges_get_site_backpack($backpack->externalbackpackid);
-
-    if ($sitebackpack->id != $CFG->badges_site_backpack) {
-        $warning = $OUTPUT->notification(get_string('backpackneedsupdate', 'badges'), 'warning');
-    }
+    $sitebackpack = badges_get_user_backpack();
 
     // If backpack is connected, need to select collections.
     $bp = new \core_badges\backpack_api($sitebackpack, $backpack);
@@ -107,16 +103,6 @@ if ($backpack) {
         $bp->set_backpack_collections($backpack->id, $groups);
         redirect(new moodle_url('/badges/mybadges.php'));
     }
-} else if (badges_open_badges_backpack_api() == OPEN_BADGES_V2P1) {
-    // If backpack is version 2.1 to redirect on the backpack site to login.
-    // User input username/email/password on the backpack site
-    // After confirm the scopes.
-    $form = new \core_badges\form\backpack(new moodle_url('/badges/mybackpack.php'));
-    if ($form->is_cancelled()) {
-        redirect(new moodle_url('/badges/mybadges.php'));
-    } else if ($data = $form->get_submitted_data()) {
-        redirect(new moodle_url('/badges/backpack-connect.php'));
-    }
 } else {
     // If backpack is not connected, need to connect first.
     // To create a new connection to the backpack, first we need to verify the user's email address:
@@ -134,25 +120,33 @@ if ($backpack) {
     $params['backpackid'] = get_user_preferences('badges_email_verify_backpackid');
 
     $form = new \core_badges\form\backpack(new moodle_url('/badges/mybackpack.php'), $params);
+    $data = $form->get_submitted_data();
     if ($form->is_cancelled()) {
         redirect(new moodle_url('/badges/mybadges.php'));
-    } else if ($data = $form->get_data()) {
-        // The form may have been submitted under one of the following circumstances:
-        // 1. After clicking 'Connect to backpack'. We'll have $data->email.
-        // 2. After clicking 'Resend verification email'. We'll have $data->email.
-        // 3. After clicking 'Connect using a different email' to cancel the verification process. We'll have $data->revertbutton.
-
-        if (isset($data->revertbutton)) {
-            badges_disconnect_user_backpack($USER->id);
-            redirect(new moodle_url('/badges/mybackpack.php'));
-        } else if (isset($data->email)) {
-            if (badges_send_verification_email($data->email, $data->backpackid, $data->backpackpassword)) {
-                $a = get_user_preferences('badges_email_verify_backpackid');
-                redirect(new moodle_url('/badges/mybackpack.php'),
-                    get_string('backpackemailverifypending', 'badges', $data->email),
-                    null, \core\output\notification::NOTIFY_INFO);
-            } else {
-                print_error ('backpackcannotsendverification', 'badges');
+    } else if ($form->is_submitted()) {
+        if (badges_open_badges_backpack_api($data->externalbackpackid) == OPEN_BADGES_V2P1) {
+            // If backpack is version 2.1 to redirect on the backpack site to login.
+            // User input username/email/password on the backpack site
+            // After confirm the scopes.
+            redirect(new moodle_url('/badges/backpack-connect.php', ['backpackid' => $data->externalbackpackid]));
+        } else if ($data = $form->get_data()) {
+            // The form may have been submitted under one of the following circumstances:
+            // 1. After clicking 'Connect to backpack'. We'll have $data->email.
+            // 2. After clicking 'Resend verification email'. We'll have $data->email.
+            // 3. After clicking 'Connect using a different email' to cancel the verification process. We'll have $data->revertbutton.
+
+            if (isset($data->revertbutton)) {
+                badges_disconnect_user_backpack($USER->id);
+                redirect(new moodle_url('/badges/mybackpack.php'));
+            } else if (isset($data->backpackemail)) {
+                if (badges_send_verification_email($data->backpackemail, $data->externalbackpackid, $data->password)) {
+                    $a = get_user_preferences('badges_email_verify_backpackid');
+                    redirect(new moodle_url('/badges/mybackpack.php'),
+                        get_string('backpackemailverifypending', 'badges', $data->backpackemail),
+                        null, \core\output\notification::NOTIFY_INFO);
+                } else {
+                    print_error ('backpackcannotsendverification', 'badges');
+                }
             }
         }
     }
index 28a182b..b561bfe 100644 (file)
@@ -80,14 +80,14 @@ class core_badges_renderer extends plugin_renderer_base {
                     $params
                 );
                 $notexpiredbadge = (empty($badge->dateexpire) || $badge->dateexpire > time());
-                $backpackexists = badges_user_has_backpack($USER->id);
-                if (!empty($CFG->badges_allowexternalbackpack) && $notexpiredbadge && $backpackexists) {
+                $userbackpack = badges_get_user_backpack();
+                if (!empty($CFG->badges_allowexternalbackpack) && $notexpiredbadge && $userbackpack) {
                     $assertion = new moodle_url('/badges/assertion.php', array('b' => $badge->uniquehash));
                     $action = null;
-                    if (badges_open_badges_backpack_api() == OPEN_BADGES_V1) {
+                    if (badges_open_badges_backpack_api($userbackpack->id) == OPEN_BADGES_V1) {
                         $action = new component_action('click', 'addtobackpack', array('assertion' => $assertion->out(false)));
                         $addurl = new moodle_url('#');
-                    } else if (badges_open_badges_backpack_api() == OPEN_BADGES_V2P1) {
+                    } else if (badges_open_badges_backpack_api($userbackpack->id) == OPEN_BADGES_V2P1) {
                         $addurl = new moodle_url('/badges/backpack-export.php', array('hash' => $badge->uniquehash));
                     } else {
                         $addurl = new moodle_url('/badges/backpack-add.php', array('hash' => $badge->uniquehash));
@@ -342,9 +342,10 @@ class core_badges_renderer extends plugin_renderer_base {
                         new moodle_url('/badges/badge.php', array('hash' => $ibadge->hash, 'bake' => true)),
                         get_string('download'),
                         'POST');
-            if (!empty($CFG->badges_allowexternalbackpack) && ($expiration > $now) && badges_user_has_backpack($USER->id)) {
+            if (!empty($CFG->badges_allowexternalbackpack) && ($expiration > $now)
+                && $userbackpack = badges_get_user_backpack($USER->id)) {
 
-                if (badges_open_badges_backpack_api() == OPEN_BADGES_V1) {
+                if (badges_open_badges_backpack_api($userbackpack->id) == OPEN_BADGES_V1) {
                     $assertion = new moodle_url('/badges/assertion.php', array('b' => $ibadge->hash));
                     $action = new component_action('click', 'addtobackpack', array('assertion' => $assertion->out(false)));
                     $attributes = array(
@@ -356,7 +357,7 @@ class core_badges_renderer extends plugin_renderer_base {
                     $this->output->add_action_handler($action, 'addbutton');
                     $output .= $tobackpack;
                 } else {
-                    if (badges_open_badges_backpack_api() == OPEN_BADGES_V2P1) {
+                    if (badges_open_badges_backpack_api($userbackpack->id) == OPEN_BADGES_V2P1) {
                         $assertion = new moodle_url('/badges/backpack-export.php', array('hash' => $ibadge->hash));
                     } else {
                         $assertion = new moodle_url('/badges/backpack-add.php', array('hash' => $ibadge->hash));
@@ -650,10 +651,6 @@ class core_badges_renderer extends plugin_renderer_base {
             $externalhtml .= html_writer::start_tag('div', array('class' => 'generalbox'));
             $externalhtml .= $this->output->heading_with_help(get_string('externalbadges', 'badges'), 'externalbadges', 'badges');
             if (!is_null($backpack)) {
-                if ($backpack->backpackid != $CFG->badges_site_backpack) {
-                    $externalhtml .= $this->output->notification(get_string('backpackneedsupdate', 'badges'), 'warning');
-
-                }
                 if ($backpack->totalcollections == 0) {
                     $externalhtml .= get_string('nobackpackcollectionssummary', 'badges', $backpack);
                 } else {
index fc80b9b..e8a6956 100644 (file)
@@ -915,4 +915,475 @@ class core_badges_badgeslib_testcase extends advanced_testcase {
         $result = badges_delete_site_backpack($backpack->id);
         $this->assertFalse($result);
     }
+
+    /**
+     * Test to validate badges_save_backpack_credentials.
+     *
+     * @dataProvider save_backpack_credentials_provider
+     * @param  bool $addbackpack True if backpack data has to be created; false otherwise (empty data will be used then).
+     * @param  string|null  $mail  Backpack mail address.
+     * @param  string|null  $password  Backpack password.
+     */
+    public function test_save_backpack_credentials(bool $addbackpack = true, ?string $mail = null, ?string $password = null) {
+        global $DB;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $data = [];
+        if ($addbackpack) {
+            $data = new \stdClass();
+            $data->apiversion = OPEN_BADGES_V2P1;
+            $data->backpackapiurl = 'https://dc.imsglobal.org/obchost/ims/ob/v2p1';
+            $data->backpackweburl = 'https://dc.imsglobal.org';
+            badges_create_site_backpack($data);
+            $backpack = $DB->get_record('badge_external_backpack', ['backpackweburl' => $data->backpackweburl]);
+            $user = $this->getDataGenerator()->create_user();
+
+            $data = [
+                'externalbackpackid' => $backpack->id,
+                'userid' => $user->id,
+            ];
+
+            if (!empty($mail)) {
+                $data['backpackemail'] = $mail;
+            }
+            if (!empty($password)) {
+                $data['password'] = $password;
+            }
+        }
+
+        $return = badges_save_backpack_credentials((object) $data);
+        if (array_key_exists('userid', $data)) {
+            $record = $DB->get_record('badge_backpack', ['userid' => $user->id]);
+        } else {
+            $record = $DB->get_records('badge_backpack');
+        }
+
+        if (!empty($mail) && !empty($password)) {
+            // The backpack credentials are created if the given information is right.
+            $this->assertNotEmpty($record);
+            $this->assertEquals($data['externalbackpackid'], $return);
+        } else if ($addbackpack) {
+            // If no email and password are given, no backpack is created/modified.
+            $this->assertEmpty($record);
+            $this->assertEquals($data['externalbackpackid'], $return);
+        } else {
+            // There weren't fields to add to the backpack so no DB change is expected.
+            $this->assertEmpty($record);
+            $this->assertEquals(0, $return);
+        }
+
+        // Confirm the existing backpack credential can be updated (if it has been created).
+        if (!empty($record)) {
+            $data['backpackemail'] = 'modified_' . $mail;
+            $data['id'] = $record->id;
+            $return = badges_save_backpack_credentials((object) $data);
+            $record = $DB->get_record('badge_backpack', ['userid' => $user->id]);
+
+            $this->assertNotEmpty($record);
+            $this->assertEquals($data['backpackemail'], $record->email);
+            $this->assertEquals($data['externalbackpackid'], $return);
+        }
+    }
+
+    /**
+     * Data provider for test_create_backpack_credentials().
+     *
+     * @return array
+     */
+    public function save_backpack_credentials_provider(): array {
+        return [
+            'Empty fields' => [
+                false,
+            ],
+            'No backpack mail or password are defined' => [
+                true,
+            ],
+            'Both backpack mail and password are defined' => [
+                true, 'test@test.com', '1234',
+            ],
+            'Only backpack mail is defined (no password is given)' => [
+                true, 'test@test.com', null,
+            ],
+            'Only backpack password is defined (no mail is given)' => [
+                true, null, '1234'
+            ],
+        ];
+    }
+
+
+    /**
+     * Test badges_save_external_backpack without any auth details and also tests duplicate entries.
+     *
+     * @param boolean $withauth Test with authentication details provided
+     * @param boolean $duplicates Test for duplicates
+     * @dataProvider test_badges_save_external_backpack_provider
+     * @throws dml_exception
+     */
+    public function test_badges_save_external_backpack($withauth, $duplicates) {
+        global $DB;
+        $this->resetAfterTest();
+        $user = $this->getDataGenerator()->create_user();
+
+        $data = [
+            'userid' => $user->id,
+            'apiversion' => 2,
+            'backpackapiurl' => 'https://api.ca.badgr.io/v2',
+            'backpackweburl' => 'https://ca.badgr.io',
+        ];
+
+        if ($withauth) {
+            $data['backpackemail'] = 'test@test.com';
+            $data['password'] = 'test';
+        }
+
+        $result = badges_save_external_backpack((object) $data);
+        $record = $DB->get_record('badge_external_backpack', ['id' => $result]);
+        $this->assertEquals($record->backpackweburl, $data['backpackweburl']);
+        $this->assertEquals($record->backpackapiurl, $data['backpackapiurl']);
+        $record = $DB->get_record('badge_backpack', ['userid' => $user->id]);
+        if (!$withauth) {
+            $this->assertEmpty($record);
+        } else {
+            $this->assertNotEmpty($record);
+        }
+
+        if ($duplicates) {
+            // We shouldn't be able to insert multiple external_backpacks with the same values.
+            $this->expectException('dml_write_exception');
+            $result = badges_save_external_backpack((object)$data);
+        }
+    }
+
+    /**
+     * Provider for test_badges_save_external_backpack
+     *
+     * @return array
+     */
+    public function test_badges_save_external_backpack_provider() {
+        return [
+            "Test without any auth details and duplicates" => [
+                false, true
+            ],
+            "Test without any auth details and without duplicates" => [
+                false, false
+            ],
+            "Test with auth details and duplicates" => [
+                true, true
+            ],
+            "Test with any auth details and duplicates" => [
+                true, false
+            ],
+        ];
+    }
+
+    /**
+     * Test backpack creation/update with auth details provided
+     *
+     * @param boolean $isadmin
+     * @param boolean $updatetest
+     * @dataProvider test_badges_create_site_backpack_provider
+     */
+    public function test_badges_create_site_backpack($isadmin, $updatetest) {
+        global $DB;
+        $this->resetAfterTest();
+
+        $data = [
+            'apiversion' => 2,
+            'backpackapiurl' => 'https://api.ca.badgr.io/v2',
+            'backpackweburl' => 'https://ca.badgr.io',
+        ];
+
+        $data['backpackemail'] = 'test@test.com';
+        $data['password'] = 'test';
+        if ($isadmin || $updatetest) {
+            $this->setAdminUser();
+            $backpack = badges_create_site_backpack((object) $data);
+        }
+
+        if ($isadmin) {
+            if ($updatetest) {
+                $record = $DB->get_record('badge_backpack', ['userid' => 0]);
+                $data['badgebackpack'] = $record->id;
+                $data['backpackapiurl'] = 'https://api.ca.badgr.io/v3';
+                badges_update_site_backpack($backpack, (object)$data);
+            }
+            $record = $DB->get_record('badge_external_backpack', ['id' => $backpack]);
+            $this->assertEquals($record->backpackweburl, $data['backpackweburl']);
+            $this->assertEquals($record->backpackapiurl, $data['backpackapiurl']);
+            $record = $DB->get_record('badge_backpack', ['userid' => 0]);
+            $this->assertNotEmpty($record);
+        } else {
+            $user = $this->getDataGenerator()->create_user();
+            $this->setUser($user);
+            $this->expectException('required_capability_exception');
+            if ($updatetest) {
+                $result = badges_update_site_backpack($backpack, (object) $data);
+            } else {
+                $result = badges_create_site_backpack((object)$data);
+            }
+        }
+    }
+
+    /**
+     * Provider for test_badges_(create/update)_site_backpack
+     */
+    public function test_badges_create_site_backpack_provider() {
+        return [
+            "Test as admin user - creation test" => [true, true],
+            "Test as admin user - update test" => [true, false],
+            "Test as normal user - creation test" => [false, true],
+            "Test as normal user - update test" => [false, false],
+        ];
+    }
+
+    /**
+     * Test the badges_open_badges_backpack_api with different backpacks
+     */
+    public function test_badges_open_badges_backpack_api() {
+        $this->resetAfterTest();
+
+        $data = [
+            'apiversion' => 2,
+            'backpackapiurl' => 'https://api.ca.badgr.io/v2',
+            'backpackweburl' => 'https://ca.badgr.io',
+        ];
+
+        // Given a complete set of unique data, a new backpack and auth records should exist in the tables.
+        $data['backpackemail'] = 'test@test.com';
+        $data['password'] = 'test';
+        $backpack1 = badges_save_external_backpack((object) $data);
+        $data['backpackweburl'] = 'https://eu.badgr.io';
+        $data['backpackapiurl'] = 'https://api.eu.badgr.io/v2';
+        $data['apiversion'] = 2.1;
+        $backpack2 = badges_save_external_backpack((object) $data);
+
+        set_config('badges_site_backpack', $backpack2);
+        // The default response should check the default site backpack api version.
+        $this->assertEquals(2.1, badges_open_badges_backpack_api());
+        // Check the api version for the other backpack created.
+        $this->assertEquals(2, badges_open_badges_backpack_api($backpack1));
+        $this->assertEquals(2.1, badges_open_badges_backpack_api($backpack2));
+    }
+
+    /**
+     * Test the badges_get_site_backpack function
+     */
+    public function test_badges_get_site_backpack() {
+        $this->resetAfterTest();
+        $user = $this->getDataGenerator()->create_user();
+        $data = [
+            'apiversion' => '2',
+            'backpackapiurl' => 'https://api.ca.badgr.io/v2',
+            'backpackweburl' => 'https://ca.badgr.io',
+        ];
+        $backpack1 = badges_save_external_backpack((object) $data);
+        $data2 = array_merge($data, [
+            'backpackapiurl' => 'https://api.eu.badgr.io/v2',
+            'backpackweburl' => 'https://eu.badgr.io',
+            'backpackemail' => 'test@test.com',
+            'password' => 'test',
+        ]);
+        $backpack2 = badges_save_external_backpack((object) $data2);
+        $data3 = array_merge($data2, [
+            'userid' => $user->id,
+            'externalbackpackid' => $backpack2,
+            'backpackemail' => 'test2@test.com'
+        ]);
+        // In the following case, the id returned below equals backpack2. So we aren't storing it.
+        badges_save_backpack_credentials((object) $data3);
+        unset($data3['userid']);
+
+        // Get a site back based on the id returned from creation and no user id provided.
+        $this->assertEquals($data, array_intersect($data, (array) badges_get_site_backpack($backpack1)));
+        $this->assertEquals($data2, array_intersect($data2, (array) badges_get_site_backpack($backpack2)));
+        $this->assertEquals($data2, array_intersect($data2, (array) badges_get_site_backpack($backpack2, 0)));
+        $this->assertEquals($data3, array_intersect($data3, (array) badges_get_site_backpack($backpack2, $user->id)));
+
+        // Non-existent user backpack should return only configuration details and not auth details.
+        $userbackpack = badges_get_site_backpack($backpack1, $user->id);
+        $this->assertNull($userbackpack->badgebackpack);
+        $this->assertNull($userbackpack->password);
+        $this->assertNull($userbackpack->backpackemail);
+    }
+
+    /**
+     * Test the badges_get_user_backpack function
+     */
+    public function test_badges_get_user_backpack() {
+        $this->resetAfterTest();
+        $user = $this->getDataGenerator()->create_user();
+        $data = [
+            'apiversion' => '2',
+            'backpackapiurl' => 'https://api.ca.badgr.io/v2',
+            'backpackweburl' => 'https://ca.badgr.io',
+        ];
+        $backpack1 = badges_save_external_backpack((object) $data);
+        $data2 = array_merge($data, [
+            'backpackapiurl' => 'https://api.eu.badgr.io/v2',
+            'backpackweburl' => 'https://eu.badgr.io',
+            'backpackemail' => 'test@test.com',
+            'password' => 'test',
+        ]);
+        $backpack2 = badges_save_external_backpack((object) $data2);
+        $data3 = array_merge($data2, [
+            'userid' => $user->id,
+            'externalbackpackid' => $backpack2,
+            'backpackemail' => 'test2@test.com'
+        ]);
+        // In the following case, the id returned below equals backpack2. So we aren't storing it.
+        badges_save_backpack_credentials((object) $data3);
+        unset($data3['userid']);
+
+        // Currently logged in as admin.
+        $this->assertEquals($data2, array_intersect($data2, (array) badges_get_user_backpack()));
+        $this->assertEquals($data2, array_intersect($data2, (array) badges_get_user_backpack(0)));
+        $this->assertEquals($data3, array_intersect($data3, (array) badges_get_user_backpack($user->id)));
+
+        // Non-existent user backpack should return nothing.
+        $this->assertFalse(badges_get_user_backpack($backpack1, $user->id));
+
+        // Login as user.
+        $this->setUser($user);
+        $this->assertEquals($data3, array_intersect($data3, (array) badges_get_user_backpack()));
+    }
+
+    /**
+     * Test the badges_get_site_primary_backpack function
+     *
+     * @param boolean $withauth Testing with authentication or not.
+     * @dataProvider test_badges_get_site_primary_backpack_provider
+     */
+    public function test_badges_get_site_primary_backpack($withauth) {
+        $data = [
+            'apiversion' => '2',
+            'backpackapiurl' => 'https://api.ca.badgr.io/v2',
+            'backpackweburl' => 'https://ca.badgr.io',
+        ];
+        if ($withauth) {
+            $data = array_merge($data, [
+                'backpackemail' => 'test@test.com',
+                'password' => 'test',
+            ]);
+        }
+        $backpack = badges_save_external_backpack((object) $data);
+
+        set_config('badges_site_backpack', $backpack);
+        $sitebackpack = badges_get_site_primary_backpack();
+        $this->assertEquals($backpack, $sitebackpack->id);
+
+        if ($withauth) {
+            $this->assertEquals($data, array_intersect($data, (array) $sitebackpack));
+            $this->assertEquals($data['password'], $sitebackpack->password);
+            $this->assertEquals($data['backpackemail'], $sitebackpack->backpackemail);
+        } else {
+            $this->assertNull($sitebackpack->badgebackpack);
+            $this->assertNull($sitebackpack->password);
+            $this->assertNull($sitebackpack->backpackemail);
+        }
+    }
+
+    /**
+     * Test the test_badges_get_site_primary_backpack function.
+     *
+     * @return array
+     */
+    public function test_badges_get_site_primary_backpack_provider() {
+        return [
+            "Test with auth details" => [true],
+            "Test without auth details" => [false],
+        ];
+    }
+
+    /**
+     * Test the Badgr URL generator function
+     *
+     * @param mixed $type Type corresponding to the badge entites
+     * @param string $expected Expected string result
+     * @dataProvider badgr_open_url_generator
+     */
+    public function test_badges_generate_badgr_open_url($type, $expected) {
+        $data = [
+            'apiversion' => '2',
+            'backpackapiurl' => 'https://api.ca.badgr.io/v2',
+            'backpackweburl' => 'https://ca.badgr.io',
+            'backpackemail' => 'test@test.com',
+            'password' => 'test',
+        ];
+        $backpack2 = badges_save_external_backpack((object) $data);
+        $backpack = badges_get_site_backpack($backpack2);
+        $this->assertEquals($expected, badges_generate_badgr_open_url($backpack, $type, 123455));
+    }
+
+    /**
+     * Data provider for test_badges_generate_badgr_open_url
+     * @return array
+     */
+    public function badgr_open_url_generator() {
+        return [
+            'Badgr Assertion URL test' => [
+                OPEN_BADGES_V2_TYPE_ASSERTION, "https://api.ca.badgr.io/public/assertions/123455"
+            ],
+            'Badgr Issuer URL test' => [
+                OPEN_BADGES_V2_TYPE_ISSUER, "https://api.ca.badgr.io/public/issuers/123455"
+            ],
+            'Badgr Badge URL test' => [
+                OPEN_BADGES_V2_TYPE_BADGE, "https://api.ca.badgr.io/public/badges/123455"
+            ]
+        ];
+    }
+
+    /**
+     * Test badges_external_get_mapping function
+     *
+     * @param int $internalid The internal id of the mapping
+     * @param int $externalid The external / remote ref to the mapping
+     * @param mixed $expected The expected result from the function
+     * @param string|null $field The field we are passing to the function. Null if we don't want to pass anything.ss
+     *
+     * @dataProvider badges_external_get_mapping_provider
+     */
+    public function test_badges_external_get_mapping($internalid, $externalid, $expected, $field = null) {
+        $data = [
+            'apiversion' => '2',
+            'backpackapiurl' => 'https://api.ca.badgr.io/v2',
+            'backpackweburl' => 'https://ca.badgr.io',
+            'backpackemail' => 'test@test.com',
+            'password' => 'test',
+        ];
+        $backpack2 = badges_save_external_backpack((object) $data);
+        badges_external_create_mapping($backpack2, OPEN_BADGES_V2_TYPE_BADGE, $internalid, $externalid);
+        $expected = $expected == "id" ? $backpack2 : $expected;
+        if ($field) {
+            $this->assertEquals($expected, badges_external_get_mapping($backpack2, OPEN_BADGES_V2_TYPE_BADGE, $internalid, $field));
+        } else {
+            $this->assertEquals($expected, badges_external_get_mapping($backpack2, OPEN_BADGES_V2_TYPE_BADGE, $internalid));
+        }
+    }
+
+    /**
+     * Data provider for badges_external_get_mapping_provider
+     *
+     * @return array
+     */
+    public function badges_external_get_mapping_provider() {
+        return [
+            "Get the site backpack value" => [
+                1234, 4321, 'id', 'sitebackpackid'
+            ],
+            "Get the type of the mapping" => [
+                1234, 4321, OPEN_BADGES_V2_TYPE_BADGE, 'type'
+            ],
+            "Get the externalid of the mapping" => [
+                1234, 4321, 4321, 'externalid'
+            ],
+            "Get the externalid of the mapping without providing a param" => [
+                1234, 4321, 4321, null
+            ],
+            "Get the internalid of the mapping" => [
+                1234, 4321, 1234, 'internalid'
+            ]
+        ];
+    }
 }
index ea02aa7..3a979f6 100644 (file)
@@ -1,13 +1,13 @@
 @core @core_badges @_file_upload
 Feature: Backpack badges
-  The settings to connect to backpack with OAuth2 service
-  As an learner
+  Test the settings to add/update a backpack for a site and user.
   I need to verify display backpack in the my profile
 
   Background:
-    Given the following "badge external backpack" exist:
+    Given the following "badge external backpacks" exist:
       | backpackapiurl                               | backpackweburl           | apiversion |
-      | https://dc.imsglobal.org/obchost/ims/ob/v2p1 | https://dc.imsglobal.org | 2.1          |
+      | https://dc.imsglobal.org/obchost/ims/ob/v2p1 | https://dc.imsglobal.org | 2.1        |
+      | https://test.com/                            | https://test.com/        | 2          |
     And the following "users" exist:
       | username | firstname | lastname | email                |
       | student1 | Student   | 1        | student1@example.com |
@@ -121,3 +121,49 @@ Feature: Backpack badges
     Then I should see "The site backpack has been deleted."
     And I should not see "https://dc.imsglobal.org"
     And "Delete" "button" should not exist
+
+  @javascript
+  Scenario: Add a new site backpack with authentication details checkbox
+    Given I am on homepage
+    And I log in as "admin"
+    And I navigate to "Badges > Manage backpacks" in site administration
+    When I press "Add a new backpack"
+    And I set the field "backpackapiurl" to "http://backpackapiurl.cat"
+    And I set the field "backpackweburl" to "http://backpackweburl.cat"
+    And I set the field "apiversion" to "2.1"
+    Then "Include authentication details with the backpack" "checkbox" should not exist
+    And I should not see "Badge issuer email address"
+    And I should not see "Badge issuer password"
+    And I set the field "apiversion" to "1"
+    And "Include authentication details with the backpack" "checkbox" should exist
+    And I click on "includeauthdetails" "checkbox"
+    And I should see "Badge issuer email address"
+    And I should not see "Badge issuer password"
+    And I set the field "apiversion" to "2"
+    And "Include authentication details with the backpack" "checkbox" should exist
+    And I should see "Badge issuer email address"
+    And I should see "Badge issuer password"
+    And I set the field "backpackemail" to "test@test.com"
+    And I set the field "password" to "123456"
+    And I press "Save changes"
+    And I click on "Edit" "link" in the "http://backpackweburl.cat" "table_row"
+    And "input[name=includeauthdetails][type=checkbox][value=1]" "css_element" should exist
+    And I click on "includeauthdetails" "checkbox"
+    And I press "Save changes"
+    And I click on "Edit" "link" in the "http://backpackweburl.cat" "table_row"
+    And "input[name=includeauthdetails][type=checkbox][value=1]" "css_element" should not exist
+    And I click on "includeauthdetails" "checkbox"
+    And I should not see "test@test.com"
+    And I log out
+
+  @javascript
+  Scenario: View backpack form as a student
+    Given I log in as "student1"
+    And I follow "Preferences" in the user menu
+    And I follow "Backpack settings"
+    When I set the field "externalbackpackid" to "https://dc.imsglobal.org"
+    Then I should not see "Email address"
+    And I should not see "Password"
+    And I set the field "externalbackpackid" to "https://test.com/"
+    And I should see "Email address"
+    And I should see "Password"
index faca603..2ca56f3 100644 (file)
@@ -1,6 +1,22 @@
 This files describes API changes in /badges/*,
 information provided here is intended especially for developers.
 
+=== 3.10 ===
+* Users can now specify a backpack that differs from the site backpack. In order to do this, connection details need to
+be set in 'Manage backpacks' with OR without auth details.
+* Introduced new functions in backpack_api
+** 'import_badge_assertion' to facilitate cross domain badge imports.
+** 'update_assertion' updates a previously defined/created assertion.
+* New badge lib functions introduced
+** badges_save_external_backpack() - This method handles inserts/updates to the site wide backpacks' configuration details.
+** badges_save_backpack_credentials() - This method handles inserts/updates any authentication details to connect to the backpacks created. This can either be site OR user backpack authentication details
+** badges_get_user_backpack() - Gets a specific user's backpack. Defaults to current user's backpack if none provided.
+** badges_get_site_primary_backpack() - Get the primary backpack set for the site as defined in $CFG->badges_site_backpack
+* badges_open_badges_backpack_api() - Now accepts a backpackid(badge_external_backpack id) to check whether the version of the provided backpack.
+  This was introduced because now there is a difference between a site and user backpack. If null, defaults to site_backpack.
+* badges_get_site_backpack() - Accepts an additional $userid param if we want to get a specific user's backpack. Defaults to 0 if we are trying to get the site/admin level backpack
+* badges_external_get_mapping() - Accepts an additional argument to indicate which value it wants returned. Defaults to 'externalid' which contains the OBv2 badge URL
+
 === 3.9 ===
 * BADGE_BACKPACKAPIURL and BADGE_BACKPACKWEBURL are deprecated and should not be used.
 * OBv2 has been set to the default value when the obversion is not defined.
diff --git a/course/amd/build/downloadcontent.min.js b/course/amd/build/downloadcontent.min.js
new file mode 100644 (file)
index 0000000..8e99320
Binary files /dev/null and b/course/amd/build/downloadcontent.min.js differ
diff --git a/course/amd/build/downloadcontent.min.js.map b/course/amd/build/downloadcontent.min.js.map
new file mode 100644 (file)
index 0000000..29cc63d
Binary files /dev/null and b/course/amd/build/downloadcontent.min.js.map differ
diff --git a/course/amd/src/downloadcontent.js b/course/amd/src/downloadcontent.js
new file mode 100644 (file)
index 0000000..67a6d83
--- /dev/null
@@ -0,0 +1,125 @@
+// 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/>.
+
+/**
+ * Functions related to downloading course content.
+ *
+ * @module     core_course/downloadcontent
+ * @package    core_course
+ * @copyright  2020 Michael Hawkins <michaelh@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+import Config from 'core/config';
+import CustomEvents from 'core/custom_interaction_events';
+import * as ModalFactory from 'core/modal_factory';
+import jQuery from 'jquery';
+import Pending from 'core/pending';
+
+/**
+ * Set up listener to trigger the download course content modal.
+ *
+ * @return {void}
+ */
+export const init = () => {
+    const pendingPromise = new Pending();
+
+    document.addEventListener('click', (e) => {
+        const downloadModalTrigger = e.target.closest('[data-downloadcourse]');
+
+        if (downloadModalTrigger) {
+            e.preventDefault();
+            displayDownloadConfirmation(downloadModalTrigger);
+        }
+    });
+
+    pendingPromise.resolve();
+};
+
+/**
+ * Display the download course content modal.
+ *
+ * @method displayDownloadConfirmation
+ * @param {Object} downloadModalTrigger The DOM element that triggered the download modal.
+ * @return {void}
+ */
+const displayDownloadConfirmation = (downloadModalTrigger) => {
+    ModalFactory.create({
+        title: downloadModalTrigger.dataset.downloadTitle,
+        type: ModalFactory.types.SAVE_CANCEL,
+        body: `<p>${downloadModalTrigger.dataset.downloadBody}</p>`,
+        buttons: {
+            save: downloadModalTrigger.dataset.downloadButtonText
+        },
+        templateContext: {
+            classes: 'downloadcoursecontentmodal'
+        }
+    })
+    .then(modal => {
+        // Display the modal.
+        modal.show();
+
+        const saveButton = document.querySelector('.modal .downloadcoursecontentmodal [data-action="save"]');
+        const cancelButton = document.querySelector('.modal .downloadcoursecontentmodal [data-action="cancel"]');
+        const modalContainer = document.querySelector('.modal[data-region="modal-container"]');
+
+        // Create listener to trigger the download when the "Download" button is pressed.
+        jQuery(saveButton).on(CustomEvents.events.activate, (e) => downloadContent(e, downloadModalTrigger, modal));
+
+        // Create listener to destroy the modal when closing modal by cancelling.
+        jQuery(cancelButton).on(CustomEvents.events.activate, () => {
+            modal.destroy();
+        });
+
+        // Create listener to destroy the modal when closing modal by clicking outside of it.
+        if (modalContainer.querySelector('.downloadcoursecontentmodal')) {
+            jQuery(modalContainer).on(CustomEvents.events.activate, () => {
+                modal.destroy();
+            });
+        }
+    });
+};
+
+/**
+ * Trigger downloading of course content.
+ *
+ * @method downloadContent
+ * @param {Event} e The event triggering the download.
+ * @param {Object} downloadModalTrigger The DOM element that triggered the download modal.
+ * @param {Object} modal The modal object.
+ * @return {void}
+ */
+const downloadContent = (e, downloadModalTrigger, modal) => {
+    e.preventDefault();
+
+    // Create a form to submit the file download request, so we can avoid sending sesskey over GET.
+    const downloadForm = document.createElement('form');
+    downloadForm.action = downloadModalTrigger.dataset.downloadLink;
+    downloadForm.method = 'POST';
+    // Open download in a new tab, so current course view is not disrupted.
+    downloadForm.target = '_blank';
+    const downloadSesskey = document.createElement('input');
+    downloadSesskey.name = 'sesskey';
+    downloadSesskey.value = Config.sesskey;
+    downloadForm.appendChild(downloadSesskey);
+    downloadForm.style.display = 'none';
+
+    document.body.appendChild(downloadForm);
+    downloadForm.submit();
+    document.body.removeChild(downloadForm);
+
+    // Destroy the modal to prevent duplicates if reopened later.
+    modal.destroy();
+};
diff --git a/course/classes/output/content_export_link.php b/course/classes/output/content_export_link.php
new file mode 100644 (file)
index 0000000..6b3deea
--- /dev/null
@@ -0,0 +1,60 @@
+<?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/>.
+
+/**
+ * Prepares content for buttons/links to course content export/download.
+ *
+ * @package   core_course
+ * @copyright 2020 Michael Hawkins <michaelh@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_course\output;
+
+/**
+ * Prepares content for buttons/links to course content export/download.
+ *
+ * @package   core_course
+ * @copyright 2020 Michael Hawkins <michaelh@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class content_export_link {
+
+    /**
+     * Prepare and return the various attributes required for a link/button to populate/trigger the download course content modal.
+     *
+     * @param \context $context The context of the content being exported.
+     * @return stdClass
+     */
+    public static function get_attributes(\context $context): \stdClass {
+        global $CFG;
+        $downloadattr = new \stdClass();
+        $downloadattr->url = new \moodle_url('/course/downloadcontent.php', ['contextid' => $context->id]);
+        $downloadattr->displaystring = get_string('downloadcoursecontent', 'course');
+        $maxfilesize = display_size($CFG->maxsizeperdownloadcoursefile);
+        $downloadlink = new \moodle_url('/course/downloadcontent.php', ['contextid' => $context->id, 'download' => 1]);
+
+        $downloadattr->elementattributes = [
+            'data-downloadcourse' => 1,
+            'data-download-body' => get_string('downloadcourseconfirmation', 'course', $maxfilesize),
+            'data-download-button-text' => get_string('download'),
+            'data-download-link' => $downloadlink->out(false),
+            'data-download-title' => get_string('downloadcoursecontent', 'course'),
+        ];
+
+        return $downloadattr;
+    }
+}
diff --git a/course/downloadcontent.php b/course/downloadcontent.php
new file mode 100644 (file)
index 0000000..7813a0f
--- /dev/null
@@ -0,0 +1,94 @@
+<?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/>.
+
+/**
+ * Download course content confirmation and execution.
+ *
+ * @package    core
+ * @subpackage course
+ * @copyright  2020 Michael Hawkins <michaelh@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require_once('../config.php');
+
+use core\content;
+use core\content\export\zipwriter;
+
+$contextid = required_param('contextid', PARAM_INT);
+$isdownload = optional_param('download', 0, PARAM_BOOL);
+$coursecontext = context::instance_by_id($contextid);
+$courseid = $coursecontext->instanceid;
+$courselink = new moodle_url('/course/view.php', ['id' => $courseid]);
+
+if (!\core\content::can_export_context($coursecontext, $USER)) {
+    redirect($courselink);
+}
+
+$PAGE->set_url('/course/downloadcontent.php', ['contextid' => $contextid]);
+require_login($courseid);
+
+$courseinfo = get_fast_modinfo($courseid)->get_course();
+$filename = str_replace('/', '', str_replace(' ', '_', $courseinfo->shortname)) . '_' . time() . '.zip';
+
+// If download confirmed, prepare and start the zipstream of the course download content.
+if ($isdownload) {
+    confirm_sesskey();
+
+    $exportoptions = null;
+
+    if (!empty($CFG->maxsizeperdownloadcoursefile)) {
+        $exportoptions = new stdClass();
+        $exportoptions->maxfilesize = $CFG->maxsizeperdownloadcoursefile;
+    }
+
+    // Use file writer in debug developer mode, so any errors can be displayed instead of being streamed into the output file.
+    if (debugging('', DEBUG_DEVELOPER)) {
+        $writer = zipwriter::get_file_writer($filename, $exportoptions);
+
+        ob_start();
+        content::export_context($coursecontext, $USER, $writer);
+        $content = ob_get_clean();
+
+        // If no errors found, output the file.
+        if (empty($content)) {
+            send_file($writer->get_file_path(), $filename);
+            redirect($courselink);
+        } else {
+            // If any errors occurred, display them instead of outputting the file.
+            debugging("Errors found while producing the download course content output:\n {$content}", DEBUG_DEVELOPER);
+        }
+    } else {
+        // If not developer debugging, stream the output file directly.
+        $writer = zipwriter::get_stream_writer($filename, $exportoptions);
+        content::export_context($coursecontext, $USER, $writer);
+
+        redirect($courselink);
+    }
+
+} else {
+    $PAGE->set_title(get_string('downloadcoursecontent', 'course'));
+    $PAGE->set_heading(format_string($courseinfo->fullname));
+
+    echo $OUTPUT->header();
+    echo $OUTPUT->heading(get_string('downloadcoursecontent', 'course'));
+
+    // Prepare download confirmation information and display it.
+    $maxfilesize = display_size($CFG->maxsizeperdownloadcoursefile);
+    $downloadlink = new moodle_url('/course/downloadcontent.php', ['contextid' => $contextid, 'download' => 1]);
+
+    echo $OUTPUT->confirm(get_string('downloadcourseconfirmation', 'course', $maxfilesize), $downloadlink, $courselink);
+}
index 8a289cc..9e7773d 100644 (file)
@@ -120,6 +120,29 @@ class course_edit_form extends moodleform {
                 $mform->setConstant('visible', $courseconfig->visible);
             }
         }
+
+        // Download course content.
+        if ($CFG->downloadcoursecontentallowed) {
+            $downloadchoices = [
+                DOWNLOAD_COURSE_CONTENT_DISABLED => get_string('no'),
+                DOWNLOAD_COURSE_CONTENT_ENABLED => get_string('yes'),
+            ];
+            $sitedefaultstring = $downloadchoices[$courseconfig->downloadcontentsitedefault];
+            $downloadchoices[DOWNLOAD_COURSE_CONTENT_SITE_DEFAULT] = get_string('sitedefaultspecified', '', $sitedefaultstring);
+            $downloadselectdefault = $courseconfig->downloadcontent ?? DOWNLOAD_COURSE_CONTENT_SITE_DEFAULT;
+
+            $mform->addElement('select', 'downloadcontent', get_string('enabledownloadcoursecontent', 'course'), $downloadchoices);
+            $mform->addHelpButton('downloadcontent', 'downloadcoursecontent', 'course');
+            $mform->setDefault('downloadcontent', $downloadselectdefault);
+
+            if ((!empty($course->id) && !has_capability('moodle/course:configuredownloadcontent', $coursecontext)) ||
+                    (empty($course->id) &&
+                    !guess_if_creator_will_have_course_capability('moodle/course:configuredownloadcontent', $categorycontext))) {
+                $mform->hardFreeze('downloadcontent');
+                $mform->setConstant('downloadcontent', $downloadselectdefault);
+            }
+        }
+
         $mform->addElement('date_time_selector', 'startdate', get_string('startdate'));
         $mform->addHelpButton('startdate', 'startdate');
         $date = (new DateTime())->setTimestamp(usergetmidnight(time()));
index a834ba7..e34df5a 100644 (file)
@@ -71,6 +71,11 @@ define('COURSE_CUSTOMFIELD_EMPTY', -1);
 // Course activity chooser footer default display option.
 define('COURSE_CHOOSER_FOOTER_NONE', 'hidden');
 
+// Download course content options.
+define('DOWNLOAD_COURSE_CONTENT_DISABLED', 0);
+define('DOWNLOAD_COURSE_CONTENT_ENABLED', 1);
+define('DOWNLOAD_COURSE_CONTENT_SITE_DEFAULT', 2);
+
 function make_log_url($module, $url) {
     switch ($module) {
         case 'course':
diff --git a/course/tests/behat/course_download_content.feature b/course/tests/behat/course_download_content.feature
new file mode 100644 (file)
index 0000000..830c2b9
--- /dev/null
@@ -0,0 +1,47 @@
+@core @core_course
+Feature: Course content can be downloaded
+  In order to retain a backup offline copy of course activity/resource data
+  As a user
+  I can download a course's content
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | teacher1 | Teacher   | 1        | teacher1@example.com |
+      | student1 | Student   | 1        | student1@example.com |
+    And the following "courses" exist:
+      | fullname   | shortname |
+      | Hockey 101 | C1        |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | teacher1 | C1     | editingteacher |
+      | student1 | C1     | student        |
+    And I log in as "admin"
+    And I navigate to "Courses > Download course content" in site administration
+    And I set the following fields to these values:
+    | Download course content feature available | 1 |
+    And I press "Save changes"
+    And I navigate to "Courses > Course default settings" in site administration
+    And I set the field "Enable download course content" to "Yes"
+    And I press "Save changes"
+    And I log out
+
+  @javascript
+  Scenario: A student can download course content when the feature is enabled in their course
+    Given I log in as "student1"
+    When I am on "Hockey 101" course homepage
+    And "Download course content" "button" should exist
+    And I press "Download course content"
+    Then I should see "You are about to download a zip file"
+    # Without the ability to check the downloaded file, the absence of an exception being thrown here is considered a success.
+    And I click on "Download" "button" in the "Download course content" "dialogue"
+
+  @javascript
+  Scenario: A teacher can download course content when the feature is enabled in their course
+    Given I log in as "teacher1"
+    When I am on "Hockey 101" course homepage
+    And "Download course content" "link" should exist in current page administration
+    And I navigate to "Download course content" in current page administration
+    Then I should see "You are about to download a zip file"
+    # Without the ability to check the downloaded file, the absence of an exception being thrown here is considered a success.
+    And I click on "Download" "button" in the "Download course content" "dialogue"
diff --git a/course/tests/behat/course_download_content_permissions.feature b/course/tests/behat/course_download_content_permissions.feature
new file mode 100644 (file)
index 0000000..7ec6648
--- /dev/null
@@ -0,0 +1,112 @@
+@core @core_course
+Feature: Access to downloading course content can be controlled
+  In order to allow or restrict access to download course content
+  As a trusted user
+  I can control access to the download course content feature
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | teacher1 | Teacher   | 1        | teacher1@example.com |
+      | student1 | Student   | 1        | student1@example.com |
+    And the following "courses" exist:
+      | fullname   | shortname |
+      | Hockey 101 | C1        |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | teacher1 | C1     | editingteacher |
+      | student1 | C1     | student        |
+    And I log in as "admin"
+    And the following config values are set as admin:
+    | downloadcoursecontentallowed | 1 |
+    And I log out
+
+  Scenario: Site admins can remove the download course content feature
+    Given I log in as "admin"
+    And I am on "Hockey 101" course homepage
+    And I navigate to "Edit settings" in current page administration
+    And I set the field "Enable download course content" to "Yes"
+    And I press "Save and display"
+    And "Download course content" "link" should exist in current page administration
+    When the following config values are set as admin:
+      | downloadcoursecontentallowed | 0 |
+    And I am on "Hockey 101" course homepage
+    Then "Download course content" "link" should not exist in current page administration
+    And I navigate to "Edit settings" in current page administration
+    And I should not see "Enable download course content"
+
+  Scenario: Site admins can set the default value for whether download course content is enabled in courses
+    Given I log in as "admin"
+    And I am on "Hockey 101" course homepage
+    And "Download course content" "link" should not exist in current page administration
+    When I navigate to "Courses > Course default settings" in site administration
+    And I set the field "Enable download course content" to "Yes"
+    And I press "Save changes"
+    And I am on "Hockey 101" course homepage
+    Then "Download course content" "link" should exist in current page administration
+
+  Scenario: A teacher can enable and disable the download course content feature when it is available
+    Given I log in as "teacher1"
+    When I am on "Hockey 101" course homepage
+    And "Download course content" "link" should not exist in current page administration
+    And I navigate to "Edit settings" in current page administration
+    And I should see "Enable download course content"
+    And I set the field "Enable download course content" to "Yes"
+    And I press "Save and display"
+    Then "Download course content" "link" should exist in current page administration
+    And I navigate to "Edit settings" in current page administration
+    And I set the field "Enable download course content" to "No"
+    And I press "Save and display"
+    And "Download course content" "link" should not exist in current page administration
+
+  Scenario: Teachers require a capability to access the download course content feature or modify its availability in a course
+    Given I log in as "admin"
+    And I navigate to "Courses > Course default settings" in site administration
+    And I set the field "Enable download course content" to "Yes"
+    And I press "Save changes"
+    And I log out
+    # Check teacher can see download option and enable dropdown.
+    And I log in as "teacher1"
+    And I am on "Hockey 101" course homepage
+    And "Download course content" "link" should exist in current page administration
+    And I navigate to "Edit settings" in current page administration
+    And "Enable download course content" "select" should exist
+    And I log out
+    # Remove teacher's capabilities for download course content.
+    And I log in as "admin"
+    And I set the following system permissions of "Teacher" role:
+      | capability                             | permission |
+      | moodle/course:downloadcoursecontent    | Prohibit   |
+      | moodle/course:configuredownloadcontent | Prohibit   |
+    And I log out
+    # Check teacher can no longer see download option, and that enable value is visible, but dropdown no longer available.
+    When I log in as "teacher1"
+    And I am on "Hockey 101" course homepage
+    Then "Download course content" "link" should not exist in current page administration
+    And I navigate to "Edit settings" in current page administration
+    And I should see "Enable download course content"
+    And I should see "Site default (Yes)"
+    And "Enable download course content" "select" should not exist
+
+  Scenario: Students require a capability to access the download course content feature in a course
+    Given I log in as "teacher1"
+    And I am on "Hockey 101" course homepage
+    And I navigate to "Edit settings" in current page administration
+    And I set the field "Enable download course content" to "Yes"
+    And I press "Save and display"
+    And I log out
+    # Check student can see download button.
+    And I log in as "student1"
+    And I am on "Hockey 101" course homepage
+    And "Download course content" "button" should exist
+    And I log out
+    And I log in as "admin"
+    # Remove student's capability for download course content.
+    When I set the following system permissions of "Student" role:
+      | capability                             | permission |
+      | moodle/course:downloadcoursecontent    | Prohibit   |
+    And I log out
+    # Check student can no longer see download button.
+    And I log in as "student1"
+    And I am on "Hockey 101" course homepage
+    Then "Download course content" "link" should not exist in current page administration
index 611ed6c..80b7026 100644 (file)
         $PAGE->requires->js_init_call('M.core_completion.init');
     }
 
+    // Determine whether the user has permission to download course content.
+    $candownloadcourse = \core\content::can_export_context($context, $USER);
+
     // We are currently keeping the button here from 1.x to help new teachers figure out
     // what to do, even though the link also appears in the course admin block.  It also
     // means you can back out of a situation where you removed the admin block. :)
     if ($PAGE->user_allowed_editing()) {
         $buttons = $OUTPUT->edit_button($PAGE->url);
         $PAGE->set_button($buttons);
+    } else if ($candownloadcourse) {
+        // Show the download course content button if user has permission to access it.
+        // Only showing this if user doesn't have edit rights, since those who do will access it via the actions menu.
+        $buttonattr = \core_course\output\content_export_link::get_attributes($context);
+        $button = new single_button($buttonattr->url, $buttonattr->displaystring, 'post', false, $buttonattr->elementattributes);
+        $PAGE->set_button($OUTPUT->render($button));
     }
 
     // If viewing a section, make the title more specific
     // Include course AJAX
     include_course_ajax($course, $modnamesused);
 
+    // If available, include the JS to prepare the download course content modal.
+    if ($candownloadcourse) {
+        $PAGE->requires->js_call_amd('core_course/downloadcontent', 'init');
+    }
+
     echo $OUTPUT->footer();
index 4f66c4f..f6768fb 100644 (file)
@@ -516,6 +516,8 @@ $string['doctonewwindow'] = 'Open in new window';
 $string['doesnotfit'] = 'Email display settings';
 $string['doesnotfitdetail'] = 'Display settings for email leaving Moodle.';
 $string['download'] = 'Download';
+$string['downloadcoursecontentallowed'] = 'Download course content feature available';
+$string['downloadcoursecontentallowed_desc'] = 'Whether the download course content feature is available to courses. When available, course content downloads can be enabled/disabled using the "Enable download course content" setting within the course edit menu (the default for this can be set in <a href={$a} target="_blank">Course default settings</a>).';
 $string['durationunits'] = 'duration units';
 $string['edithelpdocs'] = 'Edit help documents';
 $string['editlang'] = '<b>Edit</b>';
@@ -802,6 +804,8 @@ sites. If this is not what you wanted then you should make sure you are updating
 from a STABLE branch of the Moodle code. See Moodle Docs for more details.';
 $string['maxbytes'] = 'Maximum uploaded file size';
 $string['maxconsecutiveidentchars'] = 'Consecutive identical characters';
+$string['maxsizeperdownloadcoursefile'] = 'Maximum size per file';
+$string['maxsizeperdownloadcoursefile_desc'] = 'The maximum size of each file when downloading course content. Files exceeding this size will be omitted from the download.';
 $string['maxeditingtime'] = 'Maximum time to edit posts';
 $string['maxusersperpage'] = ' Maximum users per page';
 $string['configmaxusersperpage'] = 'Maximum number of users displayed within user selector in course, group, cohort, webservice etc.';
index a98db31..51b61a6 100644 (file)
@@ -83,7 +83,6 @@ $string['awards'] = 'Recipients';
 $string['backpackavailability'] = 'External badge verification';
 $string['backpackconnectionok'] = 'Backpack connection successfully established';
 $string['backpackconnectionnottested'] = 'The connection cannot be tested for this backpack because only Open Badges v2.0 backpacks support it.';
-$string['backpackneedsupdate'] = 'The backpack connected to this profile does not match the backpack for the site. You need to disconnect and reconnect the backpack.';
 $string['backpackavailability_help'] = 'For badge recipients to be able to prove they earned their badges from you, an external backpack service should be able to access your site and verify badges issued from it. Your site does not currently appear to be accessible, which means that badges you have already issued or will issue in the future cannot be verified.
 
 **Why am I seeing this message?**
@@ -137,6 +136,7 @@ In this area, you can select collections of badges from your backpack that you w
 $string['backpacksettings'] = 'Backpack settings';
 $string['backpackapiurl'] = 'Backpack API URL';
 $string['backpackweburl'] = 'Backpack URL';
+$string['backpackprovider'] = 'Backpack Provider';
 $string['badges'] = 'Badges';
 $string['badgedetails'] = 'Badge details';
 $string['badgeimage'] = 'Image';
@@ -571,7 +571,11 @@ $string['version'] = 'Version';
 $string['version_help'] = 'The version field may be used to keep track of the badge\'s development. If specified, the version is displayed on the badge page.';
 $string['warnexpired'] = ' (This badge has expired!)';
 $string['year'] = 'Year(s)';
+$string['includeauthdetails'] = "Include authentication details with the backpack";
 
 // Deprecated since Moodle 3.9.
 $string['editsettings'] = 'Edit settings';
 $string['sitebackpackverify'] = 'Backpack connection';
+
+// Deprecated since Moodle 3.10.
+$string['backpackneedsupdate'] = 'The backpack connected to this profile does not match the backpack for the site. You need to disconnect and reconnect the backpack.';
index 156ebab..a4e069d 100644 (file)
@@ -54,6 +54,10 @@ $string['customfield_visibility_help'] = 'This setting determines who can view t
 $string['customfield_visibletoall'] = 'Everyone';
 $string['customfield_visibletoteachers'] = 'Teachers';
 $string['customfieldsettings'] = 'Common course custom fields settings';
+$string['downloadcourseconfirmation'] = 'You are about to download a zip file of course content (excluding items which cannot be downloaded and any files larger than {$a}).';
+$string['downloadcoursecontent'] = 'Download course content';
+$string['downloadcoursecontent_help'] = 'This setting determines whether course content may be downloaded by users with the download course content capability (by default users with the role of student or teacher).';
+$string['enabledownloadcoursecontent'] = 'Enable download course content';
 $string['errorendbeforestart'] = 'The end date ({$a}) is before the course start date.';
 $string['favourite'] = 'Starred course';
 $string['gradetopassnotset'] = 'This course does not have a grade to pass set. It may be set in the grade item of the course (Gradebook setup).';
index 4ba2ea5..1028bfe 100644 (file)
@@ -124,3 +124,4 @@ userfilterplaceholder,core
 sitebackpackverify,core_badges
 filetypesnotwhitelisted,core_form
 modeloutputdirinfo,core_analytics
+backpackneedsupdate,core_badges
index cbd3140..94f3595 100644 (file)
@@ -1931,6 +1931,7 @@ $string['since'] = 'Since';
 $string['sincelast'] = 'since last login';
 $string['site'] = 'Site';
 $string['sitedefault'] = 'Site default';
+$string['sitedefaultspecified'] = 'Site default ({$a})';
 $string['siteerrors'] = 'Site errors';
 $string['sitefiles'] = 'Site files';
 $string['sitefilesused'] = 'Site files used in this course';
index 625c0c0..e884127 100644 (file)
@@ -172,6 +172,8 @@ $string['course:changelockedcustomfields'] = 'Change locked custom fields';
 $string['course:changeshortname'] = 'Change course short name';
 $string['course:changesummary'] = 'Change course summary';
 $string['course:configurecustomfields'] = 'Configure custom fields';
+$string['course:configuredownloadcontent'] = 'Configure download course content';
+$string['course:downloadcoursecontent'] = 'Download course content';
 $string['course:enrolconfig'] = 'Configure enrol instances in courses';
 $string['course:enrolreview'] = 'Review course enrolments';
 $string['course:setforcedlanguage'] = 'Force course language';
index 7fadd34..d3f99e0 100644 (file)
Binary files a/lib/amd/build/tree.min.js and b/lib/amd/build/tree.min.js differ
index b7d4e17..9ff2c7d 100644 (file)
Binary files a/lib/amd/build/tree.min.js.map and b/lib/amd/build/tree.min.js.map differ
index 0839895..af051da 100644 (file)
@@ -484,7 +484,7 @@ define(['jquery'], function($) {
 
         if (e.altKey || e.ctrlKey || e.shiftKey || e.metaKey) {
             // Do nothing.
-            return true;
+            return;
         }
 
         // Update the active item.
@@ -494,9 +494,6 @@ define(['jquery'], function($) {
         if (this.isGroupItem(item)) {
             this.toggleGroup(item);
         }
-
-        e.stopPropagation();
-        return true;
     };
 
     /**
index 78228c0..81bb7ef 100644 (file)
@@ -762,7 +762,7 @@ function badges_local_backpack_js($checksite = false) {
 }
 
 /**
- * Create the backpack with this data.
+ * Create the site backpack with this data.
  *
  * @param stdClass $data The new backpack data.
  * @return boolean
@@ -773,14 +773,8 @@ function badges_create_site_backpack($data) {
     require_capability('moodle/badges:manageglobalsettings', $context);
 
     $count = $DB->count_records('badge_external_backpack');
-
-    $backpack = new stdClass();
-    $backpack->apiversion = $data->apiversion;
-    $backpack->backpackapiurl = $data->backpackapiurl;
-    $backpack->backpackweburl = $data->backpackweburl;
-    $backpack->sortorder = $count;
-    $DB->insert_record('badge_external_backpack', $backpack);
-    return true;
+    $data->sortorder = $count;
+    return badges_save_external_backpack($data);
 }
 
 /**
@@ -796,15 +790,8 @@ function badges_update_site_backpack($id, $data) {
     require_capability('moodle/badges:manageglobalsettings', $context);
 
     if ($backpack = badges_get_site_backpack($id)) {
-        $backpack = new stdClass();
-        $backpack->id = $id;
-        $backpack->apiversion = $data->apiversion;
-        $backpack->backpackweburl = $data->backpackweburl;
-        $backpack->backpackapiurl = $data->backpackapiurl;
-        $backpack->password = !empty($data->password) ? $data->password : '';
-        $backpack->oauth2_issuerid = !empty($data->oauth2_issuerid) ? $data->oauth2_issuerid : '';
-        $DB->update_record('badge_external_backpack', $backpack);
-        return true;
+        $data->id = $id;
+        return badges_save_external_backpack($data);
     }
     return false;
 }
@@ -850,14 +837,88 @@ function badges_delete_site_backpack($id) {
     return false;
 }
 
+/**
+ * Perform the actual create/update of external bakpacks. Any checks on the validity of the id will need to be
+ * performed before it reaches this function.
+ *
+ * @param stdClass $data The backpack data we are updating/inserting
+ * @return int Returns the id of the new/updated record
+ */
+function badges_save_external_backpack(stdClass $data) {
+    global $DB;
+    $backpack = new stdClass();
+
+    $backpack->apiversion = $data->apiversion;
+    $backpack->backpackweburl = $data->backpackweburl;
+    $backpack->backpackapiurl = $data->backpackapiurl;
+    $backpack->oauth2_issuerid = $data->oauth2_issuerid ?? '';
+    if (isset($data->sortorder)) {
+        $backpack->sortorder = $data->sortorder;
+    }
+
+    $method = 'insert_record';
+    if (isset($data->id) && $data->id) {
+        $backpack->id = $data->id;
+        $method = 'update_record';
+    }
+    $record = $DB->$method('badge_external_backpack', $backpack, true);
+    $data->externalbackpackid = $data->id ?? $record;
+
+    unset($data->id);
+    badges_save_backpack_credentials($data);
+    return $data->externalbackpackid;
+}
+
+/**
+ * Create a backpack with the provided details. Stores the auth details of the backpack
+ *
+ * @param stdClass $data Backpack specific data.
+ * @return int The id of the external backpack that the credentials correspond to
+ */
+function badges_save_backpack_credentials(stdClass $data) {
+    global $DB;
+
+    if (isset($data->backpackemail) && isset($data->password)) {
+        $backpack = new stdClass();
+
+        $backpack->email = $data->backpackemail;
+        $backpack->password = !empty($data->password) ? $data->password : '';
+        $backpack->externalbackpackid = $data->externalbackpackid;
+        $backpack->userid = $data->userid ?? 0;
+        $backpack->backpackuid = $data->backpackuid ?? 0;
+        $backpack->autosync = $data->autosync ?? 0;
+
+        $id = null;
+        if (isset($data->badgebackpack) && $data->badgebackpack) {
+            $id = $data->badgebackpack;
+        } else if (isset($data->id) && $data->id) {
+            $id = $data->id;
+        }
+
+        $method = $id ? 'update_record' : 'insert_record';
+        if ($id) {
+            $backpack->id = $id;
+        }
+
+        $DB->$method('badge_backpack', $backpack);
+        return $backpack->externalbackpackid;
+    }
+
+    return $data->externalbackpackid ?? 0;
+}
+
 /**
  * Is any backpack enabled that supports open badges V1?
+ * @param int|null $backpackid Check the version of the given id OR if null the sitewide backpack
  * @return boolean
  */
-function badges_open_badges_backpack_api() {
-    global $CFG;
+function badges_open_badges_backpack_api(?int $backpackid = null) {
+    if (!$backpackid) {
+        global $CFG;
+        $backpackid = $CFG->badges_site_backpack;
+    }
 
-    $backpack = badges_get_site_backpack($CFG->badges_site_backpack);
+    $backpack = badges_get_site_backpack($backpackid);
     if (empty($backpack->apiversion)) {
         return OPEN_BADGES_V2;
     }
@@ -865,15 +926,54 @@ function badges_open_badges_backpack_api() {
 }
 
 /**
- * Get a site backpacks by id or url.
+ * Get a site backpacks by id for a particular user or site (if userid is 0)
  *
  * @param int $id The backpack id.
+ * @param int $userid The owner of the backpack, 0 if it's a sitewide backpack else a user's site backpack
  * @return array(stdClass)
  */
-function badges_get_site_backpack($id) {
+function badges_get_site_backpack($id, int $userid = 0) {
     global $DB;
 
-    return $DB->get_record('badge_external_backpack', ['id' => $id]);
+    $sql = "SELECT beb.*, bb.id AS badgebackpack, bb.password, bb.email AS backpackemail
+              FROM {badge_external_backpack} beb
+         LEFT JOIN {badge_backpack} bb ON bb.externalbackpackid = beb.id AND bb.userid=:userid
+             WHERE beb.id=:id";
+
+    return $DB->get_record_sql($sql, ['id' => $id, 'userid' => $userid]);
+}
+
+/**
+ * Get the user backpack for the currently logged in user OR the provided user
+ *
+ * @param int|null $userid The user whose backpack you're requesting for. If null, get the logged in user's backpack
+ * @return mixed The user's backpack or none.
+ * @throws dml_exception
+ */
+function badges_get_user_backpack(?int $userid = 0) {
+    global $DB;
+
+    if (!$userid) {
+        global $USER;
+        $userid = $USER->id;
+    }
+
+    $sql = "SELECT beb.*, bb.id AS badgebackpack, bb.password, bb.email AS backpackemail
+              FROM {badge_external_backpack} beb
+              JOIN {badge_backpack} bb ON bb.externalbackpackid = beb.id AND bb.userid=:userid";
+
+    return $DB->get_record_sql($sql, ['userid' => $userid]);
+}
+
+/**
+ * Get the primary backpack for the site
+ *
+ * @return array(stdClass)
+ */
+function badges_get_site_primary_backpack() {
+    global $CFG;
+
+    return badges_get_site_backpack($CFG->badges_site_backpack);
 }
 
 /**
@@ -917,6 +1017,7 @@ function badges_get_badge_api_versions() {
 function badges_get_default_issuer() {
     global $CFG, $SITE;
 
+    $sitebackpack = badges_get_site_primary_backpack();
     $issuer = array();
     $issuerurl = new moodle_url('/');
     $issuer['name'] = $CFG->badges_defaultissuername;
@@ -924,7 +1025,7 @@ function badges_get_default_issuer() {
         $issuer['name'] = $SITE->fullname ? $SITE->fullname : $SITE->shortname;
     }
     $issuer['url'] = $issuerurl->out(false);
-    $issuer['email'] = $CFG->badges_defaultissuercontact;
+    $issuer['email'] = $sitebackpack->backpackemail ?? $CFG->badges_defaultissuercontact;
     $issuer['@context'] = OPEN_BADGES_V2_CONTEXT;
     $issuerid = new moodle_url('/badges/issuer_json.php');
     $issuer['id'] = $issuerid->out(false);
@@ -962,9 +1063,10 @@ function badges_disconnect_user_backpack($userid) {
  * @param integer $sitebackpackid The site backpack to connect to.
  * @param string $type The type of this remote object.
  * @param string $internalid The id for this object on the Moodle site.
+ * @param string $param The param we need to return. Defaults to the externalid.
  * @return mixed The id or false if it doesn't exist.
  */
-function badges_external_get_mapping($sitebackpackid, $type, $internalid) {
+function badges_external_get_mapping($sitebackpackid, $type, $internalid, $param = 'externalid') {
     global $DB;
     // Return externalid if it exists.
     $params = [
@@ -973,9 +1075,9 @@ function badges_external_get_mapping($sitebackpackid, $type, $internalid) {
         'internalid' => $internalid
     ];
 
-    $record = $DB->get_record('badge_external_identifier', $params, 'externalid', IGNORE_MISSING);
+    $record = $DB->get_record('badge_external_identifier', $params, $param, IGNORE_MISSING);
     if ($record) {
-        return $record->externalid;
+        return $record->$param;
     }
     return false;
 }
@@ -1256,3 +1358,26 @@ function badges_get_oauth2_service_options() {
 
     return $options;
 }
+
+/**
+ * Generate a public badgr URL that conforms to OBv2. This is done because badgr responses do not currently conform to
+ * the spec.
+ *
+ * WARNING: This is an extremely hacky way of implementing this and should be removed once the standards are conformed to.
+ *
+ * @param stdClass $backpack The Badgr backpack we are pushing to
+ * @param string $type The type of object we are dealing with either Issuer, Assertion OR Badge.
+ * @param string $externalid The externalid as provided by the backpack
+ * @return string The public URL to access Badgr objects
+ */
+function badges_generate_badgr_open_url($backpack, $type, $externalid) {
+    if (badges_open_badges_backpack_api($backpack->id) == OPEN_BADGES_V2) {
+        $entity = strtolower($type);
+        if ($type == OPEN_BADGES_V2_TYPE_BADGE) {
+            $entity = "badge";
+        }
+        $url = new moodle_url($backpack->backpackapiurl);
+        return "{$url->get_scheme()}://{$url->get_host()}/public/{$entity}s/$externalid";
+
+    }
+}
\ No newline at end of file
index 1699213..d9ad2c9 100644 (file)
@@ -52,7 +52,30 @@ class content {
      * @return  bool
      */
     public static function can_export_context(context $currentcontext, stdClass $user): bool {
-        return true;
+        global $CFG;
+
+        $canexport = false;
+
+        if ($currentcontext->contextlevel == CONTEXT_COURSE) {
+            if ($CFG->downloadcoursecontentallowed &&
+                    has_capability('moodle/course:downloadcoursecontent', $currentcontext, $user)) {
+
+                $courseinfo = get_fast_modinfo($currentcontext->instanceid)->get_course();
+
+                // If enabled/disabled explicitly set on course, use that as the course setting, otherwise use site default.
+                if (isset($courseinfo->downloadcontent) && $courseinfo->downloadcontent != DOWNLOAD_COURSE_CONTENT_SITE_DEFAULT) {
+                    $canexport = $courseinfo->downloadcontent;
+                } else {
+                    $canexport = get_config('moodlecourse')->downloadcontentsitedefault;
+                }
+
+            }
+        } else if ($currentcontext->contextlevel == CONTEXT_MODULE) {
+            // Modules can only be exported if exporting is allowed in their course context.
+            $canexport = self::can_export_context($currentcontext->get_course_context(), $user);
+        }
+
+        return $canexport;
     }
 
     /**
index 4d833cb..bc812d5 100644 (file)
@@ -2577,13 +2577,34 @@ $capabilities = array(
         ]
     ],
 
+    // Allow users to download course content.
+    'moodle/course:downloadcoursecontent' => [
+        'captype' => 'read',
+        'contextlevel' => CONTEXT_COURSE,
+        'archetypes' => array(
+            'student' => CAP_ALLOW,
+            'teacher' => CAP_ALLOW,
+            'editingteacher' => CAP_ALLOW,
+            'manager' => CAP_ALLOW
+        )
+    ],
+
+    // Allow users to configure download course content functionality within a course, if the feature is available.
+    'moodle/course:configuredownloadcontent' => [
+        'captype' => 'write',
+        'contextlevel' => CONTEXT_COURSE,
+        'archetypes' => array(
+            'editingteacher' => CAP_ALLOW,
+            'manager' => CAP_ALLOW
+        ),
+    ],
+
     // Allow to manage payment accounts.
     'moodle/payment:manageaccounts' => [
         'captype' => 'write',
         'riskbitmask' => RISK_PERSONAL | RISK_CONFIG | RISK_DATALOSS,
         'contextlevel' => CONTEXT_COURSE,
-        'archetypes' => [
-        ]
+        'archetypes' => [],
     ],
 
     // Allow to view payments.
@@ -2591,7 +2612,6 @@ $capabilities = array(
         'captype' => 'read',
         'riskbitmask' => RISK_PERSONAL,
         'contextlevel' => CONTEXT_COURSE,
-        'archetypes' => [
-        ]
+        'archetypes' => [],
     ],
 );
index 5974516..87a8826 100755 (executable)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="lib/db" VERSION="20201016" COMMENT="XMLDB file for core Moodle tables"
+<XMLDB PATH="lib/db" VERSION="20201021" COMMENT="XMLDB file for core Moodle tables"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="../../lib/xmldb/xmldb.xsd"
 >
@@ -89,6 +89,7 @@
         <FIELD NAME="showreports" TYPE="int" LENGTH="4" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
         <FIELD NAME="visible" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="1" SEQUENCE="false"/>
         <FIELD NAME="visibleold" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="1" SEQUENCE="false" COMMENT="the state of visible field when hiding parent category, this helps us to recover hidden states when unhiding the parent category later"/>
+        <FIELD NAME="downloadcontent" TYPE="int" LENGTH="1" NOTNULL="false" SEQUENCE="false"/>
         <FIELD NAME="groupmode" TYPE="int" LENGTH="4" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
         <FIELD NAME="groupmodeforce" TYPE="int" LENGTH="4" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
         <FIELD NAME="defaultgroupingid" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="default grouping used in course modules, does not have key intentionally"/>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
         <KEY NAME="fk_userid" TYPE="foreign" FIELDS="userid" REFTABLE="user" REFFIELDS="id"/>
+        <KEY NAME="backpackcredentials" TYPE="unique" FIELDS="userid, externalbackpackid"/>
         <KEY NAME="externalbackpack" TYPE="foreign" FIELDS="externalbackpackid" REFTABLE="badge_external_backpack" REFFIELDS="id"/>
       </KEYS>
     </TABLE>
         <FIELD NAME="backpackweburl" TYPE="char" LENGTH="255" NOTNULL="true" SEQUENCE="false"/>
         <FIELD NAME="apiversion" TYPE="char" LENGTH="12" NOTNULL="true" DEFAULT="1.0" SEQUENCE="false"/>
         <FIELD NAME="sortorder" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
-        <FIELD NAME="password" TYPE="char" LENGTH="255" NOTNULL="false" SEQUENCE="false" COMMENT="Password to login into external backpack and issue badges."/>
         <FIELD NAME="oauth2_issuerid" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false" COMMENT="OAuth 2 Issuer"/>
       </FIELDS>
       <KEYS>
index b2f9bb8..3976d5f 100644 (file)
@@ -2774,6 +2774,7 @@ function xmldb_main_upgrade($oldversion) {
 
         // Add example field.
         $field = new xmldb_field('example', XMLDB_TYPE_TEXT, null, null, null, null, null, 'tutorial');
+
         if (!$dbman->field_exists($table, $field)) {
             $dbman->add_field($table, $field);
         }
@@ -2862,7 +2863,61 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2020102100.02);
     }
 
-    if ($oldversion < 2020102700.01) {
+    if ($oldversion < 2020102300.01) {
+        // Define field downloadcontent to be added to course.
+        $table = new xmldb_table('course');
+        $field = new xmldb_field('downloadcontent', XMLDB_TYPE_INTEGER, '1', null, null, null, null, 'visibleold');
+
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2020102300.01);
+    }
+
+    if ($oldversion < 2020102300.02) {
+        $table = new xmldb_table('badge_backpack');
+
+        // There is no key_exists, so test the equivalent index.
+        $oldindex = new xmldb_index('backpackcredentials', XMLDB_KEY_UNIQUE, ['userid', 'externalbackpackid']);
+        if (!$dbman->index_exists($table, $oldindex)) {
+            // All external backpack providers/hosts are now exclusively stored in badge_external_backpack.
+            // All credentials are stored in badge_backpack and are unique per user, backpack.
+            $uniquekey = new xmldb_key('backpackcredentials', XMLDB_KEY_UNIQUE, ['userid', 'externalbackpackid']);
+            $dbman->add_key($table, $uniquekey);
+        }
+
+        // Drop the password field as this is moved to badge_backpack.
+        $table = new xmldb_table('badge_external_backpack');
+        $field = new xmldb_field('password', XMLDB_TYPE_CHAR, '50');
+        if ($dbman->field_exists($table, $field)) {
+            // If there is a current backpack set then copy it across to the new structure.
+            if ($CFG->badges_defaultissuercontact) {
+                // Get the currently used site backpacks.
+                $records = $DB->get_records_select('badge_external_backpack', "password IS NOT NULL AND password != ''");
+                $backpack = [
+                    'userid' => '0',
+                    'email' => $CFG->badges_defaultissuercontact,
+                    'backpackuid' => -1
+                ];
+
+                // Create records corresponding to the site backpacks.
+                foreach ($records as $record) {
+                    $backpack['password'] = $record->password;
+                    $backpack['externalbackpackid'] = $record->id;
+                    $DB->insert_record('badge_backpack', (object) $backpack);
+                }
+            }
+
+            $dbman->drop_field($table, $field);
+        }
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2020102300.02);
+    }
+
+    if ($oldversion < 2020102700.04) {
 
         // Define table payment_accounts to be created.
         $table = new xmldb_table('payment_accounts');
@@ -2885,12 +2940,6 @@ function xmldb_main_upgrade($oldversion) {
             $dbman->create_table($table);
         }
 
-        // Main savepoint reached.
-        upgrade_main_savepoint(true, 2020102700.01);
-    }
-
-    if ($oldversion < 2020102700.02) {
-
         // Define table payment_gateways to be created.
         $table = new xmldb_table('payment_gateways');
 
@@ -2912,12 +2961,6 @@ function xmldb_main_upgrade($oldversion) {
             $dbman->create_table($table);
         }
 
-        // Main savepoint reached.
-        upgrade_main_savepoint(true, 2020102700.02);
-    }
-
-    if ($oldversion < 2020102700.03) {
-
         // Define table payments to be created.
         $table = new xmldb_table('payments');
 
@@ -2949,7 +2992,7 @@ function xmldb_main_upgrade($oldversion) {
         }
 
         // Main savepoint reached.
-        upgrade_main_savepoint(true, 2020102700.03);
+        upgrade_main_savepoint(true, 2020102700.04);
     }
 
     return true;
index f656e8b..d579e8e 100644 (file)
@@ -4448,7 +4448,7 @@ class settings_navigation extends navigation_node {
      * @return navigation_node|false
      */
     protected function load_course_settings($forceopen = false) {
-        global $CFG;
+        global $CFG, $USER;
         require_once($CFG->dirroot . '/course/lib.php');
 
         $course = $this->page->course;
@@ -4605,6 +4605,16 @@ class settings_navigation extends navigation_node {
             }
         }
 
+        // Prepare data for course content download functionality if it is enabled.
+        // Will only be included here if the action menu is already in use, otherwise a button will be added to the UI elsewhere.
+        if (\core\content::can_export_context($coursecontext, $USER) && !empty($coursenode->get_children_key_list())) {
+            $linkattr = \core_course\output\content_export_link::get_attributes($coursecontext);
+            $actionlink = new action_link($linkattr->url, $linkattr->displaystring, null, $linkattr->elementattributes);
+
+            $coursenode->add($linkattr->displaystring, $actionlink, self::TYPE_SETTING, null, 'download',
+                    new pix_icon('t/download', ''));
+        }
+
         // Return we are done
         return $coursenode;
     }
index 8378da8..6e5f3aa 100644 (file)
@@ -2051,7 +2051,8 @@ class moodle_page {
             $reportnode = $myprofilenode->add(get_string('reports'));
         }
         // Finally add the report to the navigation tree.
-        $reportnode->add($nodeinfo['name'], $nodeinfo['url'], navigation_node::TYPE_COURSE);
+        $reportnode->add($nodeinfo['name'], $nodeinfo['url'], navigation_node::TYPE_CUSTOM, null, null,
+            new pix_icon('i/report', $nodeinfo['name']));
     }
 
     /**
diff --git a/lib/tests/content_test.php b/lib/tests/content_test.php
new file mode 100644 (file)
index 0000000..52c8f56
--- /dev/null
@@ -0,0 +1,119 @@
+<?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/>.
+
+/**
+ * Unit tests for core\content class.
+ *
+ * @package     core
+ * @category    test
+ * @copyright   2020 Michael Hawkins <michaelh@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core;
+
+/**
+ * Unit tests for core\content class.
+ *
+ * @package     core
+ * @category    test
+ * @copyright   2020 Michael Hawkins <michaelh@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class content_test extends \advanced_testcase {
+
+    /**
+     * A test to confirm only valid cases allow exporting of course content.
+     */
+    public function test_can_export_context_course() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $course1 = $this->getDataGenerator()->create_course();
+        $course2 = $this->getDataGenerator()->create_course();
+        $course1context = \context_course::instance($course1->id);
+        $course2context = \context_course::instance($course2->id);
+
+        // Enrol user as student in course1 only.
+        $user = $this->getDataGenerator()->create_and_enrol($course1, 'student');
+
+        // Confirm by default enrolled user does not have permission to export in course1.
+        $this->assertFalse(content::can_export_context($course1context, $user));
+
+        // Make course download available on site, but not enabled in course1 or by default.
+        set_config('downloadcoursecontentallowed', true);
+
+        // Confirm user still does not have permission to export (disabled in courses by default).
+        $this->assertFalse(content::can_export_context($course1context, $user));
+
+        // Enable export in courses by default.
+        set_config('downloadcontentsitedefault', DOWNLOAD_COURSE_CONTENT_ENABLED, 'moodlecourse');
+
+        // Confirm user now has permission to export in course1 only.
+        $this->assertTrue(content::can_export_context($course1context, $user));
+
+        // Disable course downloads in course1.
+        $course1->downloadcontent = DOWNLOAD_COURSE_CONTENT_DISABLED;
+        $DB->update_record('course', $course1);
+        rebuild_course_cache($course1->id);
+
+        // Confirm user does not have permission to export in course1.
+        $this->assertFalse(content::can_export_context($course1context, $user));
+
+        // Enable course downloads in course1.
+        $course1->downloadcontent = DOWNLOAD_COURSE_CONTENT_ENABLED;
+        $DB->update_record('course', $course1);
+        rebuild_course_cache($course1->id);
+
+        // Confirm user has permission to export in course1.
+        $this->assertTrue(content::can_export_context($course1context, $user));
+
+        // Confirm user does not have permission to export in course they are not enrolled in (course2).
+        $this->assertFalse(content::can_export_context($course2context, $user));
+
+        // Disable export in courses by default.
+        set_config('downloadcontentsitedefault', DOWNLOAD_COURSE_CONTENT_DISABLED, 'moodlecourse');
+
+        // Confirm user still has permission to export in course1 (still enabled at the course level).
+        $this->assertTrue(content::can_export_context($course1context, $user));
+
+        // Disable the course downloads feature.
+        set_config('downloadcoursecontentallowed', false);
+
+        // Confirm user no longer has permission to export in course1.
+        $this->assertFalse(content::can_export_context($course1context, $user));
+    }
+
+    /**
+     * A test to confirm unsupported contexts will return false when checking whether content can be exported.
+     */
+    public function test_can_export_context_unsupported_context() {
+        $this->resetAfterTest();
+
+        $course1 = $this->getDataGenerator()->create_course();
+        $systemcontext = \context_system::instance();
+
+        // Enrol user as student in course1 only.
+        $user = $this->getDataGenerator()->create_and_enrol($course1, 'student');
+
+        // Make course download available on site (course context).
+        set_config('downloadcoursecontentallowed', true);
+
+        // Confirm system context does not gain permission to export content.
+        $this->assertFalse(content::can_export_context($systemcontext, $user));
+    }
+}
index 75f566f..075fa9e 100644 (file)
Binary files a/mod/lti/amd/build/tool_card_controller.min.js and b/mod/lti/amd/build/tool_card_controller.min.js differ
index bfe3b34..e99b87b 100644 (file)
Binary files a/mod/lti/amd/build/tool_card_controller.min.js.map and b/mod/lti/amd/build/tool_card_controller.min.js.map differ
index 4fccd1b..94998c3 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 5191052..df0e78c 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 763037f..4bd865c 100644 (file)
             return toolTypeData;
         }).then(function(toolTypeData) {
             return templates.render('mod_lti/tool_card', toolTypeData);
-        }).then(function(renderResult) {
-            var html = renderResult[0];
-            var js = renderResult[1];
-
+        }).then(function(html, js) {
             templates.replaceNode(element, html, js);
             return;
         }).catch(function() {
index 40dc885..6a38f59 100644 (file)
@@ -32,26 +32,17 @@ define(['jquery', 'core/ajax', 'core/notification', 'core/templates', 'mod_lti/e
     var SELECTORS = {
         EXTERNAL_REGISTRATION_CONTAINER: '#external-registration-container',
         EXTERNAL_REGISTRATION_PAGE_CONTAINER: '#external-registration-page-container',
+        EXTERNAL_REGISTRATION_TEMPLATE_CONTAINER: '#external-registration-template-container',
         CARTRIDGE_REGISTRATION_CONTAINER: '#cartridge-registration-container',
         CARTRIDGE_REGISTRATION_FORM: '#cartridge-registration-form',
         ADD_TOOL_FORM: '#add-tool-form',
         TOOL_LIST_CONTAINER: '#tool-list-container',
         TOOL_CREATE_BUTTON: '#tool-create-button',
+        TOOL_CREATE_LTILEGACY_BUTTON: '#tool-createltilegacy-button',
         REGISTRATION_CHOICE_CONTAINER: '#registration-choice-container',
         TOOL_URL: '#tool-url'
     };
 
-    /**
-     * Get the tool create button element.
-     *
-     * @method getToolCreateButton
-     * @private
-     * @return {Object} jQuery object
-     */
-    var getToolCreateButton = function() {
-        return $(SELECTORS.TOOL_CREATE_BUTTON);
-    };
-
     /**
      * Get the tool list container element.
      *
@@ -96,6 +87,40 @@ define(['jquery', 'core/ajax', 'core/notification', 'core/templates', 'mod_lti/e
         return $(SELECTORS.REGISTRATION_CHOICE_CONTAINER);
     };
 
+    /**
+     * Close the LTI Advantage Registration IFrame.
+     *
+     * @private
+     * @param {Object} e post message event sent from the registration frame.
+     */
+    var closeLTIAdvRegistration = function(e) {
+        if (e.data && 'org.imsglobal.lti.close' === e.data.subject) {
+            $(SELECTORS.EXTERNAL_REGISTRATION_TEMPLATE_CONTAINER).empty();
+            hideExternalRegistration();
+            showRegistrationChoices();
+            showToolList();
+            showRegistrationChoices();
+            reloadToolList();
+        }
+    };
+
+    /**
+     * Load the external registration template and render it in the DOM and display it.
+     *
+     * @method initiateRegistration
+     * @private
+     * @param {String} url where to send the registration request
+     */
+    var initiateRegistration = function(url) {
+        // Show the external registration page in an iframe.
+        $(SELECTORS.EXTERNAL_REGISTRATION_PAGE_CONTAINER).removeClass('hidden');
+        var container = $(SELECTORS.EXTERNAL_REGISTRATION_TEMPLATE_CONTAINER);
+        container.append($("<iframe src='startltiadvregistration.php?url="
+                         + encodeURIComponent(url) + "'></iframe>"));
+        showExternalRegistration();
+        window.addEventListener("message", closeLTIAdvRegistration, false);
+    };
+
     /**
      * Get the tool type URL.
      *
@@ -287,22 +312,38 @@ define(['jquery', 'core/ajax', 'core/notification', 'core/templates', 'mod_lti/e
                 });
     };
 
+    /**
+     * Start the LTI Advantage registration.
+     *
+     * @method addLTIAdvTool
+     * @private
+     */
+    var addLTIAdvTool = function() {
+        var url = $.trim(getToolURL());
+
+        if (url) {
+            $(SELECTORS.TOOL_URL).val('');
+            hideToolList();
+            initiateRegistration(url);
+        }
+
+    };
+
     /**
      * Trigger appropriate registration process process for the user input
      * URL. It can either be a cartridge or a registration url.
      *
-     * @method addTool
+     * @method addLTILegacyTool
      * @private
      * @return {Promise} jQuery Deferred object
      */
-    var addTool = function() {
+    var addLTILegacyTool = function() {
         var url = $.trim(getToolURL());
 
         if (url === "") {
             return $.Deferred().resolve();
         }
-
-        var toolButton = getToolCreateButton();
+        var toolButton = $(SELECTORS.TOOL_CREATE_LTILEGACY_BUTTON);
         startLoading(toolButton);
 
         var promise = toolType.isCartridge(url);
@@ -372,10 +413,16 @@ define(['jquery', 'core/ajax', 'core/notification', 'core/templates', 'mod_lti/e
             showRegistrationFeedback(data);
         });
 
-        var form = $(SELECTORS.ADD_TOOL_FORM);
-        form.submit(function(e) {
+        var addLegacyButton = $(SELECTORS.TOOL_CREATE_LTILEGACY_BUTTON);
+        addLegacyButton.click(function(e) {
+            e.preventDefault();
+            addLTILegacyTool();
+        });
+
+        var addLTIButton = $(SELECTORS.TOOL_CREATE_BUTTON);
+        addLTIButton.click(function(e) {
             e.preventDefault();
-            addTool();
+            addLTIAdvTool();
         });
 
     };
index 7afbbe6..419482f 100644 (file)
  * @copyright  2019 Stephen Vickers
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
+use mod_lti\local\ltiopenid\jwks_helper;
 
 define('NO_DEBUG_DISPLAY', true);
 define('NO_MOODLE_COOKIES', true);
 
 require_once(__DIR__ . '/../../config.php');
 
-$jwks = array('keys' => array());
-
-$privatekey = get_config('mod_lti', 'privatekey');
-$res = openssl_pkey_get_private($privatekey);
-$details = openssl_pkey_get_details($res);
-
-$jwk = array();
-$jwk['kty'] = 'RSA';
-$jwk['alg'] = 'RS256';
-$jwk['kid'] = get_config('mod_lti', 'kid');
-$jwk['e'] = rtrim(strtr(base64_encode($details['rsa']['e']), '+/', '-_'), '=');
-$jwk['n'] = rtrim(strtr(base64_encode($details['rsa']['n']), '+/', '-_'), '=');
-$jwk['use'] = 'sig';
-
-$jwks['keys'][] = $jwk;
-
 @header('Content-Type: application/json; charset=utf-8');
 
-echo json_encode($jwks, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
+echo json_encode(jwks_helper::get_jwks(), JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
diff --git a/mod/lti/classes/local/ltiopenid/jwks_helper.php b/mod/lti/classes/local/ltiopenid/jwks_helper.php
new file mode 100644 (file)
index 0000000..20cc956
--- /dev/null
@@ -0,0 +1,72 @@
+<?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/>.
+
+/**
+ * This files exposes functions for LTI 1.3 Key Management.
+ *
+ * @package    mod_lti
+ * @copyright  2020 Claude Vervoort (Cengage)
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace mod_lti\local\ltiopenid;
+
+/**
+ * This class exposes functions for LTI 1.3 Key Management.
+ *
+ * @package    mod_lti
+ * @copyright  2020 Claude Vervoort (Cengage)
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class jwks_helper {
+
+    /**
+     * Returns the private key to use to sign outgoing JWT.
+     *
+     * @return array keys are kid and key in PEM format.
+     */
+    public static function get_private_key() {
+        $privatekey = get_config('mod_lti', 'privatekey');
+        $kid = get_config('mod_lti', 'kid');
+        return [
+            "key" => $privatekey,
+            "kid" => $kid
+        ];
+    }
+
+    /**
+     * Returns the JWK Key Set for this site.
+     * @return array keyset exposting the site public key.
+     */
+    public static function get_jwks() {
+        $jwks = array('keys' => array());
+
+        $privatekey = self::get_private_key();
+        $res = openssl_pkey_get_private($privatekey['key']);
+        $details = openssl_pkey_get_details($res);
+
+        $jwk = array();
+        $jwk['kty'] = 'RSA';
+        $jwk['alg'] = 'RS256';
+        $jwk['kid'] = $privatekey['kid'];
+        $jwk['e'] = rtrim(strtr(base64_encode($details['rsa']['e']), '+/', '-_'), '=');
+        $jwk['n'] = rtrim(strtr(base64_encode($details['rsa']['n']), '+/', '-_'), '=');
+        $jwk['use'] = 'sig';
+
+        $jwks['keys'][] = $jwk;
+        return $jwks;
+    }
+
+}
diff --git a/mod/lti/classes/local/ltiopenid/registration_exception.php b/mod/lti/classes/local/ltiopenid/registration_exception.php
new file mode 100644 (file)
index 0000000..216f8c4
--- /dev/null
@@ -0,0 +1,32 @@
+<?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/>.
+
+/**
+ * This library exposes functions for LTI Dynamic Registration.
+ *
+ * @package    mod_lti
+ * @copyright  2020 Claude Vervoort (Cengage), Carlos Costa, Adrian Hutchinson (Macgraw Hill)
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace mod_lti\local\ltiopenid;
+
+/**
+ * Exception when transforming the registration to LTI config.
+ *
+ * Code is the HTTP Error code.
+ */
+class registration_exception extends \Exception {
+}
diff --git a/mod/lti/classes/local/ltiopenid/registration_helper.php b/mod/lti/classes/local/ltiopenid/registration_helper.php
new file mode 100644 (file)
index 0000000..448fc01
--- /dev/null
@@ -0,0 +1,345 @@
+<?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/>.
+
+/**
+ * A Helper for LTI Dynamic Registration.
+ *
+ * @package    mod_lti
+ * @copyright  2020 Claude Vervoort (Cengage), Carlos Costa, Adrian Hutchinson (Macgraw Hill)
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace mod_lti\local\ltiopenid;
+
+defined('MOODLE_INTERNAL') || die;
+
+require_once($CFG->dirroot . '/mod/lti/locallib.php');
+use Firebase\JWT\JWK;
+use Firebase\JWT\JWT;
+use stdClass;
+
+/**
+ * This class exposes functions for LTI Dynamic Registration.
+ *
+ * @package    mod_lti
+ * @copyright  2020 Claude Vervoort (Cengage), Carlos Costa, Adrian Hutchinson (Macgraw Hill)
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class registration_helper {
+    /** score scope */
+    const SCOPE_SCORE = 'https://purl.imsglobal.org/spec/lti-ags/scope/score';
+    /** result scope */
+    const SCOPE_RESULT = 'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly';
+    /** lineitem read-only scope */
+    const SCOPE_LINEITEM_RO = 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly';
+    /** lineitem full access scope */
+    const SCOPE_LINEITEM = 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem';
+    /** Names and Roles (membership) scope */
+    const SCOPE_NRPS = 'https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly';
+    /** Tool Settings scope */
+    const SCOPE_TOOL_SETTING = 'https://purl.imsglobal.org/spec/lti-ts/scope/toolsetting';
+
+
+    /**
+     * Function used to validate parameters.
+     *
+     * This function is needed because the payload contains nested
+     * objects, and optional_param() does not support arrays of arrays.
+     *
+     * @param array $payload that may contain the parameter key
+     * @param string $key the key of the value to be looked for in the payload
+     * @param bool $required if required, not finding a value will raise a registration_exception
+     *
+     * @return mixed
+     */
+    private static 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);
+            }
+            return null;
+        }
+        $parameter = $payload[$key];
+        // Cleans parameters to avoid XSS and other issues.
+        if (is_array($parameter)) {
+            return clean_param_array($parameter, PARAM_TEXT, true);
+        }
+        return clean_param($parameter, PARAM_TEXT);
+    }
+
+    /**
+     * Transforms an LTI 1.3 Registration to a Moodle LTI Config.
+     *
+     * @param array $registrationpayload the registration data received from the tool.
+     * @param string $clientid the clientid to be issued for that tool.
+     *
+     * @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,
+            'https://purl.imsglobal.org/spec/lti-tool-configuration', true);
+
+        $domain = self::get_parameter($ltitoolconfiguration, 'domain', true);
+        $targetlinkuri = self::get_parameter($ltitoolconfiguration, 'target_link_uri', true);
+        $customparameters = self::get_parameter($ltitoolconfiguration, 'custom_parameters', false);
+        $scopes = explode(" ", self::get_parameter($registrationpayload, 'scope', false) ?? '');
+        $claims = self::get_parameter($ltitoolconfiguration, 'claims', false);
+        $messages = $ltitoolconfiguration['messages'] ?? [];
+        $description = self::get_parameter($ltitoolconfiguration, 'description', false);
+
+        // Validate response type.
+        // According to specification, for this scenario, id_token must be explicitly set.
+        if (!in_array('id_token', $responsetypes)) {
+            throw new registration_exception('invalid_response_types', 400);
+        }
+
+        // According to specification, this parameter needs to be an array.
+        if (!is_array($redirecturis)) {
+            throw new registration_exception('invalid_redirect_uris', 400);
+        }
+
+        // According to specification, for this scenario private_key_jwt must be explicitly set.
+        if ($tokenendpointauthmethod !== 'private_key_jwt') {
+            throw new registration_exception('invalid_token_endpoint_auth_method', 400);
+        }
+
+        if (!empty($applicationtype) && $applicationtype !== 'web') {
+            throw new registration_exception('invalid_application_type', 400);
+        }
+
+        $config = new stdClass();
+        $config->lti_clientid = $clientid;
+        $config->lti_toolurl = $targetlinkuri;
+        $config->lti_tooldomain = $domain;
+        $config->lti_typename = $clientname;
+        $config->lti_description = $description;
+        $config->lti_ltiversion = LTI_VERSION_1P3;
+        $config->lti_organizationid_default = LTI_DEFAULT_ORGID_SITEID;
+        $config->lti_icon = $logouri;
+        $config->lti_coursevisible = LTI_COURSEVISIBLE_PRECONFIGURED;
+        $config->lti_contentitem = 0;
+        // Sets Content Item.
+        if (!empty($messages)) {
+            $messagesresponse = [];
+            foreach ($messages as $value) {
+                if ($value['type'] === 'LtiDeepLinkingRequest') {
+                    $config->lti_contentitem = 1;
+                    $config->lti_toolurl_ContentItemSelectionRequest = $value['target_link_uri'] ?? '';
+                    array_push($messagesresponse, $value);
+                }
+            }
+        }
+
+        $config->lti_keytype = 'JWK_KEYSET';
+        $config->lti_publickeyset = $jwksuri;
+        $config->lti_initiatelogin = $initiateloginuri;
+        $config->lti_redirectionuris = implode(PHP_EOL, $redirecturis);
+        $config->lti_customparameters = '';
+        // Sets custom parameters.
+        if (isset($customparameters)) {
+            $paramssarray = [];
+            foreach ($customparameters as $key => $value) {
+                array_push($paramssarray, $key . '=' . $value);
+            }
+            $config->lti_customparameters = implode(PHP_EOL, $paramssarray);
+        }
+        // Sets launch container.
+        $config->lti_launchcontainer = LTI_LAUNCH_CONTAINER_EMBED_NO_BLOCKS;
+
+        // Sets Service info based on scopes.
+        $config->lti_acceptgrades = LTI_SETTING_NEVER;
+        $config->ltiservice_gradesynchronization = 0;
+        $config->ltiservice_memberships = 0;
+        $config->ltiservice_toolsettings = 0;
+        if (isset($scopes)) {
+            // Sets Assignment and Grade Services info.
+
+            if (in_array(self::SCOPE_SCORE, $scopes)) {
+                $config->lti_acceptgrades = LTI_SETTING_DELEGATE;
+                $config->ltiservice_gradesynchronization = 1;
+            }
+            if (in_array(self::SCOPE_RESULT, $scopes)) {
+                $config->lti_acceptgrades = LTI_SETTING_DELEGATE;
+                $config->ltiservice_gradesynchronization = 1;
+            }
+            if (in_array(self::SCOPE_LINEITEM_RO, $scopes)) {
+                $config->lti_acceptgrades = LTI_SETTING_DELEGATE;
+                $config->ltiservice_gradesynchronization = 1;
+            }
+            if (in_array(self::SCOPE_LINEITEM, $scopes)) {
+                $config->lti_acceptgrades = LTI_SETTING_DELEGATE;
+                $config->ltiservice_gradesynchronization = 2;
+            }
+
+            // Sets Names and Role Provisioning info.
+            if (in_array(self::SCOPE_NRPS, $scopes)) {
+                $config->ltiservice_memberships = 1;
+            }
+
+            // Sets Tool Settings info.
+            if (in_array(self::SCOPE_TOOL_SETTING, $scopes)) {
+                $config->ltiservice_toolsettings = 1;
+            }
+        }
+
+        // Sets privacy settings.
+        $config->lti_sendname = LTI_SETTING_NEVER;
+        $config->lti_sendemailaddr = LTI_SETTING_NEVER;
+        if (isset($claims)) {
+            // Sets name privacy settings.
+
+            if (in_array('name', $claims)) {
+                $config->lti_sendname = LTI_SETTING_ALWAYS;
+            }
+            if (in_array('given_name', $claims)) {
+                $config->lti_sendname = LTI_SETTING_ALWAYS;
+            }
+            if (in_array('family_name', $claims)) {
+                $config->lti_sendname = LTI_SETTING_ALWAYS;
+            }
+
+            // Sets email privacy settings.
+            if (in_array('email', $claims)) {
+                $config->lti_sendemailaddr = LTI_SETTING_ALWAYS;
+            }
+        }
+        return $config;
+    }
+
+    /**
+     * 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.
+     *
+     * @return array the Client Registration as an associative array.
+     */
+    public static function config_to_registration(object $config, int $typeid): array {
+        $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 = [];
+        $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) {
+            $contentitemmessage = [];
+            $contentitemmessage['type'] = 'LtiDeepLinkingRequest';
+            if (isset($config->lti_toolurl_ContentItemSelectionRequest)) {
+                $contentitemmessage['target_link_uri'] = $config->lti_toolurl_ContentItemSelectionRequest;
+            }
+            $lticonfigurationresponse['messages'] = [$contentitemmessage];
+        }
+        if (isset($config->lti_customparameters) && !empty($config->lti_customparameters)) {
+            $params = [];
+            foreach (explode(PHP_EOL, $config->lti_customparameters) as $param) {
+                $split = explode('=', $param);
+                $params[$split[0]] = $split[1];
+            }
+            $lticonfigurationresponse['custom_parameters'] = $params;
+        }
+        $scopesresponse = [];
+        if ($config->ltiservice_gradesynchronization > 0) {
+            $scopesresponse[] = self::SCOPE_SCORE;
+            $scopesresponse[] = self::SCOPE_RESULT;
+            $scopesresponse[] = self::SCOPE_LINEITEM_RO;
+        }
+        if ($config->ltiservice_gradesynchronization == 2) {
+            $scopesresponse[] = self::SCOPE_LINEITEM;
+        }
+        if ($config->ltiservice_memberships == 1) {
+            $scopesresponse[] = self::SCOPE_NRPS;
+        }
+        if ($config->ltiservice_toolsettings == 1) {
+            $scopesresponse[] = self::SCOPE_TOOL_SETTING;
+        }
+        $registrationresponse['scope'] = implode(' ', $scopesresponse);
+
+        $claimsresponse = ['sub', 'iss'];
+        if ($config->lti_sendname = LTI_SETTING_ALWAYS) {
+            $claimsresponse[] = 'name';
+            $claimsresponse[] = 'family_name';
+            $claimsresponse[] = 'middle_name';
+        }
+        if ($config->lti_sendemailaddr = LTI_SETTING_ALWAYS) {
+            $claimsresponse[] = 'email';
+        }
+        $lticonfigurationresponse['claims'] = $claimsresponse;
+        $registrationresponse['https://purl.imsglobal.org/spec/lti-tool-configuration'] = $lticonfigurationresponse;
+        return $registrationresponse;
+    }
+
+    /**
+     * Validates the registration token is properly signed and not used yet.
+     * Return the client id to use for this registration.
+     *
+     * @param string $registrationtokenjwt registration token
+     *
+     * @return string client id for the registration
+     */
+    public static function validate_registration_token(string $registrationtokenjwt): string {
+        global $DB;
+        $keys = JWK::parseKeySet(jwks_helper::get_jwks());
+        $registrationtoken = JWT::decode($registrationtokenjwt, $keys, ['RS256']);
+
+        // 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);
+        }
+        return $clientid;
+    }
+
+    /**
+     * Initializes an array with the scopes for services supported by the LTI module
+     *
+     * @return array List of scopes
+     */
+    public static function lti_get_service_scopes() {
+
+        $services = lti_get_services();
+        $scopes = array();
+        foreach ($services as $service) {
+            $servicescopes = $service->get_scopes();
+            if (!empty($servicescopes)) {
+                $scopes = array_merge($scopes, $servicescopes);
+            }
+        }
+        return $scopes;
+    }
+
+}
index d95aa53..234b558 100644 (file)
@@ -207,7 +207,7 @@ abstract class service_base {
     abstract public function get_resources();
 
     /**
-     * Get the scope(s) permitted for this service.
+     * Get the scope(s) permitted for this service in the context of a particular tool type.
      *
      * A null value indicates that no scopes are required to access the service.
      *
@@ -217,6 +217,17 @@ abstract class service_base {
         return null;
     }
 
+    /**
+     * Get the scope(s) permitted for this service.
+     *
+     * A null value indicates that no scopes are required to access the service.
+     *
+     * @return array|null
+     */
+    public function get_scopes() {
+        return null;
+    }
+
     /**
      * Returns the configuration options for this service.
      *
index 51d2593..3746760 100644 (file)
@@ -66,6 +66,8 @@ $string['activate'] = 'Activate';
 $string['activatetoadddescription'] = 'You will need to activate this tool before you can add a description.';
 $string['active'] = 'Active';
 $string['activity'] = 'Activity';
+$string['add_ltiadv'] = 'Add LTI Advantage';
+$string['add_ltilegacy'] = 'Add Legacy LTI';
 $string['addnewapp'] = 'Enable external application';
 $string['addserver'] = 'Add new trusted server';
 $string['addtype'] = 'Add preconfigured tool';
index a80f1b1..44acf5e 100644 (file)
@@ -54,6 +54,7 @@ defined('MOODLE_INTERNAL') || die;
 use moodle\mod\lti as lti;
 use Firebase\JWT\JWT;
 use Firebase\JWT\JWK;
+use mod_lti\local\ltiopenid\jwks_helper;
 
 global $CFG;
 require_once($CFG->dirroot.'/mod/lti/OAuth.php');
@@ -2725,7 +2726,11 @@ function lti_get_type_type_config($id) {
 function lti_prepare_type_for_save($type, $config) {
     if (isset($config->lti_toolurl)) {
         $type->baseurl = $config->lti_toolurl;
-        $type->tooldomain = lti_get_domain_from_url($config->lti_toolurl);
+        if (isset($config->lti_tooldomain)) {
+            $type->tooldomain = $config->lti_tooldomain;
+        } else {
+            $type->tooldomain = lti_get_domain_from_url($config->lti_toolurl);
+        }
     }
     if (isset($config->lti_description)) {
         $type->description = $config->lti_description;
@@ -3281,9 +3286,8 @@ function lti_sign_jwt($parms, $endpoint, $oauthconsumerkey, $typeid = 0, $nonce
         }
     }
 
-    $privatekey = get_config('mod_lti', 'privatekey');
-    $kid = get_config('mod_lti', 'kid');
-    $jwt = JWT::encode($payload, $privatekey, 'RS256', $kid);
+    $privatekey = jwks_helper::get_private_key();
+    $jwt = JWT::encode($payload, $privatekey['key'], 'RS256', $privatekey['kid']);
 
     $newparms = array();
     $newparms['id_token'] = $jwt;
@@ -3828,6 +3832,7 @@ function lti_get_service_by_resource_id($services, $resourceid) {
 
 /**
  * Initializes an array with the scopes for services supported by the LTI module
+ * and authorized for this particular tool instance.
  *
  * @param object $type  LTI tool type
  * @param array  $typeconfig  LTI tool type configuration
@@ -3848,7 +3853,6 @@ function lti_get_permitted_service_scopes($type, $typeconfig) {
     }
 
     return $scopes;
-
 }
 
 /**
@@ -4468,3 +4472,4 @@ function lti_new_access_token($typeid, $scopes) {
     return $newtoken;
 
 }
+
diff --git a/mod/lti/openid-configuration.php b/mod/lti/openid-configuration.php
new file mode 100644 (file)
index 0000000..e30b121
--- /dev/null
@@ -0,0 +1,61 @@
+<?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/>.
+
+/**
+ * This file returns the OpenId/LTI Configuration for this site.
+ *
+ * It is part of the LTI Tool Dynamic Registration, and used by
+ * tools to get the site configuration and registration end-point.
+ *
+ * @package    mod_lti
+ * @copyright  2020 Claude Vervoort (Cengage), Carlos Costa, Adrian Hutchinson (Macgraw Hill)
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+use mod_lti\local\ltiopenid\registration_helper;
+
+define('NO_DEBUG_DISPLAY', true);
+define('NO_MOODLE_COOKIES', true);
+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[] = 'openid';
+$conf = [
+    'issuer' => $CFG->wwwroot,
+    'token_endpoint' => (new moodle_url('/mod/lti/token.php'))->out(false),
+    'token_endpoint_auth_methods_supported' => ['private_key_jwt'],
+    'token_endpoint_auth_signing_alg_values_supported' => ['RS256'],
+    'jwks_uri' => (new moodle_url('/mod/lti/certs.php'))->out(false),
+    'registration_endpoint' => (new moodle_url('/mod/lti/openid-registration.php'))->out(false),
+    'scopes_supported' => $scopes,
+    'response_types_supported' => ['id_token'],
+    'subject_types_supported' => ['public', 'pairwise'],
+    'id_token_signing_alg_values_supported' => ['RS256'],
+    'claims_supported' => ['sub', 'iss', 'name', 'given_name', 'family_name', 'email'],
+    'https://purl.imsglobal.org/spec/lti-platform-configuration ' => [
+        'product_family_code' => 'moodle',
+        'version' => $CFG->release,
+        'messages_supported' => ['LtiResourceLink', 'LtiDeepLinkingRequest'],
+        'placements' => ['AddContentMenu'],
+        'variables' => array_keys(lti_get_capabilities())
+    ]
+];
+
+@header('Content-Type: application/json; charset=utf-8');
+
+echo json_encode($conf, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
diff --git a/mod/lti/openid-registration.php b/mod/lti/openid-registration.php
new file mode 100644 (file)
index 0000000..c06e531
--- /dev/null
@@ -0,0 +1,62 @@
+<?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/>.
+
+/**
+ * This file receives a registration request along with the registration token and returns a client_id.
+ *
+ * @copyright  2020 Claude Vervoort (Cengage), Carlos Costa, Adrian Hutchinson (Macgraw Hill)
+ * @package    mod_lti
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define('NO_DEBUG_DISPLAY', true);
+define('NO_MOODLE_COOKIES', true);
+
+use mod_lti\local\ltiopenid\registration_helper;
+use mod_lti\local\ltiopenid\registration_exception;
+
+require_once(__DIR__ . '/../../config.php');
+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);
+
+    // 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();
+    }
+}
+$response = new \mod_lti\local\ltiservice\response();
+// Set code.
+$response->set_code($code);
+// Set body.
+$response->set_body($message);
+$response->send();
index 32c2a47..bfc6420 100644 (file)
@@ -81,4 +81,13 @@ class basicoutcomes extends \mod_lti\local\ltiservice\service_base {
 
     }
 
+    /**
+     * Get the scope(s) permitted for the tool relevant to this service.
+     *
+     * @return array
+     */
+    public function get_scopes() {
+        return [self::SCOPE_BASIC_OUTCOMES];
+    }
+
 }
index 9d35849..caa437a 100644 (file)
@@ -112,6 +112,16 @@ class gradebookservices extends service_base {
 
     }
 
+    /**
+     * Get the scopes defined by this service.
+     *
+     * @return array
+     */
+    public function get_scopes() {
+        return [self::SCOPE_GRADEBOOKSERVICES_LINEITEM_READ, self::SCOPE_GRADEBOOKSERVICES_RESULT_READ,
+            self::SCOPE_GRADEBOOKSERVICES_SCORE, self::SCOPE_GRADEBOOKSERVICES_LINEITEM];
+    }
+
     /**
      * Adds form elements for gradebook sync add/edit page.
      *
index 46d437c..695e33f 100644 (file)
@@ -102,6 +102,15 @@ class memberships extends \mod_lti\local\ltiservice\service_base {
 
     }
 
+    /**
+     * Get the scope(s) defined by this service.
+     *
+     * @return array
+     */
+    public function get_scopes() {
+        return [self::SCOPE_MEMBERSHIPS_READ];
+    }
+
     /**
      * Get the JSON for members.
      *
index 31ca1d6..91a851f 100644 (file)
@@ -88,6 +88,15 @@ class toolsettings extends \mod_lti\local\ltiservice\service_base {
 
     }
 
+    /**
+     * Get the scope(s) defined this service.
+     *
+     * @return array
+     */
+    public function get_scopes() {
+        return [self::SCOPE_TOOL_SETTINGS];
+    }
+
     /**
      * Get the distinct settings from each level by removing any duplicates from higher levels.
      *
diff --git a/mod/lti/startltiadvregistration.php b/mod/lti/startltiadvregistration.php
new file mode 100644 (file)
index 0000000..9644a94
--- /dev/null
@@ -0,0 +1,51 @@
+<?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/>.
+
+/**
+ * Redirect the user to registration with token and openid config url as query params.
+ *
+ * @package mod_lti
+ * @copyright  2020 Cengage
+ * @author     Claude Vervoort
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+use Firebase\JWT\JWT;
+
+use mod_lti\local\ltiopenid\jwks_helper;
+
+require_once(__DIR__ . '/../../config.php');
+require_once($CFG->libdir.'/weblib.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));
index 5e41523..4821f79 100644 (file)
                 <div class="controls">
                     <button id="cartridge-registration-submit" type="submit" class="btn btn-success">
                         <span class="btn-text">{{#str}} savechanges {{/str}}</span>
-                        <div class="btn-loader">
+                        <span class="btn-loader">
                             {{> mod_lti/loader }}
-                        </div>
+                        </span>
                     </button>
                     <button id="cartridge-registration-cancel" type="button" class="btn">
                         <span class="btn-text">{{#str}} cancel {{/str}}</span>
-                        <div class="btn-loader">
+                        <span class="btn-loader">
                             {{> mod_lti/loader }}
-                        </div>
+                        </span>
                     </button>
                 </div>
             </div>
index 6be8746..9c5a601 100644 (file)
     Context variables required for this template:
     *
 
+    Example context (json):
+    {
+    }
+
 }}
 <div id="external-registration-page-container">
     <button id="cancel-external-registration" class="btn btn-danger">
         <span class="btn-text">{{#str}} cancel {{/str}}</span>
-        <div class="btn-loader">
+        <span class="btn-loader">
             {{> mod_lti/loader }}
-        </div>
+        </span>
     </button>
     <div id="external-registration-template-container"></div>
 </div>
index 0f21877..6a4d6b9 100644 (file)
@@ -34,6 +34,6 @@
     }
 
 }}
-<div class="loader">
+<span class="loader">
     {{#pix}} i/loading, core, {{#str}} loadinghelp, moodle {{/str}} {{/pix}}
-</div>
+</span>
index 4060152..099e3c5 100644 (file)
     Context variables required for this template:
     *
 
+    Example context (json):
+    {
+        "configuremanualurl":"https://some.tool.example/mod/lti/typessettings.php?sesskey=OKl37bHflL&amp;returnto=toolconfigure",
+        "managetoolsurl":"https://some.tool.example/admin/settings.php?section=modsettinglti",
+        "managetoolproxiesurl":"https://some.tool.example/mod/lti/toolproxies.php"
+    }
+
 }}
 <h2>{{#str}} manage_external_tools, mod_lti {{/str}}</h2>
 <div id="main-content-container">
                             placeholder="{{#str}} toolurlplaceholder, mod_lti {{/str}}"
                             required>
                         <button id="tool-create-button" type="submit" class="btn btn-success">
-                            <span class="btn-text">{{#str}} add {{/str}}</span>
-                            <div class="btn-loader">
+                            <span class="btn-text">{{#str}} add_ltiadv, mod_lti {{/str}}</span>
+                            <span class="btn-loader">
+                                {{> mod_lti/loader }}
+                            </span>
+                        </button>
+                        <button id="tool-createltilegacy-button" type="button" class="btn btn-warning">
+                            <span class="btn-text">{{#str}} add_ltilegacy, mod_lti {{/str}}</span>
+                            <span class="btn-loader">
                                 {{> mod_lti/loader }}
-                            </div>
+                            </span>
                         </button>
                     </div>
                 </form>
index 171ebee..3622bf8 100644 (file)
@@ -22,7 +22,7 @@ Feature: Configure tool types
   @javascript
   Scenario: Add a tool type from a cartridge URL
     When I set the field "url" to local url "/mod/lti/tests/fixtures/ims_cartridge_basic_lti_link.xml"
-    And I press "Add"
+    And I press "Add Legacy LTI"
     Then I should see "Enter your consumer key and shared secret"
     And I press "Save changes"
     And I should see "Example tool"
@@ -30,7 +30,7 @@ Feature: Configure tool types
   @javascript
   Scenario: Try to add a non-existant cartridge
     When I set the field "url" to local url "/mod/lti/tests/fixtures/nonexistant.xml"
-    And I press "Add"
+    And I press "Add Legacy LTI"
     Then I should see "Enter your consumer key and shared secret"
     And I press "Save changes"
     And I should see "Failed to create new tool. Please check the URL and try again."
@@ -38,6 +38,6 @@ Feature: Configure tool types
   @javascript
   Scenario: Attempt to add a tool type from a configuration URL, then cancel
     When I set the field "url" to local url "/mod/lti/tests/fixtures/tool_provider.php"
-    And I press "Add"
+    And I press "Add Legacy LTI"
     Then I should see "Cancel"
     And I press "cancel-external-registration"
diff --git a/mod/lti/tests/openidregistration_test.php b/mod/lti/tests/openidregistration_test.php
new file mode 100644 (file)
index 0000000..af67829
--- /dev/null
@@ -0,0 +1,269 @@
+<?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/>.
+//
+// This file is part of BasicLTI4Moodle
+//
+// BasicLTI4Moodle is an IMS BasicLTI (Basic Learning Tools for Interoperability)
+// consumer for Moodle 1.9 and Moodle 2.0. BasicLTI is a IMS Standard that allows web
+// based learning tools to be easily integrated in LMS as native ones. The IMS BasicLTI
+// specification is part of the IMS standard Common Cartridge 1.1 Sakai and other main LMS
+// are already supporting or going to support BasicLTI. This project Implements the consumer
+// for Moodle. Moodle is a Free Open source Learning Management System by Martin Dougiamas.
+// BasicLTI4Moodle is a project iniciated and leaded by Ludo(Marc Alier) and Jordi Piguillem
+// at the GESSI research group at UPC.
+// SimpleLTI consumer for Moodle is an implementation of the early specification of LTI
+// by Charles Severance (Dr Chuck) htp://dr-chuck.com , developed by Jordi Piguillem in a
+// Google Summer of Code 2008 project co-mentored by Charles Severance and Marc Alier.
+//
+// BasicLTI4Moodle is copyright 2009 by Marc Alier Forment, Jordi Piguillem and Nikolas Galanis
+// of the Universitat Politecnica de Catalunya http://www.upc.edu
+// Contact info: Marc Alier Forment granludo @ gmail.com or marc.alier @ upc.edu.
+
+/**
+ * This file contains unit tests for lti/openidregistrationlib.php
+ *
+ * @package    mod_lti
+ * @copyright  2020 Claude Vervoort, Cengage
+ * @author     Claude Vervoort
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+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 {
+
+    /**
+     * @var string A has-it-all client registration.
+     */
+    private $registrationfulljson = <<<EOD
+    {
+        "application_type": "web",
+        "response_types": ["id_token"],
+        "grant_types": ["implict", "client_credentials"],
+        "initiate_login_uri": "https://client.example.org/lti/init",
+        "redirect_uris":
+        ["https://client.example.org/callback",
+        "https://client.example.org/callback2"],
+        "client_name": "Virtual Garden",
+        "client_name#ja": "バーチャルガーデン",
+        "jwks_uri": "https://client.example.org/.well-known/jwks.json",
+        "logo_uri": "https://client.example.org/logo.png",
+        "policy_uri": "https://client.example.org/privacy",
+        "policy_uri#ja": "https://client.example.org/privacy?lang=ja",
+        "tos_uri": "https://client.example.org/tos",
+        "tos_uri#ja": "https://client.example.org/tos?lang=ja",
+        "token_endpoint_auth_method": "private_key_jwt",
+        "contacts": ["ve7jtb@example.org", "mary@example.org"],
+        "scope": "https://purl.imsglobal.org/spec/lti-ags/scope/score https://purl.imsglobal.org/spec/lti-ags/scope/lineitem",
+        "https://purl.imsglobal.org/spec/lti-tool-configuration": {
+            "domain": "client.example.org",
+            "description": "Learn Botany by tending to your little (virtual) garden.",
+            "description#ja": "小さな(仮想)庭に行くことで植物学を学びましょう。",
+            "target_link_uri": "https://client.example.org/lti",
+            "custom_parameters": {
+                "context_history": "\$Context.id.history"
+            },
+            "claims": ["iss", "sub", "name", "given_name", "family_name", "email"],
+            "messages": [
+                {
+                    "type": "LtiDeepLinkingRequest",
+                    "target_link_uri": "https://client.example.org/lti/dl",
+                    "label": "Add a virtual garden",
+                    "label#ja": "バーチャルガーデンを追加する"
+                }
+            ]
+        }
+    }
+EOD;
+
+    /**
+     * @var string A minimalist client registration.
+     */
+    private $registrationminimaljson = <<<EOD
+    {
+        "application_type": "web",
+        "response_types": ["id_token"],
+        "grant_types": ["implict", "client_credentials"],
+        "initiate_login_uri": "https://client.example.org/lti/init",
+        "redirect_uris":
+        ["https://client.example.org/callback"],
+        "client_name": "Virtual Garden",
+        "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"
+        }
+    }
+EOD;
+
+    /**
+     * @var string A minimalist with deep linking client registration.
+     */
+    private $registrationminimaldljson = <<<EOD
+    {
+        "application_type": "web",
+        "response_types": ["id_token"],
+        "grant_types": ["implict", "client_credentials"],
+        "initiate_login_uri": "https://client.example.org/lti/init",
+        "redirect_uris":
+        ["https://client.example.org/callback"],
+        "client_name": "Virtual Garden",
+        "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",
+            "messages": [
+                {
+                    "type": "LtiDeepLinkingRequest"
+                }
+            ]
+        }
+    }
+EOD;
+
+    /**
+     * Test the mapping from Registration JSON to LTI Config for a has-it-all tool registration.
+     */
+    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');
+        $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->assertEquals('Learn Botany by tending to your little (virtual) garden.', $config->lti_description);
+        $this->assertEquals('https://client.example.org/lti/init', $config->lti_initiatelogin);
+        $this->assertEquals(implode(PHP_EOL, ["https://client.example.org/callback",
+            "https://client.example.org/callback2"]), $config->lti_redirectionuris);
+        $this->assertEquals("context_history=\$Context.id.history", $config->lti_customparameters);
+        $this->assertEquals("https://client.example.org/.well-known/jwks.json", $config->lti_publickeyset);
+        $this->assertEquals("https://client.example.org/logo.png", $config->lti_icon);
+        $this->assertEquals(2, $config->ltiservice_gradesynchronization);
+        $this->assertEquals(LTI_SETTING_DELEGATE, $config->lti_acceptgrades);
+        $this->assertEquals(1, $config->ltiservice_memberships);
+        $this->assertEquals(0, $config->ltiservice_toolsettings);
+        $this->assertEquals(LTI_SETTING_ALWAYS, $config->lti_sendname);
+        $this->assertEquals(LTI_SETTING_ALWAYS, $config->lti_sendemailaddr);
+        $this->assertEquals(1, $config->lti_contentitem);
+        $this->assertEquals('https://client.example.org/lti/dl', $config->lti_toolurl_ContentItemSelectionRequest);
+    }
+
+    /**
+     * Test the mapping from Registration JSON to LTI Config for a minimal tool registration.
+     */
+    public function test_to_config_minimal() {
+        $registration = json_decode($this->registrationminimaljson, true);
+        $config = registration_helper::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);
+        $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);
+        $this->assertEquals("https://client.example.org/.well-known/jwks.json", $config->lti_publickeyset);
+        $this->assertEmpty($config->lti_icon);
+        $this->assertEquals(0, $config->ltiservice_gradesynchronization);
+        $this->assertEquals(LTI_SETTING_NEVER, $config->lti_acceptgrades);
+        $this->assertEquals(0, $config->ltiservice_memberships);
+        $this->assertEquals(LTI_SETTING_NEVER, $config->lti_sendname);
+        $this->assertEquals(LTI_SETTING_NEVER, $config->lti_sendemailaddr);
+        $this->assertEquals(0, $config->lti_contentitem);
+    }
+
+    /**
+     * Test the mapping from Registration JSON to LTI Config for a minimal tool with
+     * deep linking support registration.
+     */
+    public function test_to_config_minimal_with_deeplinking() {
+        $registration = json_decode($this->registrationminimaldljson, true);
+        $config = registration_helper::registration_to_config($registration, 'TheClientId');
+        $this->assertEquals(1, $config->lti_contentitem);
+        $this->assertEmpty($config->lti_toolurl_ContentItemSelectionRequest);
+    }
+
+    /**
+     * Validation Test: initiation login.
+     */
+    public function test_validation_initlogin() {
+        $registration = json_decode($this->registrationfulljson, true);
+        $this->expectException(registration_exception::class);
+        $this->expectExceptionCode(400);
+        unset($registration['initiate_login_uri']);
+        registration_helper::registration_to_config($registration, 'TheClientId');
+    }
+
+    /**
+     * Validation Test: redirect uris.
+     */
+    public function test_validation_redirecturis() {
+        $registration = json_decode($this->registrationfulljson, true);
+        $this->expectException(registration_exception::class);
+        $this->expectExceptionCode(400);
+        unset($registration['redirect_uris']);
+        registration_helper::registration_to_config($registration, 'TheClientId');
+    }
+
+    /**
+     * Validation Test: jwks uri empty.
+     */
+    public function test_validation_jwks() {
+        $registration = json_decode($this->registrationfulljson, true);
+        $this->expectException(registration_exception::class);
+        $this->expectExceptionCode(400);
+        $registration['jwks_uri'] = '';
+        registration_helper::registration_to_config($registration, 'TheClientId');
+    }
+
+    /**
+     * Test the transformation from lti config to OpenId LTI Client Registration response.
+     */
+    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);
+        $this->assertEquals('clid', $reg['client_id']);
+        $this->assertEquals($orig['response_types'], $reg['response_types']);
+        $this->assertEquals($orig['initiate_login_uri'], $reg['initiate_login_uri']);
+        $this->assertEquals($orig['redirect_uris'], $reg['redirect_uris']);
+        $this->assertEquals($orig['jwks_uri'], $reg['jwks_uri']);
+        $this->assertEquals($orig['logo_uri'], $reg['logo_uri']);
+        $this->assertEquals('https://purl.imsglobal.org/spec/lti-ags/scope/score '.
+            'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly '.
+            'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly '.
+            'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem '.
+            'https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly', $reg['scope']);
+        $ltiorig = $orig['https://purl.imsglobal.org/spec/lti-tool-configuration'];
+        $lti = $reg['https://purl.imsglobal.org/spec/lti-tool-configuration'];
+        $this->assertEquals("12", $lti['deployment_id']);
+        $this->assertEquals($ltiorig['target_link_uri'], $lti['target_link_uri']);
+        $this->assertEquals($ltiorig['domain'], $lti['domain']);
+        $this->assertEquals($ltiorig['custom_parameters'], $lti['custom_parameters']);
+        $this->assertEquals($ltiorig['description'], $lti['description']);
+        $dlmsgorig = $ltiorig['messages'][0];
+        $dlmsg = $lti['messages'][0];
+        $this->assertEquals($dlmsgorig['type'], $dlmsg['type']);
+        $this->assertEquals($dlmsgorig['target_link_uri'], $dlmsg['target_link_uri']);
+    }
+}
index 6ed5a7e..a8373ae 100644 (file)
@@ -29,7 +29,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$version  = 2020102700.03;              // YYYYMMDD      = weekly release date of this DEV branch.
+$version  = 2020102700.04;              // YYYYMMDD      = weekly release date of this DEV branch.
                                         //         RR    = release increments - 00 in DEV branches.
                                         //           .XX = incremental changes.
 $release  = '3.10dev+ (Build: 20201023)';// Human-friendly version name