Merge branch 'MDL-66471' of https://github.com/paulholden/moodle
authorSara Arjona <sara@moodle.com>
Thu, 21 May 2020 15:33:12 +0000 (17:33 +0200)
committerSara Arjona <sara@moodle.com>
Thu, 21 May 2020 15:33:12 +0000 (17:33 +0200)
430 files changed:
admin/settings/courses.php
admin/tool/mobile/classes/api.php
admin/tool/mobile/classes/external.php
admin/tool/mobile/classes/output/renderer.php [new file with mode: 0644]
admin/tool/mobile/classes/output/subscription.php [new file with mode: 0644]
admin/tool/mobile/db/caches.php
admin/tool/mobile/db/services.php
admin/tool/mobile/lang/en/tool_mobile.php
admin/tool/mobile/settings.php
admin/tool/mobile/styles.css [new file with mode: 0644]
admin/tool/mobile/subscription.php [new file with mode: 0644]
admin/tool/mobile/templates/subscription.mustache [new file with mode: 0644]
admin/tool/mobile/tests/externallib_test.php
admin/tool/mobile/version.php
admin/tool/monitor/tests/eventobservers_test.php
backup/backup.class.php
backup/controller/backup_controller.class.php
backup/controller/base_controller.class.php
backup/controller/restore_controller.class.php
backup/controller/tests/controller_test.php
backup/copy.php [new file with mode: 0644]
backup/copyprogress.php [new file with mode: 0644]
backup/externallib.php
backup/moodle2/backup_final_task.class.php
backup/moodle2/backup_stepslib.php
backup/tests/course_copy_test.php [new file with mode: 0644]
backup/tests/externallib_test.php [new file with mode: 0644]
backup/util/helper/async_helper.class.php
backup/util/helper/tests/async_helper_test.php
backup/util/includes/backup_includes.php
backup/util/plan/backup_plan.class.php
backup/util/plan/backup_task.class.php
backup/util/ui/amd/build/async_backup.min.js
backup/util/ui/amd/build/async_backup.min.js.map
backup/util/ui/amd/src/async_backup.js
backup/util/ui/classes/copy/copy.php [new file with mode: 0644]
backup/util/ui/classes/output/copy_form.php [new file with mode: 0644]
backup/util/ui/renderer.php
backup/util/ui/tests/behat/import_contentbank_content.feature
badges/backpack-connect.php [new file with mode: 0644]
badges/backpack-export.php [new file with mode: 0644]
badges/classes/backpack_api2p1.php [new file with mode: 0644]
badges/classes/backpack_api2p1_mapping.php [new file with mode: 0644]
badges/classes/form/backpack.php
badges/classes/form/external_backpack.php
badges/classes/oauth2/auth.php [new file with mode: 0644]
badges/classes/oauth2/badge_backpack_oauth2.php [new file with mode: 0644]
badges/classes/oauth2/client.php [new file with mode: 0644]
badges/classes/output/external_backpacks_page.php
badges/classes/privacy/provider.php
badges/mybackpack.php
badges/oauth2callback.php [new file with mode: 0644]
badges/renderer.php
badges/tests/behat/backpack.feature [new file with mode: 0644]
blocks/timeline/templates/event-list-item.mustache
calendar/amd/build/crud.min.js
calendar/amd/build/crud.min.js.map
calendar/amd/build/selectors.min.js
calendar/amd/build/selectors.min.js.map
calendar/amd/build/summary_modal.min.js
calendar/amd/build/summary_modal.min.js.map
calendar/amd/build/view_manager.min.js
calendar/amd/build/view_manager.min.js.map
calendar/amd/src/crud.js
calendar/amd/src/selectors.js
calendar/amd/src/summary_modal.js
calendar/amd/src/view_manager.js
calendar/classes/external/calendar_event_exporter.php
calendar/classes/external/event_action_exporter.php
calendar/classes/external/event_exporter.php
calendar/classes/external/event_exporter_base.php
calendar/classes/external/event_icon_exporter.php
calendar/classes/local/event/container.php
calendar/classes/local/event/data_access/event_vault.php
calendar/classes/local/event/entities/action_event.php
calendar/classes/local/event/entities/event.php
calendar/classes/local/event/entities/event_interface.php
calendar/classes/local/event/factories/event_abstract_factory.php
calendar/classes/local/event/mappers/event_mapper.php
calendar/externallib.php
calendar/lib.php
calendar/renderer.php
calendar/templates/event_details.mustache
calendar/templates/event_item.mustache
calendar/templates/event_summary_modal.mustache
calendar/templates/month_detailed.mustache
calendar/tests/action_event_test.php
calendar/tests/event_mapper_test.php
calendar/tests/event_test.php
calendar/tests/helpers.php
calendar/tests/repeat_event_collection_test.php
calendar/upgrade.txt
config-dist.php
contentbank/classes/content.php
contentbank/classes/contentbank.php
contentbank/classes/contenttype.php
contentbank/classes/output/bankcontent.php
contentbank/contenttype/h5p/classes/contenttype.php
contentbank/contenttype/h5p/tests/behat/admin_upload_content.feature
contentbank/contenttype/h5p/tests/behat/manage_content.feature
contentbank/contenttype/h5p/tests/contenttype_h5p_test.php
contentbank/templates/bankcontent.mustache
contentbank/tests/behat/delete_content.feature
contentbank/tests/behat/events.feature
contentbank/tests/behat/search_content.feature
contentbank/tests/content_test.php
contentbank/tests/contentbank_test.php
contentbank/tests/contenttype_test.php
contentbank/tests/fixtures/testable_contenttype.php
contentbank/upload.php
contentbank/view.php
course/amd/build/activitychooser.min.js
course/amd/build/activitychooser.min.js.map
course/amd/build/copy_modal.min.js [new file with mode: 0644]
course/amd/build/copy_modal.min.js.map [new file with mode: 0644]
course/amd/build/local/activitychooser/dialogue.min.js
course/amd/build/local/activitychooser/dialogue.min.js.map
course/amd/build/local/activitychooser/selectors.min.js
course/amd/build/local/activitychooser/selectors.min.js.map
course/amd/src/activitychooser.js
course/amd/src/copy_modal.js [new file with mode: 0644]
course/amd/src/local/activitychooser/dialogue.js
course/amd/src/local/activitychooser/selectors.js
course/classes/category.php
course/classes/deletecategory_form.php
course/classes/management/helper.php
course/lib.php
course/management.php
course/modlib.php
course/renderer.php
course/templates/activity_navigation.mustache
course/templates/activitychooser.mustache
course/tests/behat/activity_chooser.feature
course/tests/behat/recommend_activities.feature
course/tests/behat/search_recommended_activities.feature
course/tests/category_hooks_test.php [new file with mode: 0644]
course/tests/externallib_test.php
course/tests/fixtures/mock_hooks.php [new file with mode: 0644]
course/view.php
enrol/manual/ajax.php
enrol/manual/amd/build/quickenrolment.min.js
enrol/manual/amd/build/quickenrolment.min.js.map
enrol/manual/amd/src/quickenrolment.js
enrol/manual/tests/behat/quickenrolment.feature
filter/emoticon/db/install.php [new file with mode: 0644]
filter/emoticon/version.php
filter/urltolink/db/install.php [new file with mode: 0644]
filter/urltolink/settings.php
filter/urltolink/version.php
grade/tests/report_graderlib_test.php
grade/tests/reportuserlib_test.php
h5p/classes/api.php
install/lang/ps/moodle.php [new file with mode: 0644]
install/lang/se/error.php
install/lang/se/install.php
lang/en/backup.php
lang/en/badges.php
lang/en/calendar.php
lang/en/contentbank.php
lang/en/course.php
lang/en/error.php
lang/en/moodle.php
lang/en/timezones.php
lang/en/user.php
lib/accesslib.php
lib/adminlib.php
lib/amd/build/checkbox-toggleall.min.js
lib/amd/build/checkbox-toggleall.min.js.map
lib/amd/build/str.min.js
lib/amd/build/str.min.js.map
lib/amd/build/templates.min.js
lib/amd/build/templates.min.js.map
lib/amd/src/checkbox-toggleall.js
lib/amd/src/str.js
lib/amd/src/templates.js
lib/badgeslib.php
lib/behat/classes/behat_core_generator.php
lib/behat/classes/behat_generator_base.php
lib/classes/event/course_category_deleted.php
lib/classes/output/icon_system_fontawesome.php
lib/classes/plugin_manager.php
lib/classes/session/redis.php
lib/classes/task/asynchronous_copy_task.php [new file with mode: 0644]
lib/db/install.xml
lib/db/services.php
lib/db/upgrade.php
lib/dml/moodle_database.php
lib/dml/moodle_read_slave_trait.php [new file with mode: 0644]
lib/dml/mysqli_native_moodle_database.php
lib/dml/pgsql_native_moodle_database.php
lib/dml/tests/dml_mysqli_read_slave_test.php [new file with mode: 0644]
lib/dml/tests/dml_pgsql_read_slave_test.php [new file with mode: 0644]
lib/dml/tests/dml_read_slave_test.php [new file with mode: 0644]
lib/dml/tests/dml_test.php
lib/dml/tests/fixtures/read_slave_moodle_database.php [new file with mode: 0644]
lib/dml/tests/fixtures/read_slave_moodle_database_mock_mysqli.php [new file with mode: 0644]
lib/dml/tests/fixtures/read_slave_moodle_database_mock_pgsql.php [new file with mode: 0644]
lib/dml/tests/fixtures/read_slave_moodle_database_special.php [new file with mode: 0644]
lib/dml/tests/fixtures/read_slave_moodle_database_table_names.php [new file with mode: 0644]
lib/dml/tests/fixtures/read_slave_moodle_recordset_special.php [new file with mode: 0644]
lib/dml/tests/fixtures/test_moodle_database.php [new file with mode: 0644]
lib/dml/tests/fixtures/test_moodle_read_slave_trait.php [new file with mode: 0644]
lib/dml/tests/fixtures/test_sql_generator.php [new file with mode: 0644]
lib/editor/atto/db/upgrade.php
lib/editor/atto/settings.php
lib/editor/atto/version.php
lib/moodlelib.php
lib/navigationlib.php
lib/tablelib.php
lib/templates/async_backup_progress.mustache
lib/templates/async_copy_complete_cell.mustache [new file with mode: 0644]
lib/templates/full_header.mustache
lib/templates/navbar.mustache
lib/templates/preferences_groups.mustache
lib/testing/generator/data_generator.php
lib/testing/tests/generator_test.php
lib/tests/accesslib_test.php
lib/tests/component_test.php
lib/tests/date_test.php
lib/tests/event/contentbank_content_viewed_test.php
lib/tests/event_profile_field_test.php
lib/tests/moodlelib_test.php
lib/tests/outputcomponents_test.php
lib/tests/session_redis_test.php
lib/tests/weblib_format_text_test.php
lib/upgrade.txt
message/amd/build/message_drawer.min.js
message/amd/build/message_drawer.min.js.map
message/amd/src/message_drawer.js
message/templates/message_drawer.mustache
message/templates/message_drawer_view_conversation_footer.mustache
message/templates/message_drawer_view_conversation_footer_content.mustache
message/templates/message_drawer_view_conversation_header.mustache
message/templates/message_drawer_view_group_info_body_content.mustache
message/templates/message_drawer_view_overview_header.mustache
message/templates/message_drawer_view_overview_section.mustache
mod/assign/locallib.php
mod/assign/tests/locallib_test.php
mod/book/lib.php
mod/book/view.php
mod/h5pactivity/backup/moodle2/backup_h5pactivity_stepslib.php
mod/h5pactivity/classes/event/report_viewed.php [new file with mode: 0644]
mod/h5pactivity/classes/external/get_attempts.php [new file with mode: 0644]
mod/h5pactivity/classes/external/get_h5pactivity_access_information.php [new file with mode: 0644]
mod/h5pactivity/classes/external/view_h5pactivity.php [new file with mode: 0644]
mod/h5pactivity/classes/local/attempt.php
mod/h5pactivity/classes/local/manager.php
mod/h5pactivity/classes/local/report.php [new file with mode: 0644]
mod/h5pactivity/classes/local/report/attempts.php [new file with mode: 0644]
mod/h5pactivity/classes/local/report/participants.php [new file with mode: 0644]
mod/h5pactivity/classes/local/report/results.php [new file with mode: 0644]
mod/h5pactivity/classes/output/attempt.php [new file with mode: 0644]
mod/h5pactivity/classes/output/reportattempts.php [new file with mode: 0644]
mod/h5pactivity/classes/output/reportlink.php [new file with mode: 0644]
mod/h5pactivity/classes/output/reportresults.php [new file with mode: 0644]
mod/h5pactivity/classes/output/result.php [new file with mode: 0644]
mod/h5pactivity/classes/output/result/choice.php [new file with mode: 0644]
mod/h5pactivity/classes/output/result/fillin.php [new file with mode: 0644]
mod/h5pactivity/classes/output/result/longfillin.php [new file with mode: 0644]
mod/h5pactivity/classes/output/result/matching.php [new file with mode: 0644]
mod/h5pactivity/classes/output/result/other.php [new file with mode: 0644]
mod/h5pactivity/classes/output/result/sequencing.php [new file with mode: 0644]
mod/h5pactivity/classes/output/result/truefalse.php [new file with mode: 0644]
mod/h5pactivity/db/access.php
mod/h5pactivity/db/install.xml
mod/h5pactivity/db/services.php [new file with mode: 0644]
mod/h5pactivity/db/upgrade.php
mod/h5pactivity/grade.php
mod/h5pactivity/lang/en/h5pactivity.php
mod/h5pactivity/lib.php
mod/h5pactivity/mod_form.php
mod/h5pactivity/report.php [new file with mode: 0644]
mod/h5pactivity/templates/attempt.mustache [new file with mode: 0644]
mod/h5pactivity/templates/attempts.mustache [new file with mode: 0644]
mod/h5pactivity/templates/reportattempts.mustache [new file with mode: 0644]
mod/h5pactivity/templates/reportlink.mustache [new file with mode: 0644]
mod/h5pactivity/templates/reportresults.mustache [new file with mode: 0644]
mod/h5pactivity/templates/result.mustache [new file with mode: 0644]
mod/h5pactivity/templates/result/answer.mustache [new file with mode: 0644]
mod/h5pactivity/templates/result/header.mustache [new file with mode: 0644]
mod/h5pactivity/templates/result/options.mustache [new file with mode: 0644]
mod/h5pactivity/tests/event/report_viewed_test.php [new file with mode: 0644]
mod/h5pactivity/tests/event/statement_received_test.php
mod/h5pactivity/tests/external/get_attempts_test.php [new file with mode: 0644]
mod/h5pactivity/tests/external/get_h5pactivity_access_information_test.php [new file with mode: 0644]
mod/h5pactivity/tests/external/view_h5pactivity_test.php [new file with mode: 0644]
mod/h5pactivity/tests/generator/lib.php
mod/h5pactivity/tests/generator_test.php
mod/h5pactivity/tests/local/attempt_test.php
mod/h5pactivity/tests/local/manager_test.php
mod/h5pactivity/version.php
mod/h5pactivity/view.php
mod/quiz/accessrule/seb/tests/access_manager_test.php [moved from mod/quiz/accessrule/seb/tests/phpunit/access_manager_test.php with 98% similarity]
mod/quiz/accessrule/seb/tests/backup_restore_test.php [moved from mod/quiz/accessrule/seb/tests/phpunit/backup_restore_test.php with 96% similarity]
mod/quiz/accessrule/seb/tests/config_key_test.php [moved from mod/quiz/accessrule/seb/tests/phpunit/config_key_test.php with 96% similarity]
mod/quiz/accessrule/seb/tests/event_test.php [moved from mod/quiz/accessrule/seb/tests/phpunit/event_test.php with 92% similarity]
mod/quiz/accessrule/seb/tests/fixtures/JSON_unencrypted_mac_001.txt [moved from mod/quiz/accessrule/seb/tests/phpunit/sample_data/JSON_unencrypted_mac_001.txt with 100% similarity]
mod/quiz/accessrule/seb/tests/fixtures/encrypted.seb [moved from mod/quiz/accessrule/seb/tests/phpunit/sample_data/encrypted.seb with 100% similarity]
mod/quiz/accessrule/seb/tests/fixtures/simpleunencrypted.seb [moved from mod/quiz/accessrule/seb/tests/phpunit/sample_data/simpleunencrypted.seb with 100% similarity]
mod/quiz/accessrule/seb/tests/fixtures/simpleunencryptedwithoutoriginator.seb [moved from mod/quiz/accessrule/seb/tests/phpunit/sample_data/simpleunencryptedwithoutoriginator.seb with 100% similarity]
mod/quiz/accessrule/seb/tests/fixtures/unencrypted.seb [moved from mod/quiz/accessrule/seb/tests/phpunit/sample_data/unencrypted.seb with 100% similarity]
mod/quiz/accessrule/seb/tests/fixtures/unencrypted_mac_001.seb [moved from mod/quiz/accessrule/seb/tests/phpunit/sample_data/unencrypted_mac_001.seb with 100% similarity]
mod/quiz/accessrule/seb/tests/fixtures/unencrypted_win_223.seb [moved from mod/quiz/accessrule/seb/tests/phpunit/sample_data/unencrypted_win_223.seb with 100% similarity]
mod/quiz/accessrule/seb/tests/generator/lib.php
mod/quiz/accessrule/seb/tests/helper_test.php [moved from mod/quiz/accessrule/seb/tests/phpunit/helper_test.php with 96% similarity]
mod/quiz/accessrule/seb/tests/hideif_rule_test.php [moved from mod/quiz/accessrule/seb/tests/phpunit/hideif_rule_test.php with 100% similarity]
mod/quiz/accessrule/seb/tests/link_generator_test.php [moved from mod/quiz/accessrule/seb/tests/phpunit/link_generator_test.php with 100% similarity]
mod/quiz/accessrule/seb/tests/privacy_provider_test.php [moved from mod/quiz/accessrule/seb/tests/phpunit/privacy_provider_test.php with 96% similarity]
mod/quiz/accessrule/seb/tests/property_list_test.php [moved from mod/quiz/accessrule/seb/tests/phpunit/property_list_test.php with 99% similarity]
mod/quiz/accessrule/seb/tests/quiz_settings_test.php [moved from mod/quiz/accessrule/seb/tests/phpunit/quiz_settings_test.php with 99% similarity]
mod/quiz/accessrule/seb/tests/rule_test.php [moved from mod/quiz/accessrule/seb/tests/phpunit/rule_test.php with 98% similarity]
mod/quiz/accessrule/seb/tests/settings_provider_test.php [moved from mod/quiz/accessrule/seb/tests/phpunit/settings_provider_test.php with 95% similarity]
mod/quiz/accessrule/seb/tests/template_test.php [moved from mod/quiz/accessrule/seb/tests/phpunit/template_test.php with 100% similarity]
mod/quiz/accessrule/seb/tests/test_helper_trait.php [moved from mod/quiz/accessrule/seb/tests/phpunit/base.php with 95% similarity]
mod/quiz/attemptlib.php
mod/quiz/comment.php
mod/quiz/editrandom.php
mod/quiz/locallib.php
mod/quiz/report/grading/classes/privacy/provider.php
mod/quiz/report/grading/gradingsettings_form.php
mod/quiz/report/grading/lang/en/quiz_grading.php
mod/quiz/report/grading/report.php
mod/quiz/report/grading/tests/behat/grading.feature
mod/quiz/report/grading/tests/privacy_provider_test.php [new file with mode: 0644]
mod/quiz/report/responses/first_or_all_responses_table.php
mod/quiz/styles.css
mod/quiz/tests/attempt_walkthrough_test.php
mod/quiz/tests/behat/behat_mod_quiz.php
mod/quiz/tests/behat/editing_add_random.feature
mod/quiz/tests/behat/editing_edit_random.feature [new file with mode: 0644]
question/behaviour/behaviourbase.php
question/behaviour/interactive/tests/walkthrough_test.php
question/behaviour/missing/tests/missingbehaviour_test.php
question/behaviour/rendererbase.php
question/classes/privacy/provider.php
question/engine/datalib.php
question/engine/lib.php
question/engine/questionattempt.php
question/engine/questionusage.php
question/engine/renderer.php
question/engine/tests/helpers.php
question/engine/tests/questionattempt_db_test.php
question/engine/tests/questionattempt_test.php
question/engine/tests/questionusagebyactivity_data_test.php
question/engine/tests/questionusagebyactivity_test.php
question/engine/tests/unitofwork_test.php
question/engine/upgrade.txt
question/format/xml/format.php
question/format/xml/tests/xmlformat_test.php
question/preview.php
question/previewlib.php
question/tests/generator/behat_core_question_generator.php [new file with mode: 0644]
question/tests/generator/lib.php
question/tests/privacy_helper.php
question/type/ddmarker/question.php
question/type/ddwtos/amd/build/ddwtos.min.js
question/type/ddwtos/amd/build/ddwtos.min.js.map
question/type/ddwtos/amd/src/ddwtos.js
question/type/ddwtos/styles.css
question/type/missingtype/tests/missingtype_test.php
question/type/multichoice/backup/moodle1/lib.php
question/type/multichoice/backup/moodle2/backup_qtype_multichoice_plugin.class.php
question/type/multichoice/db/install.xml
question/type/multichoice/db/upgrade.php
question/type/multichoice/edit_multichoice_form.php
question/type/multichoice/lang/en/qtype_multichoice.php
question/type/multichoice/question.php
question/type/multichoice/questiontype.php
question/type/multichoice/renderer.php
question/type/multichoice/tests/behat/export.feature
question/type/multichoice/tests/fixtures/testquestion.moodle.xml
question/type/multichoice/tests/helper.php
question/type/multichoice/tests/question_single_test.php
question/type/multichoice/tests/upgradelibnewqe_test.php
question/type/multichoice/tests/walkthrough_test.php
question/type/multichoice/version.php
question/type/questiontypebase.php
question/type/random/questiontype.php
report/participation/amd/build/participants.min.js [new file with mode: 0644]
report/participation/amd/build/participants.min.js.map [new file with mode: 0644]
report/participation/amd/src/participants.js [new file with mode: 0644]
report/participation/index.php
repository/contentbank/classes/browser/contentbank_browser.php [new file with mode: 0644]
repository/contentbank/classes/browser/contentbank_browser_context_course.php [new file with mode: 0644]
repository/contentbank/classes/browser/contentbank_browser_context_coursecat.php [new file with mode: 0644]
repository/contentbank/classes/browser/contentbank_browser_context_system.php [new file with mode: 0644]
repository/contentbank/classes/helper.php [new file with mode: 0644]
repository/contentbank/classes/privacy/provider.php [new file with mode: 0644]
repository/contentbank/db/access.php [new file with mode: 0644]
repository/contentbank/db/install.php [new file with mode: 0644]
repository/contentbank/lang/en/repository_contentbank.php [new file with mode: 0644]
repository/contentbank/lib.php [new file with mode: 0644]
repository/contentbank/pix/icon.png [new file with mode: 0644]
repository/contentbank/pix/icon.svg [new file with mode: 0644]
repository/contentbank/tests/behat/select_content.feature [new file with mode: 0644]
repository/contentbank/tests/browser_test.php [new file with mode: 0644]
repository/contentbank/tests/generator/lib.php [new file with mode: 0644]
repository/contentbank/version.php [new file with mode: 0644]
repository/tests/behat/behat_filepicker.php
theme/boost/scss/moodle/admin.scss
theme/boost/scss/moodle/blocks.scss
theme/boost/scss/moodle/calendar.scss
theme/boost/scss/moodle/core.scss
theme/boost/scss/moodle/course.scss
theme/boost/scss/moodle/debug.scss
theme/boost/scss/moodle/drawer.scss
theme/boost/scss/moodle/forms.scss
theme/boost/scss/moodle/message.scss
theme/boost/scss/moodle/question.scss
theme/boost/scss/preset/default.scss
theme/boost/style/moodle.css
theme/classic/scss/preset/default.scss
theme/classic/style/moodle.css
user/amd/build/local/participants/bulkactions.min.js [new file with mode: 0644]
user/amd/build/local/participants/bulkactions.min.js.map [new file with mode: 0644]
user/amd/build/participants.min.js
user/amd/build/participants.min.js.map
user/amd/build/repository.min.js
user/amd/build/repository.min.js.map
user/amd/src/local/participants/bulkactions.js [new file with mode: 0644]
user/amd/src/participants.js
user/amd/src/repository.js
user/classes/table/participants.php
user/index.php
user/tests/behat/filter_participants_showall.feature
user/tests/externallib_test.php
user/tests/privacy_test.php
user/tests/profilelib_test.php
user/tests/userlib_test.php
version.php

