Merge branch 'MDL-69930-master' of https://github.com/HuongNV13/moodle
authorJun Pataleta <jun@moodle.com>
Tue, 10 Nov 2020 06:11:54 +0000 (14:11 +0800)
committerJun Pataleta <jun@moodle.com>
Tue, 10 Nov 2020 06:11:54 +0000 (14:11 +0800)
# Conflicts:
# question/type/ddimageortext/amd/build/question.min.js
# question/type/ddimageortext/amd/build/question.min.js.map
# question/type/ddimageortext/amd/src/question.js

303 files changed:
.nvmrc
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
admin/tool/customlang/lang/en/tool_customlang.php
admin/tool/mobile/classes/api.php
admin/tool/mobile/lang/en/tool_mobile.php
admin/tool/replace/lang/en/tool_replace.php
admin/tool/task/lang/en/tool_task.php
admin/tool/task/tests/behat/running_tasks.feature
admin/tool/uploadcourse/classes/course.php
admin/tool/uploadcourse/tests/behat/enrolments.feature [new file with mode: 0644]
admin/tool/uploadcourse/tests/fixtures/enrolment_delete.csv [new file with mode: 0644]
admin/tool/uploadcourse/tests/fixtures/enrolment_disable.csv [new file with mode: 0644]
admin/tool/uploadcourse/tests/fixtures/enrolment_enable.csv [new file with mode: 0644]
admin/tool/uploaduser/classes/process.php
admin/tool/xmldb/actions/XMLDBCheckAction.class.php
auth/db/auth.php
auth/db/tests/db_test.php
backup/controller/restore_controller.class.php
backup/moodle2/backup_activity_task.class.php
backup/moodle2/restore_activity_task.class.php
backup/moodle2/restore_stepslib.php
backup/util/ui/base_moodleform.class.php
backup/util/ui/classes/output/copy_form.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/external/assertion_exporter.php
badges/classes/external/badgeclass_exporter.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
blocks/myoverview/templates/nav-display-selector.mustache
blocks/myoverview/templates/nav-grouping-selector.mustache
blocks/myoverview/templates/nav-sort-selector.mustache
blocks/news_items/block_news_items.php
blocks/timeline/amd/build/view_nav.min.js
blocks/timeline/amd/build/view_nav.min.js.map
blocks/timeline/amd/src/view_nav.js
blocks/timeline/templates/nav-day-filter.mustache
blocks/timeline/templates/nav-view-selector.mustache
cache/classes/config.php
cache/locallib.php
contentbank/templates/bankcontent.mustache
contentbank/templates/renamecontent.mustache
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
dataformat/pdf/classes/writer.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]
enrol/manual/lang/en/enrol_manual.php
enrol/tests/enrollib_test.php
grade/report/singleview/lang/en/gradereport_singleview.php
group/tests/behat/create_groups.feature
group/tests/behat/overview.feature
install/lang/arn/langconfig.php [new file with mode: 0644]
install/lang/it/install.php
lang/en/admin.php
lang/en/analytics.php
lang/en/antivirus.php
lang/en/backup.php
lang/en/badges.php
lang/en/completion.php
lang/en/contentbank.php
lang/en/course.php
lang/en/deprecated.txt
lang/en/error.php
lang/en/form.php
lang/en/group.php
lang/en/langconfig.php
lang/en/moodle.php
lang/en/payment.php [new file with mode: 0644]
lang/en/plugin.php
lang/en/repository.php
lang/en/role.php
lang/en/user.php
lib/amd/build/custom_interaction_events.min.js
lib/amd/build/custom_interaction_events.min.js.map
lib/amd/build/form-autocomplete.min.js
lib/amd/build/form-autocomplete.min.js.map
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/custom_interaction_events.js
lib/amd/src/form-autocomplete.js
lib/amd/src/modal.js
lib/amd/src/modal_events.js
lib/amd/src/tree.js
lib/badgeslib.php
lib/behat/classes/partial_named_selector.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/clilib.php
lib/components.json
lib/db/access.php
lib/db/install.xml
lib/db/services.php
lib/db/upgrade.php
lib/dml/mysqli_native_moodle_database.php
lib/dml/tests/dml_test.php
lib/editor/atto/plugins/equation/yui/build/moodle-atto_equation-button/moodle-atto_equation-button-debug.js
lib/editor/atto/plugins/equation/yui/build/moodle-atto_equation-button/moodle-atto_equation-button-min.js
lib/editor/atto/plugins/equation/yui/build/moodle-atto_equation-button/moodle-atto_equation-button.js
lib/editor/atto/plugins/equation/yui/src/button/js/button.js
lib/enrollib.php
lib/form/filemanager.js
lib/moodlelib.php
lib/navigationlib.php
lib/pagelib.php
lib/phpunit/classes/phpunit_dataset.php
lib/setuplib.php
lib/templates/form_autocomplete_selection.mustache
lib/templates/form_autocomplete_selection_items.mustache
lib/templates/form_autocomplete_suggestions.mustache
lib/templates/paged_content_paging_bar.mustache
lib/tests/component_test.php
lib/tests/content_test.php [new file with mode: 0644]
lib/tests/moodlelib_test.php
message/output/airnotifier/classes/manager.php
message/output/popup/db/upgrade.php
mod/assign/feedback/editpdf/lib.php
mod/data/classes/external.php
mod/data/field/textarea/field.class.php
mod/data/tests/externallib_test.php
mod/feedback/lang/en/feedback.php
mod/feedback/tests/behat/coursemapping.feature
mod/feedback/tests/behat/question_types_non_anon.feature
mod/forum/classes/local/exporters/post.php
mod/forum/tests/behat/advanced_search.feature
mod/forum/tests/exporters_post_test.php
mod/forum/tests/externallib_test.php
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]
mod/quiz/lib.php
mod/quiz/mod_form.php
package.json
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]
question/type/ddimageortext/amd/build/form.min.js
question/type/ddimageortext/amd/build/form.min.js.map
question/type/ddimageortext/amd/build/question.min.js
question/type/ddimageortext/amd/build/question.min.js.map
question/type/ddimageortext/amd/src/form.js
question/type/ddimageortext/amd/src/question.js
question/type/ddmarker/amd/build/form.min.js
question/type/ddmarker/amd/build/form.min.js.map
question/type/ddmarker/amd/src/form.js
question/type/edit_question_form.php
question/type/multichoice/amd/build/answers.min.js [new file with mode: 0644]
question/type/multichoice/amd/build/answers.min.js.map [new file with mode: 0644]
question/type/multichoice/amd/build/clearchoice.min.js
question/type/multichoice/amd/build/clearchoice.min.js.map
question/type/multichoice/amd/src/answers.js [new file with mode: 0644]
question/type/multichoice/amd/src/clearchoice.js
question/type/multichoice/renderer.php
question/type/multichoice/tests/behat/behat_qtype_multichoice.php [new file with mode: 0644]
question/type/multichoice/tests/behat/clearanswers.feature
question/type/multichoice/tests/behat/preview.feature
question/type/multichoice/upgrade.txt [new file with mode: 0644]
report/infectedfiles/lang/en/report_infectedfiles.php
repository/contentbank/tests/behat/file_update.feature
repository/filepicker.js
repository/onedrive/lang/en/repository_onedrive.php
repository/tests/behat/create_shortcut.feature
theme/boost/scss/moodle/bootstrap-rtl.scss
theme/boost/scss/moodle/core.scss
theme/boost/scss/moodle/forms.scss
theme/boost/scss/moodle/modules.scss
theme/boost/scss/preset/default.scss
theme/boost/style/moodle.css
theme/classic/scss/preset/default.scss
theme/classic/style/moodle.css
user/action_redir.php
user/amd/build/local/participantsfilter/filter.min.js
user/amd/build/local/participantsfilter/filter.min.js.map
user/amd/build/participantsfilter.min.js
user/amd/build/participantsfilter.min.js.map
user/amd/src/local/participantsfilter/filter.js
user/amd/src/participantsfilter.js
user/templates/local/participantsfilter/autocomplete_selection.mustache
user/templates/local/participantsfilter/autocomplete_selection_items.mustache
user/templates/local/participantsfilter/filterrow.mustache
user/tests/behat/filter_participants.feature
version.php

diff --git a/.nvmrc b/.nvmrc
index 01f1a56..55d1782 100644 (file)
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-v14.0.0
+v14.15.0
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 29dd1e2..882f920 100644 (file)
@@ -33,8 +33,8 @@ $string['checkoutinprogress'] = 'Loading language pack';
 $string['cliexportfileexists'] = 'File for {$a->lang} already exists, skipping. If you want to overwrite add the --override=true option.';
 $string['cliexportheading'] = 'Starting to export lang files.';
 $string['cliexportnofilefoundforlang'] = 'No file found to export. Skipping export for this language.';
-$string['cliexportfilenotfoundforcomponent'] = 'File {$a->filepath} not found for language {$a->lang}.Skipping this file.';
-$string['cliexportstartexport'] = 'Exporting language "{$a}"';
+$string['cliexportfilenotfoundforcomponent'] = 'File {$a->filepath} not found for language {$a->lang}. Skipping this file.';
+$string['cliexportstartexport'] = 'Exporting language {$a}';
 $string['cliexportzipdone'] = 'Zip created: {$a}';
 $string['cliexportzipfail'] = 'Cannot create zip {$a}';
 $string['clifiles'] = 'Files to import into {$a}';
