Merge branch 'MDL-69998-master-enfix' of git://github.com/mudrd8mz/moodle into master
authorEloy Lafuente (stronk7) <stronk7@moodle.org>
Tue, 27 Oct 2020 23:41:53 +0000 (00:41 +0100)
committerEloy Lafuente (stronk7) <stronk7@moodle.org>
Tue, 27 Oct 2020 23:41:53 +0000 (00:41 +0100)
169 files changed:
admin/classes/local/settings/manage_payment_gateway_plugins.php [new file with mode: 0644]
admin/settings/courses.php
admin/settings/payment.php [new file with mode: 0644]
admin/settings/plugins.php
admin/settings/top.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
enrol/fee/classes/payment/service_provider.php [new file with mode: 0644]
enrol/fee/classes/plugin.php [new file with mode: 0644]
enrol/fee/classes/privacy/provider.php [new file with mode: 0644]
enrol/fee/db/access.php [new file with mode: 0644]
enrol/fee/lang/en/enrol_fee.php [new file with mode: 0644]
enrol/fee/lib.php [new file with mode: 0644]
enrol/fee/pix/icon.svg [new file with mode: 0644]
enrol/fee/settings.php [new file with mode: 0644]
enrol/fee/templates/payment_region.mustache [new file with mode: 0644]
enrol/fee/tests/behat/fee.feature [new file with mode: 0644]
enrol/fee/version.php [new file with mode: 0644]
lang/en/admin.php
lang/en/badges.php
lang/en/course.php
lang/en/deprecated.txt
lang/en/langconfig.php
lang/en/moodle.php
lang/en/payment.php [new file with mode: 0644]
lang/en/plugin.php
lang/en/role.php
lib/amd/build/modal.min.js
lib/amd/build/modal.min.js.map
lib/amd/build/modal_events.min.js
lib/amd/build/modal_events.min.js.map
lib/amd/build/tree.min.js
lib/amd/build/tree.min.js.map
lib/amd/src/modal.js
lib/amd/src/modal_events.js
lib/amd/src/tree.js
lib/badgeslib.php
lib/classes/content.php
lib/classes/form/persistent.php
lib/classes/plugin_manager.php
lib/classes/plugininfo/paygw.php [new file with mode: 0644]
lib/components.json
lib/db/access.php
lib/db/install.xml
lib/db/services.php
lib/db/upgrade.php
lib/navigationlib.php
lib/pagelib.php
lib/tests/component_test.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]
payment/accounts.php [new file with mode: 0644]
payment/amd/build/events.min.js [new file with mode: 0644]
payment/amd/build/events.min.js.map [new file with mode: 0644]
payment/amd/build/gateways_modal.min.js [new file with mode: 0644]
payment/amd/build/gateways_modal.min.js.map [new file with mode: 0644]
payment/amd/build/modal_gateways.min.js [new file with mode: 0644]
payment/amd/build/modal_gateways.min.js.map [new file with mode: 0644]
payment/amd/build/repository.min.js [new file with mode: 0644]
payment/amd/build/repository.min.js.map [new file with mode: 0644]
payment/amd/build/selectors.min.js [new file with mode: 0644]
payment/amd/build/selectors.min.js.map [new file with mode: 0644]
payment/amd/src/events.js [new file with mode: 0644]
payment/amd/src/gateways_modal.js [new file with mode: 0644]
payment/amd/src/modal_gateways.js [new file with mode: 0644]
payment/amd/src/repository.js [new file with mode: 0644]
payment/amd/src/selectors.js [new file with mode: 0644]
payment/classes/account.php [new file with mode: 0644]
payment/classes/account_gateway.php [new file with mode: 0644]
payment/classes/event/account_created.php [new file with mode: 0644]
payment/classes/event/account_deleted.php [new file with mode: 0644]
payment/classes/event/account_updated.php [new file with mode: 0644]
payment/classes/external/get_available_gateways.php [new file with mode: 0644]
payment/classes/form/account.php [new file with mode: 0644]
payment/classes/form/account_gateway.php [new file with mode: 0644]
payment/classes/gateway.php [new file with mode: 0644]
payment/classes/helper.php [new file with mode: 0644]
payment/classes/local/callback/service_provider.php [new file with mode: 0644]
payment/classes/local/entities/payable.php [new file with mode: 0644]
payment/classes/privacy/consumer_provider.php [new file with mode: 0644]
payment/classes/privacy/paygw_provider.php [new file with mode: 0644]
payment/classes/privacy/provider.php [new file with mode: 0644]
payment/gateway/paypal/amd/build/gateways_modal.min.js [new file with mode: 0644]
payment/gateway/paypal/amd/build/gateways_modal.min.js.map [new file with mode: 0644]
payment/gateway/paypal/amd/build/repository.min.js [new file with mode: 0644]
payment/gateway/paypal/amd/build/repository.min.js.map [new file with mode: 0644]
payment/gateway/paypal/amd/src/gateways_modal.js [new file with mode: 0644]
payment/gateway/paypal/amd/src/repository.js [new file with mode: 0644]
payment/gateway/paypal/classes/external/get_config_for_js.php [new file with mode: 0644]
payment/gateway/paypal/classes/external/transaction_complete.php [new file with mode: 0644]
payment/gateway/paypal/classes/gateway.php [new file with mode: 0644]
payment/gateway/paypal/classes/paypal_helper.php [new file with mode: 0644]
payment/gateway/paypal/classes/privacy/provider.php [new file with mode: 0644]
payment/gateway/paypal/db/install.php [new file with mode: 0644]
payment/gateway/paypal/db/install.xml [new file with mode: 0644]
payment/gateway/paypal/db/services.php [new file with mode: 0644]
payment/gateway/paypal/lang/en/paygw_paypal.php [new file with mode: 0644]
payment/gateway/paypal/pix/img.svg [new file with mode: 0644]
payment/gateway/paypal/settings.php [new file with mode: 0644]
payment/gateway/paypal/styles.css [new file with mode: 0644]
payment/gateway/paypal/templates/paypal_button_placeholder.mustache [new file with mode: 0644]
payment/gateway/paypal/version.php [new file with mode: 0644]
payment/manage_account.php [new file with mode: 0644]
payment/manage_gateway.php [new file with mode: 0644]
payment/templates/fee_breakdown.mustache [new file with mode: 0644]
payment/templates/gateway.mustache [new file with mode: 0644]
payment/templates/gateways.mustache [new file with mode: 0644]
payment/templates/gateways_modal.mustache [new file with mode: 0644]
payment/templates/gateways_placeholder.mustache [new file with mode: 0644]
payment/templates/modal_gateways.mustache [new file with mode: 0644]
payment/tests/behat/accounts.feature [new file with mode: 0644]
payment/tests/generator/behat_core_payment_generator.php [new file with mode: 0644]
payment/tests/generator/lib.php [new file with mode: 0644]
payment/tests/generator_test.php [new file with mode: 0644]
payment/tests/helper_test.php [new file with mode: 0644]
theme/boost/scss/moodle/core.scss
theme/boost/style/moodle.css
theme/classic/style/moodle.css
version.php

diff --git a/admin/classes/local/settings/manage_payment_gateway_plugins.php b/admin/classes/local/settings/manage_payment_gateway_plugins.php
new file mode 100644 (file)
index 0000000..f61951c
--- /dev/null
@@ -0,0 +1,74 @@
+<?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/>.
+
+/**
+ * Payment gateway admin setting.
+ *
+ * @package    core_admin
+ * @copyright  2020 Shamim Rezaie <shamim@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_admin\local\settings;
+
+/**
+ * Generic class for managing plugins in a table that allows re-ordering and enable/disable of each plugin.
+ */
+class manage_payment_gateway_plugins extends \admin_setting_manage_plugins {
+    /**
+     * Get the admin settings section title (use get_string).
+     *
+     * @return string
+     */
+    public function get_section_title() {
+        return get_string('type_paygw_plural', 'plugin');
+    }
+
+    /**
+     * Get the type of plugin to manage.
+     *
+     * @return string
+     */
+    public function get_plugin_type() {
+        return 'paygw';
+    }
+
+    /**
+     * Get the name of the second column.
+     *
+     * @return string
+     */
+    public function get_info_column_name() {
+        return get_string('supportedcurrencies', 'core_payment');
+    }
+
+    /**
+     * Get the type of plugin to manage.
+     *
+     * @param plugininfo The plugin info class.
+     * @return string
+     */
+    public function get_info_column($plugininfo) {
+        $codes = $plugininfo->get_supported_currencies();
+
+        $currencies = [];
+        foreach ($codes as $c) {
+            $currencies[$c] = new \lang_string($c, 'core_currencies');
+        }
+
+        return implode(get_string('listsep', 'langconfig') . ' ', $currencies);
+    }
+}
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',
diff --git a/admin/settings/payment.php b/admin/settings/payment.php
new file mode 100644 (file)
index 0000000..ab4fe79
--- /dev/null
@@ -0,0 +1,29 @@
+<?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/>.
+
+/**
+ * Adds payments links to the admin tree
+ *
+ * @package    core
+ * @copyright  2020 Marina Glancy
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+$ADMIN->add('payment', new admin_externalpage(
+    'paymentaccounts',
+    new lang_string('paymentaccounts', 'payment'),
+    new moodle_url("/payment/accounts.php"),
+    ['moodle/payment:manageaccounts', 'moodle/payment:viewpayments']));
index 249bd53..8def3d7 100644 (file)
@@ -280,6 +280,25 @@ if ($hassiteconfig) {
         $plugin->load_settings($ADMIN, 'mediaplayers', $hassiteconfig);
     }
 
+    // Payment gateway plugins.
+    $ADMIN->add('modules', new admin_category('paymentgateways', new lang_string('type_paygw_plural', 'plugin')));
+    $temp = new admin_settingpage('managepaymentgateways', new lang_string('type_paygwmanage', 'plugin'));
+    $temp->add(new \core_admin\local\settings\manage_payment_gateway_plugins());
+    $temp->add(new admin_setting_description(
+        'managepaymentgatewayspostfix',
+        '',
+        new lang_string('gotopaymentaccounts', 'payment',
+            html_writer::link(new moodle_url('/payment/accounts.php'), get_string('paymentaccounts', 'payment')))
+    ));
+    $ADMIN->add('paymentgateways', $temp);
+
+    $plugins = core_plugin_manager::instance()->get_plugins_of_type('paygw');
+    core_collator::asort_objects_by_property($plugins, 'displayname');
+    foreach ($plugins as $plugin) {
+        /** @var \core\plugininfo\paygw $plugin */
+        $plugin->load_settings($ADMIN, 'paymentgateways', $hassiteconfig);
+    }
+
     // Data format settings.
     $ADMIN->add('modules', new admin_category('dataformatsettings', new lang_string('dataformats')));
     $temp = new admin_settingpage('managedataformats', new lang_string('managedataformats'));