index 8936f7c..b6ba368 100644 (file)
@@ -61,12 +61,6 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) {
             array('moodle/restore:restorecourse')
         )
     );
-    $ADMIN->add('courses',
-        new admin_externalpage('activitychooser', new lang_string('activitychooserrecommendations', 'course'),
-            new moodle_url('/course/recommendations.php'),
-            array('moodle/course:recommendactivity')
-        )
-    );
 
     // Course Default Settings Page.
     // NOTE: these settings must be applied after all other settings because they depend on them.
@@ -187,6 +181,30 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) {
                 $CFG->wwwroot . '/course/pending.php', array('moodle/site:approvecourse')));
     }
 
+    // Add a category for the Activity Chooser.
+    $ADMIN->add('courses', new admin_category('activitychooser', new lang_string('activitychoosercategory', 'course')));
+    $temp = new admin_settingpage('activitychoosersettings', new lang_string('activitychoosersettings', 'course'));
+    $temp->add(
+        new admin_setting_configselect(
+            'activitychoosertabmode',
+            new lang_string('activitychoosertabmode', 'course'),
+            new lang_string('activitychoosertabmode_desc', 'course'),
+            0,
+            [
+                0 => new lang_string('activitychoosertabmodeone', 'course'),
+                1 => new lang_string('activitychoosertabmodetwo', 'course'),
+                2 => new lang_string('activitychoosertabmodethree', 'course'),
+            ]
+        )
+    );
+    $ADMIN->add('activitychooser', $temp);
+    $ADMIN->add('activitychooser',
+        new admin_externalpage('activitychooserrecommended', new lang_string('activitychooserrecommendations', 'course'),
+            new moodle_url('/course/recommendations.php'),
+            array('moodle/course:recommendactivity')
+        )
+    );
+
     // Add a category for backups.
     $ADMIN->add('courses', new admin_category('backups', new lang_string('backups','admin')));
 
index e95cf83..a9dc790 100644 (file)
@@ -61,6 +61,10 @@ class api {
     const QR_CODE_URL = 1;
     /** @var int QR code type login value */
     const QR_CODE_LOGIN = 2;
+    /** @var string Default Android app id */
+    const DEFAULT_ANDROID_APP_ID = 'com.moodle.moodlemobile';
+    /** @var string Default iOS app id */
+    const DEFAULT_IOS_APP_ID = '633359593';
 
     /**
      * Returns a list of Moodle plugins supporting the mobile app.
@@ -660,4 +664,85 @@ class api {
 
         return $imagedata;
     }
+
+    /**
+     * Gets Moodle app plan subscription information for the current site as it is returned by the Apps Portal.
+     *
+     * @return array Subscription information
+     */
+    public static function get_subscription_information() : ?array {
+        global $CFG;
+
+        // Use session cache to prevent multiple requests.
+        $cache = \cache::make('tool_mobile', 'subscriptiondata');
+        $subscriptiondata = $cache->get(0);
+        if ($subscriptiondata !== false) {
+            return $subscriptiondata;
+        }
+
+        $mobilesettings = get_config('tool_mobile');
+
+        // To validate that the requests come from this site we need to send some private information that only is known by the
+        // Moodle Apps portal or the Sites registration database.
+        $credentials = [];
+
+        if (!empty($CFG->airnotifieraccesskey)) {
+            $credentials[] = ['type' => 'airnotifieraccesskey', 'value' => $CFG->airnotifieraccesskey];
+        }
+        if (\core\hub\registration::is_registered()) {
+            $credentials[] = ['type' => 'siteid', 'value' => $CFG->siteidentifier];
+        }
+        // Generate a hash key for validating that the request is coming from this site via WS.
+        $key = complex_random_string(32);
+        $sitesubscriptionkey = json_encode(['validuntil' => time() + 10 * MINSECS, 'key' => $key]);
+        set_config('sitesubscriptionkey', $sitesubscriptionkey, 'tool_mobile');
+        $credentials[] = ['type' => 'sitesubscriptionkey', 'value' => $key];
+
+        // Parameters for the WebService returning site information.
+        $androidappid = empty($mobilesettings->androidappid) ? static::DEFAULT_ANDROID_APP_ID : $mobilesettings->androidappid;
+        $iosappid = empty($mobilesettings->iosappid) ? static::DEFAULT_IOS_APP_ID : $mobilesettings->iosappid;
+        $fnparams = (object) [
+            'siteurl' => $CFG->wwwroot,
+            'appids' => [$androidappid, $iosappid],
+            'credentials' => $credentials,
+        ];
+        // Prepare the arguments for a request to the AJAX nologin endpoint.
+        $args = [
+            (object) [
+                'index' => 0,
+                'methodname' => 'local_apps_get_site_info',
+                'args' => $fnparams,
+            ]
+        ];
+
+        // Ask the Moodle Apps Portal for the subscription information.
+        $curl = new curl();
+        $curl->setopt(array('CURLOPT_TIMEOUT' => 10, 'CURLOPT_CONNECTTIMEOUT' => 10));
+
+        $serverurl = static::MOODLE_APPS_PORTAL_URL . "/lib/ajax/service-nologin.php";
+        $query = 'args=' . urlencode(json_encode($args));
+        $wsresponse = @json_decode($curl->post($serverurl, $query), true);
+
+        $info = $curl->get_info();
+        if ($curlerrno = $curl->get_errno()) {
+            // CURL connection error.
+            debugging("Unexpected response from the Moodle Apps Portal server, CURL error number: $curlerrno");
+            return null;
+        } else if ($info['http_code'] != 200) {
+            // Unexpected error from server.
+            debugging('Unexpected response from the Moodle Apps Portal server, HTTP code:' . $info['httpcode']);
+            return null;
+        } else if (!empty($wsresponse[0]['error'])) {
+            // Unexpected error from Moodle Apps Portal.
+            debugging('Unexpected response from the Moodle Apps Portal server:' . json_encode($wsresponse[0]));
+            return null;
+        } else if (empty($wsresponse[0]['data'])) {
+            debugging('Unexpected response from the Moodle Apps Portal server:' . json_encode($wsresponse));
+            return null;
+        }
+
+        $cache->set(0, $wsresponse[0]['data']);
+
+        return $wsresponse[0]['data'];
+    }
 }
index fa13085..b3c56bb 100644 (file)
@@ -692,4 +692,63 @@ class external extends external_api {
             ]
         );
     }
+
+    /**
+     * Returns description of validate_subscription_key() parameters.
+     *
+     * @return external_function_parameters
+     * @since  Moodle 3.9
+     */
+    public static function validate_subscription_key_parameters() {
+        return new external_function_parameters(
+            [
+                'key' => new external_value(PARAM_RAW, 'Site subscription temporary key.'),
+            ]
+        );
+    }
+
+    /**
+     * Check if the given site subscription key is valid
+     *
+     * @param string $key subscriptiion temporary key
+     * @return array with the settings and warnings
+     * @since  Moodle 3.9
+     */
+    public static function validate_subscription_key(string $key): array {
+        global $CFG, $PAGE;
+
+        $params = self::validate_parameters(self::validate_subscription_key_parameters(), ['key' => $key]);
+
+        $context = context_system::instance();
+        $PAGE->set_context($context);
+
+        $validated = false;
+        $sitesubscriptionkey = get_config('tool_mobile', 'sitesubscriptionkey');
+        if (!empty($sitesubscriptionkey) && $CFG->enablemobilewebservice && empty($CFG->disablemobileappsubscription)) {
+            $sitesubscriptionkey = json_decode($sitesubscriptionkey);
+            $validated = time() < $sitesubscriptionkey->validuntil && $params['key'] === $sitesubscriptionkey->key;
+            // Delete existing, even if not validated to enforce security and attacks prevention.
+            unset_config('sitesubscriptionkey', 'tool_mobile');
+        }
+
+        return [
+            'validated' => $validated,
+            'warnings' => [],
+        ];
+    }
+
+    /**
+     * Returns description of validate_subscription_key() result value.
+     *
+     * @return external_description
+     * @since  Moodle 3.9
+     */
+    public static function validate_subscription_key_returns() {
+        return new external_single_structure(
+            [
+                'validated' => new external_value(PARAM_BOOL, 'Whether the key is validated or not.'),
+                'warnings' => new external_warnings(),
+            ]
+        );
+    }
 }
diff --git a/admin/tool/mobile/classes/output/renderer.php b/admin/tool/mobile/classes/output/renderer.php
new file mode 100644 (file)
index 0000000..f31fa77
--- /dev/null
@@ -0,0 +1,50 @@
+<?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/>.
+
+/**
+ * Renderer.
+ *
+ * @package   tool_mobile
+ * @copyright 2020 Moodle Pty Ltd
+ * @author    <juan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_mobile\output;
+
+use plugin_renderer_base;
+
+
+/**
+ * Renderer class.
+ *
+ * @package    tool_mobile
+ * @copyright  2020 Moodle Pty Ltd
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class renderer extends plugin_renderer_base {
+
+    /**
+     * Defer to template.
+     *
+     * @param \tool_mobile\output\subscription $subscription Subscription
+     * @return string HTML
+     */
+    protected function render_subscription(\tool_mobile\output\subscription $subscription): string {
+        $data = $subscription->export_for_template($this);
+        return parent::render_from_template('tool_mobile/subscription', $data);
+    }
+}
diff --git a/admin/tool/mobile/classes/output/subscription.php b/admin/tool/mobile/classes/output/subscription.php
new file mode 100644 (file)
index 0000000..d8deaca
--- /dev/null
@@ -0,0 +1,206 @@
+<?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/>.
+
+/**
+ * Subscription page.
+ *
+ * @package   tool_mobile
+ * @copyright 2020 Moodle Pty Ltd
+ * @author    <juan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_mobile\output;
+
+/**
+ * Subscription page.
+ *
+ * @package   tool_mobile
+ * @copyright 2020 Moodle Pty Ltd
+ * @author    <juan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class subscription implements \renderable, \templatable {
+
+    /**
+     * Subscription data.
+     *
+     * @var array subscription data
+     */
+    protected $subscriptiondata;
+
+    /**
+     * Constructor for the class, sets the subscription data.
+     *
+     * @param array $subscriptiondata subscription data
+     * @return void
+     */
+    public function __construct(array $subscriptiondata) {
+        $this->subscriptiondata = $subscriptiondata;
+    }
+
+    /**
+     * Exports the data.
+     *
+     * @param \renderer_base $output
+     * @return array with the subscription information
+     */
+    public function export_for_template(\renderer_base $output): array {
+        global $CFG;
+
+        $ms = get_config('tool_mobile');    // Get mobile settings.
+
+        $data = $this->subscriptiondata;
+        $data['appsportalurl'] = \tool_mobile\api::MOODLE_APPS_PORTAL_URL;
+
+        // First prepare messages that may come from the WS.
+        if (!empty($data['messages'])) {
+            foreach ($data['messages'] as $msg) {
+                $data['messages' . $msg['type']][] = ['message' => $msg['message']];
+            }
+        }
+        unset($data['messages']);
+
+        // Now prepare statistics information.
+        if (isset($data['statistics']['notifications'])) {
+            $data['notifications'] = $data['statistics']['notifications'];
+            unset($data['statistics']['notifications']);
+
+            // Find current month data.
+            $data['notifications']['currentactivedevices'] = 0;
+
+            if (isset($data['notifications']['monthly'][0])) {
+                $currentmonth = $data['notifications']['monthly'][0];
+                $data['notifications']['currentactivedevices'] = $currentmonth['activedevices'];
+                if (!empty($currentmonth['limitreachedtime'])) {
+                    $data['notifications']['limitreachedtime'] = $currentmonth['limitreachedtime'];
+                    $data['notifications']['ignorednotificationswarning'] = [
+                        'message' => get_string('notificationslimitreached', 'tool_mobile', $data['appsportalurl'])
+                    ];
+                }
+            }
+        }
+
+        // Review features.
+        foreach ($data['subscription']['features'] as &$feature) {
+
+            // Check the type of features, if it is a limitation or functionality feature.
+            if (array_key_exists('limit', $feature)) {
+
+                if (empty($feature['limit'])) {   // Unlimited, no need to calculate current values.
+                    $feature['humanstatus'] = get_string('unlimited');
+                    $feature['showbar'] = 0;
+                    continue;
+                }
+
+                switch ($feature['name']) {
+                    // Check active devices.
+                    case 'pushnotificationsdevices':
+                        if (isset($data['notifications']['currentactivedevices'])) {
+                            $feature['status'] = $data['notifications']['currentactivedevices'];
+                        }
+                        break;
+                    // Check menu items.
+                    case 'custommenuitems':
+                        $custommenuitems = [];
+                        $els = rtrim($ms->custommenuitems, "\n");
+                        if (!empty($els)) {
+                            $custommenuitems = explode("\n", $els);
+                            // Get unique custom menu urls.
+                            $custommenuitems = array_flip(
+                                array_map(function($val) {
+                                    return explode('|', $val)[1];
+                                }, $custommenuitems)
+                            );
+                        }
+                        $feature['status'] = count($custommenuitems);
+                        break;
+                    // Check language strings.
+                    case 'customlanguagestrings':
+                        $langstrings = [];
+                        $els = rtrim($ms->customlangstrings, "\n");
+                        if (!empty($els)) {
+                            $langstrings = explode("\n", $els);
+                            // Get unique language string ids.
+                            $langstrings = array_flip(
+                                array_map(function($val) {
+                                    return explode('|', $val)[0];
+                                }, $langstrings)
+                            );
+                        }
+                        $feature['status'] = count($langstrings);
+                        break;
+                    // Check disabled features strings.
+                    case 'disabledfeatures':
+                        $feature['status'] = empty($ms->disabledfeatures) ? 0 : count(explode(',', $ms->disabledfeatures));
+                        break;
+                }
+
+                $feature['humanstatus'] = '?/' . $feature['limit'];
+                // Check if we should display the bar and how.
+                if (isset($feature['status']) && is_int($feature['status'])) {
+                    $feature['humanstatus'] = $feature['status'] . '/' . $feature['limit'];
+                    $feature['showbar'] = 1;
+
+                    if ($feature['status'] == $feature['limit']) {
+                        $feature['barclass'] = 'bg-warning';
+                    }
+
+                    if ($feature['status'] > $feature['limit']) {
+                        $feature['barclass'] = 'bg-danger';
+                        $feature['humanstatus'] .= ' - ' . get_string('subscriptionlimitsurpassed', 'tool_mobile');
+                    }
+                }
+
+            } else {
+                $feature['humanstatus'] = empty($feature['enabled']) ? get_string('notincluded') : get_string('included');
+
+                if (empty($feature['enabled'])) {
+                    switch ($feature['name']) {
+                        // Check remote themes.
+                        case 'remotethemes':
+                            if (!empty($CFG->mobilecssurl)) {
+                                $feature['message'] = [
+                                    'type' => 'danger', 'message' => get_string('subscriptionfeaturenotapplied', 'tool_mobile')];
+                            }
+                            break;
+                        // Check site logo.
+                        case 'sitelogo':
+                            if ($output->get_logo_url() || $output->get_compact_logo_url()) {
+                                $feature['message'] = [
+                                    'type' => 'danger', 'message' => get_string('subscriptionfeaturenotapplied', 'tool_mobile')];
+                            }
+                            break;
+                    }
+                }
+            }
+        }
+
+        usort($data['subscription']['features'],
+            function (array $featurea, array $featureb) {
+                $isfeaturea = !array_key_exists('limit', $featurea);
+                $isfeatureb = !array_key_exists('limit', $featureb);
+
+                if (!$isfeaturea && $isfeatureb) {
+                    return 1;
+                }
+                return 0;
+            }
+        );
+
+        return $data;
+    }
+}
index 6cfcca7..1aeed18 100644 (file)
@@ -30,5 +30,10 @@ $definitions = array(
         'simplekeys' => true,
         'staticacceleration' => true,
         'staticaccelerationsize' => 1
-    )
+    ),
+    'subscriptiondata' => array(
+        'mode' => cache_store::MODE_SESSION,
+        'simplekeys' => true,
+        'simpledata' => false,
+    ),
 );
index 530267a..9d63e9b 100644 (file)
@@ -79,6 +79,16 @@ $functions = array(
         'services'    => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
     ),
 
+    'tool_mobile_validate_subscription_key' => array(
+        'classname'   => 'tool_mobile\external',
+        'methodname'  => 'validate_subscription_key',
+        'description' => 'Check if the given site subscription key is valid.',
+        'type'        => 'write',
+        'services'    => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
+        'ajax'          => true,
+        'loginrequired' => false,
+    ),
+
     'tool_mobile_get_tokens_for_qr_login' => array(
         'classname'   => 'tool_mobile\external',
         'methodname'  => 'get_tokens_for_qr_login',
index 789cfd2..e56f280 100644 (file)
@@ -31,6 +31,7 @@ $string['apprequired'] = 'This functionality is only available when accessed via
 $string['autologinkeygenerationlockout'] = 'Auto-login key generation is blocked. You need to wait 6 minutes between requests.';
 $string['autologinnotallowedtoadmins'] = 'Auto-login is not allowed for site admins.';
 $string['cachedef_plugininfo'] = 'This stores the list of plugins with mobile addons';
+$string['cachedef_subscriptiondata'] = 'This stores the Moodle app subscription information.';
 $string['clickheretolaunchtheapp'] = 'Click here if the app does not open automatically.';
 $string['configmobilecssurl'] = 'A CSS file to customise your mobile app interface.';
 $string['customlangstrings'] = 'Custom language strings';
@@ -86,12 +87,22 @@ $string['mobileapp'] = 'Mobile app';
 $string['mobileappconnected'] = 'Mobile app connected';
 $string['mobileappenabled'] = 'This site has mobile app access enabled.<br /><a href="{$a}">Download the mobile app</a>.';
 $string['mobileappearance'] = 'Mobile appearance';
+$string['mobileappsubscription'] = 'Moodle app subscription';
 $string['mobileauthentication'] = 'Mobile authentication';
 $string['mobilecssurl'] = 'CSS';
 $string['mobilefeatures'] = 'Mobile features';
 $string['mobilenotificationsdisabledwarning'] = 'Mobile notifications are not enabled. They should be enabled in Notification settings.';
 $string['mobilesettings'] = 'Mobile settings';
 $string['moodleappsportalfeatureswarning'] = 'Please note that some features may be restricted depending on your Moodle app subscription. For details, visit the <a href="{$a}" target="_blank">Moodle Apps Portal</a>.';
+$string['notifications'] = 'Notifications';
+$string['notificationsactivedevices'] = 'Active devices';
+$string['notificationsignorednotifications'] = 'Notifications not sent';
+$string['notificationslimitreached'] = 'The monthly active user devices limit has been exceeded. Notifications for some users will not be sent. It is recommended that you upgrade your app plan in the <a href="{$a}" target="_blank">Moodle Apps Portal</a>.';
+$string['notificationsmissingwarning'] = 'Moodle app notification statistics could not be retrieved. This is most likely because mobile notifications are not yet enabled on the site. You can enable them in Site Administration / Messaging / Mobile.';
+$string['notificationsnewdevices'] = 'New devices';
+$string['notificationsseemore'] = 'Note: Moodle app usage statistics are not calculated in real time. To access more detailed statistics, including data from previous months, please log in to the <a href="{$a}" target="_blank">Moodle Apps Portal</a>.';
+$string['notificationssentnotifications'] = 'Notifications sent';
+$string['notificationscurrentactivedevices'] = 'Devices receiving notifications this month';
 $string['oauth2identityproviders'] = 'OAuth 2 identity providers';
 $string['offlineuse'] = 'Offline use';
 $string['pluginname'] = 'Moodle app tools';
@@ -111,6 +122,15 @@ $string['selfsignedoruntrustedcertificatewarning'] = 'It seems that the HTTPS ce
 $string['setuplink'] = 'App download page';
 $string['setuplink_desc'] = 'URL of page with options to download the mobile app from the App Store and Google Play. The app download page link is displayed in the page footer and in a user\'s profile. Leave blank to not display a link.';
 $string['smartappbanners'] = 'App Banners';
+$string['subscription'] = 'Subscription';
+$string['subscriptioncreated'] = 'Start date';
+$string['subscriptionerrorrequest'] = 'There was an unexpected error when trying to retrieve your Moodle app subscription information.';
+$string['subscriptionexpiration'] = 'Expiry date';
+$string['subscriptionfeaturenotapplied'] = 'This feature is configured on your site but it is not included in your Moodle app plan. Thus, the setting will have no effect.';
+$string['subscriptionfeatures'] = 'Subscription features';
+$string['subscriptionlimitsurpassed'] = 'Subscription limit exceeded';
+$string['subscriptionregister'] = 'For details of the various app plans, and to access Moodle app usage statistics, please visit the <a href="{$a}" target="_blank">Moodle Apps Portal</a>.';
+$string['subscriptionsseemore'] = 'Note: The information displayed is not updated in real time. You may need to log out and log in again to see updates. For information on upgrading your app plan, please log in to the <a href="{$a}" target="_blank">Moodle Apps Portal</a>.';
 $string['typeoflogin'] = 'Type of login';
 $string['typeoflogin_desc'] = 'If the site uses a SSO authentication method, then select via a browser window or via an embedded browser. An embedded browser provides a better user experience, though it doesn\'t work with all SSO plugins.';
 $string['getmoodleonyourmobile'] = 'Get the mobile app';
index 732bd71..90de327 100644 (file)
@@ -50,11 +50,20 @@ if ($hassiteconfig) {
 
     // Show only mobile settings if the mobile service is enabled.
     if (!empty($CFG->enablemobilewebservice)) {
-        // General notification about limited features due to app restrictions.
-        $notify = new \core\output\notification(
-            get_string('moodleappsportalfeatureswarning', 'tool_mobile', tool_mobile\api::MOODLE_APPS_PORTAL_URL),
-            \core\output\notification::NOTIFY_WARNING);
-        $featuresnotice = $OUTPUT->render($notify);
+
+        $featuresnotice = null;
+        if (empty($CFG->disablemobileappsubscription)) {
+            // General notification about limited features due to app restrictions.
+            $subscriptionurl = (new moodle_url("/$CFG->admin/tool/mobile/subscription.php"))->out(false);
+            $notify = new \core\output\notification(
+                get_string('moodleappsportalfeatureswarning', 'tool_mobile', $subscriptionurl),
+                \core\output\notification::NOTIFY_WARNING);
+            $featuresnotice = $OUTPUT->render($notify);
+
+            $ADMIN->add('mobileapp', new admin_externalpage('mobileappsubscription',
+                new lang_string('mobileappsubscription', 'tool_mobile'),
+                "$CFG->wwwroot/$CFG->admin/tool/mobile/subscription.php"));
+        }
 
         // Type of login.
         $temp = new admin_settingpage('mobileauthentication', new lang_string('mobileauthentication', 'tool_mobile'));
@@ -92,7 +101,9 @@ if ($hassiteconfig) {
         // Appearance related settings.
         $temp = new admin_settingpage('mobileappearance', new lang_string('mobileappearance', 'tool_mobile'));
 
-        $temp->add(new admin_setting_heading('tool_mobile/moodleappsportalfeaturesappearance', '', $featuresnotice));
+        if (!empty($featuresnotice)) {
+            $temp->add(new admin_setting_heading('tool_mobile/moodleappsportalfeaturesappearance', '', $featuresnotice));
+        }
 
         $temp->add(new admin_setting_configtext('mobilecssurl', new lang_string('mobilecssurl', 'tool_mobile'),
                     new lang_string('configmobilecssurl', 'tool_mobile'), '', PARAM_URL));
@@ -113,10 +124,10 @@ if ($hassiteconfig) {
                     new lang_string('enablesmartappbanners_desc', 'tool_mobile'), 0));
 
         $temp->add(new admin_setting_configtext('tool_mobile/iosappid', new lang_string('iosappid', 'tool_mobile'),
-                    new lang_string('iosappid_desc', 'tool_mobile'), '633359593', PARAM_ALPHANUM));
+                    new lang_string('iosappid_desc', 'tool_mobile'), tool_mobile\api::DEFAULT_IOS_APP_ID, PARAM_ALPHANUM));
 
         $temp->add(new admin_setting_configtext('tool_mobile/androidappid', new lang_string('androidappid', 'tool_mobile'),
-                    new lang_string('androidappid_desc', 'tool_mobile'), 'com.moodle.moodlemobile', PARAM_NOTAGS));
+                    new lang_string('androidappid_desc', 'tool_mobile'), tool_mobile\api::DEFAULT_ANDROID_APP_ID, PARAM_NOTAGS));
 
         $temp->add(new admin_setting_configtext('tool_mobile/setuplink', new lang_string('setuplink', 'tool_mobile'),
             new lang_string('setuplink_desc', 'tool_mobile'), 'https://download.moodle.org/mobile', PARAM_URL));