index 9ea41b0..45f0d25 100644 (file)
@@ -603,8 +603,21 @@ class api {
                 $timenow = time();
                 $expectedissuer = null;
                 foreach ($info['certinfo'] as $cert) {
+
+                    // Due to a bug in certain curl/openssl versions the signature algorithm isn't always correctly parsed.
+                    // See https://github.com/curl/curl/issues/3706 for reference.
+                    if (!array_key_exists('Signature Algorithm', $cert)) {
+                        // The malformed field that does contain the algorithm we're looking for looks like the following:
+                        // <WHITESPACE>Signature Algorithm: <ALGORITHM><CRLF><ALGORITHM>.
+                        preg_match('/\s+Signature Algorithm: (?<algorithm>[^\s]+)/', $cert['Public Key Algorithm'], $matches);
+
+                        $signaturealgorithm = $matches['algorithm'] ?? '';
+                    } else {
+                        $signaturealgorithm = $cert['Signature Algorithm'];
+                    }
+
                     // Check if the signature algorithm is weak (Android won't work with SHA-1).
-                    if ($cert['Signature Algorithm'] == 'sha1WithRSAEncryption' || $cert['Signature Algorithm'] == 'sha1WithRSA') {
+                    if ($signaturealgorithm == 'sha1WithRSAEncryption' || $signaturealgorithm == 'sha1WithRSA') {
                         $warnings[] = ['insecurealgorithmwarning', 'tool_mobile'];
                     }
                     // Check certificate start date.
index f3e6d7d..6eedc75 100644 (file)
@@ -64,7 +64,7 @@ $string['downloadcourses'] = 'Download courses';
 $string['enablesmartappbanners'] = 'Enable App Banners';
 $string['enablesmartappbanners_desc'] = 'If enabled, a banner promoting the mobile app will be displayed when accessing the site using a mobile browser.';
 $string['filetypeexclusionlist'] = 'File type exclusion list';
-$string['filetypeexclusionlist_desc'] = 'List of file types that we don\'t want users to try and open in the app. These files will still be listed on the app\'s course screen, but attempting to open them on iOS or Android would display a warning to the user indicating that this file type is not intended for use on a mobile device. They can then either cancel the open, or ignore the warning and open anyway.';
+$string['filetypeexclusionlist_desc'] = 'Select all file types which are not for use on a mobile device. Such files will be listed in the course, then if a user attempts to open them, a warning will be displayed advising that the file type is not intended for use on a mobile device. The user can then cancel or ignore the warning and open the file anyway.';
 $string['filetypeexclusionlistplaceholder'] = 'Mobile file type exclusion list';
 $string['forcedurlscheme'] = 'If you want to allow only your custom branded app to be opened via a browser window, then specify its URL scheme here. If you want to allow only the official app, then set the default value. Leave the field empty if you want to allow any app.';
 $string['forcedurlscheme_key'] = 'URL scheme';
index e8dce48..55e960c 100644 (file)
@@ -23,7 +23,7 @@
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 $string['additionalskiptables'] = 'Additional skip tables';
-$string['additionalskiptables_desc'] = 'Please specify the additional tables (comma separated list) you want to skip while running DB search and replace.';
+$string['additionalskiptables_desc'] = 'A list of tables (separated by commas) which should be skipped when running the database search and replace.';
 $string['cannotfit'] = 'The replacement is longer than the original and shortening is not allowed; cannot continue.';
 $string['disclaimer'] = 'I understand the risks of this operation';
 $string['doit'] = 'Yes, do it!';
index 08cf819..8532fff 100644 (file)
@@ -22,9 +22,9 @@
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
-$string['adhoc'] = 'Ad-hoc';
-$string['adhoctaskid'] = 'Ad-hoc task id: {$a}';
-$string['adhoctasks'] = 'Ad-hoc tasks';
+$string['adhoc'] = 'Ad hoc';
+$string['adhoctaskid'] = 'Ad hoc task ID: {$a}';
+$string['adhoctasks'] = 'Ad hoc tasks';
 $string['asap'] = 'ASAP';
 $string['adhocempty'] = 'Ad hoc task queue is empty';
 $string['adhocqueuesize'] = 'Ad hoc task queue has {$a} tasks';
index e5db69c..5725d5c 100644 (file)
@@ -28,13 +28,13 @@ Feature: See running scheduled tasks
     And I should see "1914" in the "Automated backups" "table_row"
 
     # Check the "asynchronous_backup_task" adhoc task details.
-    And I should see "Ad-hoc" in the "\core\task\asynchronous_backup_task" "table_row"
+    And I should see "Ad hoc" in the "\core\task\asynchronous_backup_task" "table_row"
     And I should see "2 hours" in the "core\task\asynchronous_backup_task" "table_row"
     And I should see "c69335460f7f" in the "core\task\asynchronous_backup_task" "table_row"
     And I should see "1915" in the "core\task\asynchronous_backup_task" "table_row"
 
     # Check the "asynchronous_restore_task" adhoc task details.
-    And I should see "Ad-hoc" in the "\core\task\asynchronous_restore_task" "table_row"
+    And I should see "Ad hoc" in the "\core\task\asynchronous_restore_task" "table_row"
     And I should see "2 days" in the "core\task\asynchronous_restore_task" "table_row"
     And I should see "c69335460f7f" in the "core\task\asynchronous_restore_task" "table_row"
     And I should see "1916" in the "core\task\asynchronous_restore_task" "table_row"
index fd7569e..4c45b1a 100644 (file)
@@ -980,55 +980,48 @@ class tool_uploadcourse_course {
             unset($method['delete']);
             unset($method['disable']);
 
-            if (!empty($instance) && $todelete) {
+            if ($todelete) {
                 // Remove the enrolment method.
-                foreach ($instances as $instance) {
-                    if ($instance->enrol == $enrolmethod) {
-                        $plugin = $enrolmentplugins[$instance->enrol];
-
-                        // Ensure user is able to delete the instance.
-                        if ($plugin->can_delete_instance($instance)) {
-                            $plugin->delete_instance($instance);
-                        } else {
-                            $this->error('errorcannotdeleteenrolment',
-                                new lang_string('errorcannotdeleteenrolment', 'tool_uploadcourse',
-                                    $plugin->get_instance_name($instance)));
-                        }
+                if ($instance) {
+                    $plugin = $enrolmentplugins[$instance->enrol];
 
-                        break;
-                    }
-                }
-            } else if (!empty($instance) && $todisable) {
-                // Disable the enrolment.
-                foreach ($instances as $instance) {
-                    if ($instance->enrol == $enrolmethod) {
-                        $plugin = $enrolmentplugins[$instance->enrol];
-
-                        // Ensure user is able to toggle instance status.
-                        if ($plugin->can_hide_show_instance($instance)) {
-                            $plugin->update_status($instance, ENROL_INSTANCE_DISABLED);
-                        } else {
-                            $this->error('errorcannotdisableenrolment',
-                                new lang_string('errorcannotdisableenrolment', 'tool_uploadcourse',
-                                    $plugin->get_instance_name($instance)));
-                        }
-
-                        break;
+                    // Ensure user is able to delete the instance.
+                    if ($plugin->can_delete_instance($instance)) {
+                        $plugin->delete_instance($instance);
+                    } else {
+                        $this->error('errorcannotdeleteenrolment',
+                            new lang_string('errorcannotdeleteenrolment', 'tool_uploadcourse',
+                                $plugin->get_instance_name($instance)));
                     }
                 }
             } else {
                 // Create/update enrolment.
                 $plugin = $enrolmentplugins[$enrolmethod];
 
-                // Ensure user is able to create/update instance.
+                $status = ($todisable) ? ENROL_INSTANCE_DISABLED : ENROL_INSTANCE_ENABLED;
+
+                // Create a new instance if necessary.
                 if (empty($instance) && $plugin->can_add_instance($course->id)) {
-                    $instance = new stdClass();
-                    $instance->id = $plugin->add_default_instance($course);
+                    $instanceid = $plugin->add_default_instance($course);
+                    $instance = $DB->get_record('enrol', ['id' => $instanceid]);
                     $instance->roleid = $plugin->get_config('roleid');
-                    $instance->status = ENROL_INSTANCE_ENABLED;
-                } else if (!empty($instance) && $plugin->can_edit_instance($instance)) {
-                    $plugin->update_status($instance, ENROL_INSTANCE_ENABLED);
-                } else {
+                    // On creation the user can decide the status.
+                    $plugin->update_status($instance, $status);
+                }
+
+                // Check if the we need to update the instance status.
+                if ($instance && $status != $instance->status) {
+                    if ($plugin->can_hide_show_instance($instance)) {
+                        $plugin->update_status($instance, $status);
+                    } else {
+                        $this->error('errorcannotdisableenrolment',
+                            new lang_string('errorcannotdisableenrolment', 'tool_uploadcourse',
+                                $plugin->get_instance_name($instance)));
+                        break;
+                    }
+                }
+
+                if (empty($instance) || !$plugin->can_edit_instance($instance)) {
                     $this->error('errorcannotcreateorupdateenrolment',
                         new lang_string('errorcannotcreateorupdateenrolment', 'tool_uploadcourse',
                             $plugin->get_instance_name($instance)));
diff --git a/admin/tool/uploadcourse/tests/behat/enrolments.feature b/admin/tool/uploadcourse/tests/behat/enrolments.feature
new file mode 100644 (file)
index 0000000..c8ee54d
--- /dev/null
@@ -0,0 +1,114 @@
+@tool @tool_uploadcourse @_file_upload
+Feature: An admin can update courses enrolments using a CSV file
+  In order to update courses enrolments using a CSV file
+  As an admin
+  I need to be able to upload a CSV file with enrolment methods for the courses
+
+  Background:
+    Given the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1        | 0        |
+    And I log in as "admin"
+
+  @javascript
+  Scenario: Creating enrolment method by enable it
+    Given I am on "Course 1" course homepage
+    And I navigate to "Users > Enrolment methods" in current page administration
+    And I click on "Delete" "link" in the "Guest access" "table_row"
+    And I click on "Continue" "button"
+    And I should not see "Guest access" in the "generaltable" "table"
+    And I navigate to "Courses > Upload courses" in site administration
+    And I upload "admin/tool/uploadcourse/tests/fixtures/enrolment_enable.csv" file to "File" filemanager
+    And I set the field "Upload mode" to "Only update existing courses"
+    And I set the field "Update mode" to "Update with CSV data only"
+    And I set the field "Allow deletes" to "Yes"
+    And I click on "Preview" "button"
+    When I click on "Upload courses" "button"
+    Then I should see "Course updated"
+    And I am on "Course 1" course homepage
+    And I navigate to "Users > Enrolment methods" in current page administration
+    And "Disable" "icon" should exist in the "Guest access" "table_row"
+
+  @javascript
+  Scenario: Creating enrolment method by disabling it
+    Given I am on "Course 1" course homepage
+    And I navigate to "Users > Enrolment methods" in current page administration
+    And I click on "Delete" "link" in the "Guest access" "table_row"
+    And I click on "Continue" "button"
+    And I should not see "Guest access" in the "generaltable" "table"
+    And I navigate to "Courses > Upload courses" in site administration
+    And I upload "admin/tool/uploadcourse/tests/fixtures/enrolment_disable.csv" file to "File" filemanager
+    And I set the field "Upload mode" to "Only update existing courses"
+    And I set the field "Update mode" to "Update with CSV data only"
+    And I set the field "Allow deletes" to "Yes"
+    And I click on "Preview" "button"
+    When I click on "Upload courses" "button"
+    Then I should see "Course updated"
+    And I am on "Course 1" course homepage
+    And I navigate to "Users > Enrolment methods" in current page administration
+    And "Enable" "icon" should exist in the "Guest access" "table_row"
+
+  @javascript
+  Scenario: Enabling enrolment method
+    Given I navigate to "Courses > Upload courses" in site administration
+    And I upload "admin/tool/uploadcourse/tests/fixtures/enrolment_enable.csv" file to "File" filemanager
+    And I set the field "Upload mode" to "Only update existing courses"
+    And I set the field "Update mode" to "Update with CSV data only"
+    And I set the field "Allow deletes" to "Yes"
+    And I click on "Preview" "button"
+    When I click on "Upload courses" "button"
+    Then I should see "Course updated"
+    And I am on "Course 1" course homepage
+    And I navigate to "Users > Enrolment methods" in current page administration
+    And "Disable" "icon" should exist in the "Guest access" "table_row"
+
+  @javascript
+  Scenario: Disable an enrolment method
+    Given I am on "Course 1" course homepage
+    And I navigate to "Users > Enrolment methods" in current page administration
+    And I click on "Enable" "link" in the "Guest access" "table_row"
+    And "Disable" "icon" should exist in the "Guest access" "table_row"
+    And I navigate to "Courses > Upload courses" in site administration
+    And I upload "admin/tool/uploadcourse/tests/fixtures/enrolment_disable.csv" file to "File" filemanager
+    And I set the field "Upload mode" to "Only update existing courses"
+    And I set the field "Update mode" to "Update with CSV data only"
+    And I set the field "Allow deletes" to "Yes"
+    And I click on "Preview" "button"
+    When I click on "Upload courses" "button"
+    Then I should see "Course updated"
+    And I am on "Course 1" course homepage
+    And I navigate to "Users > Enrolment methods" in current page administration
+    And "Enable" "icon" should exist in the "Guest access" "table_row"
+
+  @javascript
+  Scenario: Delete an enrolment method
+    Given I navigate to "Courses > Upload courses" in site administration
+    And I upload "admin/tool/uploadcourse/tests/fixtures/enrolment_delete.csv" file to "File" filemanager
+    And I set the field "Upload mode" to "Only update existing courses"
+    And I set the field "Update mode" to "Update with CSV data only"
+    And I set the field "Allow deletes" to "Yes"
+    And I click on "Preview" "button"
+    When I click on "Upload courses" "button"
+    Then I should see "Course updated"
+    And I am on "Course 1" course homepage
+    And I navigate to "Users > Enrolment methods" in current page administration
+    And I should not see "Guest access" in the "generaltable" "table"
+
+  @javascript
+  Scenario: Delete an unexistent enrolment method (nothing should change)
+    Given I am on "Course 1" course homepage
+    And I navigate to "Users > Enrolment methods" in current page administration
+    And I click on "Delete" "link" in the "Guest access" "table_row"
+    And I click on "Continue" "button"
+    And I should not see "Guest access" in the "generaltable" "table"
+    And I navigate to "Courses > Upload courses" in site administration
+    And I upload "admin/tool/uploadcourse/tests/fixtures/enrolment_delete.csv" file to "File" filemanager
+    And I set the field "Upload mode" to "Only update existing courses"
+    And I set the field "Update mode" to "Update with CSV data only"
+    And I set the field "Allow deletes" to "Yes"
+    And I click on "Preview" "button"
+    When I click on "Upload courses" "button"
+    Then I should see "Course updated"
+    And I am on "Course 1" course homepage
+    And I navigate to "Users > Enrolment methods" in current page administration
+    And I should not see "Guest access" in the "generaltable" "table"
diff --git a/admin/tool/uploadcourse/tests/fixtures/enrolment_delete.csv b/admin/tool/uploadcourse/tests/fixtures/enrolment_delete.csv
new file mode 100644 (file)
index 0000000..0f219d1
--- /dev/null
@@ -0,0 +1,2 @@
+shortname,category,enrolment_1,enrolment_1_delete
+C1,1,guest,1
\ No newline at end of file
diff --git a/admin/tool/uploadcourse/tests/fixtures/enrolment_disable.csv b/admin/tool/uploadcourse/tests/fixtures/enrolment_disable.csv
new file mode 100644 (file)
index 0000000..0b3ede8
--- /dev/null
@@ -0,0 +1,2 @@
+shortname,category,enrolment_1,enrolment_1_disable
+C1,1,guest,1
\ No newline at end of file
diff --git a/admin/tool/uploadcourse/tests/fixtures/enrolment_enable.csv b/admin/tool/uploadcourse/tests/fixtures/enrolment_enable.csv
new file mode 100644 (file)
index 0000000..2bae81e
--- /dev/null
@@ -0,0 +1,2 @@
+shortname,category,enrolment_1,enrolment_1_disable
+C1,1,guest,0
\ No newline at end of file
index 12c2101..5321f0e 100644 (file)
@@ -1119,10 +1119,10 @@ class process {
                     $this->upt->track('enrolments', get_string('unknowncourse', 'error', s($shortname)), 'error');
                     continue;
                 }
-                $ccache[$shortname] = $course;
-                $ccache[$shortname]->groups = null;
+                $this->ccache[$shortname] = $course;
+                $this->ccache[$shortname]->groups = null;
             }
-            $courseid      = $ccache[$shortname]->id;
+            $courseid      = $this->ccache[$shortname]->id;
             $coursecontext = \context_course::instance($courseid);
             if (!isset($this->manualcache[$courseid])) {
                 $this->manualcache[$courseid] = false;
@@ -1241,41 +1241,41 @@ class process {
                     continue;
                 }
                 // Build group cache.
-                if (is_null($ccache[$shortname]->groups)) {
-                    $ccache[$shortname]->groups = array();
+                if (is_null($this->ccache[$shortname]->groups)) {
+                    $this->ccache[$shortname]->groups = array();
                     if ($groups = groups_get_all_groups($courseid)) {
                         foreach ($groups as $gid => $group) {
-                            $ccache[$shortname]->groups[$gid] = new \stdClass();
-                            $ccache[$shortname]->groups[$gid]->id   = $gid;
-                            $ccache[$shortname]->groups[$gid]->name = $group->name;
+                            $this->ccache[$shortname]->groups[$gid] = new \stdClass();
+                            $this->ccache[$shortname]->groups[$gid]->id   = $gid;
+                            $this->ccache[$shortname]->groups[$gid]->name = $group->name;
                             if (!is_numeric($group->name)) { // Only non-numeric names are supported!!!
-                                $ccache[$shortname]->groups[$group->name] = new \stdClass();
-                                $ccache[$shortname]->groups[$group->name]->id   = $gid;
-                                $ccache[$shortname]->groups[$group->name]->name = $group->name;
+                                $this->ccache[$shortname]->groups[$group->name] = new \stdClass();
+                                $this->ccache[$shortname]->groups[$group->name]->id   = $gid;
+                                $this->ccache[$shortname]->groups[$group->name]->name = $group->name;
                             }
                         }
                     }
                 }
                 // Group exists?
                 $addgroup = $user->{'group'.$i};
-                if (!array_key_exists($addgroup, $ccache[$shortname]->groups)) {
+                if (!array_key_exists($addgroup, $this->ccache[$shortname]->groups)) {
                     // If group doesn't exist,  create it.
                     $newgroupdata = new \stdClass();
                     $newgroupdata->name = $addgroup;
-                    $newgroupdata->courseid = $ccache[$shortname]->id;
+                    $newgroupdata->courseid = $this->ccache[$shortname]->id;
                     $newgroupdata->description = '';
                     $gid = groups_create_group($newgroupdata);
                     if ($gid) {
-                        $ccache[$shortname]->groups[$addgroup] = new \stdClass();
-                        $ccache[$shortname]->groups[$addgroup]->id   = $gid;
-                        $ccache[$shortname]->groups[$addgroup]->name = $newgroupdata->name;
+                        $this->ccache[$shortname]->groups[$addgroup] = new \stdClass();
+                        $this->ccache[$shortname]->groups[$addgroup]->id   = $gid;
+                        $this->ccache[$shortname]->groups[$addgroup]->name = $newgroupdata->name;
                     } else {
                         $this->upt->track('enrolments', get_string('unknowngroup', 'error', s($addgroup)), 'error');
                         continue;
                     }
                 }
-                $gid   = $ccache[$shortname]->groups[$addgroup]->id;
-                $gname = $ccache[$shortname]->groups[$addgroup]->name;
+                $gid   = $this->ccache[$shortname]->groups[$addgroup]->id;
+                $gname = $this->ccache[$shortname]->groups[$addgroup]->name;
 
                 try {
                     if (groups_add_member($gid, $user->id)) {
index 2514c61..5ffc70e 100644 (file)
@@ -149,7 +149,7 @@ abstract class XMLDBCheckAction extends XMLDBAction {
                                 continue;
                             }
                             // Fetch metadata from physical DB. All the columns info.
-                            if (!$metacolumns = $DB->get_columns($xmldb_table->getName())) {
+                            if (!$metacolumns = $DB->get_columns($xmldb_table->getName(), false)) {
                                 // / Skip table if no metacolumns is available for it
                                 continue;
                             }
index 5c0f74f..9d3cc18 100644 (file)
@@ -460,7 +460,7 @@ class auth_plugin_db extends auth_plugin_base {
                     continue;
                 }
                 try {
-                    $id = user_create_user($user, false); // It is truly a new user.
+                    $id = user_create_user($user, false, false); // It is truly a new user.
                     $trace->output(get_string('auth_dbinsertuser', 'auth_db', array('name'=>$user->username, 'id'=>$id)), 1);
                 } catch (moodle_exception $e) {
                     $trace->output(get_string('auth_dbinsertusererror', 'auth_db', $user->username), 1);
@@ -479,6 +479,8 @@ class auth_plugin_db extends auth_plugin_base {
 
                 // Make sure user context is present.
                 context_user::instance($id);
+
+                \core\event\user_created::create_from_userid($id)->trigger();
             }
             unset($add_users);
         }
index b17a7c0..1de432c 100644 (file)
@@ -119,6 +119,7 @@ class auth_db_testcase extends advanced_testcase {
         $table->add_field('email', XMLDB_TYPE_CHAR, '255', null, null, null);
         $table->add_field('firstname', XMLDB_TYPE_CHAR, '255', null, null, null);
         $table->add_field('lastname', XMLDB_TYPE_CHAR, '255', null, null, null);
+        $table->add_field('animal', XMLDB_TYPE_CHAR, '255', null, null, null);
         $table->add_key('primary', XMLDB_KEY_PRIMARY, array('id'));
         if ($dbman->table_exists($table)) {
             $dbman->drop_table($table);
@@ -137,6 +138,15 @@ class auth_db_testcase extends advanced_testcase {
         set_config('field_updateremote_email', '0', 'auth_db');
         set_config('field_lock_email', 'unlocked', 'auth_db');
 
+        // Create a user profile field and add mapping to it.
+        $DB->insert_record('user_info_field', ['shortname' => 'pet', 'name' => 'Pet', 'required' => 0,
+            'visible' => 1, 'locked' => 0, 'categoryid' => 1, 'datatype' => 'text']);
+
+        set_config('field_map_profile_field_pet', 'animal', 'auth_db');
+        set_config('field_updatelocal_profile_field_pet', 'oncreate', 'auth_db');
+        set_config('field_updateremote_profile_field_pet', '0', 'auth_db');
+        set_config('field_lock_profile_field_pet', 'unlocked', 'auth_db');
+
         // Init the rest of settings.
         set_config('passtype', 'plaintext', 'auth_db');
         set_config('changepasswordurl', '', 'auth_db');
@@ -156,6 +166,7 @@ class auth_db_testcase extends advanced_testcase {
 
     public function test_plugin() {
         global $DB, $CFG;
+        require_once($CFG->dirroot . '/user/profile/lib.php');
 
         $this->resetAfterTest(true);
 
@@ -193,7 +204,7 @@ class auth_db_testcase extends advanced_testcase {
 
         // Test bulk user account creation.
 
-        $user2 = (object)array('name'=>'u2', 'pass'=>'heslo', 'email'=>'u2@example.com');
+        $user2 = (object)['name' => 'u2', 'pass' => 'heslo', 'email' => 'u2@example.com', 'animal' => 'cat'];
         $user2->id = $DB->insert_record('auth_db_users', $user2);
 
         $user3 = (object)array('name'=>'admin', 'pass'=>'heslo', 'email'=>'admin@example.com'); // Should be skipped.
@@ -202,13 +213,24 @@ class auth_db_testcase extends advanced_testcase {
         $this->assertCount(2, $DB->get_records('user'));
 
         $trace = new null_progress_trace();
+
+        // Sync users and make sure that two events user_created werer triggered.
+        $sink = $this->redirectEvents();
         $auth->sync_users($trace, false);
+        $events = $sink->get_events();
+        $sink->close();
+        $this->assertCount(2, $events);
+        $this->assertTrue($events[0] instanceof  \core\event\user_created);
+        $this->assertTrue($events[1] instanceof  \core\event\user_created);
 
+        // Assert the two users were created.
         $this->assertEquals(4, $DB->count_records('user'));
         $u1 = $DB->get_record('user', array('username'=>$user1->name, 'auth'=>'db'));
         $this->assertSame($user1->email, $u1->email);
+        $this->assertEmpty(profile_user_record($u1->id)->pet);
         $u2 = $DB->get_record('user', array('username'=>$user2->name, 'auth'=>'db'));
         $this->assertSame($user2->email, $u2->email);
+        $this->assertSame($user2->animal, profile_user_record($u2->id)->pet);
         $admin = $DB->get_record('user', array('username'=>'admin', 'auth'=>'manual'));
         $this->assertNotEmpty($admin);
 
@@ -217,12 +239,14 @@ class auth_db_testcase extends advanced_testcase {
 
         $user2b = clone($user2);
         $user2b->email = 'u2b@example.com';
+        $user2b->animal = 'dog';
         $DB->update_record('auth_db_users', $user2b);
 
         $auth->sync_users($trace, false);
         $this->assertEquals(4, $DB->count_records('user'));
         $u2 = $DB->get_record('user', array('username'=>$user2->name));
         $this->assertSame($user2->email, $u2->email);
+        $this->assertSame($user2->animal, profile_user_record($u2->id)->pet);
 
         $auth->sync_users($trace, true);
         $this->assertEquals(4, $DB->count_records('user'));
@@ -231,6 +255,8 @@ class auth_db_testcase extends advanced_testcase {
 
         set_config('field_updatelocal_email', 'onlogin', 'auth_db');
         $auth->config->field_updatelocal_email = 'onlogin';
+        set_config('field_updatelocal_profile_field_pet', 'onlogin', 'auth_db');
+        $auth->config->field_updatelocal_profile_field_pet = 'onlogin';
 
         $auth->sync_users($trace, false);
         $this->assertEquals(4, $DB->count_records('user'));
@@ -241,6 +267,7 @@ class auth_db_testcase extends advanced_testcase {
         $this->assertEquals(4, $DB->count_records('user'));
         $u2 = $DB->get_record('user', array('username'=>$user2->name));
         $this->assertSame($user2b->email, $u2->email);
+        $this->assertSame($user2b->animal, profile_user_record($u2->id)->pet);
 
 
         // Test sync deletes and suspends.
index 39c4116..f73cb3f 100644 (file)
@@ -369,6 +369,7 @@ class restore_controller extends base_controller {
             $options = array();
             $options['keep_roles_and_enrolments'] = $this->get_setting_value('keep_roles_and_enrolments');
             $options['keep_groups_and_groupings'] = $this->get_setting_value('keep_groups_and_groupings');
+            $options['userid'] = $this->userid;
             restore_dbops::delete_course_content($this->get_courseid(), $options);
         }
         // If this is not a course restore or single activity restore (e.g. duplicate), inform the plan we are not
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 69fcf19..8108f6e 100644 (file)
@@ -2121,14 +2121,29 @@ class restore_ras_and_caps_structure_step extends restore_structure_step {
         $data = (object)$data;
 
         // Check roleid is one of the mapped ones
-        $newroleid = $this->get_mappingid('role', $data->roleid);
+        $newrole = $this->get_mapping('role', $data->roleid);
+        $newroleid = $newrole->newitemid ?? false;
+        $userid = $this->task->get_userid();
+
         // If newroleid and context are valid assign it via API (it handles dupes and so on)
         if ($newroleid && $this->task->get_contextid()) {
-            if (!get_capability_info($data->capability)) {
+            if (!$capability = get_capability_info($data->capability)) {
                 $this->log("Capability '{$data->capability}' was not found!", backup::LOG_WARNING);
             } else {
-                // TODO: assign_capability() needs one userid param to be able to specify our restore userid.
-                assign_capability($data->capability, $data->permission, $newroleid, $this->task->get_contextid());
+                $context = context::instance_by_id($this->task->get_contextid());
+                $overrideableroles = get_overridable_roles($context, ROLENAME_SHORT);
+                $safecapability = is_safe_capability($capability);
+
+                // Check if the new role is an overrideable role AND if the user performing the restore has the
+                // capability to assign the capability.
+                if (in_array($newrole->info['shortname'], $overrideableroles) &&
+                    ($safecapability && has_capability('moodle/role:safeoverride', $context, $userid) ||
+                        !$safecapability && has_capability('moodle/role:override', $context, $userid))
+                ) {
+                    assign_capability($data->capability, $data->permission, $newroleid, $this->task->get_contextid());
+                } else {
+                    $this->log("Insufficient capability to assign capability '{$data->capability}' to role!", backup::LOG_WARNING);
+                }
             }
         }
     }
@@ -2148,11 +2163,22 @@ class restore_default_enrolments_step extends restore_execution_step {
         }
 
         $course = $DB->get_record('course', array('id'=>$this->get_courseid()), '*', MUST_EXIST);
+        // Return any existing course enrolment instances.
+        $enrolinstances = enrol_get_instances($course->id, false);
+
+        if ($enrolinstances) {
+            // Something already added instances.
+            // Get the existing enrolment methods in the course.
+            $enrolmethods = array_map(function($enrolinstance) {
+                return $enrolinstance->enrol;
+            }, $enrolinstances);
 
-        if ($DB->record_exists('enrol', array('courseid'=>$this->get_courseid(), 'enrol'=>'manual'))) {
-            // Something already added instances, do not add default instances.
             $plugins = enrol_get_plugins(true);
-            foreach ($plugins as $plugin) {
+            foreach ($plugins as $pluginname => $plugin) {
+                // Make sure all default enrolment methods exist in the course.
+                if (!in_array($pluginname, $enrolmethods)) {
+                    $plugin->course_updated(true, $course, null);
+                }
                 $plugin->restore_sync_course($course);
             }
 
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 6856101..51f8bb8 100644 (file)
@@ -152,13 +152,14 @@ class copy_form extends \moodleform {
             $mform->addHelpButton('relativedatesmodegroup', 'relativedatesmode');
         }
 
-        // Course id number.
+        // Course ID number (default to the current course ID number; blank for users who can't change ID numbers).
         $mform->addElement('text', 'idnumber', get_string('idnumbercourse'), 'maxlength="100"  size="10"');
+        $mform->setDefault('idnumber', $course->idnumber);
         $mform->addHelpButton('idnumber', 'idnumbercourse');
         $mform->setType('idnumber', PARAM_RAW);
-        if (!empty($course->id) and !has_capability('moodle/course:changeidnumber', $coursecontext)) {
+        if (!has_capability('moodle/course:changeidnumber', $coursecontext)) {
             $mform->hardFreeze('idnumber');
-            $mform->setConstants('idnumber', $course->idnumber);
+            $mform->setConstant('idnumber', '');
         }
 
         // Keep source course user data.
@@ -172,7 +173,7 @@ class copy_form extends \moodleform {
         );
         if (!has_all_capabilities($requiredcapabilities, $coursecontext)) {
             $mform->hardFreeze('userdata');
-            $mform->setConstants('userdata', 0);
+            $mform->setConstant('userdata', 0);
         }
 
         // Keep manual enrolments.
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 4e1e393..78e86b0 100644 (file)
@@ -39,6 +39,20 @@ use stdClass;
  */
 class assertion_exporter extends exporter {
 
+    /**
+     * Constructor - saves the persistent object, and the related objects.
+     *
+     * @param mixed $data - Either an stdClass or an array of values.
+     * @param array $related - An optional list of pre-loaded objects related to this object.
+     */
+    public function __construct($data, $related = array()) {
+        // Having mixed $data is causing some issues. As this class is treating $data as an object everywhere, it can be converted
+        // to object at this point, to avoid errors and get the expected behaviour always.
+        // $data is an array when this class is a request exporter in backpack_api_mapping, but it is an object when this is
+        // used as a response exporter.
+        parent::__construct((object) $data, $related);
+    }
+
     /**
      * Map from a request response data to the internal structure.
      *
@@ -104,16 +118,16 @@ class assertion_exporter extends exporter {
         global $DB;
         $result = [];
 
-        if (array_key_exists('related_badge', $this->data)) {
-            $exporter = new badgeclass_exporter($this->data['related_badge'], $this->related);
+        if (property_exists($this->data, 'related_badge')) {
+            $exporter = new badgeclass_exporter($this->data->related_badge, $this->related);
             $result['badge'] = $exporter->export($output);
         }
-        if (array_key_exists('related_recipient', $this->data)) {
-            $exporter = new recipient_exporter($this->data['related_recipient'], $this->related);
+        if (property_exists($this->data, 'related_recipient')) {
+            $exporter = new recipient_exporter($this->data->related_recipient, $this->related);
             $result['recipient'] = $exporter->export($output);
         }
-        if (array_key_exists('related_verify', $this->data)) {
-            $exporter = new verification_exporter($this->data['related_verify'], $this->related);
+        if (property_exists($this->data, 'related_verify')) {
+            $exporter = new verification_exporter($this->data->related_verify, $this->related);
             $result['verification'] = $exporter->export($output);
         }
         return $result;
index f16d762..9aee956 100644 (file)
@@ -45,6 +45,12 @@ class badgeclass_exporter extends exporter {
      * @param array $related - An optional list of pre-loaded objects related to this object.
      */
     public function __construct($data, $related = array()) {
+        // Having mixed $data is causing some issues. As this class is treating $data as an object everywhere, it can be converted
+        // to object at this point, to avoid errors and get the expected behaviour always.
+        // $data is an array when this class is a request exporter in backpack_api_mapping, but it is an object when this is
+        // used as a response exporter.
+        $data = (object) $data;
+
         $pick = $this->pick_related();
         foreach ($pick as $one) {
             $isarray = false;
@@ -54,28 +60,28 @@ class badgeclass_exporter extends exporter {
                 $isarray = true;
             }
             $prefixed = 'related_' . $one;
-            if (array_key_exists($one, $data) && !array_key_exists($one, $related)) {
+            if (property_exists($data, $one) && !array_key_exists($one, $related)) {
                 if ($isarray) {
                     $newrelated = [];
-                    foreach ($data[$one] as $item) {
+                    foreach ($data->$one as $item) {
                         $newrelated[] = (object) $item;
                     }
                     $related[$one] = $newrelated;
                 } else {
-                    $related[$one] = (object) $data[$one];
+                    $related[$one] = (object) $data->$one;
                 }
-                unset($data[$one]);
-            } else if (array_key_exists($prefixed, $data) && !array_key_exists($one, $related)) {
+                unset($data->$one);
+            } else if (property_exists($data, $prefixed) && !array_key_exists($one, $related)) {
                 if ($isarray) {
                     $newrelated = [];
-                    foreach ($data[$prefixed] as $item) {
+                    foreach ($data->$prefixed as $item) {
                         $newrelated[] = (object) $item;
                     }
                     $related[$one] = $newrelated;
                 } else {
-                    $related[$one] = (object) $data[$prefixed];
+                    $related[$one] = (object) $data->$prefixed;
                 }
-                unset($data[$prefixed]);
+                unset($data->$prefixed);
             } else if (!array_key_exists($one, $related)) {
                 $related[$one] = null;
             }
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..13415fd 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 {
@@ -808,7 +805,7 @@ class core_badges_renderer extends plugin_renderer_base {
                 );
 
         if (has_capability('moodle/badges:configuredetails', $context)) {
-            $row[] = new tabobject('details',
+            $row[] = new tabobject('badge',
                         new moodle_url('/badges/edit.php', array('id' => $badgeid, 'action' => 'badge')),
                         get_string('bdetails', 'badges')
                     );
@@ -859,7 +856,7 @@ class core_badges_renderer extends plugin_renderer_base {
         if (has_capability('moodle/badges:configuredetails', $context)) {
             $alignments = $DB->count_records_sql("SELECT COUNT(bc.id)
                       FROM {badge_alignment} bc WHERE bc.badgeid = :badgeid", array('badgeid' => $badgeid));
-            $row[] = new tabobject('balignment',
+            $row[] = new tabobject('alignment',
                 new moodle_url('/badges/alignment.php', array('id' => $badgeid)),
                 get_string('balignment', 'badges', $alignments)
             );
index fc80b9b..f764c8a 100644 (file)
@@ -915,4 +915,521 @@ 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.
+     *
+     * @dataProvider badges_save_external_backpack_provider
+     * @param  array $data  Backpack data to save.
+     * @param  bool $adduser True if a real user has to be used for creating the backpack; false otherwise.
+     * @param  bool $duplicates True if duplicates has to be tested too; false otherwise.
+     */
+    public function test_badges_save_external_backpack(array $data, bool $adduser, bool $duplicates) {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $userid = 0;
+        if ($adduser) {
+            $user = $this->getDataGenerator()->create_user();
+            $userid = $user->id;
+            $data['userid'] = $user->id;
+        }
+
+        $result = badges_save_external_backpack((object) $data);
+        $this->assertNotEquals(0, $result);
+        $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', ['externalbackpackid' => $result]);
+        if (!array_key_exists('backpackemail', $data) && !array_key_exists('password', $data)) {
+            $this->assertEmpty($record);
+            $total = $DB->count_records('badge_backpack');
+            $this->assertEquals(0, $total);
+        } else {
+            $this->assertNotEmpty($record);
+            $this->assertEquals($record->userid, $userid);
+        }
+
+        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 badges_save_external_backpack_provider() {
+        $data = [
+            'apiversion' => 2,
+            'backpackapiurl' => 'https://api.ca.badgr.io/v2',
+            'backpackweburl' => 'https://ca.badgr.io',
+        ];
+        return [
+            'Test without user and auth details. Check duplicates too' => [
+                'data' => $data,
+                'adduser' => false,
+                'duplicates' => true,
+            ],
+            'Test without user and auth details. No duplicates' => [
+                'data' => $data,
+                'adduser' => false,
+                'duplicates' => false,
+            ],
+            'Test with user and without auth details' => [
+                'data' => $data,
+                'adduser' => true,
+                'duplicates' => false,
+            ],
+            'Test with user and without auth details. Check duplicates too' => [
+                'data' => $data,
+                'adduser' => true,
+                'duplicates' => true,
+            ],
+            'Test with empty backpackemail, password and id' => [
+                'data' => array_merge($data, [
+                    'backpackemail' => '',
+                    'password' => '',
+                    'id' => 0,
+                ]),
+                'adduser' => false,
+                'duplicates' => false,
+            ],
+            'Test with empty backpackemail, password and id but with user' => [
+                'data' => array_merge($data, [
+                    'backpackemail' => '',
+                    'password' => '',
+                    'id' => 0,
+                ]),
+                'adduser' => true,
+                'duplicates' => false,
+            ],
+            'Test with auth details but without user' => [
+                'data' => array_merge($data, [
+                    'backpackemail' => 'test@test.com',
+                    'password' => 'test',
+                ]),
+                'adduser' => false,
+                'duplicates' => false,
+            ],
+            'Test with auth details and user' => [
+                'data' => array_merge($data, [
+                    'backpackemail' => 'test@test.com',
+                    'password' => 'test',
+                ]),
+                'adduser' => true,
+                'duplicates' => false,
+            ],
+        ];
+    }
+
+    /**
+     * Test backpack creation/update with auth details provided
+     *
+     * @param boolean $isadmin
+     * @param boolean $updatetest
+     * @dataProvider 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 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 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 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.
index 8a1cb32..8c5cc4c 100644 (file)
             {{#summary}}{{#str}} summary, block_myoverview {{/str}}{{/summary}}
         </span>
     </button>
-    <ul class="dropdown-menu" data-show-active-item aria-labelledby="displaydropdown">
+    <ul class="dropdown-menu" role="menu" data-show-active-item data-skip-active-class="true" aria-labelledby="displaydropdown">
     {{#layouts}}
             <li>
-                <a class="dropdown-item {{#active}}active{{/active}}" href="#" data-display-option="display" data-value="{{id}}" data-pref="{{id}}" aria-label="{{arialabel}}" aria-controls="courses-view-{{uniqid}}">
+                <a class="dropdown-item" href="#" data-display-option="display" data-value="{{id}}" data-pref="{{id}}" aria-label="{{arialabel}}" aria-controls="courses-view-{{uniqid}}" role="menuitem" {{#active}}aria-current="true"{{/active}}>
                     {{name}}
                 </a>
             </li>
index ed38dc9..c7ce41b 100644 (file)
             {{selectedcustomfield}}
         </span>
     </button>
-    <ul class="dropdown-menu" data-show-active-item data-active-item-text aria-labelledby="groupingdropdown">
+    <ul class="dropdown-menu" role="menu" data-show-active-item data-skip-active-class="true" data-active-item-text aria-labelledby="groupingdropdown">
         {{#displaygroupingallincludinghidden}}
         <li>
-            <a class="dropdown-item {{#allincludinghidden}}active{{/allincludinghidden}}" href="#" data-filter="grouping" data-value="allincludinghidden" data-pref="allincludinghidden" aria-label="{{#str}} aria:allcoursesincludinghidden, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
+            <a class="dropdown-item" href="#" data-filter="grouping" data-value="allincludinghidden" data-pref="allincludinghidden" aria-label="{{#str}} aria:allcoursesincludinghidden, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}" role="menuitem" {{#allincludinghidden}}aria-current="true"{{/allincludinghidden}}>
                 {{#str}} allincludinghidden, block_myoverview {{/str}}
             </a>
         </li>
@@ -66,7 +66,7 @@
             <span class="filler">&nbsp;</span>
         </li>
         <li>
-            <a class="dropdown-item {{#all}}active{{/all}}" href="#" data-filter="grouping" data-value="all" data-pref="all" aria-label="{{#str}} aria:allcourses, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
+            <a class="dropdown-item" href="#" data-filter="grouping" data-value="all" data-pref="all" aria-label="{{#str}} aria:allcourses, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}" role="menuitem" {{#all}}aria-current="true"{{/all}}>
                 {{#str}} all, block_myoverview {{/str}}
             </a>
         </li>
@@ -76,7 +76,7 @@
             <span class="filler">&nbsp;</span>
         </li>
         <li>
-            <a class="dropdown-item {{#inprogress}}active{{/inprogress}}" href="#" data-filter="grouping" data-value="inprogress" data-pref="inprogress" aria-label="{{#str}} aria:inprogress, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
+            <a class="dropdown-item" href="#" data-filter="grouping" data-value="inprogress" data-pref="inprogress" aria-label="{{#str}} aria:inprogress, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}" role="menuitem" {{#inprogress}}aria-current="true"{{/inprogress}}>
                 {{#str}} inprogress, block_myoverview {{/str}}
             </a>
         </li>
@@ -88,7 +88,7 @@
             </li>
             {{/displaygroupinginprogress}}
         <li>
-            <a class="dropdown-item {{#future}}active{{/future}}" href="#" data-filter="grouping" data-value="future" data-pref="future" aria-label="{{#str}} aria:future, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
+            <a class="dropdown-item" href="#" data-filter="grouping" data-value="future" data-pref="future" aria-label="{{#str}} aria:future, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}" role="menuitem" {{#future}}aria-current="true"{{/future}}>
                 {{#str}} future, block_myoverview {{/str}}
             </a>
         </li>
                 {{/displaygroupingfuture}}
             {{/displaygroupinginprogress}}
         <li>
-            <a class="dropdown-item {{#past}}active{{/past}}" href="#" data-filter="grouping" data-value="past" data-pref="past" aria-label="{{#str}} aria:past, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
+            <a class="dropdown-item" href="#" data-filter="grouping" data-value="past" data-pref="past" aria-label="{{#str}} aria:past, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}" role="menuitem" {{#past}}aria-current="true"{{/past}}>
                 {{#str}} past, block_myoverview {{/str}}
             </a>
         </li>
             </li>
             {{#customfieldvalues}}
                 <li>
-                    <a class="dropdown-item {{#active}}active{{/active}}" href="#" data-filter="grouping"
+                    <a class="dropdown-item" href="#" data-filter="grouping"
                        data-value="customfield" data-pref="customfield" data-customfieldvalue="{{value}}"
                        aria-label="{{#str}}aria:customfield, block_myoverview, {{name}}{{/str}}"
-                       aria-controls="courses-view-{{uniqid}}">
+                       aria-controls="courses-view-{{uniqid}}" role="menuitem" {{#active}}aria-current="true"{{/active}}>
                         {{name}}
                     </a>
                 </li>
             <span class="filler">&nbsp;</span>
         </li>
         <li>
-            <a class="dropdown-item {{#favourites}}active{{/favourites}}" href="#" data-filter="grouping" data-value="favourites"  data-pref="favourites" aria-label="{{#str}} aria:favourites, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
+            <a class="dropdown-item" href="#" data-filter="grouping" data-value="favourites"  data-pref="favourites" aria-label="{{#str}} aria:favourites, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}" role="menuitem" {{#favourites}}aria-current="true"{{/favourites}}>
                 {{#str}} favourites, block_myoverview {{/str}}
             </a>
         {{/displaygroupingfavourites}}
             <span class="filler">&nbsp;</span>
         </li>
         <li>
-            <a class="dropdown-item {{#hidden}}active{{/hidden}}" href="#" data-filter="grouping" data-value="hidden"  data-pref="hidden" aria-label="{{#str}} aria:hiddencourses, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
+            <a class="dropdown-item" href="#" data-filter="grouping" data-value="hidden"  data-pref="hidden" aria-label="{{#str}} aria:hiddencourses, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}" role="menuitem" {{#hidden}}aria-current="true"{{/hidden}}>
                 {{#str}} hiddencourses, block_myoverview {{/str}}
             </a>
         </li>
index 55a425d..4dde517 100644 (file)
                 {{#shortname}}{{#str}} shortname, block_myoverview {{/str}}{{/shortname}}
             </span>
         </button>
-        <ul class="dropdown-menu" data-show-active-item aria-labelledby="sortingdropdown">
+        <ul class="dropdown-menu" role="menu" data-show-active-item data-skip-active-class="true" aria-labelledby="sortingdropdown">
             <li>
-                <a class="dropdown-item {{#title}}active{{/title}}" href="#" data-filter="sort" data-pref="title" data-value="fullname" aria-label="{{#str}} aria:title, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
+                <a class="dropdown-item" href="#" data-filter="sort" data-pref="title" data-value="fullname" aria-label="{{#str}} aria:title, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}" role="menuitem" {{#title}}aria-current="true"{{/title}}>
                     {{#str}} title, block_myoverview {{/str}}
                 </a>
             </li>
             {{#showsortbyshortname}}
             <li>
-                <a class="dropdown-item {{#shortname}}active{{/shortname}}" href="#" data-filter="sort" data-pref="shortname" data-value="shortname" aria-label="{{#str}} aria:shortname, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
+                <a class="dropdown-item" href="#" data-filter="sort" data-pref="shortname" data-value="shortname" aria-label="{{#str}} aria:shortname, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}" role="menuitem" {{#shortname}}aria-current="true"{{/shortname}}>
                     {{#str}} shortname, block_myoverview {{/str}}
                 </a>
             </li>
              {{/showsortbyshortname}}
             <li>
-                <a class="dropdown-item {{#lastaccessed}}active{{/lastaccessed}}" href="#" data-filter="sort" data-pref="lastaccessed" data-value="ul.timeaccess desc" aria-label="{{#str}} aria:lastaccessed, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
+                <a class="dropdown-item" href="#" data-filter="sort" data-pref="lastaccessed" data-value="ul.timeaccess desc" aria-label="{{#str}} aria:lastaccessed, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}" role="menuitem" {{#lastaccessed}}aria-current="true"{{/lastaccessed}}>
                     {{#str}} lastaccessed, block_myoverview {{/str}}
                 </a>
             </li>
index 9cd9406..482227c 100644 (file)
@@ -115,9 +115,13 @@ class block_news_items extends block_base {
 
                 $discussion->subject = format_string($discussion->subject, true, $forum->course);
 
+                $posttime = $discussion->modified;
+                if (!empty($CFG->forum_enabletimedposts) && ($discussion->timestart > $posttime)) {
+                    $posttime = $discussion->timestart;
+                }
                 $text .= '<li class="post">'.
                          '<div class="head clearfix">'.
-                         '<div class="date">'.userdate($discussion->modified, $strftimerecent).'</div>'.
+                         '<div class="date">'.userdate($posttime, $strftimerecent).'</div>'.
                          '<div class="name">'.fullname($discussion).'</div></div>'.
                          '<div class="info"><a href="'.$CFG->wwwroot.'/mod/forum/discuss.php?d='.$discussion->discussion.'">'.$discussion->subject.'</a></div>'.
                          "</li>\n";
index cb794f9..0ed7161 100644 (file)
Binary files a/blocks/timeline/amd/build/view_nav.min.js and b/blocks/timeline/amd/build/view_nav.min.js differ
index 1e2b36f..2b6c842 100644 (file)
Binary files a/blocks/timeline/amd/build/view_nav.min.js.map and b/blocks/timeline/amd/build/view_nav.min.js.map differ
index 6fd32a6..596c75b 100644 (file)
@@ -89,7 +89,7 @@ function(
 
                 var option = $(e.target).closest(SELECTORS.TIMELINE_DAY_FILTER_OPTION);
 
-                if (option.hasClass('active')) {
+                if (option.attr('aria-current') == 'true') {
                     // If it's already active then we don't need to do anything.
                     return;
                 }
@@ -130,10 +130,12 @@ function(
 
         // Listen for when the user changes tab so that we can show the first set of courses
         // and load their events when they request the sort by courses view for the first time.
-        viewSelector.on('shown shown.bs.tab', function() {
+        viewSelector.on('shown shown.bs.tab', function(e) {
             View.shown(timelineViewRoot);
+            $(e.target).removeClass('active');
         });
 
+
         // Event selector for user_sort
         CustomEvents.define(viewSelector, [CustomEvents.events.activate]);
         viewSelector.on(CustomEvents.events.activate, "[data-toggle='tab']", function(e) {
index 52d92a6..980afd5 100644 (file)
             {{#next6months}} {{#str}}next6months, block_timeline {{/str}} {{/next6months}}
         </span>
     </button>
-    <div id="menudayfilter" role="menu" class="dropdown-menu" data-show-active-item>
+    <div id="menudayfilter" role="menu" class="dropdown-menu" data-show-active-item data-skip-active-class="true">
         <a
-            class="dropdown-item {{#all}} active {{/all}}"
+            class="dropdown-item"
             href="#"
             data-from="-14"
             data-filtername="all"
+            {{#all}}aria-current="true"{{/all}}
             aria-label="{{#str}} ariadayfilteroption, block_timeline, {{#str}} all, core {{/str}}{{/str}}"
             role="menuitem"
-            {{#all}}aria-current="true"{{/all}}
         >
             {{#str}} all, core {{/str}}
         </a>
         <a
-            class="dropdown-item {{#overdue}} active {{/overdue}}"
+            class="dropdown-item"
             href="#"
             data-from="-14"
             data-to="0"
             data-filtername="overdue"
+            {{#overdue}}aria-current="true"{{/overdue}}
             aria-label="{{#str}} ariadayfilteroption, block_timeline, {{#str}} overdue, block_timeline {{/str}}{{/str}}"
             role="menuitem"
-            {{#overdue}}aria-current="true"{{/overdue}}
         >
             {{#str}} overdue, block_timeline {{/str}}
         </a>
         <div class="dropdown-divider" role="separator"></div>
         <h6 class="dropdown-header">{{#str}} duedate, block_timeline {{/str}}</h6>
         <a
-            class="dropdown-item {{#next7days}} active {{/next7days}}"
+            class="dropdown-item"
             href="#"
             data-from="0"
             data-to="7"
             data-filtername="next7days"
+            {{#next7days}}aria-current="true"{{/next7days}}
             aria-label="{{#str}} ariadayfilteroption, block_timeline, {{#str}} next7days, block_timeline {{/str}}{{/str}}"
             role="menuitem"
-            {{#next7days}}aria-current="true"{{/next7days}}
         >
             {{#str}} next7days, block_timeline {{/str}}
         </a>
         <a
-            class="dropdown-item {{#next30days}} active {{/next30days}}"
+            class="dropdown-item"
             href="#"
             data-from="0"
             data-to="30"
             data-filtername="next30days"
+            {{#next30days}}aria-current="true"{{/next30days}}
             aria-label="{{#str}} ariadayfilteroption, block_timeline, {{#str}} next30days, block_timeline {{/str}}{{/str}}"
             role="menuitem"
-            {{#next30days}}aria-current="true"{{/next30days}}
         >
             {{#str}} next30days, block_timeline {{/str}}
         </a>
         <a
-            class="dropdown-item {{#next3months}} active {{/next3months}}"
+            class="dropdown-item"
             href="#"
             data-from="0"
             data-to="90"
             data-filtername="next3months"
+            {{#next3months}}aria-current="true"{{/next3months}}
             aria-label="{{#str}} ariadayfilteroption, block_timeline, {{#str}} next3months, block_timeline {{/str}}{{/str}}"
             role="menuitem"
-            {{#next3months}}aria-current="true"{{/next3months}}
         >
             {{#str}} next3months, block_timeline {{/str}}
         </a>
             data-from="0"
             data-to="180"
             data-filtername="next6months"
+            {{#next6months}}aria-current="true"{{/next6months}}
             aria-label="{{#str}} ariadayfilteroption, block_timeline, {{#str}} next6months, block_timeline {{/str}}{{/str}}"
             role="menuitem"
-            {{#next6months}}aria-current="true"{{/next6months}}
         >
             {{#str}} next6months, block_timeline {{/str}}
         </a>
index 106bf75..913f43c 100644 (file)
     </button>
     <div id="menusortby" role="menu" class="dropdown-menu dropdown-menu-right list-group hidden" data-show-active-item data-skip-active-class="true">
         <a
-            class="dropdown-item {{#sorttimelinedates}}active{{/sorttimelinedates}}"
+            class="dropdown-item"
             href="#view_dates_{{uniqid}}"
             data-toggle="tab"
             data-filtername="sortbydates"
+            {{#sorttimelinedates}}aria-current="true"{{/sorttimelinedates}}
             aria-label="{{#str}} ariaviewselectoroption, block_timeline, {{#str}} sortbydates, block_timeline {{/str}}{{/str}}"
             role="menuitem"
-            {{#sorttimelinedates}}aria-current="true"{{/sorttimelinedates}}
         >
             {{#str}} sortbydates, block_timeline {{/str}}
         </a>
         <a
-            class="dropdown-item {{#sorttimelinecourses}}active{{/sorttimelinecourses}}"
+            class="dropdown-item"
             href="#view_courses_{{uniqid}}"
             data-toggle="tab"
             data-filtername="sortbycourses"
+            {{#sorttimelinecourses}}aria-current="true"{{/sorttimelinecourses}}
             aria-label="{{#str}} ariaviewselectoroption, block_timeline, {{#str}} sortbycourses, block_timeline {{/str}}{{/str}}"
             role="menuitem"
-            {{#sorttimelinecourses}}aria-current="true"{{/sorttimelinecourses}}
         >
             {{#str}} sortbycourses, block_timeline {{/str}}
         </a>
index 53d0f1e..021e2a6 100644 (file)
@@ -323,7 +323,7 @@ class cache_config {
      * @throws cache_exception
      */
     protected function include_configuration() {
-        $configuration = array();
+        $configuration = null;
         // We need to allow for late static bindings to allow for class path mudling happending for unit tests.
         $cachefile = static::get_config_file_path();
 
@@ -594,4 +594,4 @@ class cache_config {
         }
         throw new cache_exception('ex_nodefaultlock');
     }
-}
\ No newline at end of file
+}
index b8509ce..db20b05 100644 (file)
@@ -102,13 +102,15 @@ class cache_config_writer extends cache_config {
         $factory = cache_factory::instance();
         $locking = $factory->create_lock_instance($lockconf);
         if ($locking->lock('configwrite', 'config', true)) {
+            $tempcachefile = "{$cachefile}.tmp";
             // Its safe to use w mode here because we have already acquired the lock.
-            $handle = fopen($cachefile, 'w');
+            $handle = fopen($tempcachefile, 'w');
             fwrite($handle, $content);
             fflush($handle);
             fclose($handle);
             $locking->unlock('configwrite', 'config');
-            @chmod($cachefile, $CFG->filepermissions);
+            @chmod($tempcachefile, $CFG->filepermissions);
+            rename($tempcachefile, $cachefile);
             // Tell PHP to recompile the script.
             core_component::invalidate_opcode_php_cache($cachefile);
         } else {
index f5c800e..e0b2095 100644 (file)
@@ -146,17 +146,17 @@ data-region="contentbank">
                     </div>
                 {{#contents}}
                     <div class="cb-listitem"
-                        data-file="{{{ title }}}"
-                        data-name="{{{ name }}}"
+                        data-file="{{ title }}"
+                        data-name="{{ name }}"
                         data-bytes="{{ bytes }}"
                         data-timemodified="{{ timemodified }}"
-                        data-type="{{{ type }}}"
-                        data-author="{{{ author }}}">
+                        data-type="{{ type }}"
+                        data-author="{{ author }}">
                         <div class="cb-file cb-column position-relative">
-                            <div class="cb-thumbnail" role="img" aria-label="{{{ name }}}"
+                            <div class="cb-thumbnail" role="img" aria-label="{{ name }}"
                             style="background-image: url('{{{ icon }}}');">
                             </div>
-                            <a href="{{{ link }}}" class="cb-link stretched-link" title="{{{ name }}}">
+                            <a href="{{{ link }}}" class="cb-link stretched-link" title="{{ name }}">
                                 <span class="cb-name word-break-all clamp-2" data-region="cb-content-name">
                                     {{{ name }}}
                                 </span>
index ea638d5..6a72db9 100644 (file)
@@ -26,5 +26,5 @@
 }}
     <div class="form-check w-100 justify-content-start">
         <label for="newname">{{#str}}contentname, core_contentbank{{/str}}</label>
-        <input type="text" size="5" id="newname" name="newname"  value="{{{ name }}}" class="form-control text-ltr">
+        <input type="text" size="5" id="newname" name="newname"  value="{{ name }}" class="form-control text-ltr">
     </div>
diff --git a/course/amd/build/downloadcontent.min.js b/course/amd/build/downloadcontent.min.js
new file mode 100644 (file)
index 0000000..901c7c8
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..b6c5863
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..2c5899a
--- /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';
+import {enter, space} from 'core/key_codes';
+
+/**
+ * Set up listener to trigger the download course content modal.
+ *
+ * @return {void}
+ */
+export const init = () => {
+    const pendingPromise = new Pending();
+
+    // Add event listeners for click and enter/space keys.
+    jQuery('[data-downloadcourse]').on('click keydown', (e) => {
+        if (e.type === 'click' || e.which === enter || e.which === space) {
+            e.preventDefault();
+            displayDownloadConfirmation(e.currentTarget);
+        }
+    });
+
+    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..cfa667a
--- /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/>.
+
+/**
+ * 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'),
+            'data-overrides-tree-activation-key-handler' => 1,
+        ];
+
+        return $downloadattr;
+    }
+}
diff --git a/course/downloadcontent.php b/course/downloadcontent.php
new file mode 100644 (file)
index 0000000..7813a0f
--- /dev/null
@@ -0,0 +1,94 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Download course content confirmation and execution.
+ *
+ * @package    core
+ * @subpackage course
+ * @copyright  2020 Michael Hawkins <michaelh@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require_once('../config.php');
+
+use core\content;
+use core\content\export\zipwriter;
+
+$contextid = required_param('contextid', PARAM_INT);
+$isdownload = optional_param('download', 0, PARAM_BOOL);
+$coursecontext = context::instance_by_id($contextid);
+$courseid = $coursecontext->instanceid;
+$courselink = new moodle_url('/course/view.php', ['id' => $courseid]);
+
+if (!\core\content::can_export_context($coursecontext, $USER)) {
+    redirect($courselink);
+}
+
+$PAGE->set_url('/course/downloadcontent.php', ['contextid' => $contextid]);
+require_login($courseid);
+
+$courseinfo = get_fast_modinfo($courseid)->get_course();
+$filename = str_replace('/', '', str_replace(' ', '_', $courseinfo->shortname)) . '_' . time() . '.zip';
+
+// If download confirmed, prepare and start the zipstream of the course download content.
+if ($isdownload) {
+    confirm_sesskey();
+
+    $exportoptions = null;
+
+    if (!empty($CFG->maxsizeperdownloadcoursefile)) {
+        $exportoptions = new stdClass();
+        $exportoptions->maxfilesize = $CFG->maxsizeperdownloadcoursefile;
+    }
+
+    // Use file writer in debug developer mode, so any errors can be displayed instead of being streamed into the output file.
+    if (debugging('', DEBUG_DEVELOPER)) {
+        $writer = zipwriter::get_file_writer($filename, $exportoptions);
+
+        ob_start();
+        content::export_context($coursecontext, $USER, $writer);
+        $content = ob_get_clean();
+
+        // If no errors found, output the file.
+        if (empty($content)) {
+            send_file($writer->get_file_path(), $filename);
+            redirect($courselink);
+        } else {
+            // If any errors occurred, display them instead of outputting the file.
+            debugging("Errors found while producing the download course content output:\n {$content}", DEBUG_DEVELOPER);
+        }
+    } else {
+        // If not developer debugging, stream the output file directly.
+        $writer = zipwriter::get_stream_writer($filename, $exportoptions);
+        content::export_context($coursecontext, $USER, $writer);
+
+        redirect($courselink);
+    }
+
+} else {
+    $PAGE->set_title(get_string('downloadcoursecontent', 'course'));
+    $PAGE->set_heading(format_string($courseinfo->fullname));
+
+    echo $OUTPUT->header();
+    echo $OUTPUT->heading(get_string('downloadcoursecontent', 'course'));
+
+    // Prepare download confirmation information and display it.
+    $maxfilesize = display_size($CFG->maxsizeperdownloadcoursefile);
+    $downloadlink = new moodle_url('/course/downloadcontent.php', ['contextid' => $contextid, 'download' => 1]);
+
+    echo $OUTPUT->confirm(get_string('downloadcourseconfirmation', 'course', $maxfilesize), $downloadlink, $courselink);
+}
index 8a289cc..9e7773d 100644 (file)
@@ -120,6 +120,29 @@ class course_edit_form extends moodleform {
                 $mform->setConstant('visible', $courseconfig->visible);
             }
         }
+
+        // Download course content.
+        if ($CFG->downloadcoursecontentallowed) {
+            $downloadchoices = [
+                DOWNLOAD_COURSE_CONTENT_DISABLED => get_string('no'),
+                DOWNLOAD_COURSE_CONTENT_ENABLED => get_string('yes'),
+            ];
+            $sitedefaultstring = $downloadchoices[$courseconfig->downloadcontentsitedefault];
+            $downloadchoices[DOWNLOAD_COURSE_CONTENT_SITE_DEFAULT] = get_string('sitedefaultspecified', '', $sitedefaultstring);
+            $downloadselectdefault = $courseconfig->downloadcontent ?? DOWNLOAD_COURSE_CONTENT_SITE_DEFAULT;
+
+            $mform->addElement('select', 'downloadcontent', get_string('enabledownloadcoursecontent', 'course'), $downloadchoices);
+            $mform->addHelpButton('downloadcontent', 'downloadcoursecontent', 'course');
+            $mform->setDefault('downloadcontent', $downloadselectdefault);
+
+            if ((!empty($course->id) && !has_capability('moodle/course:configuredownloadcontent', $coursecontext)) ||
+                    (empty($course->id) &&
+                    !guess_if_creator_will_have_course_capability('moodle/course:configuredownloadcontent', $categorycontext))) {
+                $mform->hardFreeze('downloadcontent');
+                $mform->setConstant('downloadcontent', $downloadselectdefault);
+            }
+        }
+
         $mform->addElement('date_time_selector', 'startdate', get_string('startdate'));
         $mform->addHelpButton('startdate', 'startdate');
         $date = (new DateTime())->setTimestamp(usergetmidnight(time()));
index a834ba7..e34df5a 100644 (file)
@@ -71,6 +71,11 @@ define('COURSE_CUSTOMFIELD_EMPTY', -1);
 // Course activity chooser footer default display option.
 define('COURSE_CHOOSER_FOOTER_NONE', 'hidden');
 
+// Download course content options.
+define('DOWNLOAD_COURSE_CONTENT_DISABLED', 0);
+define('DOWNLOAD_COURSE_CONTENT_ENABLED', 1);
+define('DOWNLOAD_COURSE_CONTENT_SITE_DEFAULT', 2);
+
 function make_log_url($module, $url) {
     switch ($module) {
         case 'course':
diff --git a/course/tests/behat/course_download_content.feature b/course/tests/behat/course_download_content.feature
new file mode 100644 (file)
index 0000000..830c2b9
--- /dev/null
@@ -0,0 +1,47 @@
+@core @core_course
+Feature: Course content can be downloaded
+  In order to retain a backup offline copy of course activity/resource data
+  As a user
+  I can download a course's content
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | teacher1 | Teacher   | 1        | teacher1@example.com |
+      | student1 | Student   | 1        | student1@example.com |
+    And the following "courses" exist:
+      | fullname   | shortname |
+      | Hockey 101 | C1        |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | teacher1 | C1     | editingteacher |
+      | student1 | C1     | student        |
+    And I log in as "admin"
+    And I navigate to "Courses > Download course content" in site administration
+    And I set the following fields to these values:
+    | Download course content feature available | 1 |
+    And I press "Save changes"
+    And I navigate to "Courses > Course default settings" in site administration
+    And I set the field "Enable download course content" to "Yes"
+    And I press "Save changes"
+    And I log out
+
+  @javascript
+  Scenario: A student can download course content when the feature is enabled in their course
+    Given I log in as "student1"
+    When I am on "Hockey 101" course homepage
+    And "Download course content" "button" should exist
+    And I press "Download course content"
+    Then I should see "You are about to download a zip file"
+    # Without the ability to check the downloaded file, the absence of an exception being thrown here is considered a success.
+    And I click on "Download" "button" in the "Download course content" "dialogue"
+
+  @javascript
+  Scenario: A teacher can download course content when the feature is enabled in their course
+    Given I log in as "teacher1"
+    When I am on "Hockey 101" course homepage
+    And "Download course content" "link" should exist in current page administration
+    And I navigate to "Download course content" in current page administration
+    Then I should see "You are about to download a zip file"
+    # Without the ability to check the downloaded file, the absence of an exception being thrown here is considered a success.
+    And I click on "Download" "button" in the "Download course content" "dialogue"
diff --git a/course/tests/behat/course_download_content_permissions.feature b/course/tests/behat/course_download_content_permissions.feature
new file mode 100644 (file)
index 0000000..7ec6648
--- /dev/null
@@ -0,0 +1,112 @@
+@core @core_course
+Feature: Access to downloading course content can be controlled
+  In order to allow or restrict access to download course content
+  As a trusted user
+  I can control access to the download course content feature
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | teacher1 | Teacher   | 1        | teacher1@example.com |
+      | student1 | Student   | 1        | student1@example.com |
+    And the following "courses" exist:
+      | fullname   | shortname |
+      | Hockey 101 | C1        |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | teacher1 | C1     | editingteacher |
+      | student1 | C1     | student        |
+    And I log in as "admin"
+    And the following config values are set as admin:
+    | downloadcoursecontentallowed | 1 |
+    And I log out
+
+  Scenario: Site admins can remove the download course content feature
+    Given I log in as "admin"
+    And I am on "Hockey 101" course homepage
+    And I navigate to "Edit settings" in current page administration
+    And I set the field "Enable download course content" to "Yes"
+    And I press "Save and display"
+    And "Download course content" "link" should exist in current page administration
+    When the following config values are set as admin:
+      | downloadcoursecontentallowed | 0 |
+    And I am on "Hockey 101" course homepage
+    Then "Download course content" "link" should not exist in current page administration
+    And I navigate to "Edit settings" in current page administration
+    And I should not see "Enable download course content"
+
+  Scenario: Site admins can set the default value for whether download course content is enabled in courses
+    Given I log in as "admin"
+    And I am on "Hockey 101" course homepage
+    And "Download course content" "link" should not exist in current page administration
+    When I navigate to "Courses > Course default settings" in site administration
+    And I set the field "Enable download course content" to "Yes"
+    And I press "Save changes"
+    And I am on "Hockey 101" course homepage
+    Then "Download course content" "link" should exist in current page administration
+
+  Scenario: A teacher can enable and disable the download course content feature when it is available
+    Given I log in as "teacher1"
+    When I am on "Hockey 101" course homepage
+    And "Download course content" "link" should not exist in current page administration
+    And I navigate to "Edit settings" in current page administration
+    And I should see "Enable download course content"
+    And I set the field "Enable download course content" to "Yes"
+    And I press "Save and display"
+    Then "Download course content" "link" should exist in current page administration
+    And I navigate to "Edit settings" in current page administration
+    And I set the field "Enable download course content" to "No"
+    And I press "Save and display"
+    And "Download course content" "link" should not exist in current page administration
+
+  Scenario: Teachers require a capability to access the download course content feature or modify its availability in a course
+    Given I log in as "admin"
+    And I navigate to "Courses > Course default settings" in site administration
+    And I set the field "Enable download course content" to "Yes"
+    And I press "Save changes"
+    And I log out
+    # Check teacher can see download option and enable dropdown.
+    And I log in as "teacher1"
+    And I am on "Hockey 101" course homepage
+    And "Download course content" "link" should exist in current page administration
+    And I navigate to "Edit settings" in current page administration
+    And "Enable download course content" "select" should exist
+    And I log out
+    # Remove teacher's capabilities for download course content.
+    And I log in as "admin"
+    And I set the following system permissions of "Teacher" role:
+      | capability                             | permission |
+      | moodle/course:downloadcoursecontent    | Prohibit   |
+      | moodle/course:configuredownloadcontent | Prohibit   |
+    And I log out
+    # Check teacher can no longer see download option, and that enable value is visible, but dropdown no longer available.
+    When I log in as "teacher1"
+    And I am on "Hockey 101" course homepage
+    Then "Download course content" "link" should not exist in current page administration
+    And I navigate to "Edit settings" in current page administration
+    And I should see "Enable download course content"
+    And I should see "Site default (Yes)"
+    And "Enable download course content" "select" should not exist
+
+  Scenario: Students require a capability to access the download course content feature in a course
+    Given I log in as "teacher1"
+    And I am on "Hockey 101" course homepage
+    And I navigate to "Edit settings" in current page administration
+    And I set the field "Enable download course content" to "Yes"
+    And I press "Save and display"
+    And I log out
+    # Check student can see download button.
+    And I log in as "student1"
+    And I am on "Hockey 101" course homepage
+    And "Download course content" "button" should exist
+    And I log out
+    And I log in as "admin"
+    # Remove student's capability for download course content.
+    When I set the following system permissions of "Student" role:
+      | capability                             | permission |
+      | moodle/course:downloadcoursecontent    | Prohibit   |
+    And I log out
+    # Check student can no longer see download button.
+    And I log in as "student1"
+    And I am on "Hockey 101" course homepage
+    Then "Download course content" "link" should not exist in current page administration
index 611ed6c..80b7026 100644 (file)
         $PAGE->requires->js_init_call('M.core_completion.init');
     }
 
+    // Determine whether the user has permission to download course content.
+    $candownloadcourse = \core\content::can_export_context($context, $USER);
+
     // We are currently keeping the button here from 1.x to help new teachers figure out
     // what to do, even though the link also appears in the course admin block.  It also
     // means you can back out of a situation where you removed the admin block. :)
     if ($PAGE->user_allowed_editing()) {
         $buttons = $OUTPUT->edit_button($PAGE->url);
         $PAGE->set_button($buttons);
+    } else if ($candownloadcourse) {
+        // Show the download course content button if user has permission to access it.
+        // Only showing this if user doesn't have edit rights, since those who do will access it via the actions menu.
+        $buttonattr = \core_course\output\content_export_link::get_attributes($context);
+        $button = new single_button($buttonattr->url, $buttonattr->displaystring, 'post', false, $buttonattr->elementattributes);
+        $PAGE->set_button($OUTPUT->render($button));
     }
 
     // If viewing a section, make the title more specific
     // Include course AJAX
     include_course_ajax($course, $modnamesused);
 
+    // If available, include the JS to prepare the download course content modal.
+    if ($candownloadcourse) {
+        $PAGE->requires->js_call_amd('core_course/downloadcontent', 'init');
+    }
+
     echo $OUTPUT->footer();
index 2203af5..3f916b1 100644 (file)
@@ -135,14 +135,19 @@ class writer extends \core\dataformat\base {
             // height. Solution similar to that at https://stackoverflow.com/a/1943096.
             $pdf2 = clone $this->pdf;
             $pdf2->startTransaction();
+            $numpages = $pdf2->getNumPages();
             $pdf2->AddPage('L');
             $pdf2->writeHTMLCell($this->colwidth, 0, '', '', $cell, 1, 1, false, true, 'L');
-            $rowheight = max($rowheight, $pdf2->getY() - $pdf2->getMargins()['top']);
+            $pagesadded = $pdf2->getNumPages() - $numpages;
+            $pageheight = $pdf2->getPageHeight() - $pdf2->getMargins()['top'] - $pdf2->getMargins()['bottom'];
+            $cellheight = ($pagesadded - 1) * $pageheight + $pdf2->getLastH();
+            $rowheight = max($rowheight, $cellheight);
             $pdf2->rollbackTransaction();
         }
 
         $margins = $this->pdf->getMargins();
-        if ($this->pdf->GetY() + $rowheight + $margins['bottom'] > $this->pdf->getPageHeight()) {
+        if ($this->pdf->getNumPages() > 1 &&
+                ($this->pdf->GetY() + $rowheight + $margins['bottom'] > $this->pdf->getPageHeight())) {
             $this->pdf->AddPage('L');
             $this->print_heading();
         }
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 ca36639..a8a5c1c 100644 (file)
@@ -80,6 +80,6 @@ $string['unenroluser'] = 'Do you really want to unenrol "{$a->user}" from course
 $string['unenrolusers'] = 'Unenrol users';
 $string['wscannotenrol'] = 'Plugin instance cannot manually enrol a user in the course id = {$a->courseid}';
 $string['wsnoinstance'] = 'Manual enrolment plugin instance doesn\'t exist or is disabled for the course (id = {$a->courseid})';
-$string['wsusercannotassign'] = 'You don\'t have the permission to assign this role ({$a->roleid}) to this user ({$a->userid}) in this course({$a->courseid}).';
+$string['wsusercannotassign'] = 'You don\'t have the permission to assign this role ({$a->roleid}) to this user ({$a->userid}) in this course ({$a->courseid}).';
 $string['manualpluginnotinstalled'] = 'The "Manual" plugin has not yet been installed';
 $string['privacy:metadata'] = 'The Manual enrolments plugin does not store any personal data.';
index 0965efa..acd3e60 100644 (file)
@@ -228,6 +228,219 @@ class core_enrollib_testcase extends advanced_testcase {
         $this->assertEquals(array($course2->id, $course1->id, $course3->id), array_keys($courses));
     }
 
+    /**
+     * Test enrol_course_delete() without passing a user id. When a value for user id is not present, the method
+     * should delete all enrolment related data in the course.
+     */
+    public function test_enrol_course_delete_without_userid() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        // Create users.
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        // Create a course.
+        $course = $this->getDataGenerator()->create_course();
+        $coursecontext = context_course::instance($course->id);
+
+        $studentrole = $DB->get_record('role', ['shortname' => 'student']);
+
+        $manual = enrol_get_plugin('manual');
+        $manualinstance = $DB->get_record('enrol', ['courseid' => $course->id, 'enrol' => 'manual'], '*', MUST_EXIST);
+        // Enrol user1 as a student in the course using manual enrolment.
+        $manual->enrol_user($manualinstance, $user1->id, $studentrole->id);
+
+        $self = enrol_get_plugin('self');
+        $selfinstance = $DB->get_record('enrol', ['courseid' => $course->id, 'enrol' => 'self'], '*', MUST_EXIST);
+        $self->update_status($selfinstance, ENROL_INSTANCE_ENABLED);
+        // Enrol user2 as a student in the course using self enrolment.
+        $self->enrol_user($selfinstance, $user2->id, $studentrole->id);
+
+        // Delete all enrolment related records in the course.
+        enrol_course_delete($course);
+
+        // The course enrolment of user1 should not exists.
+        $user1enrolment = $DB->get_record('user_enrolments',
+            ['enrolid' => $manualinstance->id, 'userid' => $user1->id]);
+        $this->assertFalse($user1enrolment);
+
+        // The role assignment of user1 should not exists.
+        $user1roleassignment = $DB->get_record('role_assignments',
+            ['roleid' => $studentrole->id, 'userid'=> $user1->id, 'contextid' => $coursecontext->id]
+        );
+        $this->assertFalse($user1roleassignment);
+
+        // The course enrolment of user2 should not exists.
+        $user2enrolment = $DB->get_record('user_enrolments',
+            ['enrolid' => $selfinstance->id, 'userid' => $user2->id]);
+        $this->assertFalse($user2enrolment);
+
+        // The role assignment of user2 should not exists.
+        $user2roleassignment = $DB->get_record('role_assignments',
+            ['roleid' => $studentrole->id, 'userid'=> $user2->id, 'contextid' => $coursecontext->id]);
+        $this->assertFalse($user2roleassignment);
+
+        // All existing course enrolment instances should not exists.
+        $enrolmentinstances = enrol_get_instances($course->id, false);
+        $this->assertCount(0, $enrolmentinstances);
+    }
+
+    /**
+     * Test enrol_course_delete() when user id is present.
+     * When a value for user id is present, the method should make sure the user has the proper capability to
+     * un-enrol users before removing the enrolment data. If the capabilities are missing the data should not be removed.
+     *
+     * @dataProvider enrol_course_delete_with_userid_provider
+     * @param array $excludedcapabilities The capabilities that should be excluded from the user's role
+     * @param bool $expected The expected results
+     */
+    public function test_enrol_course_delete_with_userid($excludedcapabilities, $expected) {
+        global $DB;
+
+        $this->resetAfterTest();
+        // Create users.
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        $user3 = $this->getDataGenerator()->create_user();
+        // Create a course.
+        $course = $this->getDataGenerator()->create_course();
+        $coursecontext = context_course::instance($course->id);
+
+        $studentrole = $DB->get_record('role', ['shortname' => 'student']);
+        $editingteacherrole = $DB->get_record('role', ['shortname' => 'editingteacher']);
+
+        $manual = enrol_get_plugin('manual');
+        $manualinstance = $DB->get_record('enrol', ['courseid' => $course->id, 'enrol' => 'manual'],
+            '*', MUST_EXIST);
+        // Enrol user1 as a student in the course using manual enrolment.
+        $manual->enrol_user($manualinstance, $user1->id, $studentrole->id);
+        // Enrol user3 as an editing teacher in the course using manual enrolment.
+        // By default, the editing teacher role has the capability to un-enroll users which have been enrolled using
+        // the existing enrolment methods.
+        $manual->enrol_user($manualinstance, $user3->id, $editingteacherrole->id);
+
+        $self = enrol_get_plugin('self');
+        $selfinstance = $DB->get_record('enrol', ['courseid' => $course->id, 'enrol' => 'self'],
+            '*', MUST_EXIST);
+        $self->update_status($selfinstance, ENROL_INSTANCE_ENABLED);
+        // Enrol user2 as a student in the course using self enrolment.
+        $self->enrol_user($selfinstance, $user2->id, $studentrole->id);
+
+        foreach($excludedcapabilities as $capability) {
+            // Un-assign the given capability from the editing teacher role.
+            unassign_capability($capability, $editingteacherrole->id);
+        }
+
+        // Delete only enrolment related records in the course where user3 has the required capability.
+        enrol_course_delete($course, $user3->id);
+
+        // Check the existence of the course enrolment of user1.
+        $user1enrolmentexists = (bool) $DB->count_records('user_enrolments',
+            ['enrolid' => $manualinstance->id, 'userid' => $user1->id]);
+        $this->assertEquals($expected['User 1 course enrolment exists'], $user1enrolmentexists);
+
+        // Check the existence of the role assignment of user1 in the course.
+        $user1roleassignmentexists = (bool) $DB->count_records('role_assignments',
+            ['roleid' => $studentrole->id, 'userid' => $user1->id, 'contextid' => $coursecontext->id]);
+        $this->assertEquals($expected['User 1 role assignment exists'], $user1roleassignmentexists);
+
+        // Check the existence of the course enrolment of user2.
+        $user2enrolmentexists = (bool) $DB->count_records('user_enrolments',
+            ['enrolid' => $selfinstance->id, 'userid' => $user2->id]);
+        $this->assertEquals($expected['User 2 course enrolment exists'], $user2enrolmentexists);
+
+        // Check the existence of the role assignment of user2 in the course.
+        $user2roleassignmentexists = (bool) $DB->count_records('role_assignments',
+            ['roleid' => $studentrole->id, 'userid' => $user2->id, 'contextid' => $coursecontext->id]);
+        $this->assertEquals($expected['User 2 role assignment exists'], $user2roleassignmentexists);
+
+        // Check the existence of the course enrolment of user3.
+        $user3enrolmentexists = (bool) $DB->count_records('user_enrolments',
+            ['enrolid' => $manualinstance->id, 'userid' => $user3->id]);
+        $this->assertEquals($expected['User 3 course enrolment exists'], $user3enrolmentexists);
+
+        // Check the existence of the role assignment of user3 in the course.
+        $user3roleassignmentexists = (bool) $DB->count_records('role_assignments',
+            ['roleid' => $editingteacherrole->id, 'userid' => $user3->id, 'contextid' => $coursecontext->id]);
+        $this->assertEquals($expected['User 3 role assignment exists'], $user3roleassignmentexists);
+
+        // Check the existence of the manual enrolment instance in the course.
+        $manualinstance = (bool) $DB->count_records('enrol', ['enrol' => 'manual', 'courseid' => $course->id]);
+        $this->assertEquals($expected['Manual course enrolment instance exists'], $manualinstance);
+
+        // Check existence of the self enrolment instance in the course.
+        $selfinstance = (bool) $DB->count_records('enrol', ['enrol' => 'self', 'courseid' => $course->id]);
+        $this->assertEquals($expected['Self course enrolment instance exists'], $selfinstance);
+    }
+
+    /**
+     * Data provider for test_enrol_course_delete_with_userid().
+     *
+     * @return array
+     */
+    public function enrol_course_delete_with_userid_provider() {
+        return [
+            'The teacher can un-enrol users in a course' =>
+                [
+                    'excludedcapabilities' => [],
+                    'results' => [
+                        // Whether certain enrolment related data still exists in the course after the deletion.
+                        // When the user has the capabilities to un-enrol users and the enrolment plugins allow manual
+                        // unenerolment than all course enrolment data should be removed.
+                        'Manual course enrolment instance exists' => false,
+                        'Self course enrolment instance exists' => false,
+                        'User 1 course enrolment exists' => false,
+                        'User 1 role assignment exists' => false,
+                        'User 2 course enrolment exists' => false,
+                        'User 2 role assignment exists' => false,
+                      &n