index 992436b..d567741 100644 (file)
@@ -39,6 +39,7 @@ $ADMIN->add('root', new admin_category('license', new lang_string('license')));
 $ADMIN->add('root', new admin_category('location', new lang_string('location','admin')));
 $ADMIN->add('root', new admin_category('language', new lang_string('language')));
 $ADMIN->add('root', new admin_category('messaging', new lang_string('messagingcategory', 'admin')));
+$ADMIN->add('root', new admin_category('payment', new lang_string('payments', 'payment')));
 $ADMIN->add('root', new admin_category('modules', new lang_string('plugins', 'admin')));
 $ADMIN->add('root', new admin_category('security', new lang_string('security','admin')));
 $ADMIN->add('root', new admin_category('appearance', new lang_string('appearance','admin')));
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..1ff7795 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();
diff --git a/enrol/fee/classes/payment/service_provider.php b/enrol/fee/classes/payment/service_provider.php
new file mode 100644 (file)
index 0000000..cab7b0f
--- /dev/null
@@ -0,0 +1,80 @@
+<?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/>.
+
+/**
+ * Payment subsystem callback implementation for enrol_fee.
+ *
+ * @package    enrol_fee
+ * @category   payment
+ * @copyright  2020 Shamim Rezaie <shamim@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace enrol_fee\payment;
+
+/**
+ * Payment subsystem callback implementation for enrol_fee.
+ *
+ * @copyright  2020 Shamim Rezaie <shamim@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class service_provider implements \core_payment\local\callback\service_provider {
+
+    /**
+     * Callback function that returns the enrolment cost and the accountid
+     * for the course that $instanceid enrolment instance belongs to.
+     *
+     * @param string $paymentarea
+     * @param int $instanceid The enrolment instance id
+     * @return \core_payment\local\entities\payable
+     */
+    public static function get_payable(string $paymentarea, int $instanceid): \core_payment\local\entities\payable {
+        global $DB;
+
+        $instance = $DB->get_record('enrol', ['enrol' => 'fee', 'id' => $instanceid], '*', MUST_EXIST);
+
+        return new \core_payment\local\entities\payable($instance->cost, $instance->currency, $instance->customint1);
+    }
+
+    /**
+     * Callback function that delivers what the user paid for to them.
+     *
+     * @param string $paymentarea
+     * @param int $instanceid The enrolment instance id
+     * @param int $paymentid payment id as inserted into the 'payments' table, if needed for reference
+     * @param int $userid The userid the order is going to deliver to
+     * @return bool Whether successful or not
+     */
+    public static function deliver_order(string $paymentarea, int $instanceid, int $paymentid, int $userid): bool {
+        global $DB;
+
+        $instance = $DB->get_record('enrol', ['enrol' => 'fee', 'id' => $instanceid], '*', MUST_EXIST);
+
+        $plugin = enrol_get_plugin('fee');
+
+        if ($instance->enrolperiod) {
+            $timestart = time();
+            $timeend   = $timestart + $instance->enrolperiod;
+        } else {
+            $timestart = 0;
+            $timeend   = 0;
+        }
+
+        $plugin->enrol_user($instance, $userid, $instance->roleid, $timestart, $timeend);
+
+        return true;
+    }
+}
diff --git a/enrol/fee/classes/plugin.php b/enrol/fee/classes/plugin.php
new file mode 100644 (file)
index 0000000..78cbbcd
--- /dev/null
@@ -0,0 +1,430 @@
+<?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/>.
+
+/**
+ * Fee enrolment plugin.
+ *
+ * This plugin allows you to set up paid courses.
+ *
+ * @package    enrol_fee
+ * @copyright  2019 Shamim Rezaie <shamim@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/**
+ * Fee enrolment plugin implementation.
+ *
+ * @copyright  2019 Shamim Rezaie <shamim@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class enrol_fee_plugin extends enrol_plugin {
+
+    /**
+     * Returns the list of currencies that the payment subsystem supports and therefore we can work with.
+     *
+     * @return array[currencycode => currencyname]
+     */
+    public function get_possible_currencies(): array {
+        $codes = \core_payment\helper::get_supported_currencies();
+
+        $currencies = [];
+        foreach ($codes as $c) {
+            $currencies[$c] = new lang_string($c, 'core_currencies');
+        }
+
+        return $currencies;
+    }
+
+    /**
+     * Returns optional enrolment information icons.
+     *
+     * This is used in course list for quick overview of enrolment options.
+     *
+     * We are not using single instance parameter because sometimes
+     * we might want to prevent icon repetition when multiple instances
+     * of one type exist. One instance may also produce several icons.
+     *
+     * @param array $instances all enrol instances of this type in one course
+     * @return array of pix_icon
+     */
+    public function get_info_icons(array $instances) {
+        $found = false;
+        foreach ($instances as $instance) {
+            if ($instance->enrolstartdate != 0 && $instance->enrolstartdate > time()) {
+                continue;
+            }
+            if ($instance->enrolenddate != 0 && $instance->enrolenddate < time()) {
+                continue;
+            }
+            $found = true;
+            break;
+        }
+        if ($found) {
+            return array(new pix_icon('icon', get_string('pluginname', 'enrol_fee'), 'enrol_fee'));
+        }
+        return array();
+    }
+
+    public function roles_protected() {
+        // Users with role assign cap may tweak the roles later.
+        return false;
+    }
+
+    public function allow_unenrol(stdClass $instance) {
+        // Users with unenrol cap may unenrol other users manually - requires enrol/fee:unenrol.
+        return true;
+    }
+
+    public function allow_manage(stdClass $instance) {
+        // Users with manage cap may tweak period and status - requires enrol/fee:manage.
+        return true;
+    }
+
+    public function show_enrolme_link(stdClass $instance) {
+        return ($instance->status == ENROL_INSTANCE_ENABLED);
+    }
+
+    /**
+     * Returns true if the user can add a new instance in this course.
+     * @param int $courseid
+     * @return boolean
+     */
+    public function can_add_instance($courseid) {
+        $context = context_course::instance($courseid, MUST_EXIST);
+
+        if (empty(\core_payment\helper::get_supported_currencies())) {
+            return false;
+        }
+
+        if (!has_capability('moodle/course:enrolconfig', $context) or !has_capability('enrol/fee:config', $context)) {
+            return false;
+        }
+
+        // Multiple instances supported - different cost for different roles.
+        return true;
+    }
+
+    /**
+     * We are a good plugin and don't invent our own UI/validation code path.
+     *
+     * @return boolean
+     */
+    public function use_standard_editing_ui() {
+        return true;
+    }
+
+    /**
+     * Add new instance of enrol plugin.
+     * @param object $course
+     * @param array $fields instance fields
+     * @return int id of new instance, null if can not be created
+     */
+    public function add_instance($course, array $fields = null) {
+        if ($fields && !empty($fields['cost'])) {
+            $fields['cost'] = unformat_float($fields['cost']);
+        }
+        return parent::add_instance($course, $fields);
+    }
+
+    /**
+     * Update instance of enrol plugin.
+     * @param stdClass $instance
+     * @param stdClass $data modified instance fields
+     * @return boolean
+     */
+    public function update_instance($instance, $data) {
+        if ($data) {
+            $data->cost = unformat_float($data->cost);
+        }
+        return parent::update_instance($instance, $data);
+    }
+
+    /**
+     * Creates course enrol form, checks if form submitted
+     * and enrols user if necessary. It can also redirect.
+     *
+     * @param stdClass $instance
+     * @return string html text, usually a form in a text box
+     */
+    public function enrol_page_hook(stdClass $instance) {
+        global $CFG, $USER, $OUTPUT, $PAGE, $DB;
+
+        ob_start();
+
+        if ($DB->record_exists('user_enrolments', array('userid' => $USER->id, 'enrolid' => $instance->id))) {
+            return ob_get_clean();
+        }
+
+        if ($instance->enrolstartdate != 0 && $instance->enrolstartdate > time()) {
+            return ob_get_clean();
+        }
+
+        if ($instance->enrolenddate != 0 && $instance->enrolenddate < time()) {
+            return ob_get_clean();
+        }
+
+        $course = $DB->get_record('course', array('id' => $instance->courseid));
+        $context = context_course::instance($course->id);
+
+        $shortname = format_string($course->shortname, true, array('context' => $context));
+        $strloginto = get_string("loginto", "", $shortname);
+        $strcourses = get_string("courses");
+
+        // Pass $view=true to filter hidden caps if the user cannot see them.
+        if ($users = get_users_by_capability($context, 'moodle/course:update', 'u.*', 'u.id ASC',
+                                             '', '', '', '', false, true)) {
+            $users = sort_by_roleassignment_authority($users, $context);
+            $teacher = array_shift($users);
+        } else {
+            $teacher = false;
+        }
+
+        if ( (float) $instance->cost <= 0 ) {
+            $cost = (float) $this->get_config('cost');
+        } else {
+            $cost = (float) $instance->cost;
+        }
+
+        if (abs($cost) < 0.01) { // No cost, other enrolment methods (instances) should be used.
+            echo '<p>'.get_string('nocost', 'enrol_fee').'</p>';
+        } else {
+
+            $data = [
+                'isguestuser' => isguestuser(),
+                'cost' => \core_payment\helper::get_cost_as_string($cost, $instance->currency),
+                'instanceid' => $instance->id,
+                'description' => get_string('purchasedescription', 'enrol_fee',
+                    format_string($course->fullname, true, ['context' => $context])),
+            ];
+            echo $OUTPUT->render_from_template('enrol_fee/payment_region', $data);
+        }
+
+        return $OUTPUT->box(ob_get_clean());
+    }
+
+    /**
+     * Restore instance and map settings.
+     *
+     * @param restore_enrolments_structure_step $step
+     * @param stdClass $data
+     * @param stdClass $course
+     * @param int $oldid
+     */
+    public function restore_instance(restore_enrolments_structure_step $step, stdClass $data, $course, $oldid) {
+        global $DB;
+        if ($step->get_task()->get_target() == backup::TARGET_NEW_COURSE) {
+            $merge = false;
+        } else {
+            $merge = array(
+                'courseid'   => $data->courseid,
+                'enrol'      => $this->get_name(),
+                'roleid'     => $data->roleid,
+                'cost'       => $data->cost,
+                'currency'   => $data->currency,
+            );
+        }
+        if ($merge and $instances = $DB->get_records('enrol', $merge, 'id')) {
+            $instance = reset($instances);
+            $instanceid = $instance->id;
+        } else {
+            $instanceid = $this->add_instance($course, (array) $data);
+        }
+        $step->set_mapping('enrol', $oldid, $instanceid);
+    }
+
+    /**
+     * Restore user enrolment.
+     *
+     * @param restore_enrolments_structure_step $step
+     * @param stdClass $data
+     * @param stdClass $instance
+     * @param int $oldinstancestatus
+     * @param int $userid
+     */
+    public function restore_user_enrolment(restore_enrolments_structure_step $step, $data, $instance, $userid, $oldinstancestatus) {
+        $this->enrol_user($instance, $userid, null, $data->timestart, $data->timeend, $data->status);
+    }
+
+    /**
+     * Return an array of valid options for the status.
+     *
+     * @return array
+     */
+    protected function get_status_options() {
+        $options = array(ENROL_INSTANCE_ENABLED  => get_string('yes'),
+                         ENROL_INSTANCE_DISABLED => get_string('no'));
+        return $options;
+    }
+
+    /**
+     * Return an array of valid options for the roleid.
+     *
+     * @param stdClass $instance
+     * @param context $context
+     * @return array
+     */
+    protected function get_roleid_options($instance, $context) {
+        if ($instance->id) {
+            $roles = get_default_enrol_roles($context, $instance->roleid);
+        } else {
+            $roles = get_default_enrol_roles($context, $this->get_config('roleid'));
+        }
+        return $roles;
+    }
+
+
+    /**
+     * Add elements to the edit instance form.
+     *
+     * @param stdClass $instance
+     * @param MoodleQuickForm $mform
+     * @param context $context
+     * @return bool
+     */
+    public function edit_instance_form($instance, MoodleQuickForm $mform, $context) {
+
+        $mform->addElement('text', 'name', get_string('custominstancename', 'enrol'));
+        $mform->setType('name', PARAM_TEXT);
+
+        $options = $this->get_status_options();
+        $mform->addElement('select', 'status', get_string('status', 'enrol_fee'), $options);
+        $mform->setDefault('status', $this->get_config('status'));
+
+        $accounts = \core_payment\helper::get_payment_accounts_menu($context);
+        if ($accounts) {
+            $accounts = ((count($accounts) > 1) ? ['' => ''] : []) + $accounts;
+            $mform->addElement('select', 'customint1', get_string('paymentaccount', 'payment'), $accounts);
+        } else {
+            $mform->addElement('static', 'customint1_text', get_string('paymentaccount', 'payment'),
+                html_writer::span(get_string('noaccountsavilable', 'payment'), 'alert alert-danger'));
+            $mform->addElement('hidden', 'customint1');
+            $mform->setType('customint1', PARAM_INT);
+        }
+        $mform->addHelpButton('customint1', 'paymentaccount', 'enrol_fee');
+
+        $mform->addElement('text', 'cost', get_string('cost', 'enrol_fee'), array('size' => 4));
+        $mform->setType('cost', PARAM_RAW);
+        $mform->setDefault('cost', format_float($this->get_config('cost'), 2, true));
+
+        $supportedcurrencies = $this->get_possible_currencies();
+        $mform->addElement('select', 'currency', get_string('currency', 'enrol_fee'), $supportedcurrencies);
+        $mform->setDefault('currency', $this->get_config('currency'));
+
+        $roles = $this->get_roleid_options($instance, $context);
+        $mform->addElement('select', 'roleid', get_string('assignrole', 'enrol_fee'), $roles);
+        $mform->setDefault('roleid', $this->get_config('roleid'));
+
+        $options = array('optional' => true, 'defaultunit' => 86400);
+        $mform->addElement('duration', 'enrolperiod', get_string('enrolperiod', 'enrol_fee'), $options);
+        $mform->setDefault('enrolperiod', $this->get_config('enrolperiod'));
+        $mform->addHelpButton('enrolperiod', 'enrolperiod', 'enrol_fee');
+
+        $options = array('optional' => true);
+        $mform->addElement('date_time_selector', 'enrolstartdate', get_string('enrolstartdate', 'enrol_fee'), $options);
+        $mform->setDefault('enrolstartdate', 0);
+        $mform->addHelpButton('enrolstartdate', 'enrolstartdate', 'enrol_fee');
+
+        $options = array('optional' => true);
+        $mform->addElement('date_time_selector', 'enrolenddate', get_string('enrolenddate', 'enrol_fee'), $options);
+        $mform->setDefault('enrolenddate', 0);
+        $mform->addHelpButton('enrolenddate', 'enrolenddate', 'enrol_fee');
+
+        if (enrol_accessing_via_instance($instance)) {
+            $warningtext = get_string('instanceeditselfwarningtext', 'core_enrol');
+            $mform->addElement('static', 'selfwarn', get_string('instanceeditselfwarning', 'core_enrol'), $warningtext);
+        }
+    }
+
+    /**
+     * Perform custom validation of the data used to edit the instance.
+     *
+     * @param array $data array of ("fieldname"=>value) of submitted data
+     * @param array $files array of uploaded files "element_name"=>tmp_file_path
+     * @param object $instance The instance loaded from the DB
+     * @param context $context The context of the instance we are editing
+     * @return array of "element_name"=>"error_description" if there are errors,
+     *         or an empty array if everything is OK.
+     * @return void
+     */
+    public function edit_instance_validation($data, $files, $instance, $context) {
+        $errors = array();
+
+        if (!empty($data['enrolenddate']) and $data['enrolenddate'] < $data['enrolstartdate']) {
+            $errors['enrolenddate'] = get_string('enrolenddaterror', 'enrol_fee');
+        }
+
+        $cost = str_replace(get_string('decsep', 'langconfig'), '.', $data['cost']);
+        if (!is_numeric($cost)) {
+            $errors['cost'] = get_string('costerror', 'enrol_fee');
+        }
+
+        $validstatus = array_keys($this->get_status_options());
+        $validcurrency = array_keys($this->get_possible_currencies());
+        $validroles = array_keys($this->get_roleid_options($instance, $context));
+        $tovalidate = array(
+            'name' => PARAM_TEXT,
+            'status' => $validstatus,
+            'currency' => $validcurrency,
+            'roleid' => $validroles,
+            'enrolperiod' => PARAM_INT,
+            'enrolstartdate' => PARAM_INT,
+            'enrolenddate' => PARAM_INT
+        );
+
+        $typeerrors = $this->validate_param_types($data, $tovalidate);
+        $errors = array_merge($errors, $typeerrors);
+
+        if ($data['status'] == ENROL_INSTANCE_ENABLED &&
+                (!$data['customint1']
+                    || !array_key_exists($data['customint1'], \core_payment\helper::get_payment_accounts_menu($context)))) {
+            $errors['status'] = 'Enrolments can not be enabled without specifying the payment account';
+        }
+
+        return $errors;
+    }
+
+    /**
+     * Execute synchronisation.
+     * @param progress_trace $trace
+     * @return int exit code, 0 means ok
+     */
+    public function sync(progress_trace $trace) {
+        $this->process_expirations($trace);
+        return 0;
+    }
+
+    /**
+     * Is it possible to delete enrol instance via standard UI?
+     *
+     * @param stdClass $instance
+     * @return bool
+     */
+    public function can_delete_instance($instance) {
+        $context = context_course::instance($instance->courseid);
+        return has_capability('enrol/fee:config', $context);
+    }
+
+    /**
+     * Is it possible to hide/show enrol instance via standard UI?
+     *
+     * @param stdClass $instance
+     * @return bool
+     */
+    public function can_hide_show_instance($instance) {
+        $context = context_course::instance($instance->courseid);
+        return has_capability('enrol/fee:config', $context);
+    }
+}
diff --git a/enrol/fee/classes/privacy/provider.php b/enrol/fee/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..8c9b4c0
--- /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/>.
+
+/**
+ * Privacy Subsystem implementation for enrol_fee.
+ *
+ * @package    enrol_fee
+ * @category   privacy
+ * @copyright  2020 Shamim Rezaie <shamim@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace enrol_fee\privacy;
+
+use core_privacy\local\request\approved_contextlist;
+use core_privacy\local\request\approved_userlist;
+use core_privacy\local\request\transform;
+use core_privacy\local\request\userlist;
+use core_privacy\local\request\writer;
+use core_payment\helper as payment_helper;
+
+/**
+ * Privacy Subsystem for enrol_fee implementing null_provider.
+ *
+ * @copyright  2020 Shamim Rezaie <shamim@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class provider implements
+    \core_privacy\local\metadata\null_provider,
+    \core_payment\privacy\consumer_provider
+{
+    /**
+     * Get the language string identifier with the component's language
+     * file to explain why this plugin stores no data.
+     *
+     * @return  string
+     */
+    public static function get_reason(): string {
+        return 'privacy:metadata';
+    }
+
+    public static function get_contextid_for_payment(string $paymentarea, int $itemid): ?int {
+        global $DB;
+
+        $sql = "SELECT ctx.id
+                  FROM {enrol} e
+                  JOIN {context} ctx ON (e.courseid = ctx.instanceid AND ctx.contextlevel = :contextcourse)
+                 WHERE e.id = :enrolid AND e.enrol = :enrolname";
+        $params = [
+            'contextcourse' => CONTEXT_COURSE,
+            'enrolid' => $itemid,
+            'enrolname' => 'fee',
+        ];
+        $contextid = $DB->get_field_sql($sql, $params);
+
+        return $contextid ?: null;
+    }
+
+    public static function get_users_in_context(userlist $userlist) {
+        $context = $userlist->get_context();
+
+        if ($context instanceof \context_course) {
+            $sql = "SELECT p.userid
+                      FROM {payments} p
+                      JOIN {enrol} e ON (p.component = :component AND p.itemid = e.id)
+                     WHERE e.courseid = :courseid";
+            $params = [
+                'component' => 'enrol_fee',
+                'courseid' => $context->instanceid,
+            ];
+            $userlist->add_from_sql('userid', $sql, $params);
+        } else if ($context instanceof \context_system) {
+            // If context is system, then the enrolment belongs to a deleted enrolment.
+            $sql = "SELECT p.userid
+                      FROM {payments} p
+                 LEFT JOIN {enrol} e ON p.itemid = e.id
+                     WHERE p.component = :component AND e.id IS NULL";
+            $params = [
+                'component' => 'enrol_fee',
+            ];
+            $userlist->add_from_sql('userid', $sql, $params);
+        }
+    }
+
+    /**
+     * Export all user data for the specified user, in the specified contexts.
+     *
+     * @param approved_contextlist $contextlist The approved contexts to export information for.
+     */
+    public static function export_user_data(approved_contextlist $contextlist) {
+        global $DB;
+
+        $subcontext = [
+            get_string('pluginname', 'enrol_fee'),
+        ];
+        foreach ($contextlist as $context) {
+            if (!$context instanceof \context_course) {
+                continue;
+            }
+            $feeplugins = $DB->get_records('enrol', ['courseid' => $context->instanceid, 'enrol' => 'fee']);
+
+            foreach ($feeplugins as $feeplugin) {
+                \core_payment\privacy\provider::export_payment_data_for_user_in_context(
+                    $context,
+                    $subcontext,
+                    $contextlist->get_user()->id,
+                    'enrol_fee',
+                    'fee',
+                    $feeplugin->id
+                );
+            }
+        }
+
+        if (in_array(SYSCONTEXTID, $contextlist->get_contextids())) {
+            // Orphaned payments.
+            $sql = "SELECT p.*
+                      FROM {payments} p
+                 LEFT JOIN {enrol} e ON p.itemid = e.id
+                     WHERE p.userid = :userid AND p.component = :component AND e.id IS NULL";
+            $params = [
+                'component' => 'enrol_fee',
+                'userid' => $contextlist->get_user()->id,
+            ];
+
+            $orphanedpayments = $DB->get_recordset_sql($sql, $params);
+            foreach ($orphanedpayments as $payment) {
+                \core_payment\privacy\provider::export_payment_data_for_user_in_context(
+                    \context_system::instance(),
+                    $subcontext,
+                    $payment->userid,
+                    $payment->component,
+                    $payment->paymentarea,
+                    $payment->itemid
+                );
+            }
+            $orphanedpayments->close();
+        }
+    }
+
+    /**
+     * Delete all data for all users in the specified context.
+     *
+     * @param context $context The specific context to delete data for.
+     */
+    public static function delete_data_for_all_users_in_context(\context $context) {
+        if ($context instanceof \context_course) {
+            $sql = "SELECT p.id
+                      FROM {payments} p
+                      JOIN {enrol} e ON (p.component = :component AND p.itemid = e.id)
+                     WHERE e.courseid = :courseid";
+            $params = [
+                'component' => 'enrol_fee',
+                'courseid' => $context->instanceid,
+            ];
+
+            \core_payment\privacy\provider::delete_data_for_payment_sql($sql, $params);
+        } else if ($context instanceof \context_system) {
+            // If context is system, then the enrolment belongs to a deleted enrolment.
+            $sql = "SELECT p.id
+                      FROM {payments} p
+                 LEFT JOIN {enrol} e ON p.itemid = e.id
+                     WHERE p.component = :component AND e.id IS NULL";
+            $params = [
+                'component' => 'enrol_fee',
+            ];
+
+            \core_payment\privacy\provider::delete_data_for_payment_sql($sql, $params);
+        }
+    }
+
+    /**
+     * Delete all user data for the specified user, in the specified contexts.
+     *
+     * @param approved_contextlist $contextlist The approved contexts and user information to delete information for.
+     */
+    public static function delete_data_for_user(approved_contextlist $contextlist) {
+        global $DB;
+
+        if (empty($contextlist->count())) {
+            return;
+        }
+
+        $contexts = $contextlist->get_contexts();
+
+        $courseids = [];
+        foreach ($contexts as $context) {
+            if ($context instanceof \context_course) {
+                $courseids[] = $context->instanceid;
+            }
+        }
+
+        [$insql, $inparams] = $DB->get_in_or_equal($courseids, SQL_PARAMS_NAMED);
+
+        $sql = "SELECT p.id
+                  FROM {payments} p
+                  JOIN {enrol} e ON (p.component = :component AND p.itemid = e.id)
+                 WHERE p.userid = :userid AND e.courseid $insql";
+        $params = $inparams + [
+            'component' => 'enrol_fee',
+            'userid' => $contextlist->get_user()->id,
+        ];
+
+        \core_payment\privacy\provider::delete_data_for_payment_sql($sql, $params);
+
+        if (in_array(SYSCONTEXTID, $contextlist->get_contextids())) {
+            // Orphaned payments.
+            $sql = "SELECT p.id
+                      FROM {payments} p
+                 LEFT JOIN {enrol} e ON p.itemid = e.id
+                     WHERE p.component = :component AND p.userid = :userid AND e.id IS NULL";
+            $params = [
+                'component' => 'enrol_fee',
+                'userid' => $contextlist->get_user()->id,
+            ];
+
+            \core_payment\privacy\provider::delete_data_for_payment_sql($sql, $params);
+        }
+    }
+
+    /**
+     * Delete multiple users within a single context.
+     *
+     * @param approved_userlist $userlist The approved context and user information to delete information for.
+     */
+    public static function delete_data_for_users(approved_userlist $userlist) {
+        global $DB;
+
+        $context = $userlist->get_context();
+
+        if ($context instanceof \context_course) {
+            [$usersql, $userparams] = $DB->get_in_or_equal($userlist->get_userids(), SQL_PARAMS_NAMED);
+            $sql = "SELECT p.id
+                      FROM {payments} p
+                      JOIN {enrol} e ON (p.component = :component AND p.itemid = e.id)
+                     WHERE e.courseid = :courseid AND p.userid $usersql";
+            $params = $userparams + [
+                'component' => 'enrol_fee',
+                'courseid' => $context->instanceid,
+            ];
+
+            \core_payment\privacy\provider::delete_data_for_payment_sql($sql, $params);
+        } else if ($context instanceof \context_system) {
+            // Orphaned payments.
+            [$usersql, $userparams] = $DB->get_in_or_equal($userlist->get_userids(), SQL_PARAMS_NAMED);
+            $sql = "SELECT p.id
+                      FROM {payments} p
+                 LEFT JOIN {enrol} e ON p.itemid = e.id
+                     WHERE p.component = :component AND p.userid $usersql AND e.id IS NULL";
+            $params = $userparams + [
+                'component' => 'enrol_fee',
+            ];
+
+            \core_payment\privacy\provider::delete_data_for_payment_sql($sql, $params);
+        }
+    }
+}
diff --git a/enrol/fee/db/access.php b/enrol/fee/db/access.php
new file mode 100644 (file)
index 0000000..4dc1777
--- /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/>.
+
+/**
+ * Capabilities for fee enrolment plugin.
+ *
+ * @package    enrol_fee
+ * @copyright  2019 Shamim Rezaie <shamim@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$capabilities = array(
+
+    'enrol/fee:config' => array(
+        'captype' => 'write',
+        'contextlevel' => CONTEXT_COURSE,
+        'archetypes' => array(
+            'manager' => CAP_ALLOW,
+        )
+    ),
+
+    'enrol/fee:manage' => array(
+        'captype' => 'write',
+        'contextlevel' => CONTEXT_COURSE,
+        'archetypes' => array(
+            'manager' => CAP_ALLOW,
+            'editingteacher' => CAP_ALLOW,
+        )
+    ),
+
+    'enrol/fee:unenrol' => array(
+        'captype' => 'write',
+        'contextlevel' => CONTEXT_COURSE,
+        'archetypes' => array(
+            'manager' => CAP_ALLOW,
+        )
+    ),
+
+    'enrol/fee:unenrolself' => array(
+        'captype' => 'write',
+        'contextlevel' => CONTEXT_COURSE,
+        'archetypes' => array(
+        )
+    ),
+
+);
diff --git a/enrol/fee/lang/en/enrol_fee.php b/enrol/fee/lang/en/enrol_fee.php
new file mode 100644 (file)
index 0000000..ec59549
--- /dev/null
@@ -0,0 +1,54 @@
+<?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/>.
+
+/**
+ * Strings for component 'enrol_fee', language 'en'
+ *
+ * @package    enrol_fee
+ * @copyright  2019 Shamim Rezaie <shamim@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+$string['assignrole'] = 'Assign role';
+$string['cost'] = 'Enrolment fee';
+$string['costerror'] = 'The enrolment fee must be a number.';
+$string['currency'] = 'Currency';
+$string['defaultrole'] = 'Default role assignment';
+$string['defaultrole_desc'] = 'Select the role to assign to users after making a payment.';
+$string['enrolenddate'] = 'End date';
+$string['enrolenddate_help'] = 'If enabled, users can be enrolled until this date only.';
+$string['enrolenddaterror'] = 'The enrolment end date cannot be earlier than the start date.';
+$string['enrolperiod'] = 'Enrolment duration';
+$string['enrolperiod_desc'] = 'Default length of time that the enrolment is valid. If set to zero, the enrolment duration will be unlimited by default.';
+$string['enrolperiod_help'] = 'Length of time that the enrolment is valid, starting with the moment the user is enrolled. If disabled, the enrolment duration will be unlimited.';
+$string['enrolstartdate'] = 'Start date';
+$string['enrolstartdate_help'] = 'If enabled, users can only be enrolled from this date onwards.';
+$string['expiredaction'] = 'Enrolment expiry action';
+$string['expiredaction_help'] = 'Select the action to be performed when a user\'s enrolment expires. Please note that some user data and settings are deleted when a user is unenrolled.';
+$string['fee:config'] = 'Configure enrolment on payment enrol instances';
+$string['fee:manage'] = 'Manage enrolled users';
+$string['fee:unenrol'] = 'Unenrol users from course';
+$string['fee:unenrolself'] = 'Unenrol self from course';
+$string['nocost'] = 'There is no cost to enrol in this course!';
+$string['paymentaccount'] = 'Payment account';
+$string['paymentaccount_help'] = 'Enrolment fees will be paid to this account.';
+$string['pluginname'] = 'Enrolment on payment';
+$string['pluginname_desc'] = 'The enrolment on payment enrolment method allows you to set up courses requiring a payment. If the fee for any course is set to zero, then students are not asked to pay for entry. There is a site-wide fee that you set here as a default for the whole site and then a course setting that you can set for each course individually. The course fee overrides the site fee.';
+$string['privacy:metadata'] = 'The enrolment on payment enrolment plugin does not store any personal data.';
+$string['purchasedescription'] = 'Enrolment in course {$a}';
+$string['sendpaymentbutton'] = 'Select payment type';
+$string['status'] = 'Allow enrolment on payment enrolments';
+$string['status_desc'] = 'Allow users to make a payment to enrol into a course by default.';
diff --git a/enrol/fee/lib.php b/enrol/fee/lib.php
new file mode 100644 (file)
index 0000000..8e88922
--- /dev/null
@@ -0,0 +1,25 @@
+<?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/>.
+
+/**
+ * Fee enrolment plugin.
+ *
+ * This plugin allows you to set up paid courses.
+ *
+ * @package    enrol_fee
+ * @copyright  2019 Shamim Rezaie <shamim@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
diff --git a/enrol/fee/pix/icon.svg b/enrol/fee/pix/icon.svg
new file mode 100644 (file)
index 0000000..f70fe66
--- /dev/null
@@ -0,0 +1,78 @@
+<?xml version="1.0" encoding="iso-8859-1"?>\r
+<!-- Generator: Adobe Illustrator 18.1.1, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->\r
+<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"\r
+        viewBox="0 0 32.001 32.001" style="enable-background:new 0 0 32.001 32.001;" xml:space="preserve" preserveAspectRatio="xMinYMid meet">\r
+<g>\r
+       <g id="banknote">\r
+               <path style="fill:#010002;" d="M31.415,10.586l-10-10C20.919,0.09,20.2-0.109,19.519,0.059c-0.359,0.088-0.68,0.273-0.934,0.527\r
+                       c-0.227,0.227-0.398,0.508-0.496,0.822c-0.453,1.469-1.236,2.746-2.395,3.904C14.146,6.859,12.13,8.031,9.997,9.27\r
+                       c-2.264,1.312-4.603,2.672-6.52,4.588c-1.629,1.631-2.738,3.445-3.388,5.551c-0.219,0.711-0.028,1.48,0.496,2.006l10,10\r
+                       c0.496,0.496,1.215,0.695,1.896,0.527c0.359-0.09,0.68-0.273,0.934-0.527c0.227-0.227,0.398-0.508,0.496-0.824\r
+                       c0.454-1.469,1.237-2.746,2.397-3.904c1.547-1.547,3.562-2.717,5.697-3.955c2.262-1.314,4.602-2.674,6.518-4.59\r
+                       c1.629-1.629,2.738-3.445,3.389-5.551C32.13,11.881,31.938,11.109,31.415,10.586z M12,30c-3.312-3.312-6.688-6.689-10-10\r
+                       c2.842-9.201,15.16-8.799,18-18c3.312,3.311,6.689,6.688,10.002,10C27.159,21.199,14.841,20.799,12,30z"/>\r
+               <path style="fill:#010002;" d="M19.562,14.9c-0.326-0.273-0.654-0.459-0.984-0.551c-0.328-0.092-0.656-0.129-0.988-0.105\r
+                       c-0.328,0.025-0.664,0.1-1,0.229c-0.336,0.131-0.674,0.273-1.014,0.438c-0.537-0.617-1.074-1.227-1.611-1.793\r
+                       c0.242-0.219,0.477-0.33,0.703-0.338c0.227-0.01,0.445,0.014,0.652,0.066c0.211,0.053,0.404,0.098,0.582,0.133\r
+                       c0.18,0.035,0.336-0.004,0.473-0.119c0.145-0.125,0.225-0.287,0.236-0.482c0.01-0.197-0.064-0.389-0.229-0.576\r
+                       c-0.211-0.242-0.465-0.389-0.77-0.443c-0.301-0.053-0.609-0.049-0.93,0.021c-0.316,0.072-0.617,0.191-0.902,0.359\r
+                       s-0.514,0.34-0.684,0.508c-0.065-0.062-0.13-0.123-0.195-0.184c-0.072-0.066-0.162-0.102-0.27-0.1\r
+                       c-0.109,0-0.199,0.047-0.273,0.133c-0.072,0.084-0.105,0.182-0.092,0.285c0.01,0.107,0.053,0.189,0.127,0.252\r
+                       c0.065,0.055,0.13,0.109,0.195,0.166c-0.256,0.309-0.467,0.65-0.633,1.01c-0.168,0.361-0.268,0.719-0.305,1.066\r
+                       c-0.039,0.35-0.002,0.67,0.105,0.967c0.107,0.299,0.305,0.553,0.594,0.793c0.471,0.391,1.025,0.557,1.668,0.52\r
+                       c0.641-0.039,1.332-0.23,2.075-0.629c0.59,0.682,1.182,1.359,1.773,1.988c-0.25,0.211-0.469,0.332-0.662,0.371\r
+                       c-0.193,0.041-0.365,0.037-0.521-0.01c-0.156-0.049-0.301-0.119-0.434-0.209c-0.133-0.092-0.264-0.17-0.395-0.234\r
+                       c-0.129-0.064-0.262-0.1-0.398-0.102s-0.281,0.064-0.441,0.201c-0.164,0.143-0.246,0.309-0.246,0.496\r
+                       c0,0.186,0.086,0.375,0.254,0.566c0.17,0.191,0.391,0.352,0.658,0.479s0.569,0.207,0.901,0.229\r
+                       c0.332,0.023,0.682-0.027,1.051-0.164c0.371-0.135,0.738-0.379,1.1-0.742c0.174,0.17,0.35,0.332,0.525,0.488\r
+                       c0.074,0.064,0.164,0.096,0.273,0.088c0.105-0.004,0.197-0.053,0.27-0.141c0.074-0.09,0.105-0.189,0.094-0.293\r
+                       c-0.01-0.105-0.053-0.186-0.125-0.244c-0.176-0.141-0.352-0.289-0.527-0.445c0.299-0.367,0.539-0.754,0.717-1.137\r
+                       c0.178-0.385,0.283-0.756,0.318-1.1c0.035-0.346-0.006-0.658-0.119-0.941C20.046,15.383,19.847,15.137,19.562,14.9z\r
+                        M13.971,15.578c-0.283,0.012-0.53-0.082-0.746-0.291c-0.092-0.088-0.156-0.195-0.195-0.322c-0.041-0.127-0.055-0.266-0.039-0.418\r
+                       c0.014-0.15,0.059-0.307,0.137-0.465c0.074-0.158,0.184-0.316,0.324-0.469c0.507,0.504,1.013,1.057,1.52,1.629\r
+                       C14.588,15.453,14.254,15.566,13.971,15.578z M18.688,17.58c-0.09,0.166-0.193,0.314-0.314,0.443\r
+                       c-0.561-0.566-1.121-1.188-1.68-1.826c0.143-0.064,0.293-0.131,0.455-0.199s0.324-0.113,0.486-0.141\r
+                       c0.166-0.025,0.33-0.018,0.494,0.021c0.162,0.041,0.316,0.129,0.459,0.268c0.141,0.141,0.23,0.287,0.266,0.445\r
+                       c0.039,0.16,0.041,0.322,0.014,0.488C18.839,17.246,18.78,17.412,18.688,17.58z"/>\r
+               <path style="fill:#010002;" d="M14.717,22.18h-0.002c-0.625,0.48-1.235,0.986-1.8,1.553c-0.543,0.543-1.034,1.115-1.461,1.699\r
+                       l-0.684,0.934l0.002,0.002c-0.125,0.195-0.104,0.457,0.066,0.627c0.195,0.195,0.514,0.195,0.71,0\r
+                       c0.03-0.031,0.054-0.064,0.074-0.1l0.639-0.875c0.396-0.541,0.854-1.074,1.361-1.58c0.561-0.561,1.081-0.994,1.714-1.475\r
+                       l-0.002-0.002c0.018-0.014,0.035-0.025,0.051-0.041c0.196-0.195,0.196-0.514,0-0.709C15.204,22.029,14.914,22.021,14.717,22.18z"\r
+                       />\r
+               <path style="fill:#010002;" d="M18.378,7.715c-0.523,0.523-1.09,0.994-1.678,1.443c-0.041,0.023-0.082,0.049-0.117,0.086\r
+                       c-0.197,0.197-0.197,0.518,0,0.715c0.191,0.193,0.5,0.197,0.699,0.014l0.004,0.002c0.625-0.48,1.234-0.988,1.799-1.553\r
+                       c0.543-0.543,1.033-1.113,1.461-1.697l0.684-0.938l-0.002-0.002c0.17-0.199,0.162-0.496-0.025-0.684\r
+                       c-0.197-0.197-0.516-0.197-0.713,0c-0.037,0.037-0.064,0.08-0.088,0.123l-0.664,0.91C19.341,6.678,18.884,7.209,18.378,7.715z"/>\r
+       </g>\r
+</g>\r
+<g>\r
+</g>\r
+<g>\r
+</g>\r
+<g>\r
+</g>\r
+<g>\r
+</g>\r
+<g>\r
+</g>\r
+<g>\r
+</g>\r
+<g>\r
+</g>\r
+<g>\r
+</g>\r
+<g>\r
+</g>\r
+<g>\r
+</g>\r
+<g>\r
+</g>\r
+<g>\r
+</g>\r
+<g>\r
+</g>\r
+<g>\r
+</g>\r
+<g>\r
+</g>\r
+</svg>\r
diff --git a/enrol/fee/settings.php b/enrol/fee/settings.php
new file mode 100644 (file)
index 0000000..2f383b8
--- /dev/null
@@ -0,0 +1,79 @@
+<?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/>.
+
+/**
+ * Settings for the Fee enrolment plugin
+ *
+ * @package    enrol_fee
+ * @copyright  2019 Shamim Rezaie <shamim@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+if ($ADMIN->fulltree) {
+
+    $currencies = enrol_get_plugin('fee')->get_possible_currencies();
+
+    if (empty($currencies)) {
+        $notify = new \core\output\notification(
+            get_string('nocurrencysupported', 'core_payment'),
+            \core\output\notification::NOTIFY_WARNING
+        );
+        $settings->add(new admin_setting_heading('enrol_fee_nocurrency', '', $OUTPUT->render($notify)));
+    }
+
+    $settings->add(new admin_setting_heading('enrol_fee_settings', '', get_string('pluginname_desc', 'enrol_fee')));
+
+    // Note: let's reuse the ext sync constants and strings here, internally it is very similar,
+    // it describes what should happen when users are not supposed to be enrolled any more.
+    $options = array(
+        ENROL_EXT_REMOVED_KEEP           => get_string('extremovedkeep', 'enrol'),
+        ENROL_EXT_REMOVED_SUSPENDNOROLES => get_string('extremovedsuspendnoroles', 'enrol'),
+        ENROL_EXT_REMOVED_UNENROL        => get_string('extremovedunenrol', 'enrol'),
+    );
+    $settings->add(new admin_setting_configselect(
+        'enrol_fee/expiredaction',
+        get_string('expiredaction', 'enrol_fee'),
+        get_string('expiredaction_help', 'enrol_fee'),
+        ENROL_EXT_REMOVED_SUSPENDNOROLES,
+        $options));
+
+    $settings->add(new admin_setting_heading('enrol_fee_defaults',
+        get_string('enrolinstancedefaults', 'admin'), get_string('enrolinstancedefaults_desc', 'admin')));
+
+    $options = array(ENROL_INSTANCE_ENABLED  => get_string('yes'),
+                     ENROL_INSTANCE_DISABLED => get_string('no'));
+    $settings->add(new admin_setting_configselect('enrol_fee/status',
+        get_string('status', 'enrol_fee'), get_string('status_desc', 'enrol_fee'), ENROL_INSTANCE_DISABLED, $options));
+
+    if (!empty($currencies)) {
+        $settings->add(new admin_setting_configtext('enrol_fee/cost', get_string('cost', 'enrol_fee'), '', 0, PARAM_FLOAT, 4));
+        $settings->add(new admin_setting_configselect('enrol_fee/currency', get_string('currency', 'enrol_fee'), '', 'USD',
+            $currencies));
+    }
+
+    if (!during_initial_install()) {
+        $options = get_default_enrol_roles(context_system::instance());
+        $student = get_archetype_roles('student');
+        $student = reset($student);
+        $settings->add(new admin_setting_configselect('enrol_fee/roleid',
+            get_string('defaultrole', 'enrol_fee'), get_string('defaultrole_desc', 'enrol_fee'), $student->id, $options));
+    }
+
+    $settings->add(new admin_setting_configduration('enrol_fee/enrolperiod',
+        get_string('enrolperiod', 'enrol_fee'), get_string('enrolperiod_desc', 'enrol_fee'), 0));
+}
diff --git a/enrol/fee/templates/payment_region.mustache b/enrol/fee/templates/payment_region.mustache
new file mode 100644 (file)
index 0000000..b8947aa
--- /dev/null
@@ -0,0 +1,76 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template enrol_fee/payment_region
+
+    This template will render information about course fee along with a button for payment.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * data-component
+    * data-paymentarea
+    * data-itemid
+    * data-cost
+    * data-description
+
+    Context variables required for this template:
+    * cost - Human readable cost string including amount and currency
+    * instanceid - Id of the enrolment instance
+    * description - The description for this purchase
+
+    Example context (json):
+    {
+        "cost": "$108.50",
+        "instanceid": 11,
+        "description": "Enrolment in course Introduction to algorithms",
+        "isguestuser": false
+    }
+
+}}
+<div class="enrol_fee_payment_region text-center">
+    {{#isguestuser}}
+        <div class="mdl-align">
+            <p>{{# str }} paymentrequired {{/ str}}</p>
+            <p><b>{{cost}}</b></p>
+            <p><a href="{{config.wwwroot}}/login/">{{# str }} loginsite {{/ str }}</a></p>
+        </div>
+    {{/isguestuser}}
+    {{^isguestuser}}
+        <p>{{# str }} paymentrequired {{/ str}}</p>
+        <p><b>{{cost}}</b></p>
+        <button
+            class="btn btn-secondary"
+            type="button"
+            id="gateways-modal-trigger-{{ uniqid }}"
+            data-action="core_payment/triggerPayment"
+            data-component="enrol_fee"
+            data-paymentarea="fee"
+            data-itemid="{{instanceid}}"
+            data-cost="{{cost}}"
+            data-description={{# quote }}{{description}}{{/ quote }}
+        >
+            {{# str }} sendpaymentbutton, enrol_fee {{/ str }}
+        </button>
+    {{/isguestuser}}
+</div>
+{{#js}}
+    require(['core_payment/gateways_modal'], function(modal) {
+        modal.init();
+    });
+{{/js}}
diff --git a/enrol/fee/tests/behat/fee.feature b/enrol/fee/tests/behat/fee.feature
new file mode 100644 (file)
index 0000000..eabc61e
--- /dev/null
@@ -0,0 +1,44 @@
+@enrol @enrol_fee
+Feature: Signing up for a course with a fee enrolment method
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | teacher1 | Teacher   | 1        | teacher1@example.com |
+      | student1 | Student   | 1        | student1@example.com |
+      | manager1 | Manager   | 1        | manager1@example.com |
+    And the following "courses" exist:
+      | fullname | shortname | format | summary |
+      | Course 1 | C1        | topics |         |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | teacher1 | C1     | editingteacher |
+      | manager1 | C1     | manager        |
+    And the following "core_payment > payment accounts" exist:
+      | name           | gateways |
+      | Account1       | paypal   |
+    And I log in as "admin"
+    And I navigate to "Plugins > Enrolments > Manage enrol plugins" in site administration
+    And I click on "Enable" "link" in the "Enrolment on payment" "table_row"
+    And I log out
+
+  @javascript
+  Scenario: Student can see the payment prompt on the course enrolment page
+    When I log in as "manager1"
+    And I am on "Course 1" course homepage
+    And I navigate to "Users > Enrolment methods" in current page administration
+    And I select "Enrolment on payment" from the "Add method" singleselect
+    And I set the following fields to these values:
+      | Payment account | Account1 |
+      | Enrolment fee   | 123.45   |
+      | Currency        | Euro     |
+    And I press "Add method"
+    And I log out
+    And I log in as "student1"
+    And I am on course index
+    And I follow "Course 1"
+    Then I should see "This course requires a payment for entry."
+    And I should see "123.45"
+    And I press "Select payment type"
+    And I should see "PayPal" in the "Select payment type" "dialogue"
+    And I click on "Cancel" "button" in the "Select payment type" "dialogue"
diff --git a/enrol/fee/version.php b/enrol/fee/version.php
new file mode 100644 (file)
index 0000000..aa4884f
--- /dev/null
@@ -0,0 +1,29 @@
+<?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/>.
+
+/**
+ * Fee enrolment plugin version specification.
+ *
+ * @package    enrol_fee
+ * @copyright  2019 Shamim Rezaie <shamim@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$plugin->version   = 2021052500;        // The current plugin version (Date: YYYYMMDDXX).
+$plugin->requires  = 2021052500;        // Requires this Moodle version.
+$plugin->component = 'enrol_fee';       // Full name of the plugin (used for diagnostics).
index b2aa631..e6103e5 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 7ec6c45..8d7be16 100644 (file)
@@ -32,6 +32,7 @@ $string['iso6392'] = 'eng';
 $string['labelsep'] = ': ';
 $string['listsep'] = ',';
 $string['locale'] = 'en_AU.UTF-8';
+$string['localecldr'] = 'en-AU';
 $string['localewin'] = 'English_Australia.1252';
 $string['localewincharset'] = '';
 $string['oldcharset'] = 'ISO-8859-1';
index e4319e6..8fbcca4 100644 (file)
@@ -1126,6 +1126,7 @@ $string['ip_address'] = 'IP address';
 $string['jump'] = 'Jump';
 $string['jumpto'] = 'Jump to...';
 $string['keep'] = 'Keep';
+$string['labelvalue'] = '{$a->label}: {$a->value}';
 $string['langltr'] = 'Language direction left-to-right';
 $string['langrtl'] = 'Language direction right-to-left';
 $string['language'] = 'Language';
@@ -1930,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';
diff --git a/lang/en/payment.php b/lang/en/payment.php
new file mode 100644 (file)
index 0000000..1081158
--- /dev/null
@@ -0,0 +1,66 @@
+<?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/>.
+
+/**
+ * Strings for component 'payment', language 'en'
+ *
+ * @package   core_payment
+ * @copyright 2019 Shamim Rezaie <shamim@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+$string['accountarchived'] = 'Archived';
+$string['accountdeleteconfirm'] = 'If this account has previous payments, it will be archived, otherwise its configuration data will be permanently deleted. Are you sure you want to continue?';
+$string['accountconfignote'] = 'Payment gateways for this account will be configured separately';
+$string['accountidnumber'] = 'ID number';
+$string['accountidnumber_help'] = 'The ID number is only used when matching the account against external systems and is not displayed anywhere on the site. If the account has an official code name it may be entered, otherwise the field can be left blank.';
+$string['accountname'] = 'Account name';
+$string['accountname_help'] = 'How this account will be identified for teachers or managers who set up payments (for example in the course enrolment plugin)';
+$string['accountnotavailable'] = 'Not available';
+$string['paymentaccountsexplained'] = 'Create one or multiple payment accounts for this site. Each account includes configuration for available payment gateways. The person who configures payments on the site (for example, payment for the course enrolment) will be able to chose from the available accounts.';
+$string['createaccount'] = 'Create payment account';
+$string['deleteorarchive'] = 'Delete or archive';
+$string['eventaccountcreated'] = 'Payment account created';
+$string['eventaccountdeleted'] = 'Payment account deleted';
+$string['eventaccountupdated'] = 'Payment account updated';
+$string['feeincludesurcharge'] = '{$a->fee} (includes {$a->surcharge}% surcharge for using this payment type)';
+$string['gatewaycannotbeenabled'] = 'The payment gateway cannot be enabled because the configuration is incomplete.';
+$string['gatewaydisabled'] = 'Disabled';
+$string['gatewayenabled'] = 'Enabled';
+$string['gatewaynotfound'] = 'Gateway not found';
+$string['gotomanageplugins'] = 'Enable and disable payment gateways and set surcharges via {$a}.';
+$string['gotopaymentaccounts'] = 'You can create multiple payment accounts using any of these gateways on the {$a} page';
+$string['hidearchived'] = 'Hide archived';
+$string['noaccountsavilable'] = 'No payment accounts are available.';
+$string['nocurrencysupported'] = 'No payment in any currency is supported. Please make sure that at least one payment gateway is enabled.';
+$string['nogateway'] = 'There is no payment gateway that can be used.';
+$string['nogatewayselected'] = 'You first need to select a payment gateway.';
+$string['payments'] = 'Payments';
+$string['paymentaccount'] = 'Payment account';
+$string['paymentaccounts'] = 'Payment accounts';
+$string['privacy:metadata:database:payments'] = 'Information about the payments.';
+$string['privacy:metadata:database:payments:amount'] = 'The amount for the payment.';
+$string['privacy:metadata:database:payments:currency'] = 'The currency of the payment.';
+$string['privacy:metadata:database:payments:gateway'] = 'The payment gateway that is used for the payment.';
+$string['privacy:metadata:database:payments:timecreated'] = 'The time when the payment was made.';
+$string['privacy:metadata:database:payments:timemodified'] = 'The time when the payment record was last updated.';
+$string['privacy:metadata:database:payments:userid'] = 'The user who made the payment.';
+$string['restoreaccount'] = 'Restore';
+$string['selectpaymenttype'] = 'Select payment type';
+$string['showarchived'] = 'Show archived';
+$string['supportedcurrencies'] = 'Supported currencies';
+$string['surcharge'] = 'Surcharge (percentage)';
+$string['surcharge_desc'] = 'The surcharge is an additional percentage charged to users who choose to pay using this payment gateway.';
index 14e3f57..d25ee12 100644 (file)
@@ -165,6 +165,9 @@ $string['type_mnetservice'] = 'MNet service';
 $string['type_mnetservice_plural'] = 'MNet services';
 $string['type_mod'] = 'Activity module';
 $string['type_mod_plural'] = 'Activity modules';
+$string['type_paygwmanage'] = 'Manage payment gateways';
+$string['type_paygw'] = 'Payment gateway';
+$string['type_paygw_plural'] = 'Payment gateways';
 $string['type_plagiarism'] = 'Plagiarism plugin';
 $string['type_plagiarism_plural'] = 'Plagiarism plugins';
 $string['type_portfolio'] = 'Portfolio';
index 4e39e73..a9500d7 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';
@@ -329,6 +331,8 @@ $string['overrideroles'] = 'Override roles';
 $string['overriderolesin'] = 'Override roles in {$a}';
 $string['overrides'] = 'Overrides';
 $string['overridesbycontext'] = 'Overrides (by context)';
+$string['payment:manageaccounts'] = 'Manage accounts';
+$string['payment:viewpayments'] = 'View payments';
 $string['permission'] = 'Permission';
 $string['permission_help'] = 'Permissions are capability settings. There are 4 options:
 
index 530e371..1374a94 100644 (file)
Binary files a/lib/amd/build/modal.min.js and b/lib/amd/build/modal.min.js differ
index 7935256..3b050c2 100644 (file)
Binary files a/lib/amd/build/modal.min.js.map and b/lib/amd/build/modal.min.js.map differ
index ca97402..ad3019a 100644 (file)
Binary files a/lib/amd/build/modal_events.min.js and b/lib/amd/build/modal_events.min.js differ
index d7749db..eb37ddd 100644 (file)
Binary files a/lib/amd/build/modal_events.min.js.map and b/lib/amd/build/modal_events.min.js.map differ
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 823adcd..37ce574 100644 (file)
@@ -780,7 +780,12 @@ define([
                 // So, we check if we can still find the container element or not. If not, then the DOM tree is changed.
                 // It's best not to hide the modal in that case.
                 if ($(e.target).closest(SELECTORS.CONTAINER).length) {
-                    this.hideIfNotForm();
+                    var outsideClickEvent = $.Event(ModalEvents.outsideClick);
+                    this.getRoot().trigger(outsideClickEvent, this);
+
+                    if (!outsideClickEvent.isDefaultPrevented()) {
+                        this.hideIfNotForm();
+                    }
                 }
             }
         }.bind(this));
index 234d3d7..b57d504 100644 (file)
@@ -29,6 +29,7 @@ define([], function() {
         hidden: 'modal:hidden',
         destroyed: 'modal:destroyed',
         bodyRendered: 'modal:bodyRendered',
+        outsideClick: 'modal:outsideClick',
         // ModalSaveCancel events.
         save: 'modal-save-cancel:save',
         cancel: 'modal-save-cancel:cancel',
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 0a146cc..2993053 100644 (file)
@@ -268,7 +268,7 @@ abstract class persistent extends moodleform {
     /**
      * Return the persistent object associated with this form instance.
      *
-     * @return core\persistent
+     * @return \core\persistent
      */
     final protected function get_persistent() {
         return $this->persistent;
index d59d585..0ec1070 100644 (file)
@@ -1853,7 +1853,7 @@ class core_plugin_manager {
             'enrol' => array(
                 'category', 'cohort', 'database', 'flatfile',
                 'guest', 'imsenterprise', 'ldap', 'lti', 'manual', 'meta', 'mnet',
-                'paypal', 'self'
+                'paypal', 'self', 'fee',
             ),
 
             'filter' => array(
@@ -1923,6 +1923,10 @@ class core_plugin_manager {
                 'quiz', 'resource', 'scorm', 'survey', 'url', 'wiki', 'workshop'
             ),
 
+            'paygw' => [
+                'paypal',
+            ],
+
             'plagiarism' => array(
             ),
 
diff --git a/lib/classes/plugininfo/paygw.php b/lib/classes/plugininfo/paygw.php
new file mode 100644 (file)
index 0000000..35c6beb
--- /dev/null
@@ -0,0 +1,143 @@
+<?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/>.
+
+/**
+ * Contains subplugin info class for payment gateways.
+ *
+ * @package   core_payment
+ * @copyright 2019 Shamim Rezaie <shamim@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\plugininfo;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Payment gateway subplugin info class.
+ *
+ * @copyright 2019 Shamim Rezaie <shamim@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class paygw extends base {
+    public function is_uninstall_allowed() {
+        return true;
+    }
+
+    public function get_settings_section_name() {
+        return 'paymentgateway' . $this->name;
+    }
+
+    public function load_settings(\part_of_admin_tree $adminroot, $parentnodename, $hassiteconfig) {
+        global $CFG, $USER, $DB, $OUTPUT, $PAGE; // In case settings.php wants to refer to them.
+        $ADMIN = $adminroot; // May be used in settings.php.
+        $plugininfo = $this; // Also can be used inside settings.php.
+
+        if (!$this->is_installed_and_upgraded()) {
+            return;
+        }
+
+        if (!$hassiteconfig) {
+            return;
+        }
+
+        $section = $this->get_settings_section_name();
+
+        $settings = null;
+        if (file_exists($this->full_path('settings.php'))) {
+            $settings = new \admin_settingpage($section, $this->displayname, 'moodle/site:config', $this->is_enabled() === false);
+            include($this->full_path('settings.php')); // This may also set $settings to null.
+        }
+        if ($settings) {
+            $ADMIN->add($parentnodename, $settings);
+        }
+    }
+
+    public static function get_manage_url() {
+        return new \moodle_url('/admin/settings.php', array('section' => 'managepaymentgateways'));
+    }
+
+    public static function get_enabled_plugins() {
+        global $CFG;
+
+        $order = (!empty($CFG->paygw_plugins_sortorder)) ? explode(',', $CFG->paygw_plugins_sortorder) : [];
+        if ($order) {
+            $plugins = \core_plugin_manager::instance()->get_installed_plugins('paygw');
+            $order = array_intersect($order, array_keys($plugins));
+        }
+
+        return array_combine($order, $order);
+    }
+
+    /**
+     * Sets the current plugin as enabled or disabled
+     * When enabling tries to guess the sortorder based on default rank returned by the plugin.
+     *
+     * @param bool $newstate
+     */
+    public function set_enabled(bool $newstate = true) {
+        $enabled = self::get_enabled_plugins();
+        if (array_key_exists($this->name, $enabled) == $newstate) {
+            // Nothing to do.
+            return;
+        }
+        if ($newstate) {
+            // Enable gateway plugin.
+            $plugins = \core_plugin_manager::instance()->get_plugins_of_type('paygw');
+            if (!array_key_exists($this->name, $plugins)) {
+                // Can not be enabled.
+                return;
+            }
+            $enabled[$this->name] = $this->name;
+            self::set_enabled_plugins($enabled);
+        } else {
+            // Disable gateway plugin.
+            unset($enabled[$this->name]);
+            self::set_enabled_plugins($enabled);
+        }
+    }
+
+    /**
+     * Set the list of enabled payment gateways in the specified sort order
+     * To be used when changing settings or in unit tests.
+     *
+     * @param string|array $list list of plugin names without frankenstyle prefix - comma-separated string or an array
+     */
+    public static function set_enabled_plugins($list) {
+        if (empty($list)) {
+            $list = [];
+        } else if (!is_array($list)) {
+            $list = explode(',', $list);
+        }
+        if ($list) {
+            $plugins = \core_plugin_manager::instance()->get_installed_plugins('paygw');
+            $list = array_intersect($list, array_keys($plugins));
+        }
+        set_config('paygw_plugins_sortorder', join(',', $list));
+        \core_plugin_manager::reset_caches();
+    }
+
+    /**
+     * Returns the list of currencies that the payment gateway supports.
+     *
+     * @return string[] An array of the currency codes in the three-character ISO-4217 format
+     */
+    public function get_supported_currencies(): array {
+        $classname = '\paygw_'.$this->name.'\gateway';
+
+        return $classname::get_supported_currencies();
+    }
+}
index 81fcc1a..3d9dfc9 100644 (file)
@@ -38,7 +38,8 @@
         "contenttype": "contentbank\/contenttype",
         "theme": "theme",
         "local": "local",
-        "h5plib": "h5p\/h5plib"
+        "h5plib": "h5p\/h5plib",
+        "paygw": "payment\/gateway"
     },
     "subsystems": {
         "access": null,
@@ -94,6 +95,7 @@
         "my": "my",
         "notes": "notes",
         "pagetype": null,
+        "payment" : "payment",
         "pix": null,
         "plagiarism": "plagiarism",
         "plugin": null,
index 5078f4d..bc812d5 100644 (file)
@@ -2576,4 +2576,42 @@ $capabilities = array(
             'editingteacher' => CAP_ALLOW,
         ]
     ],
+
+    // 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' => [],
+    ],
+
+    // Allow to view payments.
+    'moodle/payment:viewpayments' => [
+        'captype' => 'read',
+        'riskbitmask' => RISK_PERSONAL,
+        'contextlevel' => CONTEXT_COURSE,
+        'archetypes' => [],
+    ],
 );