@@ -126,7 +137,9 @@ if ($hassiteconfig) {
         // Features related settings.
         $temp = new admin_settingpage('mobilefeatures', new lang_string('mobilefeatures', 'tool_mobile'));
 
-        $temp->add(new admin_setting_heading('tool_mobile/moodleappsportalfeatures', '', $featuresnotice));
+        if (!empty($featuresnotice)) {
+            $temp->add(new admin_setting_heading('tool_mobile/moodleappsportalfeatures', '', $featuresnotice));
+        }
 
         $temp->add(new admin_setting_heading('tool_mobile/logout',
                     new lang_string('logout'), ''));
diff --git a/admin/tool/mobile/styles.css b/admin/tool/mobile/styles.css
new file mode 100644 (file)
index 0000000..f51e5c7
--- /dev/null
@@ -0,0 +1,32 @@
+/**
+ * Styles for admin tool mobile.
+ */
+
+#page-admin-tool-mobile-subscription dl dt {
+    clear: both;
+    display: inline-block;
+    width: 40%;
+    min-width: 100px;
+    vertical-align: top;
+    padding-top: 1px;
+}
+
+#page-admin-tool-mobile-subscription dl dd {
+    display: inline-block;
+    width: 59%;
+    margin-left: 1%;
+    vertical-align: top;
+    padding-top: 1px;
+}
+
+#page-admin-tool-mobile-subscription dl.list-narrow dt {
+    width: 30%;
+}
+
+#page-admin-tool-mobile-subscription dl.list-narrow dd {
+    width: 69%;
+}
+
+#page-admin-tool-mobile-subscription progress {
+    width: 100%;
+}
\ No newline at end of file
diff --git a/admin/tool/mobile/subscription.php b/admin/tool/mobile/subscription.php
new file mode 100644 (file)
index 0000000..6dca2c5
--- /dev/null
@@ -0,0 +1,51 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Moodle app subscription information for the current site.
+ *
+ * @package   tool_mobile
+ * @copyright 2020 Moodle Pty Ltd
+ * @author    <juan@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require_once(__DIR__ . '/../../../config.php');
+require_once($CFG->libdir . '/adminlib.php');
+
+admin_externalpage_setup('mobileappsubscription', '', null, '');
+
+// Check Mobile web services enabled. This page should not be linked in that case, but avoid just in case.
+if (!$CFG->enablemobilewebservice) {
+    print_error('enablewsdescription', 'webservice');
+}
+// Check is this feature is globaly disabled.
+if (!empty($CFG->disablemobileappsubscription)) {
+    print_error('disabled', 'admin');
+}
+
+$subscriptiondata = \tool_mobile\api::get_subscription_information();
+
+echo $OUTPUT->header();
+
+if (empty($subscriptiondata)) {
+    echo $OUTPUT->notification(get_string('subscriptionerrorrequest', 'tool_mobile'), \core\output\notification::NOTIFY_ERROR);
+} else {
+    $templatable = new \tool_mobile\output\subscription($subscriptiondata);
+    echo $PAGE->get_renderer('tool_mobile')->render($templatable);
+}
+
+echo $OUTPUT->footer();
diff --git a/admin/tool/mobile/templates/subscription.mustache b/admin/tool/mobile/templates/subscription.mustache
new file mode 100644 (file)
index 0000000..0759b3d
--- /dev/null
@@ -0,0 +1,229 @@
+{{!
+    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 tool_mobile/subscription
+
+    Template for subscription information.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * none
+
+    Context variables required for this template:
+    * registered - Whether the site is registered
+    * appsportalurl - Apps portal url
+
+    Example context (json):
+    {
+        "registered" : true,
+        "appsportalurl": "https://apps.moodle.com",
+        "subscription": {
+            "name": "Pro",
+            "description": "This subscription has a Moodle Product Premium plan free of charge",
+            "timecreated": 1587548810,
+            "expiretime": 1618963200,
+            "features": [
+                {
+                    "name": "multimediapushnotifications",
+                    "enabled": true,
+                    "description": "Multimedia push notifications",
+                    "humanstatus": "Enabled",
+                    "message": {
+                        "type" : "warning",
+                        "message" : "Temporary disabled for a promotion"
+                    }
+                },
+                {
+                    "name": "pushnotificationsdevices",
+                    "enabled": false,
+                    "limit": 50,
+                    "showbar": 1,
+                    "description": "Active user devices for notifications",
+                    "status": 55,
+                    "humanstatus": "55/50",
+                    "barclass": "bg-danger"
+                },
+                {
+                    "name": "custommenuitems",
+                    "enabled": false,
+                    "limit": 4,
+                    "showbar": 1,
+                    "description": "Custom menu items",
+                    "status": 2,
+                    "humanstatus": "2/4"
+                }
+            ]
+        },
+        "messageswarning": [
+            {
+                "message" : "You have surpassed your monthly active user devices limit, some messages are beign ignored. We recommend you to upgrade to a paid plan."
+            }
+        ],
+        "notifications": {
+            "totalsentnotifications" : 7600,
+            "totaldevices" : 60,
+            "currentactivedevices" : 55,
+            "ignorednotificationswarning": {
+                "message" : "You have surpassed your monthly active user devices limit, some messages are beign ignored. We recommend you to upgrade to a paid plan."
+            },
+            "monthly" : [
+                {
+                    "year": 2020,
+                    "month": 4,
+                    "sentnotifications": 4500,
+                    "newdevices": 20,
+                    "activedevices": 55,
+                    "ignorednotifications": 40,
+                    "limitreachedtime": 1586548810
+                },
+                {
+                    "year": 2020,
+                    "month": 3,
+                    "sentnotifications": 4500,
+                    "newdevices":10,
+                    "activedevices": 45,
+                    "ignorednotifications": 0,
+                    "limitreachedtime": 0
+                }
+            ]
+        }
+    }
+}}
+
+{{#messageserror}}
+    {{> core/notification_error}}
+{{/messageserror}}
+{{#messagessuccess}}
+    {{> core/notification_success}}
+{{/messagessuccess}}
+{{#messageswarning}}
+    {{> core/notification_warning}}
+{{/messageswarning}}
+{{#messagesinfo}}
+    {{> core/notification_info}}
+{{/messagesinfo}}
+
+<div id="subscription-overview" class="box">
+
+    <h2>{{# str }} mobileappsubscription, tool_mobile {{/ str }}</h2>
+
+    {{#messageshtml}}
+    {{{message}}}
+    {{/messageshtml}}
+
+    {{#subscription}}
+    <dl class="list-narrow">
+        <dt>{{# str }} name {{/ str }}</dt><dd>{{name}}</dd>
+        <dt>{{# str }} description {{/ str }}</dt><dd>{{description}}</dd>
+        <dt>{{# str }} subscriptioncreated, tool_mobile {{/ str }}</dt><dd>{{#userdate}} {{timecreated}}, {{#str}} strftimedate {{/str}} {{/userdate}}</dd>
+        {{#expiretime}}
+        <dt>{{# str }} subscriptionexpiration, tool_mobile {{/ str }}</dt><dd>{{#userdate}} {{expiretime}}, {{#str}} strftimedate {{/str}} {{/userdate}}</dd>
+        {{/expiretime}}
+    </dl>
+
+    {{^registered}}
+    {{# str }} subscriptionregister, tool_mobile, {{ appsportalurl }} {{/ str }}
+    {{/registered}}
+
+    <h3>{{# str }} subscriptionfeatures, tool_mobile {{/ str }}</h3>
+
+    {{#features}}
+    <dl>
+    {{^limit}}
+        <dt>{{{description}}}</dt><dd>{{{humanstatus}}}</dd>
+    {{/limit}}
+    {{#limit}}
+        <dt>{{{description}}}</dt><dd>
+        {{#showbar}}
+            <div class="progress">
+                <div class="progress-bar progress-bar-animated {{barclass}}" role="progressbar" style="width: 100%" aria-valuenow="{{status}}" aria-valuemin="0" aria-valuemax="{{limit}}">{{humanstatus}}
+                </div>
+            </div>
+        {{/showbar}}
+        {{^showbar}}
+            {{humanstatus}}
+        {{/showbar}}
+        </dd>
+    {{/limit}}
+    {{#message}}
+        <span class="badge badge-{{type}}">{{message}}</span>
+    {{/message}}
+    </dl>
+    {{/features}}
+
+    {{#registered}}
+    {{# str }} subscriptionsseemore, tool_mobile, {{ appsportalurl }} {{/ str }}
+    {{/registered}}
+
+    {{/subscription}}
+</div>
+
+<div id="notifications-overview" class="box">
+    <h3>{{# str }} notifications, tool_mobile {{/ str }}</h3>
+
+    {{^registered}}
+    {{# str }} subscriptionregister, tool_mobile, {{ appsportalurl }} {{/ str }}
+    {{/registered}}
+
+    {{#notifications}}
+
+    {{#ignorednotificationswarning}}
+        {{> core/notification_error}}
+    {{/ignorednotificationswarning}}
+
+    <dl>
+        <dt>{{# str }} notificationscurrentactivedevices, tool_mobile {{/ str }}</dt><dd>{{currentactivedevices}}</dd>
+    </dl>
+
+    <table id="notificationstable" class="generaltable fullwidth">
+        <thead>
+            <tr>
+                <th class="text-center" scope="col">{{#str}}year, form{{/str}}</th>
+                <th class="text-center" scope="col">{{#str}}month{{/str}}</th>
+                <th class="text-center" scope="col">{{#str}}notificationssentnotifications, tool_mobile{{/str}}</th>
+                <th class="text-center" scope="col">{{#str}}notificationsactivedevices, tool_mobile{{/str}}</th>
+                <th class="text-center" scope="col">{{#str}}notificationsnewdevices, tool_mobile{{/str}}</th>
+                <th class="text-center" scope="col">{{#str}}notificationsignorednotifications, tool_mobile{{/str}}</th>
+            </tr>
+        </thead>
+        <tbody>
+            {{#notifications.monthly}}
+            <tr>
+                <td class="text-center">{{year}}</td>
+                <td class="text-center">{{month}}</td>
+                <td class="text-center">{{sentnotifications}}</td>
+                <td class="text-center">{{activedevices}}</td>
+                <td class="text-center">{{newdevices}}</td>
+                <td class="text-center">{{ignorednotifications}}</td>
+            </tr>
+           {{/notifications.monthly}}
+        </tbody>
+    </table>
+
+    {{#registered}}
+    {{# str }} notificationsseemore, tool_mobile, {{ appsportalurl }} {{/ str }}
+    {{/registered}}
+
+    {{/notifications}}
+
+    {{^notifications}}
+    {{# str }} notificationsmissingwarning, tool_mobile {{/ str }}
+    {{/notifications}}
+
+</div>
index 7b05175..f39846e 100644 (file)
@@ -725,4 +725,67 @@ class tool_mobile_external_testcase extends externallib_advanced_testcase {
         $this->expectExceptionMessage(get_string('apprequired', 'tool_mobile'));
         $result = external::get_tokens_for_qr_login('', $USER->id);
     }
+
+    /**
+     * Test validate subscription key.
+     */
+    public function test_validate_subscription_key_valid() {
+        $this->resetAfterTest(true);
+
+        $sitesubscriptionkey = ['validuntil' => time() + MINSECS, 'key' => complex_random_string(32)];
+        set_config('sitesubscriptionkey', json_encode($sitesubscriptionkey), 'tool_mobile');
+
+        $result = external::validate_subscription_key($sitesubscriptionkey['key']);
+        $result = external_api::clean_returnvalue(external::validate_subscription_key_returns(), $result);
+        $this->assertEmpty($result['warnings']);
+        $this->assertTrue($result['validated']);
+    }
+
+    /**
+     * Test validate subscription key invalid first and then a valid one.
+     */
+    public function test_validate_subscription_key_invalid_key_first() {
+        $this->resetAfterTest(true);
+
+        $sitesubscriptionkey = ['validuntil' => time() + MINSECS, 'key' => complex_random_string(32)];
+        set_config('sitesubscriptionkey', json_encode($sitesubscriptionkey), 'tool_mobile');
+
+        $result = external::validate_subscription_key('fakekey');
+        $result = external_api::clean_returnvalue(external::validate_subscription_key_returns(), $result);
+        $this->assertEmpty($result['warnings']);
+        $this->assertFalse($result['validated']);
+
+        // The valid one has been invalidated because the previous attempt.
+        $result = external::validate_subscription_key($sitesubscriptionkey['key']);
+        $result = external_api::clean_returnvalue(external::validate_subscription_key_returns(), $result);
+        $this->assertEmpty($result['warnings']);
+        $this->assertFalse($result['validated']);
+    }
+
+    /**
+     * Test validate subscription key invalid.
+     */
+    public function test_validate_subscription_key_invalid_key() {
+        $this->resetAfterTest(true);
+
+        $result = external::validate_subscription_key('fakekey');
+        $result = external_api::clean_returnvalue(external::validate_subscription_key_returns(), $result);
+        $this->assertEmpty($result['warnings']);
+        $this->assertFalse($result['validated']);
+    }
+
+    /**
+     * Test validate subscription key invalid.
+     */
+    public function test_validate_subscription_key_outdated() {
+        $this->resetAfterTest(true);
+
+        $sitesubscriptionkey = ['validuntil' => time() - MINSECS, 'key' => complex_random_string(32)];
+        set_config('sitesubscriptionkey', json_encode($sitesubscriptionkey), 'tool_mobile');
+
+        $result = external::validate_subscription_key($sitesubscriptionkey['key']);
+        $result = external_api::clean_returnvalue(external::validate_subscription_key_returns(), $result);
+        $this->assertEmpty($result['warnings']);
+        $this->assertFalse($result['validated']);
+    }
 }
index afba9ff..1af3f16 100644 (file)
@@ -23,7 +23,7 @@
  */
 
 defined('MOODLE_INTERNAL') || die();
-$plugin->version   = 2019111801; // The current plugin version (Date: YYYYMMDDXX).
+$plugin->version   = 2019111802; // The current plugin version (Date: YYYYMMDDXX).
 $plugin->requires  = 2019111200; // Requires this Moodle version.
 $plugin->component = 'tool_mobile'; // Full name of the plugin (used for diagnostics).
 $plugin->dependencies = array(
index a1cb384..f74c10b 100644 (file)
@@ -498,8 +498,8 @@ class tool_monitor_eventobservers_testcase extends advanced_testcase {
 
         $modurl = new moodle_url('/mod/book/view.php', array('id' => $book->cmid));
 
-        $this->assertContains('<h2>'.$event->get_url()->out().'</h2>', $msg->fullmessagehtml);
-        $this->assertContains('<li>'.$modurl->out().'</li>', $msg->fullmessagehtml);
+        $this->assertRegExp('~<h2>.*' . preg_quote($event->get_url()->out(), '~') . '.*</h2>~', $msg->fullmessagehtml);
+        $this->assertRegExp('~<li>.*' . preg_quote($modurl->out(), '~') . '.*</li>~', $msg->fullmessagehtml);
         $this->assertContains('<li><strong>'.$rule->get_name($context).'</strong></li>', $msg->fullmessagehtml);
         $this->assertContains('<li>'.$rule->get_description($context).'</li>', $msg->fullmessagehtml);
         $this->assertContains('<li>'.$rule->get_event_name().'</li>', $msg->fullmessagehtml);
index 260058d..034c1bf 100644 (file)
@@ -85,6 +85,15 @@ abstract class backup implements checksumable {
      */
     const MODE_ASYNC = 70;
 
+    /**
+     * This mode is for course copies.
+     * It is similar to async, but identifies back up and restore tasks
+     * as course copies.
+     *
+     * These copies will run via adhoc scheduled tasks.
+     */
+    const MODE_COPY = 80;
+
     // Target (new/existing/current/adding/deleting)
     const TARGET_CURRENT_DELETING = 0;
     const TARGET_CURRENT_ADDING   = 1;
index c2c7fdd..781e8cb 100644 (file)
@@ -70,6 +70,12 @@ class backup_controller extends base_controller {
 
     protected $checksum; // Cache @checksumable results for lighter @is_checksum_correct() uses
 
+    /**
+     * The role ids to keep in a copy operation.
+     * @var array
+     */
+    protected $keptroles = array();
+
     /**
      * Constructor for the backup controller class.
      *
@@ -97,7 +103,7 @@ class backup_controller extends base_controller {
         $this->checksum = '';
 
         // Set execution based on backup mode.
-        if ($mode == backup::MODE_ASYNC) {
+        if ($mode == backup::MODE_ASYNC || $mode == backup::MODE_COPY) {
             $this->execution = backup::EXECUTION_DELAYED;
         } else {
             $this->execution = backup::EXECUTION_INMEDIATE;
@@ -291,7 +297,7 @@ class backup_controller extends base_controller {
 
         // When a backup is intended for the same site, we don't need to include the files.
         // Note, this setting is only used for duplication of an entire course.
-        if ($this->get_mode() === backup::MODE_SAMESITE) {
+        if ($this->get_mode() === backup::MODE_SAMESITE || $this->get_mode() === backup::MODE_COPY) {
             $includefiles = false;
         }
 
@@ -352,6 +358,22 @@ class backup_controller extends base_controller {
         return $this->plan;
     }
 
+    /**
+     * Sets the user roles that should be kept in the destination course
+     * for a course copy operation.
+     *
+     * @param array $roleids
+     * @throws backup_controller_exception
+     */
+    public function set_kept_roles(array $roleids): void {
+        // Only allow of keeping user roles when controller is in copy mode.
+        if ($this->mode != backup::MODE_COPY) {
+            throw new backup_controller_exception('cannot_set_keep_roles_wrong_mode');
+        }
+
+        $this->keptroles = $roleids;
+    }
+
     /**
      * Executes the backup
      * @return void Throws and exception of completes
@@ -379,6 +401,12 @@ class backup_controller extends base_controller {
             $this->log('notifying plan about excluded activities by type', backup::LOG_DEBUG);
             $this->plan->set_excluding_activities();
         }
+
+        // Handle copy operation specific settings.
+        if ($this->mode == backup::MODE_COPY) {
+            $this->plan->set_kept_roles($this->keptroles);
+        }
+
         return $this->plan->execute();
     }
 
index 32aa06c..8e8d0d1 100644 (file)
@@ -36,6 +36,13 @@ abstract class base_controller extends backup implements loggable {
     /** @var bool Whether this backup should release the session. */
     protected $releasesession = backup::RELEASESESSION_NO;
 
+    /**
+     * Holds the relevant destination information for course copy operations.
+     *
+     * @var \stdClass.
+     */
+    protected $copy;
+
     /**
      * Gets the progress reporter, which can be used to report progress within
      * the backup or restore process.
@@ -95,4 +102,30 @@ abstract class base_controller extends backup implements loggable {
     public function get_releasesession() {
         return $this->releasesession;
     }
+
+    /**
+     * Store extra data for course copy operations.
+     *
+     * For a course copying these is data required to be passed to the restore step.
+     * We store this data in its own section of the backup controller
+     *
+     * @param \stdClass $data The course copy data.
+     * @throws backup_controller_exception
+     */
+    public function set_copy(\stdClass $data): void {
+        // Only allow setting of copy data when controller is in copy mode.
+        if ($this->mode != backup::MODE_COPY) {
+            throw new backup_controller_exception('cannot_set_copy_vars_wrong_mode');
+        }
+        $this->copy = $data;
+    }
+
+    /**
+     * Get the course copy data.
+     *
+     * @return \stdClass
+     */
+    public function get_copy(): \stdClass {
+        return $this->copy;
+    }
 }
index cf37e55..39c4116 100644 (file)
@@ -116,7 +116,7 @@ class restore_controller extends base_controller {
         $this->logger = backup_factory::get_logger_chain($this->interactive, $this->execution, $this->restoreid);
 
         // Set execution based on backup mode.
-        if ($mode == backup::MODE_ASYNC) {
+        if ($mode == backup::MODE_ASYNC || $mode == backup::MODE_COPY) {
             $this->execution = backup::EXECUTION_DELAYED;
         } else {
             $this->execution = backup::EXECUTION_INMEDIATE;
@@ -529,6 +529,30 @@ class restore_controller extends base_controller {
         $this->progress->end_progress();
     }
 
+    /**
+     * Do the necessary copy preparation actions.
+     * This method should only be called once the backup of a copy operation is completed.
+     *
+     * @throws restore_controller_exception
+     */
+    public function prepare_copy(): void {
+        // Check that we are in the correct mode.
+        if ($this->mode != backup::MODE_COPY) {
+            throw new restore_controller_exception('cannot_prepare_copy_wrong_mode');
+        }
+
+        $this->progress->start_progress('Prepare Copy');
+
+        // If no exceptions were thrown, then we are in the proper format.
+        $this->format = backup::FORMAT_MOODLE;
+
+        // Load plan, apply security and set status based on interactivity.
+        $this->load_plan();
+
+        $this->set_status(backup::STATUS_NEED_PRECHECK);
+        $this->progress->end_progress();
+    }
+
 // Protected API starts here
 
     protected function calculate_restoreid() {
index 0488185..f638be0 100644 (file)
@@ -60,6 +60,20 @@ class core_backup_controller_testcase extends advanced_testcase {
         $CFG->backup_file_logger_level_extra = backup::LOG_NONE;
     }
 
+    /**
+     * Test set copy method.
+     */
+    public function test_base_controller_set_copy() {
+        $this->expectException(\backup_controller_exception::class);
+        $copy = new \stdClass();
+
+        // Set up controller as a non-copy operation.
+        $bc = new \backup_controller(backup::TYPE_1COURSE, $this->courseid, backup::FORMAT_MOODLE,
+            backup::INTERACTIVE_NO, backup::MODE_GENERAL, $this->userid, backup::RELEASESESSION_YES);
+
+        $bc->set_copy($copy);
+    }
+
     /*
      * test base_setting class
      */
@@ -103,6 +117,19 @@ class core_backup_controller_testcase extends advanced_testcase {
         $this->assertEquals($bc->get_include_files(), 0);
     }
 
+    /**
+     * Test set kept roles method.
+     */
+    public function test_backup_controller_set_kept_roles() {
+        $this->expectException(\backup_controller_exception::class);
+
+        // Set up controller as a non-copy operation.
+        $bc = new \backup_controller(backup::TYPE_1COURSE, $this->courseid, backup::FORMAT_MOODLE,
+            backup::INTERACTIVE_NO, backup::MODE_GENERAL, $this->userid, backup::RELEASESESSION_YES);
+
+        $bc->set_kept_roles(array(1, 3, 5));
+    }
+
     /**
      * Tests the restore_controller.
      */
@@ -150,6 +177,29 @@ class core_backup_controller_testcase extends advanced_testcase {
         $this->assertTrue($alltrue);
     }
 
+    /**
+     * Test prepare copy method.
+     */
+    public function test_restore_controller_prepare_copy() {
+        $this->expectException(\restore_controller_exception::class);
+
+        global $CFG;
+
+        // Make a backup.
+        make_backup_temp_directory('');
+        $bc = new backup_controller(backup::TYPE_1ACTIVITY, $this->moduleid, backup::FORMAT_MOODLE,
+            backup::INTERACTIVE_NO, backup::MODE_IMPORT, $this->userid);
+        $backupid = $bc->get_backupid();
+        $bc->execute_plan();
+        $bc->destroy();
+
+        // Set up restore.
+        $rc = new restore_controller($backupid, $this->courseid,
+            backup::INTERACTIVE_NO, backup::MODE_SAMESITE, $this->userid,
+            backup::TARGET_EXISTING_ADDING);
+        $rc->prepare_copy();
+    }
+
     /**
      * Test restore of deadlock causing backup.
      */
diff --git a/backup/copy.php b/backup/copy.php
new file mode 100644 (file)
index 0000000..6243090
--- /dev/null
@@ -0,0 +1,95 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * This script is used to configure and execute the course copy proccess.
+ *
+ * @package    core_backup
+ * @copyright  2020 onward The Moodle Users Association <https://moodleassociation.org/>
+ * @author     Matt Porritt <mattp@catalyst-au.net>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require_once('../config.php');
+
+defined('MOODLE_INTERNAL') || die();
+
+$courseid = required_param('id', PARAM_INT);
+$returnto = optional_param('returnto', 'course', PARAM_ALPHANUM); // Generic navigation return page switch.
+$returnurl = optional_param('returnurl', '', PARAM_LOCALURL); // A return URL. returnto must also be set to 'url'.
+
+$url = new moodle_url('/backup/copy.php', array('id' => $courseid));
+$course = get_course($courseid);
+$coursecontext = context_course::instance($course->id);
+
+// Security and access checks.
+require_login($course, false);
+$copycaps = \core_course\management\helper::get_course_copy_capabilities();
+require_all_capabilities($copycaps, $coursecontext);
+
+if ($returnurl != '') {
+    $returnurl = new moodle_url($returnurl);
+} else if ($returnto == 'catmanage') {
+    // Redirect to category management page.
+    $returnurl = new moodle_url('/course/management.php', array('categoryid' => $course->category));
+} else {
+    // Redirect back to course page if we came from there.
+    $returnurl = new moodle_url('/course/view.php', array('id' => $courseid));
+}
+
+// Setup the page.
+$title = get_string('copycoursetitle', 'backup', $course->shortname);
+$heading = get_string('copycourseheading', 'backup');
+$PAGE->set_url($url);
+$PAGE->set_pagelayout('admin');
+$PAGE->set_title($title);
+$PAGE->set_heading($heading);
+
+// Get data ready for mform.
+$mform = new \core_backup\output\copy_form(
+    $url,
+    array('course' => $course, 'returnto' => $returnto, 'returnurl' => $returnurl));
+
+if ($mform->is_cancelled()) {
+    // The form has been cancelled, take them back to what ever the return to is.
+    redirect($returnurl);
+
+} else if ($mdata = $mform->get_data()) {
+
+    // Process the form and create the copy task.
+    $backupcopy = new \core_backup\copy\copy($mdata);
+    $backupcopy->create_copy();
+
+    if (!empty($mdata->submitdisplay)) {
+        // Redirect to the copy progress overview.
+        $progressurl = new moodle_url('/backup/copyprogress.php', array('id' => $courseid));
+        redirect($progressurl);
+    } else {
+        // Redirect to the course view page.
+        $coursesurl = new moodle_url('/course/view.php', array('id' => $courseid));
+        redirect($coursesurl);
+    }
+
+} else {
+    // This branch is executed if the form is submitted but the data doesn't validate,
+    // or on the first display of the form.
+
+    // Build the page output.
+    echo $OUTPUT->header();
+    echo $OUTPUT->heading($title);
+    $mform->display();
+    echo $OUTPUT->footer();
+}
diff --git a/backup/copyprogress.php b/backup/copyprogress.php
new file mode 100644 (file)
index 0000000..d43db02
--- /dev/null
@@ -0,0 +1,59 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * This script is used to configure and execute the course copy proccess.
+ *
+ * @package    core_backup
+ * @copyright  2020 onward The Moodle Users Association <https://moodleassociation.org/>
+ * @author     Matt Porritt <mattp@catalyst-au.net>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require_once('../config.php');
+require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php');
+require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php');
+
+defined('MOODLE_INTERNAL') || die();
+
+$courseid = required_param('id', PARAM_INT);
+
+$url = new moodle_url('/backup/copyprogress.php', array('id' => $courseid));
+$course = get_course($courseid);
+$coursecontext = context_course::instance($course->id);
+
+// Security and access checks.
+require_login($course, false);
+$copycaps = \core_course\management\helper::get_course_copy_capabilities();
+require_all_capabilities($copycaps, $coursecontext);
+
+// Setup the page.
+$title = get_string('copyprogresstitle', 'backup');
+$PAGE->set_url($url);
+$PAGE->set_pagelayout('admin');
+$PAGE->set_title($title);
+$PAGE->set_heading($title);
+$PAGE->requires->js_call_amd('core_backup/async_backup', 'asyncCopyAllStatus');
+
+// Build the page output.
+echo $OUTPUT->header();
+echo $OUTPUT->heading_with_help(get_string('copyprogressheading', 'backup'), 'copyprogressheading', 'backup');
+echo $OUTPUT->container_start();
+$renderer = $PAGE->get_renderer('core', 'backup');
+echo $renderer->copy_progress_viewer($USER->id, $courseid);
+echo $OUTPUT->container_end();
+
+echo $OUTPUT->footer();
index dd70910..e05b9db 100644 (file)
@@ -28,6 +28,7 @@ defined('MOODLE_INTERNAL') || die;
 
 require_once("$CFG->libdir/externallib.php");
 require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php');
+require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php');
 
 /**
  * Backup external functions.
@@ -67,10 +68,6 @@ class core_backup_external extends external_api {
      * @since Moodle 3.7
      */
     public static function get_async_backup_progress($backupids, $contextid) {
-        global $CFG;
-        require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php');
-        require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php');
-
         // Release session lock.
         \core\session\manager::write_close();
 
@@ -224,7 +221,12 @@ class core_backup_external extends external_api {
                 );
 
         // Context validation.
-        $context = context::instance_by_id($contextid);
+        if ($contextid == 0) {
+            $copyrec = \async_helper::get_backup_record($backupid);
+            $context = context_course::instance($copyrec->itemid);
+        } else {
+            $context = context::instance_by_id($contextid);
+        }
         self::validate_context($context);
         require_capability('moodle/restore:restorecourse', $context);
 
@@ -245,4 +247,163 @@ class core_backup_external extends external_api {
                     'restoreurl' => new external_value(PARAM_URL, 'Restore url'),
                 ), 'Table row data.');
     }
+
+    /**
+     * Returns description of method parameters
+     *
+     * @return external_function_parameters
+     * @since Moodle 3.9
+     */
+    public static function get_copy_progress_parameters() {
+        return new external_function_parameters(
+            array(
+                'copies' => new external_multiple_structure(
+                    new external_single_structure(
+                        array(
+                            'backupid' => new external_value(PARAM_ALPHANUM, 'Backup id'),
+                            'restoreid' => new external_value(PARAM_ALPHANUM, 'Restore id'),
+                            'operation' => new external_value(PARAM_ALPHANUM, 'Operation type'),
+                        ), 'Copy data'
+                    ), 'Copy data'
+                ),
+            )
+        );
+    }
+
+    /**
+     * Get the data to be used when generating the table row for a course copy,
+     * the table row updates via ajax when copy is complete.
+     *
+     * @param array $copies Array of ids.
+     * @return array $results The array of results.
+     * @since Moodle 3.9
+     */
+    public static function get_copy_progress($copies) {
+        // Release session lock.
+        \core\session\manager::write_close();
+
+        // Parameter validation.
+        self::validate_parameters(
+            self::get_copy_progress_parameters(),
+            array('copies' => $copies)
+            );
+
+        $results = array();
+
+        foreach ($copies as $copy) {
+
+            if ($copy['operation'] == \backup::OPERATION_BACKUP) {
+                $copyid = $copy['backupid'];
+            } else {
+                $copyid = $copy['restoreid'];
+            }
+
+            $copyrec = \async_helper::get_backup_record($copyid);
+            $context = context_course::instance($copyrec->itemid);
+            self::validate_context($context);
+
+            $copycaps = \core_course\management\helper::get_course_copy_capabilities();
+            require_all_capabilities($copycaps, $context);
+
+            if ($copy['operation'] == \backup::OPERATION_BACKUP) {
+                $result = \backup_controller_dbops::get_progress($copyid);
+                if ($result['status'] == \backup::STATUS_FINISHED_OK) {
+                    $copyid = $copy['restoreid'];
+                }
+            }
+
+            $results[] = \backup_controller_dbops::get_progress($copyid);
+        }
+
+        return $results;
+    }
+
+    /**
+     * Returns description of method result value.
+     *
+     * @return external_description
+     * @since Moodle 3.9
+     */
+    public static function get_copy_progress_returns() {
+        return new external_multiple_structure(
+            new external_single_structure(
+                array(
+                    'status'   => new external_value(PARAM_INT, 'Copy Status'),
+                    'progress' => new external_value(PARAM_FLOAT, 'Copy progress'),
+                    'backupid' => new external_value(PARAM_ALPHANUM, 'Copy id'),
+                    'operation' => new external_value(PARAM_ALPHANUM, 'Operation type'),
+                ), 'Copy completion status'
+            ), 'Copy data'
+        );
+    }
+
+    /**
+     * Returns description of method parameters
+     *
+     * @return external_function_parameters
+     * @since Moodle 3.9
+     */
+    public static function submit_copy_form_parameters() {
+        return new external_function_parameters(
+            array(
+                'jsonformdata' => new external_value(PARAM_RAW, 'The data from the create copy form, encoded as a json array')
+            )
+        );
+    }
+
+    /**
+     * Submit the course group form.
+     *
+     * @param string $jsonformdata The data from the form, encoded as a json array.
+     * @return int new group id.
+     */
+    public static function submit_copy_form($jsonformdata) {
+
+        // Release session lock.
+        \core\session\manager::write_close();
+
+        // We always must pass webservice params through validate_parameters.
+        $params = self::validate_parameters(
+            self::submit_copy_form_parameters(),
+            array('jsonformdata' => $jsonformdata)
+            );
+
+        $formdata = json_decode($params['jsonformdata']);
+
+        $data = array();
+        parse_str($formdata, $data);
+
+        $context = context_course::instance($data['courseid']);
+        self::validate_context($context);
+        $copycaps = \core_course\management\helper::get_course_copy_capabilities();
+        require_all_capabilities($copycaps, $context);
+
+        // Submit the form data.
+        $course = get_course($data['courseid']);
+        $mform = new \core_backup\output\copy_form(
+            null,
+            array('course' => $course, 'returnto' => '', 'returnurl' => ''),
+            'post', '', ['class' => 'ignoredirty'], true, $data);
+        $mdata = $mform->get_data();
+
+        if ($mdata) {
+            // Create the copy task.
+            $backupcopy = new \core_backup\copy\copy($mdata);
+            $copyids = $backupcopy->create_copy();
+        } else {
+            throw new moodle_exception('copyformfail', 'backup');
+        }
+
+        return json_encode($copyids);
+    }
+
+    /**
+     * Returns description of method result value.
+     *
+     * @return external_description
+     * @since Moodle 3.9
+     */
+    public static function submit_copy_form_returns() {
+        return new external_value(PARAM_RAW, 'JSON response.');
+    }
 }
index 6f69c59..affe6bf 100644 (file)
@@ -69,14 +69,14 @@ class backup_final_task extends backup_task {
         // This step also ensures that the contexts for all the users exist, so next
         // step can be safely executed (join between users and contexts)
         // Not executed if backup is without users of anonymized
-        if ($this->get_setting_value('users') && !$this->get_setting_value('anonymize')) {
+        if (($this->get_setting_value('users') || !empty($this->get_kept_roles())) && !$this->get_setting_value('anonymize')) {
             $this->add_step(new backup_annotate_all_user_files('user_files'));
         }
 
         // Generate the users file (conditionally) with the final annotated users
         // including custom profile fields, preferences, tags, role assignments and
         // overrides
-        if ($this->get_setting_value('users')) {
+        if ($this->get_setting_value('users') || !empty($this->get_kept_roles())) {
             $this->add_step(new backup_users_structure_step('users', 'users.xml'));
         }
 
index cf0115a..c1372cc 100644 (file)
@@ -510,9 +510,11 @@ class backup_enrolments_structure_step extends backup_structure_step {
     }
 
     protected function define_structure() {
+        global $DB;
 
         // To know if we are including users
         $users = $this->get_setting_value('users');
+        $keptroles = $this->task->get_kept_roles();
 
         // Define each element separated
 
@@ -545,10 +547,28 @@ class backup_enrolments_structure_step extends backup_structure_step {
         // Define sources - the instances are restored using the same sortorder, we do not need to store it in xml and deal with it afterwards.
         $enrol->set_source_table('enrol', array('courseid' => backup::VAR_COURSEID), 'sortorder ASC');
 
-        // User enrolments only added only if users included
-        if ($users) {
+        // User enrolments only added only if users included.
+        if (empty($keptroles) && $users) {
             $enrolment->set_source_table('user_enrolments', array('enrolid' => backup::VAR_PARENTID));
             $enrolment->annotate_ids('user', 'userid');
+        } else if (!empty($keptroles)) {
+            list($insql, $inparams) = $DB->get_in_or_equal($keptroles);
+            $params = array(
+                backup::VAR_CONTEXTID,
+                backup::VAR_PARENTID
+            );
+            foreach ($inparams as $inparam) {
+                $params[] = backup_helper::is_sqlparam($inparam);
+            }
+            $enrolment->set_source_sql(
+               "SELECT ue.*
+                  FROM {user_enrolments} ue
+            INNER JOIN {role_assignments} ra ON ue.userid = ra.userid
+                 WHERE ra.contextid = ?
+                       AND ue.enrolid = ?
+                       AND ra.roleid $insql",
+                $params);
+            $enrolment->annotate_ids('user', 'userid');
         }
 
         $enrol->annotate_ids('role', 'roleid');
@@ -1451,7 +1471,6 @@ class backup_users_structure_step extends backup_structure_step {
             // Define id annotations (as final)
             $override->annotate_ids('rolefinal', 'roleid');
         }
-
         // Return root element (users)
         return $users;
     }
diff --git a/backup/tests/course_copy_test.php b/backup/tests/course_copy_test.php
new file mode 100644 (file)
index 0000000..906dbfe
--- /dev/null
@@ -0,0 +1,636 @@
+<?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/>.
+
+/**
+ * Course copy tests.
+ *
+ * @package    core_backup
+ * @copyright  2020 onward The Moodle Users Association <https://moodleassociation.org/>
+ * @author     Matt Porritt <mattp@catalyst-au.net>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php');
+require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php');
+require_once($CFG->libdir . '/completionlib.php');
+
+/**
+ * Course copy tests.
+ *
+ * @package    core_backup
+ * @copyright  2020 onward The Moodle Users Association <https://moodleassociation.org/>
+ * @author     Matt Porritt <mattp@catalyst-au.net>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_backup_course_copy_testcase extends advanced_testcase {
+
+    /**
+     *
+     * @var \stdClass Course used for testing.
+     */
+    protected $course;
+
+    /**
+     *
+     * @var int User used to perform backups.
+     */
+    protected $userid;
+
+    /**
+     *
+     * @var array Ids of users in test course.
+     */
+    protected $courseusers;
+
+    /**
+     *
+     * @var array Names of the created activities.
+     */
+    protected $activitynames;
+
+    /**
+     * Set up tasks for all tests.
+     */
+    protected function setUp() {
+        global $DB, $CFG, $USER;
+
+        $this->resetAfterTest(true);
+
+        $CFG->enableavailability = true;
+        $CFG->enablecompletion = true;
+
+        // Create a course with some availability data set.
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course(
+            array('format' => 'topics', 'numsections' => 3,
+                'enablecompletion' => COMPLETION_ENABLED),
+            array('createsections' => true));
+        $forum = $generator->create_module('forum', array(
+            'course' => $course->id));
+        $forum2 = $generator->create_module('forum', array(
+            'course' => $course->id, 'completion' => COMPLETION_TRACKING_MANUAL));
+
+        // We need a grade, easiest is to add an assignment.
+        $assignrow = $generator->create_module('assign', array(
+            'course' => $course->id));
+        $assign = new assign(context_module::instance($assignrow->cmid), false, false);
+        $item = $assign->get_grade_item();
+
+        // Make a test grouping as well.
+        $grouping = $generator->create_grouping(array('courseid' => $course->id,
+            'name' => 'Grouping!'));
+
+        // Create some users.
+        $user1 = $generator->create_user();
+        $user2 = $generator->create_user();
+        $user3 = $generator->create_user();
+        $user4 = $generator->create_user();
+        $this->courseusers = array(
+            $user1->id, $user2->id, $user3->id, $user4->id
+        );
+
+        // Enrol users into the course.
+        $generator->enrol_user($user1->id, $course->id, 'student');
+        $generator->enrol_user($user2->id, $course->id, 'editingteacher');
+        $generator->enrol_user($user3->id, $course->id, 'manager');
+        $generator->enrol_user($user4->id, $course->id, 'editingteacher');
+        $generator->enrol_user($user4->id, $course->id, 'manager');
+
+        $availability = '{"op":"|","show":false,"c":[' .
+            '{"type":"completion","cm":' . $forum2->cmid .',"e":1},' .
+            '{"type":"grade","id":' . $item->id . ',"min":4,"max":94},' .
+            '{"type":"grouping","id":' . $grouping->id . '}' .
+            ']}';
+        $DB->set_field('course_modules', 'availability', $availability, array(
+            'id' => $forum->cmid));
+        $DB->set_field('course_sections', 'availability', $availability, array(
+            'course' => $course->id, 'section' => 1));
+
+        // Add some user data to the course.
+        $discussion = $generator->get_plugin_generator('mod_forum')->create_discussion(['course' => $course->id,
+            'forum' => $forum->id, 'userid' => $user1->id, 'timemodified' => time(),
+            'name' => 'Frog']);
+        $generator->get_plugin_generator('mod_forum')->create_post(['discussion' => $discussion->id, 'userid' => $user1->id]);
+
+        $this->course  = $course;
+        $this->userid = $USER->id; // Admin.
+        $this->activitynames = array(
+            $forum->name,
+            $forum2->name,
+            $assignrow->name
+        );
+
+        // Set the user doing the backup to be a manager in the course.
+        // By default Managers can restore courses AND users, teachers can only do users.
+        $this->setUser($user3);
+
+        // Disable all loggers.
+        $CFG->backup_error_log_logger_level = backup::LOG_NONE;
+        $CFG->backup_output_indented_logger_level = backup::LOG_NONE;
+        $CFG->backup_file_logger_level = backup::LOG_NONE;
+        $CFG->backup_database_logger_level = backup::LOG_NONE;
+        $CFG->backup_file_logger_level_extra = backup::LOG_NONE;
+    }
+
+    /**
+     * Test creating a course copy.
+     */
+    public function test_create_copy() {
+
+        // Mock up the form data.
+        $formdata = new \stdClass;
+        $formdata->courseid = $this->course->id;
+        $formdata->fullname = 'foo';
+        $formdata->shortname = 'bar';
+        $formdata->category = 1;
+        $formdata->visible = 1;
+        $formdata->startdate = 1582376400;
+        $formdata->enddate = 0;
+        $formdata->idnumber = 123;
+        $formdata->userdata = 1;
+        $formdata->role_1 = 1;
+        $formdata->role_3 = 3;
+        $formdata->role_5 = 5;
+
+        $coursecopy = new \core_backup\copy\copy($formdata);
+        $result = $coursecopy->create_copy();
+
+        // Load the controllers, to extract the data we need.
+        $bc = \backup_controller::load_controller($result['backupid']);
+        $rc = \restore_controller::load_controller($result['restoreid']);
+
+        // Check the backup controller.
+        $this->assertEquals($result, $bc->get_copy()->copyids);
+        $this->assertEquals(backup::MODE_COPY, $bc->get_mode());
+        $this->assertEquals($this->course->id, $bc->get_courseid());
+        $this->assertEquals(backup::TYPE_1COURSE, $bc->get_type());
+
+        // Check the restore controller.
+        $newcourseid = $rc->get_courseid();
+        $newcourse = get_course($newcourseid);
+
+        $this->assertEquals($result, $rc->get_copy()->copyids);
+        $this->assertEquals(get_string('copyingcourse', 'backup'), $newcourse->fullname);
+        $this->assertEquals(get_string('copyingcourseshortname', 'backup'), $newcourse->shortname);
+        $this->assertEquals(backup::MODE_COPY, $rc->get_mode());
+        $this->assertEquals($newcourseid, $rc->get_courseid());
+
+        // Check the created ad-hoc task.
+        $now = time();
+        $task = \core\task\manager::get_next_adhoc_task($now);
+
+        $this->assertInstanceOf('\\core\\task\\asynchronous_copy_task', $task);
+        $this->assertEquals($result, (array)$task->get_custom_data());
+        $this->assertFalse($task->is_blocking());
+
+        \core\task\manager::adhoc_task_complete($task);
+    }
+
+    /**
+     * Test getting the current copies.
+     */
+    public function test_get_copies() {
+        global $USER;
+
+        // Mock up the form data.
+        $formdata = new \stdClass;
+        $formdata->courseid = $this->course->id;
+        $formdata->fullname = 'foo';
+        $formdata->shortname = 'bar';
+        $formdata->category = 1;
+        $formdata->visible = 1;
+        $formdata->startdate = 1582376400;
+        $formdata->enddate = 0;
+        $formdata->idnumber = '';
+        $formdata->userdata = 1;
+        $formdata->role_1 = 1;
+        $formdata->role_3 = 3;
+        $formdata->role_5 = 5;
+
+        $formdata2 = clone($formdata);
+        $formdata2->shortname = 'tree';
+
+        // Create some copies.
+        $coursecopy = new \core_backup\copy\copy($formdata);
+        $result = $coursecopy->create_copy();
+
+        // Backup, awaiting.
+        $copies = \core_backup\copy\copy::get_copies($USER->id);
+        $this->assertEquals($result['backupid'], $copies[0]->backupid);
+        $this->assertEquals($result['restoreid'], $copies[0]->restoreid);
+        $this->assertEquals(\backup::STATUS_AWAITING, $copies[0]->status);
+        $this->assertEquals(\backup::OPERATION_BACKUP, $copies[0]->operation);
+
+        $bc = \backup_controller::load_controller($result['backupid']);
+
+        // Backup, in progress.
+        $bc->set_status(\backup::STATUS_EXECUTING);
+        $copies = \core_backup\copy\copy::get_copies($USER->id);
+        $this->assertEquals($result['backupid'], $copies[0]->backupid);
+        $this->assertEquals($result['restoreid'], $copies[0]->restoreid);
+        $this->assertEquals(\backup::STATUS_EXECUTING, $copies[0]->status);
+        $this->assertEquals(\backup::OPERATION_BACKUP, $copies[0]->operation);
+
+        // Restore, ready to process.
+        $bc->set_status(\backup::STATUS_FINISHED_OK);
+        $copies = \core_backup\copy\copy::get_copies($USER->id);
+        $this->assertEquals($result['backupid'], $copies[0]->backupid);
+        $this->assertEquals($result['restoreid'], $copies[0]->restoreid);
+        $this->assertEquals(\backup::STATUS_REQUIRE_CONV, $copies[0]->status);
+        $this->assertEquals(\backup::OPERATION_RESTORE, $copies[0]->operation);
+
+        // No records.
+        $bc->set_status(\backup::STATUS_FINISHED_ERR);
+        $copies = \core_backup\copy\copy::get_copies($USER->id);
+        $this->assertEmpty($copies);
+
+        $coursecopy2 = new \core_backup\copy\copy($formdata2);
+        $result2 = $coursecopy2->create_copy();
+        // Set the second copy to be complete.
+        $bc = \backup_controller::load_controller($result2['backupid']);
+        $bc->set_status(\backup::STATUS_FINISHED_OK);
+        // Set the restore to be finished.
+        $rc = \backup_controller::load_controller($result2['restoreid']);
+        $rc->set_status(\backup::STATUS_FINISHED_OK);
+
+        // No records.
+        $copies = \core_backup\copy\copy::get_copies($USER->id);
+        $this->assertEmpty($copies);
+    }
+
+    /**
+     * Test getting the current copies for specific course.
+     */
+    public function test_get_copies_course() {
+        global $USER;
+
+        // Mock up the form data.
+        $formdata = new \stdClass;
+        $formdata->courseid = $this->course->id;
+        $formdata->fullname = 'foo';
+        $formdata->shortname = 'bar';
+        $formdata->category = 1;
+        $formdata->visible = 1;
+        $formdata->startdate = 1582376400;
+        $formdata->enddate = 0;
+        $formdata->idnumber = '';
+        $formdata->userdata = 1;
+        $formdata->role_1 = 1;
+        $formdata->role_3 = 3;
+        $formdata->role_5 = 5;
+
+        // Create some copies.
+        $coursecopy = new \core_backup\copy\copy($formdata);
+        $coursecopy->create_copy();
+
+        // No copies match this course id.
+        $copies = \core_backup\copy\copy::get_copies($USER->id, ($this->course->id + 1));
+        $this->assertEmpty($copies);
+    }
+
+    /**
+     * Test getting the current copies if course has been deleted.
+     */
+    public function test_get_copies_course_deleted() {
+        global $USER;
+
+        // Mock up the form data.
+        $formdata = new \stdClass;
+        $formdata->courseid = $this->course->id;
+        $formdata->fullname = 'foo';
+        $formdata->shortname = 'bar';
+        $formdata->category = 1;
+        $formdata->visible = 1;
+        $formdata->startdate = 1582376400;
+        $formdata->enddate = 0;
+        $formdata->idnumber = '';
+        $formdata->userdata = 1;
+        $formdata->role_1 = 1;
+        $formdata->role_3 = 3;
+        $formdata->role_5 = 5;
+
+        // Create some copies.
+        $coursecopy = new \core_backup\copy\copy($formdata);
+        $coursecopy->create_copy();
+
+        delete_course($this->course->id, false);
+
+        // No copies match this course id as it has been deleted.
+        $copies = \core_backup\copy\copy::get_copies($USER->id, ($this->course->id));
+        $this->assertEmpty($copies);
+    }
+
+    /*
+     * Test course copy.
+     */
+    public function test_course_copy() {
+        global $DB;
+
+        // Mock up the form data.
+        $formdata = new \stdClass;
+        $formdata->courseid = $this->course->id;
+        $formdata->fullname = 'copy course';
+        $formdata->shortname = 'copy course short';
+        $formdata->category = 1;
+        $formdata->visible = 0;
+        $formdata->startdate = 1582376400;
+        $formdata->enddate = 1582386400;
+        $formdata->idnumber = 123;
+        $formdata->userdata = 1;
+        $formdata->role_1 = 1;
+        $formdata->role_3 = 3;
+        $formdata->role_5 = 5;
+
+        // Create the course copy records and associated ad-hoc task.
+        $coursecopy = new \core_backup\copy\copy($formdata);
+        $copyids = $coursecopy->create_copy();
+
+        $courseid = $this->course->id;
+
+        // We are expecting trace output during this test.
+        $this->expectOutputRegex("/$courseid/");
+
+        // Execute adhoc task.
+        $now = time();
+        $task = \core\task\manager::get_next_adhoc_task($now);
+        $this->assertInstanceOf('\\core\\task\\asynchronous_copy_task', $task);
+        $task->execute();
+        \core\task\manager::adhoc_task_complete($task);
+
+        $postbackuprec = $DB->get_record('backup_controllers', array('backupid' => $copyids['backupid']));
+        $postrestorerec = $DB->get_record('backup_controllers', array('backupid' => $copyids['restoreid']));
+
+        // Check backup was completed successfully.
+        $this->assertEquals(backup::STATUS_FINISHED_OK, $postbackuprec->status);
+        $this->assertEquals(1.0, $postbackuprec->progress);
+
+        // Check restore was completed successfully.
+        $this->assertEquals(backup::STATUS_FINISHED_OK, $postrestorerec->status);
+        $this->assertEquals(1.0, $postrestorerec->progress);
+
+        // Check the restored course itself.
+        $coursecontext = context_course::instance($postrestorerec->itemid);
+        $users = get_enrolled_users($coursecontext);
+
+        $modinfo = get_fast_modinfo($postrestorerec->itemid);
+        $forums = $modinfo->get_instances_of('forum');
+        $forum = reset($forums);
+        $discussions = forum_get_discussions($forum);
+        $course = $modinfo->get_course();
+
+        $this->assertEquals($formdata->startdate, $course->startdate);
+        $this->assertEquals($formdata->enddate, $course->enddate);
+        $this->assertEquals('copy course', $course->fullname);
+        $this->assertEquals('copy course short',  $course->shortname);
+        $this->assertEquals(0,  $course->visible);
+        $this->assertEquals(123,  $course->idnumber);
+
+        foreach ($modinfo->get_cms() as $cm) {
+            $this->assertContains($cm->get_formatted_name(), $this->activitynames);
+        }
+
+        foreach ($this->courseusers as $user) {
+            $this->assertEquals($user, $users[$user]->id);
+        }
+
+        $this->assertEquals(count($this->courseusers), count($users));
+        $this->assertEquals(2, count($discussions));
+    }
+
+    /*
+     * Test course copy, not including any users (or data).
+     */
+    public function test_course_copy_no_users() {
+        global $DB;
+
+        // Mock up the form data.
+        $formdata = new \stdClass;
+        $formdata->courseid = $this->course->id;
+        $formdata->fullname = 'copy course';
+        $formdata->shortname = 'copy course short';
+        $formdata->category = 1;
+        $formdata->visible = 0;
+        $formdata->startdate = 1582376400;
+        $formdata->enddate = 1582386400;
+        $formdata->idnumber = 123;
+        $formdata->userdata = 1;
+        $formdata->role_1 = 0;
+        $formdata->role_3 = 0;
+        $formdata->role_5 = 0;
+
+        // Create the course copy records and associated ad-hoc task.
+        $coursecopy = new \core_backup\copy\copy($formdata);
+        $copyids = $coursecopy->create_copy();
+
+        $courseid = $this->course->id;
+
+        // We are expecting trace output during this test.
+        $this->expectOutputRegex("/$courseid/");
+
+        // Execute adhoc task.
+        $now = time();
+        $task = \core\task\manager::get_next_adhoc_task($now);
+        $this->assertInstanceOf('\\core\\task\\asynchronous_copy_task', $task);
+        $task->execute();
+        \core\task\manager::adhoc_task_complete($task);
+
+        $postrestorerec = $DB->get_record('backup_controllers', array('backupid' => $copyids['restoreid']));
+
+        // Check the restored course itself.
+        $coursecontext = context_course::instance($postrestorerec->itemid);
+        $users = get_enrolled_users($coursecontext);
+
+        $modinfo = get_fast_modinfo($postrestorerec->itemid);
+        $forums = $modinfo->get_instances_of('forum');
+        $forum = reset($forums);
+        $discussions = forum_get_discussions($forum);
+        $course = $modinfo->get_course();
+
+        $this->assertEquals($formdata->startdate, $course->startdate);
+        $this->assertEquals($formdata->enddate, $course->enddate);
+        $this->assertEquals('copy course', $course->fullname);
+        $this->assertEquals('copy course short',  $course->shortname);
+        $this->assertEquals(0,  $course->visible);
+        $this->assertEquals(123,  $course->idnumber);
+
+        foreach ($modinfo->get_cms() as $cm) {
+            $this->assertContains($cm->get_formatted_name(), $this->activitynames);
+        }
+
+        // Should be no discussions as the user that made them wasn't included.
+        $this->assertEquals(0, count($discussions));
+
+        // There should only be one user in the new course, and that's the user who did the copy.
+        $this->assertEquals(1, count($users));
+        $this->assertEquals($this->courseusers[2], $users[$this->courseusers[2]]->id);
+
+    }
+
+    /*
+     * Test course copy, including students and their data.
+     */
+    public function test_course_copy_students_data() {
+        global $DB;
+
+        // Mock up the form data.
+        $formdata = new \stdClass;
+        $formdata->courseid = $this->course->id;
+        $formdata->fullname = 'copy course';
+        $formdata->shortname = 'copy course short';
+        $formdata->category = 1;
+        $formdata->visible = 0;
+        $formdata->startdate = 1582376400;
+        $formdata->enddate = 1582386400;
+        $formdata->idnumber = 123;
+        $formdata->userdata = 1;
+        $formdata->role_1 = 0;
+        $formdata->role_3 = 0;
+        $formdata->role_5 = 5;
+
+        // Create the course copy records and associated ad-hoc task.
+        $coursecopy = new \core_backup\copy\copy($formdata);
+        $copyids = $coursecopy->create_copy();
+
+        $courseid = $this->course->id;
+
+        // We are expecting trace output during this test.
+        $this->expectOutputRegex("/$courseid/");
+
+        // Execute adhoc task.
+        $now = time();
+        $task = \core\task\manager::get_next_adhoc_task($now);
+        $this->assertInstanceOf('\\core\\task\\asynchronous_copy_task', $task);
+        $task->execute();
+        \core\task\manager::adhoc_task_complete($task);
+
+        $postrestorerec = $DB->get_record('backup_controllers', array('backupid' => $copyids['restoreid']));
+
+        // Check the restored course itself.
+        $coursecontext = context_course::instance($postrestorerec->itemid);
+        $users = get_enrolled_users($coursecontext);
+
+        $modinfo = get_fast_modinfo($postrestorerec->itemid);
+        $forums = $modinfo->get_instances_of('forum');
+        $forum = reset($forums);
+        $discussions = forum_get_discussions($forum);
+        $course = $modinfo->get_course();
+
+        $this->assertEquals($formdata->startdate, $course->startdate);
+        $this->assertEquals($formdata->enddate, $course->enddate);
+        $this->assertEquals('copy course', $course->fullname);
+        $this->assertEquals('copy course short',  $course->shortname);
+        $this->assertEquals(0,  $course->visible);
+        $this->assertEquals(123,  $course->idnumber);
+
+        foreach ($modinfo->get_cms() as $cm) {
+            $this->assertContains($cm->get_formatted_name(), $this->activitynames);
+        }
+
+        // Should be no discussions as the user that made them wasn't included.
+        $this->assertEquals(2, count($discussions));
+
+        // There should only be two users in the new course. The copier and one student.
+        $this->assertEquals(2, count($users));
+        $this->assertEquals($this->courseusers[2], $users[$this->courseusers[2]]->id);
+        $this->assertEquals($this->courseusers[0], $users[$this->courseusers[0]]->id);
+    }
+
+    /*
+     * Test course copy, not including any users (or data).
+     */
+    public function test_course_copy_no_data() {
+        global $DB;
+
+        // Mock up the form data.
+        $formdata = new \stdClass;
+        $formdata->courseid = $this->course->id;
+        $formdata->fullname = 'copy course';
+        $formdata->shortname = 'copy course short';
+        $formdata->category = 1;
+        $formdata->visible = 0;
+        $formdata->startdate = 1582376400;
+        $formdata->enddate = 1582386400;
+        $formdata->idnumber = 123;
+        $formdata->userdata = 0;
+        $formdata->role_1 = 1;
+        $formdata->role_3 = 3;
+        $formdata->role_5 = 5;
+
+        // Create the course copy records and associated ad-hoc task.
+        $coursecopy = new \core_backup\copy\copy($formdata);
+        $copyids = $coursecopy->create_copy();
+
+        $courseid = $this->course->id;
+
+        // We are expecting trace output during this test.
+        $this->expectOutputRegex("/$courseid/");
+
+        // Execute adhoc task.
+        $now = time();
+        $task = \core\task\manager::get_next_adhoc_task($now);
+        $this->assertInstanceOf('\\core\\task\\asynchronous_copy_task', $task);
+        $task->execute();
+        \core\task\manager::adhoc_task_complete($task);
+
+        $postrestorerec = $DB->get_record('backup_controllers', array('backupid' => $copyids['restoreid']));
+
+        // Check the restored course itself.
+        $coursecontext = context_course::instance($postrestorerec->itemid);
+        $users = get_enrolled_users($coursecontext);
+
+        get_fast_modinfo($postrestorerec->itemid, 0, true);
+        $modinfo = get_fast_modinfo($postrestorerec->itemid);
+        $forums = $modinfo->get_instances_of('forum');
+        $forum = reset($forums);
+        $discussions = forum_get_discussions($forum);
+        $course = $modinfo->get_course();
+
+        $this->assertEquals($formdata->startdate, $course->startdate);
+        $this->assertEquals($formdata->enddate, $course->enddate);
+        $this->assertEquals('copy course', $course->fullname);
+        $this->assertEquals('copy course short',  $course->shortname);
+        $this->assertEquals(0,  $course->visible);
+        $this->assertEquals(123,  $course->idnumber);
+
+        foreach ($modinfo->get_cms() as $cm) {
+            $this->assertContains($cm->get_formatted_name(), $this->activitynames);
+        }
+
+        // Should be no discussions as the user data wasn't included.
+        $this->assertEquals(0, count($discussions));
+
+        // There should only be all users in the new course.
+        $this->assertEquals(count($this->courseusers), count($users));
+    }
+
+    /*
+     * Test instantiation with incomplete formdata.
+     */
+    public function test_malformed_instantiation() {
+        // Mock up the form data, missing things so we get an exception.
+        $formdata = new \stdClass;
+        $formdata->courseid = $this->course->id;
+        $formdata->fullname = 'copy course';
+        $formdata->shortname = 'copy course short';
+        $formdata->category = 1;
+
+        // Expect and exception as form data is incomplete.
+        $this->expectException(\moodle_exception::class);
+        new \core_backup\copy\copy($formdata);
+    }
+}
\ No newline at end of file
diff --git a/backup/tests/externallib_test.php b/backup/tests/externallib_test.php
new file mode 100644 (file)
index 0000000..8cb6e4d
--- /dev/null
@@ -0,0 +1,182 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Backup webservice tests.
+ *
+ * @package    core_backup
+ * @copyright  2020 onward The Moodle Users Association <https://moodleassociation.org/>
+ * @author     Matt Porritt <mattp@catalyst-au.net>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php');
+require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php');
+require_once($CFG->dirroot . '/webservice/tests/helpers.php');
+require_once($CFG->dirroot . '/backup/externallib.php');
+
+/**
+ * Backup webservice tests.
+ *
+ * @package    core_backup
+ * @copyright  2020 onward The Moodle Users Association <https://moodleassociation.org/>
+ * @author     Matt Porritt <mattp@catalyst-au.net>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class backup_external_testcase extends externallib_advanced_testcase {
+
+    /**
+     * Set up tasks for all tests.
+     */
+    protected function setUp() {
+        global $CFG;
+
+        $this->resetAfterTest(true);
+
+        // Disable all loggers.
+        $CFG->backup_error_log_logger_level = backup::LOG_NONE;
+        $CFG->backup_output_indented_logger_level = backup::LOG_NONE;
+        $CFG->backup_file_logger_level = backup::LOG_NONE;
+        $CFG->backup_database_logger_level = backup::LOG_NONE;
+        $CFG->backup_file_logger_level_extra = backup::LOG_NONE;
+    }
+
+    /**
+     * Test getting course copy progress.
+     */
+    public function test_get_copy_progress() {
+        global $USER;
+
+        $this->setAdminUser();
+
+        // Create a course with some availability data set.
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course();
+        $courseid = $course->id;
+
+        // Mock up the form data for use in tests.
+        $formdata = new \stdClass;
+        $formdata->courseid = $courseid;
+        $formdata->fullname = 'foo';
+        $formdata->shortname = 'bar';
+        $formdata->category = 1;
+        $formdata->visible = 1;
+        $formdata->startdate = 1582376400;
+        $formdata->enddate = 0;
+        $formdata->idnumber = 123;
+        $formdata->userdata = 1;
+        $formdata->role_1 = 1;
+        $formdata->role_3 = 3;
+        $formdata->role_5 = 5;
+
+        $coursecopy = new \core_backup\copy\copy($formdata);
+        $copydetails = $coursecopy->create_copy();
+        $copydetails['operation'] = \backup::OPERATION_BACKUP;
+
+        $params = array('copies' => $copydetails);
+        $returnvalue = core_backup_external::get_copy_progress($params);
+
+        // We need to execute the return values cleaning process to simulate the web service server.
+        $returnvalue = external_api::clean_returnvalue(core_backup_external::get_copy_progress_returns(), $returnvalue);
+
+        $this->assertEquals(\backup::STATUS_AWAITING, $returnvalue[0]['status']);
+        $this->assertEquals(0, $returnvalue[0]['progress']);
+        $this->assertEquals($copydetails['backupid'], $returnvalue[0]['backupid']);
+        $this->assertEquals(\backup::OPERATION_BACKUP, $returnvalue[0]['operation']);
+
+        // We are expecting trace output during this test.
+        $this->expectOutputRegex("/$courseid/");
+
+        // Execute adhoc task and create the copy.
+        $now = time();
+        $task = \core\task\manager::get_next_adhoc_task($now);
+        $this->assertInstanceOf('\\core\\task\\asynchronous_copy_task', $task);
+        $task->execute();
+        \core\task\manager::adhoc_task_complete($task);
+
+        // Check the copy progress now.
+        $params = array('copies' => $copydetails);
+        $returnvalue = core_backup_external::get_copy_progress($params);
+
+        $returnvalue = external_api::clean_returnvalue(core_backup_external::get_copy_progress_returns(), $returnvalue);
+
+        $this->assertEquals(\backup::STATUS_FINISHED_OK, $returnvalue[0]['status']);
+        $this->assertEquals(1, $returnvalue[0]['progress']);
+        $this->assertEquals($copydetails['restoreid'], $returnvalue[0]['backupid']);
+        $this->assertEquals(\backup::OPERATION_RESTORE, $returnvalue[0]['operation']);
+
+    }
+
+    /**
+     * Test ajax submission of course copy process.
+     */
+    public function test_submit_copy_form() {
+        global $DB;
+
+        $this->setAdminUser();
+
+        // Create a course with some availability data set.
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course();
+        $courseid = $course->id;
+
+        // Moodle form requires this for validation.
+        $sesskey = sesskey();
+        $_POST['sesskey'] = $sesskey;
+
+        // Mock up the form data for use in tests.
+        $formdata = new \stdClass;
+        $formdata->courseid = $courseid;
+        $formdata->returnto = '';
+        $formdata->returnurl = '';
+        $formdata->sesskey = $sesskey;
+        $formdata->_qf__core_backup_output_copy_form = 1;
+        $formdata->fullname = 'foo';
+        $formdata->shortname = 'bar';
+        $formdata->category = 1;
+        $formdata->visible = 1;
+        $formdata->startdate = array('day' => 5, 'month' => 5, 'year' => 2020, 'hour' => 0, 'minute' => 0);
+        $formdata->idnumber = 123;
+        $formdata->userdata = 1;
+        $formdata->role_1 = 1;
+        $formdata->role_3 = 3;
+        $formdata->role_5 = 5;
+
+        $urlform = http_build_query($formdata, '', '&'); // Take the form data and url encode it.
+        $jsonformdata = json_encode($urlform); // Take form string and JSON encode.
+
+        $returnvalue = core_backup_external::submit_copy_form($jsonformdata);
+
+        $returnjson = external_api::clean_returnvalue(core_backup_external::submit_copy_form_returns(), $returnvalue);
+        $copyids = json_decode($returnjson, true);
+
+        $backuprec = $DB->get_record('backup_controllers', array('backupid' => $copyids['backupid']));
+        $restorerec = $DB->get_record('backup_controllers', array('backupid' => $copyids['restoreid']));
+
+        // Check backup was completed successfully.
+        $this->assertEquals(backup::STATUS_AWAITING, $backuprec->status);
+        $this->assertEquals(0, $backuprec->progress);
+        $this->assertEquals('backup', $backuprec->operation);
+
+        // Check restore was completed successfully.
+        $this->assertEquals(backup::STATUS_REQUIRE_CONV, $restorerec->status);
+        $this->assertEquals(0, $restorerec->progress);
+        $this->assertEquals('restore', $restorerec->operation);
+    }
+}
\ No newline at end of file
index 309c478..b57c4b5 100644 (file)
@@ -64,7 +64,7 @@ class async_helper  {
     public function __construct($type, $id) {
         $this->type = $type;
         $this->backupid = $id;
-        $this->backuprec = $this->get_backup_record($id);
+        $this->backuprec = self::get_backup_record($id);
         $this->user = $this->get_user();
     }
 
@@ -76,7 +76,7 @@ class async_helper  {
      * @param int $id The backup id to get.
      * @return object $backuprec The backup controller record.
      */
-    private function get_backup_record($id) {
+    static public function get_backup_record($id) {
         global $DB;
 
         $backuprec = $DB->get_record('backup_controllers', array('backupid' => $id), '*', MUST_EXIST);
@@ -215,18 +215,21 @@ class async_helper  {
         require_once($CFG->dirroot . '/backup/util/interfaces/checksumable.class.php');
         require_once($CFG->dirroot . '/backup/backup.class.php');
 
-        if (self::is_async_enabled()) {
-            $select = 'userid = ? AND itemid = ? AND type = ? AND operation = ? AND execution = ? AND status < ? AND status > ?';
-            $params = array(
-                $USER->id,
-                $id,
-                $type,
-                $operation,
-                backup::EXECUTION_DELAYED,
-                backup::STATUS_FINISHED_ERR,
-                backup::STATUS_NEED_PRECHECK
-            );
-            $asyncpending = $DB->record_exists_select('backup_controllers', $select, $params);
+        $select = 'userid = ? AND itemid = ? AND type = ? AND operation = ? AND execution = ? AND status < ? AND status > ?';
+        $params = array(
+            $USER->id,
+            $id,
+            $type,
+            $operation,
+            backup::EXECUTION_DELAYED,
+            backup::STATUS_FINISHED_ERR,
+            backup::STATUS_NEED_PRECHECK
+        );
+
+        $asyncrecord= $DB->get_record_select('backup_controllers', $select, $params);
+
+        if ((self::is_async_enabled() && $asyncrecord) || ($asyncrecord && $asyncrecord->purpose == backup::MODE_COPY)) {
+            $asyncpending = true;
         }
         return $asyncpending;
     }
index 70b50a2..fffcd16 100644 (file)
@@ -145,4 +145,91 @@ class core_backup_async_helper_testcase extends \core_privacy\tests\provider_tes
         $this->assertEquals(1, count($result));
         $this->assertEquals('backup.mbz', $result[0][0]);
     }
+
+    /**
+     * Tests getting the backup record.
+     */
+    public function test_get_backup_record() {
+        global $USER;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course();
+
+        // Create the initial backupcontoller.
+        $bc = new \backup_controller(\backup::TYPE_1COURSE, $course->id, \backup::FORMAT_MOODLE,
+            \backup::INTERACTIVE_NO, \backup::MODE_COPY, $USER->id, \backup::RELEASESESSION_YES);
+        $backupid = $bc->get_backupid();
+        $bc->destroy();
+        $copyrec = \async_helper::get_backup_record($backupid);
+
+        $this->assertEquals($backupid, $copyrec->backupid);
+
+    }
+
+    /**
+     * Tests is async pending conditions.
+     */
+    public function test_is_async_pending() {
+        global $USER;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course();
+
+        set_config('enableasyncbackup', '0');
+        $ispending = async_helper::is_async_pending($course->id, 'course', 'backup');
+
+        // Should be false as there are no backups and async backup is false.
+        $this->assertFalse($ispending);
+
+        // Create the initial backupcontoller.
+        $bc = new \backup_controller(\backup::TYPE_1COURSE, $course->id, \backup::FORMAT_MOODLE,
+            \backup::INTERACTIVE_NO, \backup::MODE_ASYNC, $USER->id, \backup::RELEASESESSION_YES);
+        $bc->destroy();
+        $ispending = async_helper::is_async_pending($course->id, 'course', 'backup');
+
+        // Should be false as there as async backup is false.
+        $this->assertFalse($ispending);
+
+        set_config('enableasyncbackup', '1');
+        // Should be true as there as async backup is true and there is a pending backup.
+        $this->assertFalse($ispending);
+    }
+
+    /**
+     * Tests is async pending conditions for course copies.
+     */
+    public function test_is_async_pending_copy() {
+        global $USER;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course();
+
+        set_config('enableasyncbackup', '0');
+        $ispending = async_helper::is_async_pending($course->id, 'course', 'backup');
+
+        // Should be false as there are no copies and async backup is false.
+        $this->assertFalse($ispending);
+
+        // Create the initial backupcontoller.
+        $bc = new \backup_controller(\backup::TYPE_1COURSE, $course->id, \backup::FORMAT_MOODLE,
+            \backup::INTERACTIVE_NO, \backup::MODE_COPY, $USER->id, \backup::RELEASESESSION_YES);
+        $bc->destroy();
+        $ispending = async_helper::is_async_pending($course->id, 'course', 'backup');
+
+        // Should be True as this a copy operation.
+        $this->assertTrue($ispending);
+
+        set_config('enableasyncbackup', '1');
+        $ispending = async_helper::is_async_pending($course->id, 'course', 'backup');
+
+        // Should be true as there as async backup is true and there is a pending copy.
+        $this->assertTrue($ispending);
+    }
+
 }
index 0080a0e..9df1964 100644 (file)
@@ -97,6 +97,7 @@ require_once($CFG->dirroot . '/backup/util/ui/backup_moodleform.class.php');
 require_once($CFG->dirroot . '/backup/util/ui/backup_ui.class.php');
 require_once($CFG->dirroot . '/backup/util/ui/backup_ui_stage.class.php');
 require_once($CFG->dirroot . '/backup/util/ui/backup_ui_setting.class.php');
+require_once($CFG->dirroot . '/backup/util/ui/classes/copy/copy.php');
 
 // And some moodle stuff too
 require_once($CFG->dirroot.'/course/lib.php');
index e3db201..a3dc2b0 100644 (file)
@@ -33,6 +33,12 @@ class backup_plan extends base_plan implements loggable {
     protected $basepath;   // Fullpath to dir where backup is created
     protected $excludingdactivities;
 
+    /**
+     * The role ids to keep in a copy operation.
+     * @var array
+     */
+    protected $keptroles = array();
+
     /**
      * Constructor - instantiates one object of this class
      */
@@ -104,6 +110,26 @@ class backup_plan extends base_plan implements loggable {
         $this->excludingdactivities = true;
     }
 
+    /**
+     * Sets the user roles that should be kept in the destination course
+     * for a course copy operation.
+     *
+     * @param array $roleids
+     */
+    public function set_kept_roles(array $roleids): void {
+        $this->keptroles = $roleids;
+    }
+
+    /**
+     * Get the user roles that should be kept in the destination course
+     * for a course copy operation.
+     *
+     * @return array
+     */
+    public function get_kept_roles(): array {
+        return $this->keptroles;
+    }
+
     public function log($message, $level, $a = null, $depth = null, $display = false) {
         backup_helper::log($message, $level, $a, $depth, $display, $this->get_logger());
     }
index d6313a1..818b315 100644 (file)
@@ -46,6 +46,16 @@ abstract class backup_task extends base_task {
     public function is_excluding_activities() {
         return $this->plan->is_excluding_activities();
     }
+
+    /**
+     * Get the user roles that should be kept in the destination course
+     * for a course copy operation.
+     *
+     * @return array
+     */
+    public function get_kept_roles(): array {
+        return $this->plan->get_kept_roles();
+    }
 }
 
 /*
index 202c88c..15d852a 100644 (file)
Binary files a/backup/util/ui/amd/build/async_backup.min.js and b/backup/util/ui/amd/build/async_backup.min.js differ
index 2dd8491..42387a1 100644 (file)
Binary files a/backup/util/ui/amd/build/async_backup.min.js.map and b/backup/util/ui/amd/build/async_backup.min.js.map differ
index b44e025..59a4bc9 100644 (file)
@@ -48,23 +48,25 @@ define(['jquery', 'core/ajax', 'core/str', 'core/notification', 'core/templates'
     var typeid; //  The type of operation backup or restore.
     var backupintervalid; //  The id of the setInterval function.
     var allbackupintervalid; //  The id of the setInterval function.
+    var allcopyintervalid; //  The id of the setInterval function.
     var timeout = 2000; // Timeout for ajax requests.
 
     /**
      * Helper function to update UI components.
      *
      * @param {string} backupid The id to match elements on.
+     * @param {string} type The type of operation, backup or restore.
      * @param {number} percentage The completion percentage to apply.
      */
-    function updateElement(backupid, percentage) {
+    function updateElement(backupid, type, percentage) {
         var percentagewidth = Math.round(percentage) + '%';
-        var elementbar = $('#' + backupid + '_bar');
+        var elementbar = document.querySelectorAll("[data-" + type + "id=" + CSS.escape(backupid) + "]")[0];
         var percentagetext = percentage.toFixed(2) + '%';
 
         // Set progress bar percentage indicators
-        elementbar.attr('aria-valuenow', percentagewidth);
-        elementbar.css('width', percentagewidth);
-        elementbar.text(percentagetext);
+        elementbar.setAttribute('aria-valuenow', percentagewidth);
+        elementbar.style.width = percentagewidth;
+        elementbar.innerHTML = percentagetext;
     }
 
     /**
@@ -160,6 +162,56 @@ define(['jquery', 'core/ajax', 'core/str', 'core/notification', 'core/templates'
         });
     }
 
+    /**
+     * Update copy table row when an course copy completes.
+     *
+     * @param {string} backupid The id to match elements on.
+     */
+    function updateCopyTableRow(backupid) {
+        var elementbar = document.querySelectorAll("[data-restoreid=" + CSS.escape(backupid) + "]")[0];
+        var restorecourse = elementbar.closest('tr').children[1];
+        var coursename = restorecourse.innerHTML;
+        var courselink = document.createElement('a');
+        var elementbarparent = elementbar.closest('td');
+        var operation = elementbarparent.previousElementSibling;
+
+        // Replace the prgress bar.
+        Str.get_string('complete').then(function(content) {
+            operation.innerHTML = content;
+            return;
+        }).catch(function() {
+            notification.exception(new Error('Failed to load string: complete'));
+            return;
+        });
+
+        Templates.render('core/async_copy_complete_cell', {}).then(function(html, js) {
+            Templates.replaceNodeContents(elementbarparent, html, js);
+            return;
+        }).fail(function() {
+            notification.exception(new Error('Failed to load table cell'));
+            return;
+        });
+
+        // Update the destination course name to a link to that course.
+        ajax.call([{
+            methodname: 'core_backup_get_async_backup_links_restore',
+            args: {
+                'backupid': backupid,
+                'contextid': 0
+            },
+        }])[0].done(function(response) {
+            courselink.setAttribute('href', response.restoreurl);
+            courselink.innerHTML = coursename;
+            restorecourse.innerHTML = null;
+            restorecourse.appendChild(courselink);
+
+            return;
+        }).fail(function() {
+            notification.exception(new Error('Failed to update table row'));
+            return;
+        });
+    }
+
     /**
      * Update the Moodle user interface with the progress of
      * the backup process.
@@ -168,7 +220,8 @@ define(['jquery', 'core/ajax', 'core/str', 'core/notification', 'core/templates'
      */
     function updateProgress(progress) {
         var percentage = progress.progress * 100;
-        var elementbar = $('#' + backupid + '_bar');
+        var type = 'backup';
+        var elementbar = document.querySelectorAll("[data-" + type + "id=" + CSS.escape(backupid) + "]")[0];
         var elementstatus = $('#' + backupid + '_status');
         var elementdetail = $('#' + backupid + '_detail');
         var elementbutton = $('#' + backupid + '_button');
@@ -176,16 +229,16 @@ define(['jquery', 'core/ajax', 'core/str', 'core/notification', 'core/templates'
 
         if (progress.status == STATUS_EXECUTING) {
             // Process is in progress.
-            // Add in progress class color to bar
-            elementbar.addClass('bg-success');
+            // Add in progress class color to bar.
+            elementbar.classList.add('bg-success');
 
-            updateElement(backupid, percentage);
+            updateElement(backupid, type, percentage);
 
-            // Change heading
+            // Change heading.
             var strProcessing = 'async' + typeid + 'processing';
             Str.get_string(strProcessing, 'backup').then(function(title) {
                 elementstatus.text(title);
-                return title;
+                return;
             }).catch(function() {
                 notification.exception(new Error('Failed to load string: backup ' + strProcessing));
             });
@@ -193,15 +246,15 @@ define(['jquery', 'core/ajax', 'core/str', 'core/notification', 'core/templates'
         } else if (progress.status == STATUS_FINISHED_ERR) {
             // Process completed with error.
 
-            // Add in fail class color to bar
-            elementbar.addClass('bg-danger');
+            // Add in fail class color to bar.
+            elementbar.classList.add('bg-danger');
 
-            // Remove in progress class color to bar
-            elementbar.removeClass('bg-success');
+            // Remove in progress class color to bar.
+            elementbar.classList.remove('bg-success');
 
-            updateElement(backupid, 100);
+            updateElement(backupid, type, 100);
 
-            // Change heading and text
+            // Change heading and text.
             var strStatus = 'async' + typeid + 'error';
             var strStatusDetail = 'async' + typeid + 'errordetail';
             stringRequests = [
@@ -212,7 +265,7 @@ define(['jquery', 'core/ajax', 'core/str', 'core/notification', 'core/templates'
                 elementstatus.text(strings[0]);
                 elementdetail.text(strings[1]);
 
-                return strings;
+                return;
             })
             .catch(function() {
                 notification.exception(new Error('Failed to load string'));
@@ -229,15 +282,15 @@ define(['jquery', 'core/ajax', 'core/str', 'core/notification', 'core/templates'
             // Process completed successfully.
 
             // Add in progress class color to bar
-            elementbar.addClass('bg-success');
+            elementbar.classList.add('bg-success');
 
-            updateElement(backupid, 100);
+            updateElement(backupid, type, 100);
 
             // Change heading and text
             var strComplete = 'async' + typeid + 'complete';
             Str.get_string(strComplete, 'backup').then(function(title) {
                 elementstatus.text(title);
-                return title;
+                return;
             }).catch(function() {
                 notification.exception(new Error('Failed to load string: backup ' + strComplete));
             });
@@ -262,7 +315,7 @@ define(['jquery', 'core/ajax', 'core/str', 'core/notification', 'core/templates'
                         elementbutton.text(strings[1]);
                         elementbutton.attr('href', response.restoreurl);
 
-                        return strings;
+                        return;
                     })
                     .catch(function() {
                         notification.exception(new Error('Failed to load string'));
@@ -282,7 +335,7 @@ define(['jquery', 'core/ajax', 'core/str', 'core/notification', 'core/templates'
                     elementbutton.text(strings[1]);
                     elementbutton.attr('href', restoreurl);
 
-                    return strings;
+                    return;
                 })
                 .catch(function() {
                     notification.exception(new Error('Failed to load string'));
@@ -301,7 +354,7 @@ define(['jquery', 'core/ajax', 'core/str', 'core/notification', 'core/templates'
 
     /**
      * Update the Moodle user interface with the progress of
-     * all the pending processes.
+     * all the pending processes for backup and restore operations.
      *
      * @param {object} progress The progress and status of the process.
      */
@@ -309,37 +362,37 @@ define(['jquery', 'core/ajax', 'core/str', 'core/notification', 'core/templates'
         progress.forEach(function(element) {
             var percentage = element.progress * 100;
             var backupid = element.backupid;
-            var elementbar = $('#' + backupid + '_bar');
             var type = element.operation;
+            var elementbar = document.querySelectorAll("[data-" + type + "id=" + CSS.escape(backupid) + "]")[0];
 
             if (element.status == STATUS_EXECUTING) {
                 // Process is in element.
 
                 // Add in element class color to bar
-                elementbar.addClass('bg-success');
+                elementbar.classList.add('bg-success');
 
-                updateElement(backupid, percentage);
+                updateElement(backupid, type, percentage);
 
             } else if (element.status == STATUS_FINISHED_ERR) {
                 // Process completed with error.
 
                 // Add in fail class color to bar
-                elementbar.addClass('bg-danger');
-                elementbar.addClass('complete');
+                elementbar.classList.add('bg-danger');
+                elementbar.classList.add('complete');
 
                 // Remove in element class color to bar
-                $('#' + backupid + '_bar').removeClass('bg-success');
+                elementbar.classList.remove('bg-success');
 
-                updateElement(backupid, 100);
+                updateElement(backupid, type, 100);
 
             } else if (element.status == STATUS_FINISHED_OK) {
                 // Process completed successfully.
 
                 // Add in element class color to bar
-                elementbar.addClass('bg-success');
-                elementbar.addClass('complete');
+                elementbar.classList.add('bg-success');
+                elementbar.classList.add('complete');
 
-                updateElement(backupid, 100);
+                updateElement(backupid, type, 100);
 
                 // We have a successful backup. Update the UI with download and file details.
                 if (type == 'backup') {
@@ -353,6 +406,65 @@ define(['jquery', 'core/ajax', 'core/str', 'core/notification', 'core/templates'
         });
     }
 
+    /**
+     * Update the Moodle user interface with the progress of
+     * all the pending processes for copy operations.
+     *
+     * @param {object} progress The progress and status of the process.
+     */
+    function updateProgressCopy(progress) {
+        progress.forEach(function(element) {
+            var percentage = element.progress * 100;
+            var backupid = element.backupid;
+            var type = element.operation;
+            var elementbar = document.querySelectorAll("[data-" + type + "id=" + CSS.escape(backupid) + "]")[0];
+
+            if (type == 'restore') {
+                 let restorecell = elementbar.closest('tr').children[3];
+                 Str.get_string('restore').then(function(content) {
+                     restorecell.innerHTML = content;
+                     return;
+                 }).catch(function() {
+                     notification.exception(new Error('Failed to load string: restore'));
+                 });
+            }
+
+            if (element.status == STATUS_EXECUTING) {
+                // Process is in element.
+
+                // Add in element class color to bar
+                elementbar.classList.add('bg-success');
+
+                updateElement(backupid, type, percentage);
+
+            } else if (element.status == STATUS_FINISHED_ERR) {
+                // Process completed with error.
+
+                // Add in fail class color to bar
+                elementbar.classList.add('bg-danger');
+                elementbar.classList.add('complete');
+
+                // Remove in element class color to bar
+                elementbar.classList.remove('bg-success');
+
+                updateElement(backupid, type, 100);
+
+            } else if ((element.status == STATUS_FINISHED_OK) && (type == 'restore')) {
+                // Process completed successfully.
+
+                // Add in element class color to bar
+                elementbar.classList.add('bg-success');
+                elementbar.classList.add('complete');
+
+                updateElement(backupid, type, 100);
+
+                // We have a successful copy. Update the UI link to copied course.
+                updateCopyTableRow(backupid);
+            }
+
+        });
+    }
+
     /**
      * Get the progress of the backup process via ajax.
      */
@@ -407,6 +519,42 @@ define(['jquery', 'core/ajax', 'core/str', 'core/notification', 'core/templates'
         }
     }
 
+    /**
+     * Get the progress of all copy processes via ajax.
+     */
+    function getAllCopyProgress() {
+        var copyids = [];
+        var progressbars = $('.progress').find('.progress-bar').not('.complete');
+
+        progressbars.each(function() {
+            let progressvars = {
+                    'backupid': this.dataset.backupid,
+                    'restoreid': this.dataset.restoreid,
+                    'operation': this.dataset.operation,
+            };
+            copyids.push(progressvars);
+        });
+
+        if (copyids.length > 0) {
+            ajax.call([{
+                // Get the copy progress via webservice.
+                methodname: 'core_backup_get_copy_progress',
+                args: {
+                    'copies': copyids
+                },
+            }], true, true, false, timeout)[0].done(function(response) {
+                updateProgressCopy(response);
+                checkdelay = checkdelayoriginal;
+                allcopyintervalid = updateInterval(allcopyintervalid, getAllCopyProgress, checkdelayoriginal);
+            }).fail(function() {
+                checkdelay = checkdelay * checkdelaymultipler;
+                allcopyintervalid = updateInterval(allcopyintervalid, getAllCopyProgress, checkdelay);
+            });
+        } else {
+            clearInterval(allcopyintervalid); // No more progress bars to update, stop checking.
+        }
+    }
+
     /**
      * Get status updates for all backups.
      *
@@ -418,6 +566,15 @@ define(['jquery', 'core/ajax', 'core/str', 'core/notification', 'core/templates'
         allbackupintervalid = setInterval(getAllBackupProgress, checkdelay);
     };
 
+    /**
+     * Get status updates for all course copies.
+     *
+     * @public
+     */
+    Asyncbackup.asyncCopyAllStatus = function() {
+        allcopyintervalid = setInterval(getAllCopyProgress, checkdelay);
+    };
+
     /**
      * Get status updates for backup.
      *
diff --git a/backup/util/ui/classes/copy/copy.php b/backup/util/ui/classes/copy/copy.php
new file mode 100644 (file)
index 0000000..3e2d269
--- /dev/null
@@ -0,0 +1,309 @@
+<?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/>.
+
+/**
+ * Course copy class.
+ *
+ * Handles procesing data submitted by UI copy form
+ * and sets up the course copy process.
+ *
+ * @package    core_backup
+ * @copyright  2020 onward The Moodle Users Association <https://moodleassociation.org/>
+ * @author     Matt Porritt <mattp@catalyst-au.net>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_backup\copy;
+
+defined('MOODLE_INTERNAL') || die;
+
+global $CFG;
+require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php');
+require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php');
+
+/**
+ * Course copy class.
+ *
+ * Handles procesing data submitted by UI copy form
+ * and sets up the course copy process.
+ *
+ * @package    core_backup
+ * @copyright  2020 onward The Moodle Users Association <https://moodleassociation.org/>
+ * @author     Matt Porritt <mattp@catalyst-au.net>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class copy  {
+
+    /**
+     * The fields required for copy operations.
+     *
+     * @var array
+     */
+    private $copyfields = array(
+        'courseid',  // Course id integer.
+        'fullname', // Fullname of the destination course.
+        'shortname', // Shortname of the destination course.
+        'category', // Category integer ID that contains the destination course.
+        'visible', // Integer to detrmine of the copied course will be visible.
+        'startdate', // Integer timestamp of the start of the destination course.
+        'enddate', // Integer timestamp of the end of the destination course.
+        'idnumber', // ID of the destination course.
+        'userdata', // Integer to determine if the copied course will contain user data.
+    );
+
+    /**
+     * Data required for course copy operations.
+     *
+     * @var array
+     */
+    private $copydata = array();
+
+    /**
+     * List of role ids to keep enrolments for in the destination course.
+     *
+     * @var array
+     */
+    private $roles = array();
+
+    /**
+     * Constructor for the class.
+     *
+     * @param \stdClass $formdata Data from the validated course copy form.
+     */
+    public function __construct(\stdClass $formdata) {
+        $this->copydata = $this->get_copy_data($formdata);
+        $this->roles = $this->get_enrollment_roles($formdata);
+    }
+
+    /**
+     * Extract the enrolment roles to keep in the copied course
+     * from the raw submitted form data.
+     *
+     * @param \stdClass $formdata Data from the validated course copy form.
+     * @return array $keptroles The roles to keep.
+     */
+    private final function get_enrollment_roles(\stdClass $formdata): array {
+        $keptroles = array();
+
+        foreach ($formdata as $key => $value) {
+            if ((substr($key, 0, 5 ) === 'role_') && ($value != 0)) {
+                $keptroles[] = $value;
+            }
+        }
+
+        return $keptroles;
+    }
+
+    /**
+     *  Take the validated form data and extract the required information for copy operations.
+     *
+     * @param \stdClass $formdata Data from the validated course copy form.
+     * @throws \moodle_exception
+     * @return \stdClass $copydata Data required for course copy operations.
+     */
+    private final function get_copy_data(\stdClass $formdata): \stdClass {
+        $copydata = new \stdClass();
+
+        foreach ($this->copyfields as $field) {
+            if (isset($formdata->{$field})) {
+                $copydata->{$field} = $formdata->{$field};
+            } else {
+                throw new \moodle_exception('copy_class_field_not_found');
+            }
+        }
+
+        return $copydata;
+    }
+
+    /**
+     * Creates a course copy.
+     * Sets up relevant controllers and adhoc task.
+     *
+     * @return array $copyids THe backup and restore controller ids.
+     */
+    public function create_copy(): array {
+        global $USER;
+        $copyids = array();
+
+        // Create the initial backupcontoller.
+        $bc = new \backup_controller(\backup::TYPE_1COURSE, $this->copydata->courseid, \backup::FORMAT_MOODLE,
+            \backup::INTERACTIVE_NO, \backup::MODE_COPY, $USER->id, \backup::RELEASESESSION_YES);
+        $copyids['backupid'] = $bc->get_backupid();
+
+        // Create the initial restore contoller.
+        list($fullname, $shortname) = \restore_dbops::calculate_course_names(
+            0, get_string('copyingcourse', 'backup'), get_string('copyingcourseshortname', 'backup'));
+        $newcourseid = \restore_dbops::create_new_course($fullname, $shortname, $this->copydata->category);
+        $rc = new \restore_controller($copyids['backupid'], $newcourseid,
+            \backup::INTERACTIVE_NO, \backup::MODE_COPY, $USER->id,
+            \backup::TARGET_NEW_COURSE);
+        $copyids['restoreid'] = $rc->get_restoreid();
+
+        // Configure the controllers based on the submitted data.
+        $copydata = $this->copydata;
+        $copydata->copyids = $copyids;
+        $copydata->keptroles = $this->roles;
+        $bc->set_copy($copydata);
+        $bc->set_status(\backup::STATUS_AWAITING);
+        $bc->get_status();
+
+        $rc->set_copy($copydata);
+        $rc->save_controller();
+
+        // Create the ad-hoc task to perform the course copy.
+        $asynctask = new \core\task\asynchronous_copy_task();
+        $asynctask->set_blocking(false);
+        $asynctask->set_custom_data($copyids);
+        \core\task\manager::queue_adhoc_task($asynctask);
+
+        // Clean up the controller.
+        $bc->destroy();
+
+        return $copyids;
+    }
+
+    /**
+     * Filters an array of copy records by course ID.
+     *
+     * @param array $copyrecords
+     * @param int $courseid
+     * @return array $copies Filtered array of records.
+     */
+    static private function filter_copies_course(array $copyrecords, int $courseid): array {
+        $copies = array();
+
+        foreach ($copyrecords as $copyrecord) {
+            if ($copyrecord->operation == \backup::OPERATION_RESTORE) { // Restore records.
+                if ($copyrecord->status == \backup::STATUS_FINISHED_OK
+                    || $copyrecord->status == \backup::STATUS_FINISHED_ERR) {
+                        continue;
+                } else {
+                    $rc = \restore_controller::load_controller($copyrecord->restoreid);
+                    if ($rc->get_copy()->courseid == $courseid) {
+                        $copies[] = $copyrecord;
+                    }
+                }
+            } else { // Backup records.
+                if ($copyrecord->itemid == $courseid) {
+                    $copies[] = $copyrecord;
+                }
+            }
+        }
+        return $copies;
+    }
+
+    /**
+     * Get the in progress course copy operations for a user.
+     *
+     * @param int $userid User id to get the course copies for.
+     * @param int $courseid The optional source course id to get copies for.
+     * @return array $copies Details of the inprogress copies.
+     */
+    static public function get_copies(int $userid, int $courseid=0): array {
+        global $DB;
+        $copies = array();
+        $params = array($userid, \backup::EXECUTION_DELAYED, \backup::MODE_COPY);
+        $sql = 'SELECT bc.backupid, bc.itemid, bc.operation, bc.status, bc.timecreated
+                  FROM {backup_controllers} bc
+            INNER JOIN {course} c ON bc.itemid = c.id
+                 WHERE bc.userid = ?
+                       AND bc.execution = ?
+                       AND bc.purpose = ?
+              ORDER BY bc.timecreated DESC';
+
+        $copyrecords = $DB->get_records_sql($sql, $params);
+
+        foreach ($copyrecords as $copyrecord) {
+            $copy = new \stdClass();
+            $copy->itemid = $copyrecord->itemid;
+            $copy->time = $copyrecord->timecreated;
+            $copy->operation = $copyrecord->operation;
+            $copy->status = $copyrecord->status;
+            $copy->backupid = null;
+            $copy->restoreid = null;
+
+            if ($copyrecord->operation == \backup::OPERATION_RESTORE) {
+                $copy->restoreid = $copyrecord->backupid;
+                // If record is complete or complete with errors, it means the backup also completed.
+                // It also means there are no controllers. In this case just skip and move on.
+                if ($copyrecord->status == \backup::STATUS_FINISHED_OK
+                    || $copyrecord->status == \backup::STATUS_FINISHED_ERR) {
+                        continue;
+                } else if ($copyrecord->status > \backup::STATUS_REQUIRE_CONV) {
+                    // If record is a restore and it's in progress (>200), it means the backup is finished.
+                    // In this case return the restore.
+                    $rc = \restore_controller::load_controller($copyrecord->backupid);
+                    $course = get_course($rc->get_copy()->courseid);
+
+                    $copy->source = $course->shortname;
+                    $copy->sourceid = $course->id;
+                    $copy->destination = $rc->get_copy()->shortname;
+                    $copy->backupid = $rc->get_copy()->copyids['backupid'];
+                    $rc->destroy();
+
+                } else if ($copyrecord->status == \backup::STATUS_REQUIRE_CONV) {
+                    // If record is a restore and it is waiting (=200), load the controller
+                    // and check the status of the backup.
+                    // If the backup has finished successfully we have and edge case. Process as per in progress restore.
+                    // If the backup has any other code it will be handled by backup processing.
+                    $rc = \restore_controller::load_controller($copyrecord->backupid);
+                    $bcid = $rc->get_copy()->copyids['backupid'];
+                    if (empty($copyrecords[$bcid])) {
+                        continue;
+                    }
+                    $backuprecord = $copyrecords[$bcid];
+                    $backupstatus = $backuprecord->status;
+                    if ($backupstatus == \backup::STATUS_FINISHED_OK) {
+                        $course = get_course($rc->get_copy()->courseid);
+
+                        $copy->source = $course->shortname;
+                        $copy->sourceid = $course->id;
+                        $copy->destination = $rc->get_copy()->shortname;
+                        $copy->backupid = $rc->get_copy()->copyids['backupid'];
+                    } else {
+                        continue;
+                    }
+                }
+            } else { // Record is a backup.
+                $copy->backupid = $copyrecord->backupid;
+                if ($copyrecord->status == \backup::STATUS_FINISHED_OK
+                    || $copyrecord->status == \backup::STATUS_FINISHED_ERR) {
+                        // If successfully finished then skip it. Restore procesing will look after it.
+                        // If it has errored then we can't go any further.
+                        continue;
+                } else {
+                    // If is in progress then process it.
+                    $bc = \backup_controller::load_controller($copyrecord->backupid);
+                    $course = get_course($bc->get_courseid());
+
+                    $copy->source = $course->shortname;
+                    $copy->sourceid = $course->id;
+                    $copy->destination = $bc->get_copy()->shortname;
+                    $copy->restoreid = $bc->get_copy()->copyids['restoreid'];
+                }
+            }
+
+            $copies[] = $copy;
+        }
+
+        // Extra processing to filter records for a given course.
+        if ($courseid != 0 ) {
+            $copies = self::filter_copies_course($copies, $courseid);
+        }
+
+        return $copies;
+    }
+}
diff --git a/backup/util/ui/classes/output/copy_form.php b/backup/util/ui/classes/output/copy_form.php
new file mode 100644 (file)
index 0000000..ff7a55d
--- /dev/null
@@ -0,0 +1,235 @@
+<?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/>.
+
+/**
+ * Course copy form class.
+ *
+ * @package     core_backup
+ * @copyright   2020 onward The Moodle Users Association <https://moodleassociation.org/>
+ * @author      Matt Porritt <mattp@catalyst-au.net>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_backup\output;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once("$CFG->libdir/formslib.php");
+
+require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php');
+require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php');
+
+/**
+ * Course copy form class.
+ *
+ * @package     core_backup
+ * @copyright  2020 onward The Moodle Users Association <https://moodleassociation.org/>
+ * @author     Matt Porritt <mattp@catalyst-au.net>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class copy_form extends \moodleform {
+
+    /**
+     * Build form for the course copy settings.
+     *
+     * {@inheritDoc}
+     * @see \moodleform::definition()
+     */
+    public function definition() {
+        global $CFG, $OUTPUT, $USER;
+
+        $mform = $this->_form;
+        $course = $this->_customdata['course'];
+        $coursecontext = \context_course::instance($course->id);
+        $courseconfig = get_config('moodlecourse');
+        $returnto = $this->_customdata['returnto'];
+        $returnurl = $this->_customdata['returnurl'];
+
+        if (empty($course->category)) {
+            $course->category = $course->categoryid;
+        }
+
+        // Course ID.
+        $mform->addElement('hidden', 'courseid', $course->id);
+        $mform->setType('courseid', PARAM_INT);
+
+        // Return to type.
+        $mform->addElement('hidden', 'returnto', null);
+        $mform->setType('returnto', PARAM_ALPHANUM);
+        $mform->setConstant('returnto', $returnto);
+
+        // Notifications of current copies.
+        $copies = \core_backup\copy\copy::get_copies($USER->id, $course->id);
+        if (!empty($copies)) {
+            $progresslink = new \moodle_url('/backup/copyprogress.php?', array('id' => $course->id));
+            $notificationmsg = get_string('copiesinprogress', 'backup', $progresslink->out());
+            $notification = $OUTPUT->notification($notificationmsg, 'notifymessage');
+            $mform->addElement('html', $notification);
+        }
+
+        // Return to URL.
+        $mform->addElement('hidden', 'returnurl', null);
+        $mform->setType('returnurl', PARAM_LOCALURL);
+        $mform->setConstant('returnurl', $returnurl);
+
+        // Form heading.
+        $mform->addElement('html', \html_writer::div(get_string('copycoursedesc', 'backup'), 'form-description mb-3'));
+
+        // Course fullname.
+        $mform->addElement('text', 'fullname', get_string('fullnamecourse'), 'maxlength="254" size="50"');
+        $mform->addHelpButton('fullname', 'fullnamecourse');
+        $mform->addRule('fullname', get_string('missingfullname'), 'required', null, 'client');
+        $mform->setType('fullname', PARAM_TEXT);
+
+        // Course shortname.
+        $mform->addElement('text', 'shortname', get_string('shortnamecourse'), 'maxlength="100" size="20"');
+        $mform->addHelpButton('shortname', 'shortnamecourse');
+        $mform->addRule('shortname', get_string('missingshortname'), 'required', null, 'client');
+        $mform->setType('shortname', PARAM_TEXT);
+
+        // Course category.
+        $displaylist = \core_course_category::make_categories_list(\core_course\management\helper::get_course_copy_capabilities());
+        if (!isset($displaylist[$course->category])) {
+            // Always keep current category.
+            $displaylist[$course->category] = \core_course_category::get($course->category, MUST_EXIST, true)->get_formatted_name();
+        }
+        $mform->addElement('select', 'category', get_string('coursecategory'), $displaylist);
+        $mform->addHelpButton('category', 'coursecategory');
+
+        // Course visibility.
+        $choices = array();
+        $choices['0'] = get_string('hide');
+        $choices['1'] = get_string('show');
+        $mform->addElement('select', 'visible', get_string('coursevisibility'), $choices);
+        $mform->addHelpButton('visible', 'coursevisibility');
+        $mform->setDefault('visible', $courseconfig->visible);
+        if (!has_capability('moodle/course:visibility', $coursecontext)) {
+            $mform->hardFreeze('visible');
+            $mform->setConstant('visible', $course->visible);
+        }
+
+        // Course start date.
+        $mform->addElement('date_time_selector', 'startdate', get_string('startdate'));
+        $mform->addHelpButton('startdate', 'startdate');
+        $date = (new \DateTime())->setTimestamp(usergetmidnight(time()));
+        $date->modify('+1 day');
+        $mform->setDefault('startdate', $date->getTimestamp());
+
+        // Course enddate.
+        $mform->addElement('date_time_selector', 'enddate', get_string('enddate'), array('optional' => true));
+        $mform->addHelpButton('enddate', 'enddate');
+
+        if (!empty($CFG->enablecourserelativedates)) {
+            $attributes = [
+                'aria-describedby' => 'relativedatesmode_warning'
+            ];
+            if (!empty($course->id)) {
+                $attributes['disabled'] = true;
+            }
+            $relativeoptions = [
+                0 => get_string('no'),
+                1 => get_string('yes'),
+            ];
+            $relativedatesmodegroup = [];
+            $relativedatesmodegroup[] = $mform->createElement('select', 'relativedatesmode', get_string('relativedatesmode'),
+                $relativeoptions, $attributes);
+            $relativedatesmodegroup[] = $mform->createElement('html', \html_writer::span(get_string('relativedatesmode_warning'),
+                '', ['id' => 'relativedatesmode_warning']));
+            $mform->addGroup($relativedatesmodegroup, 'relativedatesmodegroup', get_string('relativedatesmode'), null, false);
+            $mform->addHelpButton('relativedatesmodegroup', 'relativedatesmode');
+        }
+
+        // Course id number.
+        $mform->addElement('text', 'idnumber', get_string('idnumbercourse'), 'maxlength="100"  size="10"');
+        $mform->addHelpButton('idnumber', 'idnumbercourse');
+        $mform->setType('idnumber', PARAM_RAW);
+        if (!empty($course->id) and !has_capability('moodle/course:changeidnumber', $coursecontext)) {
+            $mform->hardFreeze('idnumber');
+            $mform->setConstants('idnumber', $course->idnumber);
+        }
+
+        // Keep source course user data.
+        $requiredcapabilities = array(
+            'moodle/restore:createuser', 'moodle/backup:userinfo', 'moodle/restore:userinfo'
+        );
+        if (has_all_capabilities($requiredcapabilities, $coursecontext)) {
+            $dataarray = array();
+            $dataarray[] = $mform->createElement('advcheckbox', 'userdata',
+                get_string('enable'), '', array('group' => 1), array(0, 1));
+            $mform->addGroup($dataarray, 'dataarray', get_string('userdata', 'backup'), ' ', false);
+            $mform->addHelpButton('dataarray', 'userdata', 'backup');
+        }
+
+        // Keep manual enrolments.
+        // Only get roles actually used in this course.
+        $roles = role_fix_names(get_roles_used_in_context($coursecontext, false), $coursecontext);
+
+        // Only add the option if there are roles in this course.
+        if (!empty($roles) && has_capability('moodle/restore:createuser', $coursecontext)) {
+            $rolearray = array();
+            foreach ($roles as $role) {
+                $roleid = 'role_' . $role->id;
+                $rolearray[] = $mform->createElement('advcheckbox', $roleid,
+                    $role->localname, '', array('group' => 2), array(0, $role->id));
+            }
+
+            $mform->addGroup($rolearray, 'rolearray', get_string('keptroles', 'backup'), ' ', false);
+            $mform->addHelpButton('rolearray', 'keptroles', 'backup');
+            $this->add_checkbox_controller(2);
+        }
+
+        $buttonarray = array();
+        $buttonarray[] = $mform->createElement('submit', 'submitreturn', get_string('copyreturn', 'backup'));
+        $buttonarray[] = $mform->createElement('submit', 'submitdisplay', get_string('copyview', 'backup'));
+        $buttonarray[] = $mform->createElement('cancel');
+        $mform->addGroup($buttonarray, 'buttonar', '', ' ', false);
+
+    }
+
+    /**
+     * Validation of the form.
+     *
+     * @param array $data
+     * @param array $files
+     * @return array the errors that were found
+     */
+    public function validation($data, $files) {
+        global $DB;
+        $errors = parent::validation($data, $files);
+
+        // Add field validation check for duplicate shortname.
+        $courseshortname = $DB->get_record('course', array('shortname' => $data['shortname']), 'fullname', IGNORE_MULTIPLE);
+        if ($courseshortname) {
+            $errors['shortname'] = get_string('shortnametaken', '', $courseshortname->fullname);
+        }
+
+        // Add field validation check for duplicate idnumber.
+        if (!empty($data['idnumber'])) {
+            $courseidnumber = $DB->get_record('course', array('idnumber' => $data['idnumber']), 'fullname', IGNORE_MULTIPLE);
+            if ($courseidnumber) {
+                $errors['idnumber'] = get_string('courseidnumbertaken', 'error', $courseidnumber->fullname);
+            }
+        }
+
+        // Validate the dates (make sure end isn't greater than start).
+        if ($errorcode = course_validate_dates($data)) {
+            $errors['enddate'] = get_string($errorcode, 'error');
+        }
+
+        return $errors;
+    }
+
+}
index d9bdbe1..d5b44ce 100644 (file)
@@ -572,11 +572,15 @@ class core_backup_renderer extends plugin_renderer_base {
      * @param string $backupid The backup record id.
      * @return string|boolean $status The status indicator for the operation.
      */
-    public function get_status_display($statuscode, $backupid) {
-        if ($statuscode == backup::STATUS_AWAITING || $statuscode == backup::STATUS_EXECUTING) {  // Inprogress.
+    public function get_status_display($statuscode, $backupid, $restoreid=null, $operation='backup') {
+        if ($statuscode == backup::STATUS_AWAITING
+            || $statuscode == backup::STATUS_EXECUTING
+            || $statuscode == backup::STATUS_REQUIRE_CONV) {  // In progress.
             $progresssetup = array(
-                    'backupid' => $backupid,
-                    'width' => '100'
+                'backupid' => $backupid,
+                'restoreid' => $restoreid,
+                'operation' => $operation,
+                'width' => '100'
             );
             $status = $this->render_from_template('core/async_backup_progress', $progresssetup);
         } else if ($statuscode == backup::STATUS_FINISHED_ERR) { // Error.
@@ -963,7 +967,7 @@ class core_backup_renderer extends plugin_renderer_base {
 
             $restorename = \async_helper::get_restore_name($context);
             $timecreated = $restore->timecreated;
-            $status = $this->get_status_display($restore->status, $restore->backupid);
+            $status = $this->get_status_display($restore->status, $restore->backupid, $restore->backupid, null, 'restore');
 
             $tablerow = array($restorename, userdate($timecreated), $status);
             $tabledata[] = $tablerow;
@@ -974,6 +978,50 @@ class core_backup_renderer extends plugin_renderer_base {
 
         return $html;
     }
+
+    /**
+     * Get markup to render table for all of a users course copies.
+     *
+     * @param int $userid The Moodle user id.
+     * @param int $courseid The id of the course to get the backups for.
+     * @return string $html The table HTML.
+     */
+    public function copy_progress_viewer(int $userid, int $courseid): string {
+        $tablehead = array(
+            get_string('copysource', 'backup'),
+            get_string('copydest', 'backup'),
+            get_string('time'),
+            get_string('copyop', 'backup'),
+            get_string('status', 'backup')
+        );
+
+        $table = new html_table();
+        $table->attributes['class'] = 'backup-files-table generaltable';
+        $table->head = $tablehead;
+
+        $tabledata = array();
+
+        // Get all in progress course copies for this user.
+        $copies = \core_backup\copy\copy::get_copies($userid, $courseid);
+
+        foreach ($copies as $copy) {
+            $sourceurl = new \moodle_url('/course/view.php', array('id' => $copy->sourceid));
+
+            $tablerow = array(
+                html_writer::link($sourceurl, $copy->source),
+                $copy->destination,
+                userdate($copy->time),
+                get_string($copy->operation),
+                $this->get_status_display($copy->status, $copy->backupid, $copy->restoreid, $copy->operation)
+            );
+            $tabledata[] = $tablerow;
+        }
+
+        $table->data = $tabledata;
+        $html = html_writer::table($table);
+
+        return $html;
+    }
 }
 
 /**
index 68f2fb3..eb7507f 100644 (file)
@@ -17,8 +17,8 @@ Feature: Import course content bank content
       | teacher1 | C1 | editingteacher |
       | teacher1 | C2 | editingteacher |
     And the following "contentbank content" exist:
-      | course| contenttype     | user     | contentname       |
-      | C1    | contenttype_h5p | teacher1 | ipsums.h5p        |
+      | contextlevel | reference | contenttype     | user     | contentname |
+      | Course       | C1        | contenttype_h5p | teacher1 | ipsums.h5p  |
     And I log in as "teacher1"
 
   Scenario: Import content bank content to another course
diff --git a/badges/backpack-connect.php b/badges/backpack-connect.php
new file mode 100644 (file)
index 0000000..cece808
--- /dev/null
@@ -0,0 +1,58 @@
+<?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/>.
+
+/**
+ * Connect to backpack site.
+ *
+ * @package    core_badges
+ * @copyright  2020 Tung Thai
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @author     Tung Thai <Tung.ThaiDuc@nashtechglobal.com>
+ */
+
+require_once(__DIR__ . '/../config.php');
+require_once($CFG->libdir . '/badgeslib.php');
+
+$scope = optional_param('scope', '', PARAM_RAW);
+$action = optional_param('action', null, PARAM_RAW);
+
+if (badges_open_badges_backpack_api() != 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);
+$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()]);
+
+    $client = new core_badges\oauth2\client($issuer, $returnurl, $scope, $externalbackpack);
+    if ($client) {
+        if (!$client->is_logged_in()) {
+            redirect($client->get_login_url());
+        }
+        $wantsurl = new moodle_url('/badges/mybadges.php');
+        $auth = new \core_badges\oauth2\auth();
+        $auth->complete_data($client, $wantsurl);
+    } else {
+        throw new moodle_exception('Could not get an OAuth client.');
+    }
+} else {
+    throw new moodle_exception('Unknown OAuth client.');
+}
diff --git a/badges/backpack-export.php b/badges/backpack-export.php
new file mode 100644 (file)
index 0000000..e1015b7
--- /dev/null
@@ -0,0 +1,62 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Export badges to the backpack site.
+ *
+ * @package    core_badges
+ * @copyright  2020 Tung Thai
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @author     Tung Thai <Tung.ThaiDuc@nashtechglobal.com>
+ */
+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');
+$url = new moodle_url('/badges/backpack-export.php');
+
+require_login();
+if (empty($CFG->badges_allowexternalbackpack) || empty($CFG->enablebadges)) {
+    redirect($CFG->wwwroot);
+}
+$backpack = badges_get_site_backpack($CFG->badges_site_backpack);
+$userbadges = badges_get_user_badges($USER->id);
+$context = context_user::instance($USER->id);
+
+$PAGE->set_context($context);
+$PAGE->set_url($url);
+$title = get_string('badges', 'badges');
+$PAGE->set_title($title);
+$PAGE->set_heading(fullname($USER));
+$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) {
+        redirect($redirecturl, $notify['message'], null, \core\output\notification::NOTIFY_SUCCESS);
+    } else if (!empty($notify['status']) && $notify['status'] == \core\output\notification::NOTIFY_ERROR) {
+        redirect($redirecturl, $notify['message'], null, \core\output\notification::NOTIFY_ERROR);
+    }
+}
+redirect($redirecturl);
\ No newline at end of file
diff --git a/badges/classes/backpack_api2p1.php b/badges/classes/backpack_api2p1.php
new file mode 100644 (file)
index 0000000..055a3c3
--- /dev/null
@@ -0,0 +1,236 @@
+<?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/>.
+
+/**
+ * Communicate with backpacks.
+ *
+ * @copyright  2020 Tung Thai based on Totara Learning Solutions Ltd {@link http://www.totaralms.com/} dode
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @author     Tung Thai <Tung.ThaiDuc@nashtechglobal.com>
+ */
+
+namespace core_badges;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->libdir . '/filelib.php');
+
+use cache;
+use coding_exception;
+use context_system;
+use moodle_url;
+use core_badges\backpack_api2p1_mapping;
+use core_badges\oauth2\client;
+use curl;
+use stdClass;
+
+/**
+ * To process badges with backpack and control api request and this class using for Open Badge API v2.1 methods.
+ *
+ * @package   core_badges
+ * @copyright  2020 Tung Thai
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class backpack_api2p1 {
+
+    /** @var object is the external backpack. */
+    private $externalbackpack;
+
+    /** @var array define api mapping. */
+    private $mappings = [];
+
+    /** @var false|null|stdClass|\core_badges\backpack_api2p1 to */
+    private $tokendata;
+
+    /** @var null clienid. */
+    private $clientid = null;
+
+    /** @var null version api of the backpack. */
+    protected $backpackapiversion;
+
+    /** @var null api URL of the backpack. */
+    protected $backpackapiurl = '';
+
+    /**
+     * backpack_api2p1 constructor.
+     *
+     * @param object $externalbackpack object
+     * @throws coding_exception error message
+     */
+    public function __construct($externalbackpack) {
+
+        if (!empty($externalbackpack)) {
+            $this->externalbackpack = $externalbackpack;
+            $this->backpackapiversion = $externalbackpack->apiversion;
+            $this->backpackapiurl = $externalbackpack->backpackapiurl;
+            $this->get_clientid = $this->get_clientid($externalbackpack->oauth2_issuerid);
+
+            if (!($this->tokendata = $this->get_stored_token($externalbackpack->id))
+                && $this->backpackapiversion != OPEN_BADGES_V2P1) {
+                throw new coding_exception('Backpack incorrect');
+            }
+        }
+
+        $this->define_mappings();
+    }
+
+
+    /**
+     * Define the mappings supported by this usage and api version.
+     */
+    private function define_mappings() {
+        if ($this->backpackapiversion == OPEN_BADGES_V2P1) {
+
+            $mapping = [];
+            $mapping[] = [
+                'post.assertions',                               // Action.
+                '[URL]/assertions',   // URL
+                '[PARAM]',                                  // Post params.
+                false,                                      // Multiple.
+                'post',                                     // Method.
+                true,                                       // JSON Encoded.
+                true                                        // Auth required.
+            ];
+
+            $mapping[] = [
+                'get.assertions',                               // Action.
+                '[URL]/assertions',   // URL
+                '[PARAM]',                                  // Post params.
+                false,                                      // Multiple.
+                'get',                                     // Method.
+                true,                                       // JSON Encoded.
+                true                                        // Auth required.
+            ];
+
+            foreach ($mapping as $map) {
+                $map[] = false; // Site api function.
+                $map[] = OPEN_BADGES_V2P1; // V2 function.
+                $this->mappings[] = new backpack_api2p1_mapping(...$map);
+            }
+
+        }
+    }
+
+    /**
+     * Disconnect the backpack from this user.
+     *
+     * @param object $backpack to disconnect.
+     * @return bool
+     * @throws \dml_exception
+     */
+    public function disconnect_backpack($backpack) {
+        global $USER, $DB;
+
+        if ($backpack) {
+            $DB->delete_records_select('badge_external', 'backpackid = :backpack', ['backpack' => $backpack->id]);
+            $DB->delete_records('badge_backpack', ['id' => $backpack->id]);
+            $DB->delete_records('badge_backpack_oauth2', ['externalbackpackid' => $this->externalbackpack->id,
+                'userid' => $USER->id]);
+
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Make an api request.
+     *
+     * @param string $action The api function.
+     * @param string $postdata The body of the api request.
+     * @return mixed
+     */
+    public function curl_request($action, $postdata = null) {
+        $tokenkey = $this->tokendata->token;
+        foreach ($this->mappings as $mapping) {
+            if ($mapping->is_match($action)) {
+                return $mapping->request(
+                    $this->backpackapiurl,
+                    $tokenkey,
+                    $postdata
+                );
+            }
+        }
+
+        throw new coding_exception('Unknown request');
+    }
+
+    /**
+     * Get token.
+     *
+     * @param int $externalbackpackid ID of external backpack.
+     * @return oauth2\badge_backpack_oauth2|false|stdClass|null
+     */
+    protected function get_stored_token($externalbackpackid) {
+        global $USER;
+
+        $token = \core_badges\oauth2\badge_backpack_oauth2::get_record(
+            ['externalbackpackid' => $externalbackpackid, 'userid' => $USER->id]);
+        if ($token !== false) {
+            $token = $token->to_record();
+            return $token;
+        }
+        return null;
+    }
+
+    /**
+     * Get client id.
+     *
+     * @param int $issuerid id of Oauth2 service.
+     * @throws coding_exception
+     */
+    private function get_clientid($issuerid) {
+        $issuer = \core\oauth2\api::get_issuer($issuerid);
+        if (!empty($issuer)) {
+            $this->clientid = $issuer->get('clientid');
+        }
+    }
+
+    /**
+     * Export a badge to the backpack site.
+     *
+     * @param string $hash of badge issued.
+     * @return array
+     * @throws \moodle_exception
+     * @throws coding_exception
+     */
+    public function put_assertions($hash) {
+        $data = [];
+        if (!$hash) {
+            return false;
+        }
+
+        $issuer = new \core\oauth2\issuer($this->externalbackpack->oauth2_issuerid);
+        $client = new client($issuer, new moodle_url('/badges/mybadges.php'), '', $this->externalbackpack);
+        if (!$client->is_logged_in()) {
+            $redirecturl = new moodle_url('/badges/mybadges.php', ['error' => 'backpackexporterror']);
+            redirect($redirecturl);
+        }
+
+        $this->tokendata = $this->get_stored_token($this->externalbackpack->id);
+
+        $assertion = new \core_badges_assertion($hash, OPEN_BADGES_V2);
+        $data['assertion'] = $assertion->get_badge_assertion();
+        $response = $this->curl_request('post.assertions', $data);
+        if ($response && isset($response->status->statusCode) && $response->status->statusCode == 200) {
+            $msg['status'] = \core\output\notification::NOTIFY_SUCCESS;
+            $msg['message'] = get_string('addedtobackpack', 'badges');
+        } else {
+            $msg['status'] = \core\output\notification::NOTIFY_ERROR;
+            $msg['message'] = get_string('backpackexporterror', 'badges', $data['assertion']['badge']['name']);
+        }
+        return $msg;
+    }
+}
diff --git a/badges/classes/backpack_api2p1_mapping.php b/badges/classes/backpack_api2p1_mapping.php
new file mode 100644 (file)
index 0000000..7419f3b
--- /dev/null
@@ -0,0 +1,178 @@
+<?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/>.
+
+/**
+ * Represent the url for each method and the encoding of the parameters and response.
+ *
+ * The code is based on badges/classes/backpack_api_mapping.php by Yuliya Bozhko <yuliya.bozhko@totaralms.com>.
+ *
+ * @copyright  2020 Tung Thai
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @author     Tung Thai <Tung.ThaiDuc@nashtechglobal.com>
+ */
+
+namespace core_badges;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->libdir . '/filelib.php');
+
+use context_system;
+use curl;
+
+/**
+ * Represent a single method for the remote api and this class using for Open Badge API v2.1 methods.
+ *
+ * @package   core_badges
+ * @copyright  2020 Tung Thai
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class backpack_api2p1_mapping {
+
+    /** @var string The action of this method. */
+    public $action;
+
+    /** @var string The base url of this backpack. */
+    private $url;
+
+    /** @var array List of parameters for this method. */
+    public $params;
+
+    /** @var boolean This method returns an array of responses. */
+    public $multiple;
+
+    /** @var string get or post methods. */
+    public $method;
+
+    /** @var boolean json decode the response. */
+    public $json;
+
+    /** @var boolean Authentication is required for this request. */
+    public $authrequired;
+
+    /** @var boolean Differentiate the function that can be called on a user backpack or a site backpack. */
+    private $isuserbackpack;
+
+    /**
+     * Create a mapping.
+     *
+     * @param string $action The action of this method.
+     * @param string $url The base url of this backpack.
+     * @param mixed $postparams List of parameters for this method.
+     * @param boolean $multiple This method returns an array of responses.
+     * @param string $method get or post methods.
+     * @param boolean $json json decode the response.
+     * @param boolean $authrequired Authentication is required for this request.
+     * @param boolean $isuserbackpack user backpack or a site backpack.
+     * @param integer $backpackapiversion OpenBadges version 1 or 2.
+     */
+    public function __construct($action, $url, $postparams,
+                                $multiple, $method, $json, $authrequired, $isuserbackpack, $backpackapiversion) {
+        $this->action = $action;
+        $this->url = $url;
+        $this->postparams = $postparams;
+        $this->multiple = $multiple;
+        $this->method = $method;
+        $this->json = $json;
+        $this->authrequired = $authrequired;
+        $this->isuserbackpack = $isuserbackpack;
+        $this->backpackapiversion = $backpackapiversion;
+    }
+
+    /**
+     * Does the action match this mapping?
+     *
+     * @param string $action The action.
+     * @return boolean
+     */
+    public function is_match($action) {
+        return $this->action == $action;
+    }
+
+    /**
+     * Parse the method url and insert parameters.
+     *
+     * @param string $apiurl The raw apiurl.
+     * @return string
+     */
+    private function get_url($apiurl) {
+        $urlscheme = parse_url($apiurl, PHP_URL_SCHEME);
+        $urlhost = parse_url($apiurl, PHP_URL_HOST);
+
+        $url = $this->url;
+        $url = str_replace('[SCHEME]', $urlscheme, $url);
+        $url = str_replace('[HOST]', $urlhost, $url);
+        $url = str_replace('[URL]', $apiurl, $url);
+
+        return $url;
+    }
+
+    /**
+     * Standard options used for all curl requests.
+     *
+     * @return array
+     */
+    private function get_curl_options() {
+        return array(
+            'FRESH_CONNECT'     => true,
+            'RETURNTRANSFER'    => true,
+            'FORBID_REUSE'      => true,
+            'HEADER'            => 0,
+            'CONNECTTIMEOUT'    => 3,
+            'CONNECTTIMEOUT'    => 3,
+            // Follow redirects with the same type of request when sent 301, or 302 redirects.
+            'CURLOPT_POSTREDIR' => 3,
+        );
+    }
+
+    /**
+     * Make an api request and parse the response.
+     *
+     * @param string $apiurl Raw request url.
+     * @param string $tokenkey to verify authorization.
+     * @param array $post request method.
+     * @return bool|mixed
+     */
+    public function request($apiurl, $tokenkey, $post = []) {
+        $curl = new curl();
+        $url = $this->get_url($apiurl);
+        if ($tokenkey) {
+            $curl->setHeader('Authorization: Bearer ' . $tokenkey);
+        }
+
+        if ($this->json) {
+            $curl->setHeader(array('Content-type: application/json'));
+            if ($this->method == 'post') {
+                $post = json_encode($post);
+            }
+        }
+
+        $curl->setHeader(array('Accept: application/json', 'Expect:'));
+        $options = $this->get_curl_options();
+        if ($this->method == 'get') {
+            $response = $curl->get($url, $post, $options);
+        } else if ($this->method == 'post') {
+            $response = $curl->post($url, $post, $options);
+        }
+        $response = json_decode($response);
+        if (isset($response->result)) {
+            $response = $response->result;
+        }
+
+        return $response;
+    }
+}
\ No newline at end of file
index a060d85..6c7370c 100644 (file)
@@ -88,16 +88,18 @@ class backpack extends moodleform {
             $status = html_writer::tag('span', get_string('notconnected', 'badges'),
                 array('class' => 'notconnected', 'id' => 'connection-status'));
             $mform->addElement('static', 'status', get_string('status'), $status);
-            $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);
+            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'));
         }
@@ -107,7 +109,12 @@ class backpack extends moodleform {
      * Validates form data
      */
     public function validation($data, $files) {
+        global $CFG;
+
         $errors = parent::validation($data, $files);
+        if (badges_open_badges_backpack_api() == OPEN_BADGES_V2P1) {
+            return $errors;
+        }
         // 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();
index 7b7d653..1bb90c5 100644 (file)
@@ -68,7 +68,7 @@ class external_backpack extends \moodleform {
         $label = $options[$backpack->apiversion];
         $mform->addElement('static', 'apiversioninfo', get_string('apiversion', 'core_badges'), $label);
         $mform->addElement('hidden', 'apiversion', $backpack->apiversion);
-        $mform->setType('apiversion', PARAM_INTEGER);
+        $mform->setType('apiversion', PARAM_RAW);
 
         $mform->addElement('hidden', 'id', $backpack->id);
         $mform->setType('id', PARAM_INTEGER);
@@ -81,12 +81,16 @@ class external_backpack extends \moodleform {
 
         $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', 'eq', 1);
-
+        if ($backpack->apiversion != OPEN_BADGES_V2P1) {
+            $mform->addElement('passwordunmask', 'password', get_string('defaultissuerpassword', 'core_badges'));
+            $mform->setType('password', PARAM_RAW);
+            $mform->addHelpButton('password', 'defaultissuerpassword', 'badges');
+            $mform->hideIf('password', 'apiversion', 'eq', 1);
+        } else {
+            $oauth2options = badges_get_oauth2_service_options();
+            $mform->addElement('select', 'oauth2_issuerid', get_string('oauth2issuer', 'core_badges'), $oauth2options);
+            $mform->setType('oauth2_issuerid', PARAM_INT);
+        }
         $this->set_data($backpack);
 
         // Disable short forms.
diff --git a/badges/classes/oauth2/auth.php b/badges/classes/oauth2/auth.php
new file mode 100644 (file)
index 0000000..038689c
--- /dev/null
@@ -0,0 +1,96 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * This file to proccess Oauth2 connects for backpack.
+ *
+ * @package    core_badges
+ * @subpackage badges
+ * @copyright  2020 Tung Thai
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @author     Tung Thai <Tung.ThaiDuc@nashtechglobal.com>
+ */
+
+namespace core_badges\oauth2;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->libdir.'/authlib.php');
+
+use stdClass;
+
+/**
+ * Proccess Oauth2 connects to backpack site.
+ *
+ * @copyright  2020 Tung Thai
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @author     Tung Thai <Tung.ThaiDuc@nashtechglobal.com>
+ */
+class auth extends \auth_oauth2\auth {
+
+    /**
+     * To complete data after login.
+     *
+     * @param client $client object.
+     * @param string $redirecturl the url redirect.
+     */
+    public function complete_data(\core_badges\oauth2\client $client, $redirecturl) {
+        global $DB, $USER;
+
+        $userinfo = $client->get_userinfo();
+        $badgebackpack = new stdClass();
+        $badgebackpack->userid = $USER->id;
+        if ($userinfo && isset($userinfo->email)) {
+            $badgebackpack->email = $userinfo->email;
+        } else {
+            $badgebackpack->email = $USER->email;
+        }
+        $badgebackpack->externalbackpackid = $client->backpack->id;
+        $badgebackpack->backpackuid = 0;
+        $badgebackpack->autosync = 0;
+        $badgebackpack->password = '';
+        $record = $DB->get_record('badge_backpack', ['userid' => $USER->id,
+            'externalbackpackid' => $client->backpack->id]);
+        if (!$record) {
+            $DB->insert_record('badge_backpack', $badgebackpack);
+        } else {
+            $badgebackpack->id = $record->id;
+            $DB->update_record('badge_backpack', $badgebackpack);
+        }
+
+        redirect($redirecturl, get_string('backpackconnected', 'badges'), null,
+            \core\output\notification::NOTIFY_SUCCESS);
+    }
+
+    /**
+     * Check user has been logged the backpack site.
+     *
+     * @param int $externalbackpackid ID of external backpack.
+     * @param int $userid ID of user.
+     * @return bool
+     */
+    public static function is_logged_oauth2($externalbackpackid, $userid) {
+        global $USER;
+        if (empty($userid)) {
+            $userid = $USER->id;
+        }
+        $persistedtoken = badge_backpack_oauth2::get_record(['externalbackpackid' => $externalbackpackid, 'userid' => $userid]);
+        if ($persistedtoken) {
+            return true;
+        }
+        return false;
+    }
+}
diff --git a/badges/classes/oauth2/badge_backpack_oauth2.php b/badges/classes/oauth2/badge_backpack_oauth2.php
new file mode 100644 (file)
index 0000000..7e108ed
--- /dev/null
@@ -0,0 +1,77 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * This file contains the form add/update oauth2 for backpack is connected.
+ *
+ * @package    core_badges
+ * @subpackage badges
+ * @copyright  2020 Tung Thai
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @author     Tung Thai <Tung.ThaiDuc@nashtechglobal.com>
+ */
+
+namespace core_badges\oauth2;
+
+defined('MOODLE_INTERNAL') || die();
+
+use core\persistent;
+
+/**
+ * Class badge_backpack_oauth2 for backpack is connected.
+ *
+ * @copyright  2020 Tung Thai
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @author     Tung Thai <Tung.ThaiDuc@nashtechglobal.com>
+ */
+class badge_backpack_oauth2 extends persistent {
+
+    /**
+     * The table name.
+     */
+    const TABLE = 'badge_backpack_oauth2';
+
+    /**
+     * Return the definition of the properties of this model.
+     *
+     * @return array
+     */
+    protected static function define_properties() {
+        return array(
+            'userid' => array(
+                'type' => PARAM_INT,
+            ),
+            'issuerid' => array(
+                'type' => PARAM_INT
+            ),
+            'externalbackpackid' => array(
+                'type' => PARAM_INT
+            ),
+            'token' => array(
+                'type' => PARAM_TEXT
+            ),
+            'refreshtoken' => array(
+                'type' => PARAM_TEXT
+            ),
+            'expires' => array(
+                'type' => PARAM_INT
+            ),
+            'scope' => array(
+                'type' => PARAM_TEXT
+            ),
+        );
+    }
+}
\ No newline at end of file
diff --git a/badges/classes/oauth2/client.php b/badges/classes/oauth2/client.php
new file mode 100644 (file)
index 0000000..c214b5c
--- /dev/null
@@ -0,0 +1,348 @@
+<?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/>.
+
+/**
+ * Configurable OAuth2 client class.
+ *
+ * @package    core_badges
+ * @subpackage badges
+ * @copyright  2020 Tung Thai
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @author     Tung Thai <Tung.ThaiDuc@nashtechglobal.com>
+ */
+
+namespace core_badges\oauth2;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->libdir . '/oauthlib.php');
+require_once($CFG->libdir . '/filelib.php');
+require_once('badge_backpack_oauth2.php');
+
+use moodle_url;
+use moodle_exception;
+use stdClass;
+
+define('BACKPACK_CHALLENGE_METHOD', 'S256');
+define('BACKPACK_CODE_VERIFIER_TIME', 60);
+
+/**
+ * Configurable OAuth2 client to request authorization and store token. Use the PKCE method to verifier authorization.
+ *
+ * @copyright  2020 Tung Thai
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @author     Tung Thai <Tung.ThaiDuc@nashtechglobal.com>
+ */
+class client extends \core\oauth2\client {
+
+    /**  @var \core\oauth2\issuer */
+    private $issuer;
+
+    /** @var string $clientid client identifier issued to the client */
+    private $clientid = '';
+
+    /** @var string $clientsecret The client secret. */
+    private $clientsecret = '';
+
+    /** @var moodle_url $returnurl URL to return to after authenticating */
+    private $returnurl = null;
+
+    /** @var string $grantscope */
+    protected $grantscope = '';
+
+    /** @var string $scope */
+    protected $scope = '';
+
+    /** @var bool basicauth */
+    protected $basicauth = true;
+
+    /** @var string|null backpack object */
+    public $backpack = '';
+
+    /**
+     * client constructor.
+     *
+     * @param issuer $issuer oauth2 service.
+     * @param string $returnurl return url after login
+     * @param string $additionalscopes the scopes has been granted
+     * @param null $backpack backpack object.
+     * @throws \coding_exception error message.
+     */
+    public function __construct(\core\oauth2\issuer $issuer, $returnurl = '', $additionalscopes = '',
+                                $backpack = null) {
+        $this->issuer = $issuer;
+        $this->clientid = $issuer->get('clientid');
+        $this->returnurl = $returnurl;
+        $this->clientsecret = $issuer->get('clientsecret');
+        $this->backpack = $backpack;
+        $this->grantscope = $additionalscopes;
+        $this->scope = $additionalscopes;
+        parent::__construct($issuer, $returnurl, $additionalscopes, false);
+    }
+
+    /**
+     * Get login url.
+     *
+     * @return moodle_url
+     * @throws \coding_exception
+     * @throws moodle_exception
+     */
+    public function get_login_url() {
+        $callbackurl = self::callback_url();
+        $scopes = $this->issuer->get('scopessupported');
+
+        // Removed the scopes does not support in authorization.
+        $excludescopes = ['profile', 'openid'];
+        $arrascopes = explode(' ', $scopes);
+        foreach ($excludescopes as $exscope) {
+            $key = array_search($exscope, $arrascopes);
+            if (isset($key)) {
+                unset($arrascopes[$key]);
+            }
+        }
+        $scopes = implode(' ', $arrascopes);
+
+        $params = array_merge(
+            [
+                'client_id' => $this->clientid,
+                'response_type' => 'code',
+                'redirect_uri' => $callbackurl->out(false),
+                'state' => $this->returnurl->out_as_local_url(false),
+                'scope' => $scopes,
+                'code_challenge' => $this->code_challenge(),
+                'code_challenge_method' => BACKPACK_CHALLENGE_METHOD,
+            ]
+        );
+        return new moodle_url($this->auth_url(), $params);
+    }
+
+    /**
+     * Generate code challenge.
+     *
+     * @return string
+     */
+    public function code_challenge() {
+        $random = bin2hex(openssl_random_pseudo_bytes(43));
+        $verifier = $this->base64url_encode(pack('H*', $random));
+        $challenge = $this->base64url_encode(pack('H*', hash('sha256', $verifier)));
+        $_SESSION['SESSION']->code_verifier = $verifier;
+        return $challenge;
+    }
+
+    /**
+     * Get code verifier.
+     *
+     * @return bool
+     */
+    public function code_verifier() {
+        if (isset($_SESSION['SESSION']) && !empty($_SESSION['SESSION']->code_verifier)) {
+            return $_SESSION['SESSION']->code_verifier;
+        }
+        return false;
+    }
+
+    /**
+     * Generate base64url encode.
+     *
+     * @param string $plaintext text to convert.
+     * @return string
+     */
+    public function base64url_encode($plaintext) {
+        $base64 = base64_encode($plaintext);
+        $base64 = trim($base64, "=");
+        $base64url = strtr($base64, '+/', '-_');
+        return ($base64url);
+    }
+
+    /**
+     * Callback url where the request is returned to.
+     *
+     * @return moodle_url url of callback
+     */
+    public static function callback_url() {
+        return new moodle_url('/badges/oauth2callback.php');
+    }
+
+    /**
+     * Check and refresh token to keep login on backpack site.
+     *
+     * @return bool
+     * @throws \coding_exception
+     * @throws moodle_exception
+     */
+    public function is_logged_in() {
+
+        // Has the token expired?
+        if (isset($this->accesstoken->expires) && time() >= $this->accesstoken->expires) {
+            if (isset($this->accesstoken->refreshtoken)) {
+                return $this->upgrade_token($this->accesstoken->refreshtoken, 'refresh_token');
+            } else {
+                throw new moodle_exception('Could not refresh oauth token, please try again.');
+            }
+        }
+
+        if (isset($this->accesstoken->token) && isset($this->accesstoken->scope)) {
+            return true;
+        }
+
+        // If we've been passed then authorization code generated by the
+        // authorization server try and upgrade the token to an access token.
+        $code = optional_param('oauth2code', null, PARAM_RAW);
+        // Note - sometimes we may call is_logged_in twice in the same request - we don't want to attempt
+        // to upgrade the same token twice.
+        if ($code && $this->upgrade_token($code, 'authorization_code')) {
+            return true;
+        }
+
+        return false;
+    }
+
+    /**
+     * Request new token.
+     *
+     * @param string $code code verify from Auth site.
+     * @param string $granttype grant type.
+     * @return bool
+     * @throws moodle_exception
+     */
+    public function upgrade_token($code, $granttype = 'authorization_code') {
+        $callbackurl = self::callback_url();
+
+        if ($granttype == 'authorization_code') {
+            $params = array('code' => $code,
+                'grant_type' => $granttype,
+                'redirect_uri' => $callbackurl->out(false),
+                'scope' => $this->get_scopes(),
+                'code_verifier' => $this->code_verifier()
+            );
+        } else if ($granttype == 'refresh_token') {
+            $this->basicauth = false;
+            $params = array('refresh_token' => $code,
+                'grant_type' => $granttype,
+                'scope' => $this->get_scopes(),
+            );
+        }
+        if ($this->basicauth) {
+            $idsecret = urlencode($this->clientid) . ':' . urlencode($this->clientsecret);
+            $this->setHeader('Authorization: Basic ' . base64_encode($idsecret));
+        } else {
+            $params['client_id'] = $this->clientid;
+            $params['client_secret'] = $this->clientsecret;
+        }
+        // Requests can either use http GET or POST.
+        $response = $this->post($this->token_url(), $this->build_post_data($params));
+        $r = json_decode($response);
+        if ($this->info['http_code'] !== 200) {
+            throw new moodle_exception('Could not upgrade oauth token');
+        }
+
+        if (is_null($r)) {
+            throw new moodle_exception("Could not decode JSON token response");
+        }
+
+        if (!empty($r->error)) {
+            throw new moodle_exception($r->error . ' ' . $r->error_description);
+        }
+
+        if (!isset($r->access_token)) {
+            return false;
+        }
+
+        // Store the token an expiry time.
+        $accesstoken = new stdClass;
+        $accesstoken->token = $r->access_token;
+        if (isset($r->expires_in)) {
+            // Expires 10 seconds before actual expiry.
+            $accesstoken->expires = (time() + ($r->expires_in - 10));
+        }
+        if (isset($r->refresh_token)) {
+            $this->refreshtoken = $r->refresh_token;
+            $accesstoken->refreshtoken = $r->refresh_token;
+        }
+        $accesstoken->scope = $r->scope;
+
+        // Also add the scopes.
+        $this->store_token($accesstoken);
+
+        return true;
+    }
+
+    /**
+     * Store a token to verify for send request.
+     *
+     * @param null|stdClass $token
+     */
+    protected function store_token($token) {
+        global $USER;
+
+        $this->accesstoken = $token;
+        // Create or update a DB record with the new token.
+        $persistedtoken = badge_backpack_oauth2::get_record(['externalbackpackid' => $this->backpack->id, 'userid' => $USER->id]);
+        if ($token !== null) {
+            if (!$persistedtoken) {
+                $persistedtoken = new badge_backpack_oauth2();
+                $persistedtoken->set('issuerid', $this->backpack->oauth2_issuerid);
+                $persistedtoken->set('externalbackpackid', $this->backpack->id);
+                $persistedtoken->set('userid', $USER->id);
+            } else {
+                $persistedtoken->set('timemodified', time());
+            }
+            // Update values from $token. Don't use from_record because that would skip validation.
+            $persistedtoken->set('usermodified', $USER->id);
+            $persistedtoken->set('token', $token->token);
+            $persistedtoken->set('refreshtoken', $token->refreshtoken);
+            $persistedtoken->set('expires', $token->expires);
+            $persistedtoken->set('scope', $token->scope);
+            $persistedtoken->save();
+        } else {
+            if ($persistedtoken) {
+                $persistedtoken->delete();
+            }
+        }
+    }
+
+    /**
+     * Get token of current user.
+     *
+     * @return stdClass|null token object
+     */
+    protected function get_stored_token() {
+        global $USER;
+
+        $token = badge_backpack_oauth2::get_record(['externalbackpackid' => $this->backpack->id, 'userid' => $USER->id]);
+        if ($token !== false) {
+            $token = $token->to_record();
+            return $token;
+        }
+        return null;
+    }
+
+    /**
+     * Get scopes granted.
+     *
+     * @return null|string
+     */
+    protected function get_scopes() {
+        if (!empty($this->grantscope)) {
+            return $this->grantscope;
+        }
+        $token = $this->get_stored_token();
+        if ($token) {
+            return $token->scope;
+        }
+        return null;
+    }
+}
index aa9592f..839a731 100644 (file)
@@ -63,7 +63,7 @@ class external_backpacks_page implements \renderable {
         foreach ($this->backpacks as $backpack) {
             $exporter = new backpack_exporter($backpack);
             $backpack = $exporter->export($output);
-            if ($backpack->apiversion == OPEN_BADGES_V2) {
+            if ($backpack->apiversion == OPEN_BADGES_V2 || $backpack->apiversion == OPEN_BADGES_V2P1) {
                 $backpack->canedit = true;
             } else {
                 $backpack->canedit = false;
index cd0c010..06dfd7a 100644 (file)
@@ -105,6 +105,14 @@ class provider implements
             'issuer' => 'privacy:metadata:external:backpacks:issuer',
         ], 'privacy:metadata:external:backpacks');
 
+        $collection->add_database_table('badge_backpack_oauth2', [
+            'userid' => 'privacy:metadata:backpackoauth2:userid',
+            'usermodified' => 'privacy:metadata:backpackoauth2:usermodified',
+            'token' => 'privacy:metadata:backpackoauth2:token',
+            'issuerid' => 'privacy:metadata:backpackoauth2:issuerid',
+            'scope' => 'privacy:metadata:backpackoauth2:scope',
+        ], 'privacy:metadata:backpackoauth2');
+
         return $collection;
     }
 
index bcb9482..61dd374 100644 (file)
@@ -56,10 +56,17 @@ $badgescache = cache::make('core', 'externalbadges');
 if ($disconnect && $backpack) {
     require_sesskey();
     $sitebackpack = badges_get_site_backpack($backpack->externalbackpackid);
-    // If backpack is connected, need to select collections.
-    $bp = new \core_badges\backpack_api($sitebackpack, $backpack);
-    $bp->disconnect_backpack($USER->id, $backpack->id);
-    redirect(new moodle_url('/badges/mybackpack.php'));
+    if ($sitebackpack->apiversion == OPEN_BADGES_V2P1) {
+        $bp = new \core_badges\backpack_api2p1($sitebackpack);
+        $bp->disconnect_backpack($backpack);
+        redirect(new moodle_url('/badges/mybackpack.php'), get_string('backpackdisconnected', 'badges'), null,
+            \core\output\notification::NOTIFY_SUCCESS);
+    } else {
+        // If backpack is connected, need to select collections.
+        $bp = new \core_badges\backpack_api($sitebackpack, $backpack);
+        $bp->disconnect_backpack($USER->id, $backpack->id);
+        redirect(new moodle_url('/badges/mybackpack.php'));
+    }
 }
 $warning = '';
 if ($backpack) {
@@ -100,6 +107,16 @@ 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