index 4781461..87a8826 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="lib/db" VERSION="20201007" 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 NAME="instance" UNIQUE="false" FIELDS="contextid, contenttype, instanceid"/>
       </INDEXES>
     </TABLE>
+    <TABLE NAME="payment_accounts" COMMENT="Payment accounts">
+      <FIELDS>
+        <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
+        <FIELD NAME="name" TYPE="char" LENGTH="255" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="idnumber" TYPE="char" LENGTH="100" NOTNULL="false" SEQUENCE="false"/>
+        <FIELD NAME="contextid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="enabled" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
+        <FIELD NAME="archived" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
+        <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false"/>
+        <FIELD NAME="timemodified" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false"/>
+      </FIELDS>
+      <KEYS>
+        <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
+      </KEYS>
+    </TABLE>
+    <TABLE NAME="payment_gateways" COMMENT="Configuration for one gateway for one payment account">
+      <FIELDS>
+        <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
+        <FIELD NAME="accountid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="gateway" TYPE="char" LENGTH="100" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="enabled" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="1" SEQUENCE="false"/>
+        <FIELD NAME="config" TYPE="text" NOTNULL="false" SEQUENCE="false"/>
+        <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="timemodified" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+      </FIELDS>
+      <KEYS>
+        <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
+        <KEY NAME="accountid" TYPE="foreign" FIELDS="accountid" REFTABLE="payment_accounts" REFFIELDS="id"/>
+      </KEYS>
+    </TABLE>
+    <TABLE NAME="payments" COMMENT="Stores information about payments">
+      <FIELDS>
+        <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
+        <FIELD NAME="component" TYPE="char" LENGTH="100" NOTNULL="true" SEQUENCE="false" COMMENT="The plugin this payment belongs to."/>
+        <FIELD NAME="paymentarea" TYPE="char" LENGTH="50" NOTNULL="true" SEQUENCE="false" COMMENT="The name of payable area"/>
+        <FIELD NAME="itemid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="userid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="amount" TYPE="char" LENGTH="20" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="currency" TYPE="char" LENGTH="3" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="accountid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="gateway" TYPE="char" LENGTH="100" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
+        <FIELD NAME="timemodified" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
+      </FIELDS>
+      <KEYS>
+        <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
+        <KEY NAME="userid" TYPE="foreign" FIELDS="userid" REFTABLE="user" REFFIELDS="id"/>
+        <KEY NAME="accountid" TYPE="foreign" FIELDS="accountid" REFTABLE="payment_accounts" REFFIELDS="id"/>
+      </KEYS>
+      <INDEXES>
+        <INDEX NAME="gateway" UNIQUE="false" FIELDS="gateway"/>
+        <INDEX NAME="component-paymentarea-itemid" UNIQUE="false" FIELDS="component, paymentarea, itemid"/>
+      </INDEXES>
+    </TABLE>
     <TABLE NAME="infected_files" COMMENT="Table to store infected file details.">
       <FIELDS>
         <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
index 9f21e40..ef653f0 100644 (file)
@@ -2720,6 +2720,13 @@ $functions = array(
         'ajax'          => 'true',
         'capabilities'  => '',
     ],
+    'core_payment_get_available_gateways' => [
+        'classname'   => 'core_payment\external\get_available_gateways',
+        'methodname'  => 'execute',
+        'description' => 'Get the list of payment gateways that support the given component/area',
+        'type'        => 'read',
+        'ajax'        => true,
+    ],
 );
 
 $services = array(
index b9c5d34..4372338 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,5 +2863,136 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2021052500.30);
     }
 
+    if ($oldversion < 2021052500.32) {
+        // 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, 2021052500.32);
+    }
+
+    if ($oldversion < 2021052500.33) {
+        $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, 2021052500.33);
+        }
+    }
+
+    if ($oldversion < 2021052500.36) {
+        // Define table payment_accounts to be created.
+        $table = new xmldb_table('payment_accounts');
+
+        // Adding fields to table payment_accounts.
+        $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null);
+        $table->add_field('name', XMLDB_TYPE_CHAR, '255', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('idnumber', XMLDB_TYPE_CHAR, '100', null, null, null, null);
+        $table->add_field('contextid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('enabled', XMLDB_TYPE_INTEGER, '1', null, XMLDB_NOTNULL, null, '0');
+        $table->add_field('archived', XMLDB_TYPE_INTEGER, '1', null, XMLDB_NOTNULL, null, '0');
+        $table->add_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, null, null, null);
+        $table->add_field('timemodified', XMLDB_TYPE_INTEGER, '10', null, null, null, null);
+
+        // Adding keys to table payment_accounts.
+        $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']);
+
+        // Conditionally launch create table for payment_accounts.
+        if (!$dbman->table_exists($table)) {
+            $dbman->create_table($table);
+        }
+
+        // Define table payment_gateways to be created.
+        $table = new xmldb_table('payment_gateways');
+
+        // Adding fields to table payment_gateways.
+        $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null);
+        $table->add_field('accountid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('gateway', XMLDB_TYPE_CHAR, '100', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('enabled', XMLDB_TYPE_INTEGER, '1', null, XMLDB_NOTNULL, null, '1');
+        $table->add_field('config', XMLDB_TYPE_TEXT, null, null, null, null, null);
+        $table->add_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('timemodified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+
+        // Adding keys to table payment_gateways.
+        $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']);
+        $table->add_key('accountid', XMLDB_KEY_FOREIGN, ['accountid'], 'payment_accounts', ['id']);
+
+        // Conditionally launch create table for payment_gateways.
+        if (!$dbman->table_exists($table)) {
+            $dbman->create_table($table);
+        }
+
+        // Define table payments to be created.
+        $table = new xmldb_table('payments');
+
+        // Adding fields to table payments.
+        $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null);
+        $table->add_field('component', XMLDB_TYPE_CHAR, '100', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('paymentarea', XMLDB_TYPE_CHAR, '50', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('itemid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('userid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('amount', XMLDB_TYPE_CHAR, '20', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('currency', XMLDB_TYPE_CHAR, '3', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('accountid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('gateway', XMLDB_TYPE_CHAR, '100', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0');
+        $table->add_field('timemodified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0');
+
+        // Adding keys to table payments.
+        $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']);
+        $table->add_key('userid', XMLDB_KEY_FOREIGN, ['userid'], 'user', ['id']);
+        $table->add_key('accountid', XMLDB_KEY_FOREIGN, ['accountid'], 'payment_accounts', ['id']);
+
+        // Adding indexes to table payments.
+        $table->add_index('gateway', XMLDB_INDEX_NOTUNIQUE, ['gateway']);
+        $table->add_index('component-paymentarea-itemid', XMLDB_INDEX_NOTUNIQUE, ['component', 'paymentarea', 'itemid']);
+
+        // Conditionally launch create table for payments.
+        if (!$dbman->table_exists($table)) {
+            $dbman->create_table($table);
+        }
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2021052500.36);
+    }
+
     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']));
     }
 
     /**
index 0111d66..4a3bb07 100644 (file)
@@ -36,7 +36,7 @@ class core_component_testcase extends advanced_testcase {
      * this is defined here to annoy devs that try to add more without any thinking,
      * always verify that it does not collide with any existing add-on modules and subplugins!!!
      */
-    const SUBSYSTEMCOUNT = 71;
+    const SUBSYSTEMCOUNT = 72;
 
     public function setUp(): void {
         $psr0namespaces = new ReflectionProperty('core_component', 'psr0namespaces');
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']);
+    }
+}
diff --git a/payment/accounts.php b/payment/accounts.php
new file mode 100644 (file)
index 0000000..012b2b9
--- /dev/null
@@ -0,0 +1,97 @@
+<?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/>.
+
+/**
+ * Management of payment accounts
+ *
+ * @package    core_payment
+ * @copyright  2020 Marina Glancy
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require_once(__DIR__ . '/../config.php');
+require_once($CFG->libdir . '/adminlib.php');
+
+$showarchived = optional_param('showarchived', false, PARAM_BOOL);
+
+admin_externalpage_setup('paymentaccounts');
+$PAGE->set_heading(get_string('paymentaccounts', 'payment'));
+
+$enabledplugins = \core\plugininfo\paygw::get_enabled_plugins();
+
+echo $OUTPUT->header();
+
+$accounts = \core_payment\helper::get_payment_accounts_to_manage(context_system::instance(), $showarchived);
+$table = new html_table();
+$table->head = [get_string('accountname', 'payment'), get_string('type_paygw_plural', 'plugin'), ''];
+$table->colclasses = ['', '', 'mdl-right'];
+$table->data = [];
+foreach ($accounts as $account) {
+    $gateways = [];
+    $canmanage = has_capability('moodle/payment:manageaccounts', $account->get_context());
+    foreach ($account->get_gateways() as $gateway) {
+        $status = $gateway->get('enabled') ? $OUTPUT->pix_icon('i/valid', get_string('gatewayenabled', 'payment')) :
+            $OUTPUT->pix_icon('i/invalid', get_string('gatewaydisabled', 'payment'));
+        $gateways[] = $status .
+            ($canmanage ? html_writer::link($gateway->get_edit_url(), $gateway->get_display_name()) : $gateway->get_display_name());
+    }
+    $name = $account->get_formatted_name();
+    if (!$account->is_available()) {
+        $name .= ' ' . html_writer::span(get_string('accountnotavailable', 'payment'), 'badge badge-warning');
+    }
+    if ($account->get('archived')) {
+        $name .= ' ' . html_writer::span(get_string('accountarchived', 'payment'), 'badge badge-secondary');
+    }
+
+    $menu = new action_menu();
+    $menu->set_alignment(action_menu::TL, action_menu::BL);
+    $menu->set_menu_trigger(get_string('edit'));
+    if ($canmanage) {
+        $menu->add(new action_menu_link_secondary($account->get_edit_url(), null, get_string('edit')));
+        if (!$account->get('archived')) {
+            $deleteurl = $account->get_edit_url(['delete' =>&n