Merge branch 'MDL-71205' of https://github.com/mkassaei/moodle
authorabgreeve <adrian@moodle.com>
Wed, 21 Apr 2021 05:32:14 +0000 (13:32 +0800)
committerabgreeve <adrian@moodle.com>
Wed, 21 Apr 2021 05:32:14 +0000 (13:32 +0800)
585 files changed:
.eslintignore
.github/workflows/push.yml
.grunt/tasks/ignorefiles.js
.stylelintignore
.travis.yml
admin/message.php
admin/oauth2callback.php
admin/purgecaches.php
admin/settings/courses.php
admin/tool/cohortroles/classes/output/cohort_role_assignments_table.php
admin/tool/cohortroles/index.php
admin/tool/dataprivacy/amd/build/contactdpo.min.js [new file with mode: 0644]
admin/tool/dataprivacy/amd/build/contactdpo.min.js.map [new file with mode: 0644]
admin/tool/dataprivacy/amd/build/myrequestactions.min.js
admin/tool/dataprivacy/amd/build/myrequestactions.min.js.map
admin/tool/dataprivacy/amd/src/contactdpo.js [new file with mode: 0644]
admin/tool/dataprivacy/amd/src/myrequestactions.js
admin/tool/dataprivacy/classes/external.php
admin/tool/dataprivacy/classes/form/contactdpo.php [new file with mode: 0644]
admin/tool/dataprivacy/classes/output/renderer.php
admin/tool/dataprivacy/lang/en/tool_dataprivacy.php
admin/tool/dataprivacy/lib.php
admin/tool/dataprivacy/templates/contact_dpo.mustache [deleted file]
admin/tool/dataprivacy/tests/behat/contact_privacy_officer.feature
admin/tool/dataprivacy/tests/behat/my_data_requests.feature [new file with mode: 0644]
admin/tool/lp/templates/user_competency_summary.mustache
admin/tool/oauth2/issuers.php
admin/tool/oauth2/lang/en/tool_oauth2.php
admin/tool/oauth2/tests/behat/basic_settings.feature
admin/tool/usertours/classes/manager.php
analytics/classes/prediction.php
analytics/tests/prediction_actions_test.php
analytics/upgrade.txt
availability/condition/completion/tests/behat/availability_completion.feature
backup/moodle2/backup_course_task.class.php
backup/moodle2/backup_root_task.class.php
backup/moodle2/backup_settingslib.php
backup/moodle2/backup_stepslib.php
backup/moodle2/restore_final_task.class.php
backup/moodle2/restore_root_task.class.php
backup/moodle2/restore_settingslib.php
backup/moodle2/restore_stepslib.php
backup/tests/backup_cleanup_task_test.php
backup/tests/backup_restore_base_testcase.php [new file with mode: 0644]
backup/tests/backup_restore_permission_test.php [new file with mode: 0644]
backup/upgrade.txt
backup/util/dbops/backup_controller_dbops.class.php
backup/util/dbops/restore_controller_dbops.class.php
backup/util/ui/tests/behat/import_contentbank_content.feature
backup/util/ui/tests/behat/import_course.feature
backup/util/ui/tests/behat/restore_moodle2_courses.feature
badges/backpack-connect.php
badges/classes/form/external_backpack.php
badges/classes/oauth2/client.php
badges/criteria/award_criteria_cohort.php
badges/renderer.php
badges/tests/badgeslib_test.php
badges/tests/behat/award_badge.feature
badges/tests/behat/criteria_activity.feature
completion/criteria/completion_criteria_grade.php
completion/tests/behat/behat_completion.php
completion/tests/behat/completion_course_page_display.feature [moved from completion/tests/behat/completion_course_page_checkboxes.feature with 67% similarity]
completion/tests/behat/custom_completion_display_conditions.feature
completion/tests/behat/enable_manual_complete_mark.feature
completion/tests/behat/restrict_section_availability.feature
completion/upgrade.txt
contentbank/amd/build/upload.min.js [new file with mode: 0644]
contentbank/amd/build/upload.min.js.map [new file with mode: 0644]
contentbank/amd/src/upload.js [new file with mode: 0644]
contentbank/classes/form/upload_files.php [new file with mode: 0644]
contentbank/contenttype/h5p/classes/content.php
contentbank/contenttype/h5p/classes/contenttype.php
contentbank/contenttype/h5p/tests/behat/admin_replace_content.feature
contentbank/contenttype/h5p/tests/behat/admin_upload_content.feature
contentbank/contenttype/h5p/tests/behat/disable_contenttypes.feature [new file with mode: 0644]
contentbank/contenttype/h5p/tests/behat/manage_content.feature
contentbank/contenttype/h5p/tests/behat/teacher_replace_content.feature
contentbank/contenttype/h5p/tests/behat/teacher_upload_content.feature
contentbank/contenttype/h5p/tests/content_h5p_test.php
contentbank/files_form.php [deleted file]
contentbank/index.php
contentbank/templates/bankcontent/toolbar.mustache
contentbank/tests/behat/delete_content.feature
contentbank/tests/behat/download_content.feature
contentbank/tests/behat/edit_content.feature
contentbank/tests/behat/events.feature
contentbank/tests/behat/search_content.feature
contentbank/tests/behat/sort_content.feature
contentbank/tests/behat/view_preferences.feature
contentbank/tests/behat/visibility.feature
contentbank/tests/contentbank_test.php
contentbank/upgrade.txt
contentbank/upload.php [deleted file]
contentbank/view.php
course/amd/build/events.min.js
course/amd/build/events.min.js.map
course/amd/build/manual_completion_toggle.min.js
course/amd/build/manual_completion_toggle.min.js.map
course/amd/build/view.min.js [new file with mode: 0644]
course/amd/build/view.min.js.map [new file with mode: 0644]
course/amd/src/events.js
course/amd/src/manual_completion_toggle.js
course/amd/src/view.js [new file with mode: 0644]
course/classes/output/activity_information.php
course/classes/output/cm_format.php
course/classes/output/section_format/cmitem.php
course/completion.js [deleted file]
course/modlib.php
course/moodleform_mod.php
course/renderer.php
course/templates/activity_info.mustache
course/templates/completion_manual.mustache
course/templates/local/cm_format.mustache
course/templates/local/section_format/cmitem.mustache
course/tests/behat/behat_course.php
course/tests/behat/course_activity_dates.feature
course/togglecompletion.php
course/upgrade.txt
course/view.php
enrol/self/tests/behat/self_enrolment.feature
enrol/tests/enrollib_test.php
filter/displayh5p/tests/behat/h5p_filter.feature
filter/tex/mimetex.darwin
filter/tex/mimetex.exe
filter/tex/mimetex.freebsd
filter/tex/mimetex.linux
filter/tex/mimetex.linux.aarch64
filter/tex/readme_moodle.txt
filter/tex/thirdpartylibs.xml
grade/classes/external/create_gradecategories.php [new file with mode: 0644]
grade/grading/form/guide/db/services.php
grade/grading/form/rubric/db/services.php
grade/grading/manage.php
grade/grading/tests/behat/behat_grading.php
grade/import/csv/classes/load_data.php
grade/report/grader/lib.php
grade/report/overview/lib.php
grade/report/user/externallib.php
grade/report/user/lib.php
grade/tests/external/create_gradecategories_test.php [new file with mode: 0644]
group/index.php
group/lib.php
group/overview.php
group/tests/behat/custom_fields.feature [new file with mode: 0644]
group/tests/lib_test.php
h5p/classes/api.php
h5p/classes/core.php
h5p/classes/editor_ajax.php
h5p/classes/framework.php
h5p/classes/helper.php
h5p/classes/local/library/handler.php
h5p/classes/output/libraries.php
h5p/classes/player.php
h5p/libraries.php
h5p/templates/h5plibraries.mustache
h5p/tests/api_test.php
h5p/tests/behat/h5p_libraries.feature
h5p/tests/editor_framework_test.php
h5p/tests/generator/lib.php
h5p/tests/generator_test.php
h5p/upgrade.txt
install/lang/da_wp/langconfig.php [new file with mode: 0644]
install/lang/id/error.php [new file with mode: 0644]
install/lang/mk/install.php
install/lang/mk/langconfig.php
install/lang/pt/install.php
install/lang/vi/moodle.php
lang/en/backup.php
lang/en/contentbank.php
lang/en/course.php
lang/en/deprecated.txt
lang/en/h5p.php
lang/en/moodle.php
lib/adodb/LICENSE.md
lib/adodb/README.md
lib/adodb/adodb-active-record.inc.php
lib/adodb/adodb-active-recordx.inc.php
lib/adodb/adodb-csvlib.inc.php
lib/adodb/adodb-datadict.inc.php
lib/adodb/adodb-error.inc.php
lib/adodb/adodb-errorhandler.inc.php
lib/adodb/adodb-errorpear.inc.php
lib/adodb/adodb-exceptions.inc.php
lib/adodb/adodb-iterator.inc.php
lib/adodb/adodb-lib.inc.php
lib/adodb/adodb-loadbalancer.inc.php [new file with mode: 0644]
lib/adodb/adodb-memcache.lib.inc.php
lib/adodb/adodb-pager.inc.php
lib/adodb/adodb-pear.inc.php
lib/adodb/adodb-perf.inc.php
lib/adodb/adodb-php4.inc.php
lib/adodb/adodb-time.inc.php
lib/adodb/adodb-xmlschema.inc.php
lib/adodb/adodb-xmlschema03.inc.php
lib/adodb/adodb.inc.php
lib/adodb/datadict/datadict-access.inc.php
lib/adodb/datadict/datadict-db2.inc.php
lib/adodb/datadict/datadict-firebird.inc.php
lib/adodb/datadict/datadict-generic.inc.php
lib/adodb/datadict/datadict-ibase.inc.php
lib/adodb/datadict/datadict-informix.inc.php
lib/adodb/datadict/datadict-mssql.inc.php
lib/adodb/datadict/datadict-mssqlnative.inc.php
lib/adodb/datadict/datadict-mysql.inc.php
lib/adodb/datadict/datadict-oci8.inc.php
lib/adodb/datadict/datadict-postgres.inc.php
lib/adodb/datadict/datadict-sapdb.inc.php
lib/adodb/datadict/datadict-sqlite.inc.php
lib/adodb/datadict/datadict-sybase.inc.php
lib/adodb/drivers/adodb-access.inc.php
lib/adodb/drivers/adodb-ado.inc.php
lib/adodb/drivers/adodb-ado5.inc.php
lib/adodb/drivers/adodb-ado_access.inc.php
lib/adodb/drivers/adodb-ado_mssql.inc.php
lib/adodb/drivers/adodb-ads.inc.php
lib/adodb/drivers/adodb-borland_ibase.inc.php
lib/adodb/drivers/adodb-csv.inc.php
lib/adodb/drivers/adodb-db2.inc.php
lib/adodb/drivers/adodb-db2oci.inc.php
lib/adodb/drivers/adodb-db2ora.inc.php
lib/adodb/drivers/adodb-fbsql.inc.php
lib/adodb/drivers/adodb-firebird.inc.php
lib/adodb/drivers/adodb-ibase.inc.php
lib/adodb/drivers/adodb-informix.inc.php
lib/adodb/drivers/adodb-informix72.inc.php
lib/adodb/drivers/adodb-ldap.inc.php
lib/adodb/drivers/adodb-mssql.inc.php
lib/adodb/drivers/adodb-mssql_n.inc.php
lib/adodb/drivers/adodb-mssqlnative.inc.php
lib/adodb/drivers/adodb-mssqlpo.inc.php
lib/adodb/drivers/adodb-mysql.inc.php
lib/adodb/drivers/adodb-mysqli.inc.php
lib/adodb/drivers/adodb-mysqlpo.inc.php
lib/adodb/drivers/adodb-mysqlt.inc.php
lib/adodb/drivers/adodb-netezza.inc.php
lib/adodb/drivers/adodb-oci8.inc.php
lib/adodb/drivers/adodb-oci805.inc.php
lib/adodb/drivers/adodb-oci8po.inc.php
lib/adodb/drivers/adodb-oci8quercus.inc.php
lib/adodb/drivers/adodb-odbc.inc.php
lib/adodb/drivers/adodb-odbc_db2.inc.php
lib/adodb/drivers/adodb-odbc_mssql.inc.php
lib/adodb/drivers/adodb-odbc_mssql2012.inc.php [new file with mode: 0644]
lib/adodb/drivers/adodb-odbc_oracle.inc.php
lib/adodb/drivers/adodb-odbtp.inc.php
lib/adodb/drivers/adodb-odbtp_unicode.inc.php
lib/adodb/drivers/adodb-oracle.inc.php
lib/adodb/drivers/adodb-pdo.inc.php
lib/adodb/drivers/adodb-pdo_dblib.inc.php [new file with mode: 0644]
lib/adodb/drivers/adodb-pdo_firebird.inc.php [new file with mode: 0644]
lib/adodb/drivers/adodb-pdo_mssql.inc.php
lib/adodb/drivers/adodb-pdo_mysql.inc.php
lib/adodb/drivers/adodb-pdo_oci.inc.php
lib/adodb/drivers/adodb-pdo_pgsql.inc.php
lib/adodb/drivers/adodb-pdo_sqlite.inc.php
lib/adodb/drivers/adodb-pdo_sqlsrv.inc.php
lib/adodb/drivers/adodb-postgres.inc.php
lib/adodb/drivers/adodb-postgres64.inc.php
lib/adodb/drivers/adodb-postgres7.inc.php
lib/adodb/drivers/adodb-postgres8.inc.php
lib/adodb/drivers/adodb-postgres9.inc.php
lib/adodb/drivers/adodb-proxy.inc.php
lib/adodb/drivers/adodb-sapdb.inc.php
lib/adodb/drivers/adodb-sqlanywhere.inc.php
lib/adodb/drivers/adodb-sqlite.inc.php
lib/adodb/drivers/adodb-sqlite3.inc.php
lib/adodb/drivers/adodb-sqlitepo.inc.php
lib/adodb/drivers/adodb-sybase.inc.php
lib/adodb/drivers/adodb-sybase_ase.inc.php
lib/adodb/drivers/adodb-text.inc.php [new file with mode: 0644]
lib/adodb/drivers/adodb-vfp.inc.php
lib/adodb/perf/perf-db2.inc.php
lib/adodb/perf/perf-informix.inc.php
lib/adodb/perf/perf-mssql.inc.php
lib/adodb/perf/perf-mssqlnative.inc.php
lib/adodb/perf/perf-mysql.inc.php
lib/adodb/perf/perf-oci8.inc.php
lib/adodb/perf/perf-postgres.inc.php
lib/adodb/pivottable.inc.php
lib/adodb/readme_moodle.txt
lib/adodb/rsfilter.inc.php
lib/adodb/toexport.inc.php
lib/adodb/tohtml.inc.php
lib/adodb/xmlschema03.dtd
lib/amd/build/templates.min.js
lib/amd/build/templates.min.js.map
lib/amd/build/toast.min.js.map
lib/amd/src/templates.js
lib/amd/src/toast.js
lib/antivirus/clamav/settings.php
lib/behat/form_field/behat_form_date.php
lib/classes/event/base.php
lib/classes/grades_external.php
lib/classes/navigation/views/primary.php [new file with mode: 0644]
lib/classes/oauth2/api.php
lib/classes/oauth2/issuer.php
lib/classes/oauth2/service/facebook.php [new file with mode: 0644]
lib/classes/oauth2/service/imsobv2p1.php
lib/classes/oauth2/service/microsoft.php [new file with mode: 0644]
lib/classes/oauth2/service/nextcloud.php [new file with mode: 0644]
lib/datalib.php
lib/db/install.xml
lib/db/services.php
lib/db/upgrade.php
lib/enrollib.php
lib/filelib.php
lib/form/filemanager.js
lib/grade/grade_item.php
lib/navigationlib.php
lib/outputrenderers.php
lib/outputrequirementslib.php
lib/pagelib.php
lib/plagiarismlib.php
lib/setup.php
lib/setuplib.php
lib/templates/local/toast/message.mustache
lib/tests/behat/datetime_any.feature [new file with mode: 0644]
lib/tests/completion_daily_task_test.php
lib/tests/datalib_test.php
lib/tests/event/contentbank_content_uploaded_test.php
lib/tests/navigation/views/primary_test.php [new file with mode: 0644]
lib/tests/task_database_logger_test.php
lib/thirdpartylibs.xml
lib/upgrade.txt
lib/upgradelib.php
media/player/videojs/amd/build/loader.min.js
media/player/videojs/amd/build/loader.min.js.map
media/player/videojs/amd/src/loader.js
media/player/videojs/db/services.php
message/amd/build/message_drawer_view_conversation_patcher.min.js
message/amd/build/message_drawer_view_conversation_patcher.min.js.map
message/amd/src/message_drawer_view_conversation_patcher.js
message/output/airnotifier/checkconfiguration.php [new file with mode: 0644]
message/output/airnotifier/classes/manager.php
message/output/airnotifier/lang/en/message_airnotifier.php
message/output/airnotifier/settings.php
message/output/airnotifier/tests/manager_test.php [new file with mode: 0644]
mod/assign/classes/completion/custom_completion.php [new file with mode: 0644]
mod/assign/feedback/offline/importgradeslib.php
mod/assign/gradingtable.php
mod/assign/lang/en/assign.php
mod/assign/lib.php
mod/assign/locallib.php
mod/assign/renderer.php
mod/assign/tests/custom_completion_test.php [new file with mode: 0644]
mod/assign/tests/lib_test.php
mod/book/view.php
mod/chat/view.php
mod/data/classes/completion/custom_completion.php [new file with mode: 0644]
mod/data/lang/en/data.php
mod/data/lib.php
mod/data/tests/custom_completion_test.php [new file with mode: 0644]
mod/data/view.php
mod/feedback/classes/completion/custom_completion.php [new file with mode: 0644]
mod/feedback/lang/en/feedback.php
mod/feedback/tests/custom_completion_test.php [new file with mode: 0644]
mod/feedback/view.php
mod/folder/view.php
mod/forum/classes/completion/custom_completion.php [new file with mode: 0644]
mod/forum/classes/privacy/provider.php
mod/forum/lang/en/forum.php
mod/forum/tests/behat/completion_condition_number_discussions.feature
mod/forum/tests/custom_completion_test.php [new file with mode: 0644]
mod/forum/tests/privacy_provider_test.php
mod/forum/view.php
mod/glossary/classes/completion/custom_completion.php [new file with mode: 0644]
mod/glossary/db/services.php
mod/glossary/lang/en/glossary.php
mod/glossary/lib.php
mod/glossary/tests/custom_completion_test.php [new file with mode: 0644]
mod/glossary/view.php
mod/h5pactivity/db/services.php
mod/h5pactivity/tests/behat/result_longfillin.feature
mod/h5pactivity/view.php
mod/imscp/view.php
mod/label/view.php
mod/lesson/classes/completion/custom_completion.php [new file with mode: 0644]
mod/lesson/index.php
mod/lesson/lang/en/lesson.php
mod/lesson/lib.php
mod/lesson/renderer.php
mod/lesson/settings.php
mod/lesson/tests/behat/completion_condition_end_reached.feature
mod/lesson/tests/behat/completion_condition_time_spent.feature
mod/lesson/tests/custom_completion_test.php [new file with mode: 0644]
mod/lti/tests/behat/lti_activity_completion.feature [new file with mode: 0644]
mod/lti/view.php
mod/page/tests/behat/page_activity_completion.feature [new file with mode: 0644]
mod/page/view.php
mod/quiz/attemptlib.php
mod/quiz/classes/completion/custom_completion.php [new file with mode: 0644]
mod/quiz/lang/en/quiz.php
mod/quiz/lib.php
mod/quiz/locallib.php
mod/quiz/renderer.php
mod/quiz/report/attemptsreport_table.php
mod/quiz/report/default.php
mod/quiz/report/overview/report.php
mod/quiz/review.php
mod/quiz/reviewquestion.php
mod/quiz/tests/behat/attempt_basic.feature
mod/quiz/tests/behat/attempt_redo_questions.feature
mod/quiz/tests/behat/completion_condition_attempts_used.feature
mod/quiz/tests/behat/completion_condition_minimum_attempts.feature
mod/quiz/tests/behat/completion_condition_passing_grade.feature
mod/quiz/tests/behat/editing_add_from_question_bank.feature
mod/quiz/tests/behat/editing_add_random.feature
mod/quiz/tests/behat/info_page.feature [new file with mode: 0644]
mod/quiz/tests/behat/preview.feature
mod/quiz/tests/custom_completion_test.php [new file with mode: 0644]
mod/quiz/tests/lib_test.php
mod/quiz/view.php
mod/resource/locallib.php
mod/scorm/classes/completion/custom_completion.php [new file with mode: 0644]
mod/scorm/lang/en/scorm.php
mod/scorm/lib.php
mod/scorm/mod_form.php
mod/scorm/tests/custom_completion_test.php [new file with mode: 0644]
mod/scorm/view.php
mod/survey/classes/completion/custom_completion.php [new file with mode: 0644]
mod/survey/lang/en/survey.php
mod/survey/tests/behat/survey_completion.feature
mod/survey/tests/custom_completion_test.php [new file with mode: 0644]
mod/survey/view.php
mod/url/locallib.php
mod/url/tests/behat/url_activity_completion.feature [new file with mode: 0644]
mod/wiki/renderer.php
mod/wiki/tests/behat/wiki_activity_completion.feature [new file with mode: 0644]
mod/workshop/lang/en/workshop.php
mod/workshop/tests/behat/workshop_activity_completion.feature [new file with mode: 0644]
mod/workshop/tests/behat/workshop_assessment.feature
mod/workshop/view.php
payment/gateway/paypal/db/services.php
plagiarism/upgrade.txt
question/engine/lib.php
question/engine/questionattemptstep.php
question/engine/questionusage.php
question/engine/renderer.php
question/engine/tests/questionattemptstep_test.php
question/engine/tests/questionusagebyactivity_test.php
question/engine/tests/walkthrough_test.php
question/type/calculatedmulti/edit_calculatedmulti_form.php
question/type/ddimageortext/edit_ddimageortext_form.php
question/type/ddimageortext/questiontype.php
question/type/ddimageortext/tests/behat/add.feature
question/type/ddmarker/edit_ddmarker_form.php
question/type/ddmarker/questiontype.php
question/type/ddmarker/tests/behat/add.feature
question/type/edit_question_form.php
question/type/essay/edit_essay_form.php
question/type/essay/question.php
question/type/essay/questiontype.php
question/type/essay/renderer.php
question/type/essay/tests/behat/add.feature
question/type/essay/tests/question_test.php
question/type/match/edit_match_form.php
question/type/match/questiontype.php
question/type/match/tests/behat/add.feature
question/type/multianswer/edit_multianswer_form.php
question/type/multichoice/edit_multichoice_form.php
report/insights/classes/output/insight.php
report/progress/classes/local/helper.php [new file with mode: 0644]
report/progress/classes/output/renderer.php [new file with mode: 0644]
report/progress/index.php
report/progress/lang/en/report_progress.php
report/progress/tests/behat/activity_completion_report.feature
report/progress/tests/behat/activity_completion_report_filter.feature [new file with mode: 0644]
report/progress/tests/report_progress_helper_testcase.php [new file with mode: 0644]
repository/contentbank/tests/browser_test.php
repository/contentbank/tests/search_test.php
repository/googledocs/classes/googledocs_content.php [new file with mode: 0644]
repository/googledocs/classes/googledocs_content_search.php [new file with mode: 0644]
repository/googledocs/classes/helper.php [new file with mode: 0644]
repository/googledocs/classes/local/browser/googledocs_drive_content.php [new file with mode: 0644]
repository/googledocs/classes/local/browser/googledocs_root_content.php [new file with mode: 0644]
repository/googledocs/classes/local/browser/googledocs_shared_drives_content.php [new file with mode: 0644]
repository/googledocs/classes/local/node/file_node.php [new file with mode: 0644]
repository/googledocs/classes/local/node/folder_node.php [new file with mode: 0644]
repository/googledocs/classes/local/node/node.php [new file with mode: 0644]
repository/googledocs/classes/rest.php
repository/googledocs/lang/en/repository_googledocs.php
repository/googledocs/lib.php
repository/googledocs/tests/googledocs_content_testcase.php [new file with mode: 0644]
repository/googledocs/tests/googledocs_search_content_test.php [new file with mode: 0644]
repository/googledocs/tests/helper_test.php [new file with mode: 0644]
repository/googledocs/tests/local/browser/googledocs_drive_content_test.php [new file with mode: 0644]
repository/googledocs/tests/local/browser/googledocs_root_content_test.php [new file with mode: 0644]
repository/googledocs/tests/local/browser/googledocs_shared_drives_content_test.php [new file with mode: 0644]
repository/googledocs/tests/local/node/file_node_test.php [new file with mode: 0644]
repository/googledocs/tests/local/node/folder_node_test.php [new file with mode: 0644]
repository/googledocs/tests/repository_googledocs_testcase.php [new file with mode: 0644]
repository/googledocs/version.php
repository/upgrade.txt
security.txt
theme/boost/amd/build/bootstrap/alert.min.js
theme/boost/amd/build/bootstrap/alert.min.js.map
theme/boost/amd/build/bootstrap/button.min.js
theme/boost/amd/build/bootstrap/button.min.js.map
theme/boost/amd/build/bootstrap/carousel.min.js
theme/boost/amd/build/bootstrap/carousel.min.js.map
theme/boost/amd/build/bootstrap/collapse.min.js
theme/boost/amd/build/bootstrap/collapse.min.js.map
theme/boost/amd/build/bootstrap/dropdown.min.js
theme/boost/amd/build/bootstrap/dropdown.min.js.map
theme/boost/amd/build/bootstrap/index.min.js [deleted file]
theme/boost/amd/build/bootstrap/index.min.js.map [deleted file]
theme/boost/amd/build/bootstrap/modal.min.js
theme/boost/amd/build/bootstrap/modal.min.js.map
theme/boost/amd/build/bootstrap/popover.min.js
theme/boost/amd/build/bootstrap/popover.min.js.map
theme/boost/amd/build/bootstrap/scrollspy.min.js
theme/boost/amd/build/bootstrap/scrollspy.min.js.map
theme/boost/amd/build/bootstrap/tab.min.js
theme/boost/amd/build/bootstrap/tab.min.js.map
theme/boost/amd/build/bootstrap/toast.min.js
theme/boost/amd/build/bootstrap/toast.min.js.map
theme/boost/amd/build/bootstrap/tools/sanitizer.min.js.map
theme/boost/amd/build/bootstrap/tooltip.min.js
theme/boost/amd/build/bootstrap/tooltip.min.js.map
theme/boost/amd/build/bootstrap/util.min.js.map
theme/boost/amd/build/index.min.js [new file with mode: 0644]
theme/boost/amd/build/index.min.js.map [new file with mode: 0644]
theme/boost/amd/build/loader.min.js
theme/boost/amd/build/loader.min.js.map
theme/boost/amd/src/bootstrap/alert.js
theme/boost/amd/src/bootstrap/button.js
theme/boost/amd/src/bootstrap/carousel.js
theme/boost/amd/src/bootstrap/collapse.js
theme/boost/amd/src/bootstrap/dropdown.js
theme/boost/amd/src/bootstrap/index.js [deleted file]
theme/boost/amd/src/bootstrap/modal.js
theme/boost/amd/src/bootstrap/popover.js
theme/boost/amd/src/bootstrap/scrollspy.js
theme/boost/amd/src/bootstrap/tab.js
theme/boost/amd/src/bootstrap/toast.js
theme/boost/amd/src/bootstrap/tools/sanitizer.js
theme/boost/amd/src/bootstrap/tooltip.js
theme/boost/amd/src/bootstrap/util.js
theme/boost/amd/src/index.js [new file with mode: 0644]
theme/boost/amd/src/loader.js
theme/boost/readme_moodle.txt
theme/boost/scss/bootstrap/_alert.scss
theme/boost/scss/bootstrap/_breadcrumb.scss
theme/boost/scss/bootstrap/_card.scss
theme/boost/scss/bootstrap/_carousel.scss
theme/boost/scss/bootstrap/_custom-forms.scss
theme/boost/scss/bootstrap/_dropdown.scss
theme/boost/scss/bootstrap/_functions.scss
theme/boost/scss/bootstrap/_grid.scss
theme/boost/scss/bootstrap/_input-group.scss
theme/boost/scss/bootstrap/_list-group.scss
theme/boost/scss/bootstrap/_modal.scss
theme/boost/scss/bootstrap/_nav.scss
theme/boost/scss/bootstrap/_navbar.scss
theme/boost/scss/bootstrap/_pagination.scss
theme/boost/scss/bootstrap/_progress.scss
theme/boost/scss/bootstrap/_reboot.scss
theme/boost/scss/bootstrap/_root.scss
theme/boost/scss/bootstrap/_spinners.scss
theme/boost/scss/bootstrap/_toasts.scss
theme/boost/scss/bootstrap/_type.scss
theme/boost/scss/bootstrap/_variables.scss
theme/boost/scss/bootstrap/bootstrap-grid.scss
theme/boost/scss/bootstrap/bootstrap-reboot.scss
theme/boost/scss/bootstrap/bootstrap.scss
theme/boost/scss/bootstrap/mixins/_border-radius.scss
theme/boost/scss/bootstrap/mixins/_forms.scss
theme/boost/scss/bootstrap/mixins/_grid-framework.scss
theme/boost/scss/bootstrap/mixins/_grid.scss
theme/boost/scss/bootstrap/mixins/_image.scss
theme/boost/scss/bootstrap/mixins/_screen-reader.scss
theme/boost/scss/bootstrap/mixins/_transition.scss
theme/boost/scss/bootstrap/utilities/_borders.scss
theme/boost/scss/bootstrap/utilities/_text.scss
theme/boost/scss/moodle.scss
theme/boost/scss/moodle/core.scss
theme/boost/scss/moodle/course.scss
theme/boost/scss/moodle/toasts.scss [new file with mode: 0644]
theme/boost/scss/preset/default.scss
theme/boost/style/moodle.css
theme/boost/thirdpartylibs.xml
theme/classic/scss/preset/default.scss
theme/classic/style/moodle.css
user/selector/lib.php
version.php

index a6d5413..2262b1f 100644 (file)
@@ -84,7 +84,6 @@ theme/boost/amd/src/bootstrap/button.js
 theme/boost/amd/src/bootstrap/carousel.js
 theme/boost/amd/src/bootstrap/collapse.js
 theme/boost/amd/src/bootstrap/dropdown.js
-theme/boost/amd/src/bootstrap/index.js
 theme/boost/amd/src/bootstrap/modal.js
 theme/boost/amd/src/bootstrap/popover.js
 theme/boost/amd/src/bootstrap/tools/sanitizer.js
@@ -93,4 +92,5 @@ theme/boost/amd/src/bootstrap/tab.js
 theme/boost/amd/src/bootstrap/toast.js
 theme/boost/amd/src/bootstrap/tooltip.js
 theme/boost/amd/src/bootstrap/util.js
-theme/boost/scss/fontawesome/
\ No newline at end of file
+theme/boost/amd/src/index.js
+theme/boost/scss/fontawesome/
index 35fd4a7..0c4f1ba 100644 (file)
@@ -1,6 +1,12 @@
 name: Core
 
-on: [push]
+on:
+  push:
+    branches-ignore:
+      - master
+      - MOODLE_[0-9]+_STABLE
+    tags-ignore:
+      - v[0-9]+.[0-9]+.[0-9]+*
 
 env:
   php: 7.4
index d8b9ec1..9cc2f6c 100644 (file)
@@ -41,7 +41,7 @@ module.exports = grunt => {
             '*/**/yui/src/*/meta/',
             '*/**/build/',
         ].concat(thirdPartyPaths);
-        grunt.file.write('.eslintignore', eslintIgnores.join('\n'));
+        grunt.file.write('.eslintignore', eslintIgnores.join('\n') + '\n');
 
         // Generate .stylelintignore.
         const stylelintIgnores = [
@@ -50,7 +50,7 @@ module.exports = grunt => {
             'theme/boost/style/moodle.css',
             'theme/classic/style/moodle.css',
         ].concat(thirdPartyPaths);
-        grunt.file.write('.stylelintignore', stylelintIgnores.join('\n'));
+        grunt.file.write('.stylelintignore', stylelintIgnores.join('\n') + '\n');
     };
 
     grunt.registerTask('ignorefiles', 'Generate ignore files for linters', handler);
index 0207e41..2d28f80 100644 (file)
@@ -84,7 +84,6 @@ theme/boost/amd/src/bootstrap/button.js
 theme/boost/amd/src/bootstrap/carousel.js
 theme/boost/amd/src/bootstrap/collapse.js
 theme/boost/amd/src/bootstrap/dropdown.js
-theme/boost/amd/src/bootstrap/index.js
 theme/boost/amd/src/bootstrap/modal.js
 theme/boost/amd/src/bootstrap/popover.js
 theme/boost/amd/src/bootstrap/tools/sanitizer.js
@@ -93,4 +92,5 @@ theme/boost/amd/src/bootstrap/tab.js
 theme/boost/amd/src/bootstrap/toast.js
 theme/boost/amd/src/bootstrap/tooltip.js
 theme/boost/amd/src/bootstrap/util.js
-theme/boost/scss/fontawesome/
\ No newline at end of file
+theme/boost/amd/src/index.js
+theme/boost/scss/fontawesome/
index 0c565f3..6d52469 100644 (file)
@@ -19,6 +19,12 @@ services:
 addons:
   postgresql: "9.6"
 
+branches:
+  except:
+    - master
+    - /MOODLE_[0-9]+_STABLE/
+    - /^v[0-9]+\.[0-9]+\.[0-9]+.*/
+
 jobs:
     # Enable fast finish.
     # This will fail the build if a single job fails (except those in allow_failures).
index b5c31d7..aa632b1 100644 (file)
@@ -119,10 +119,19 @@ if (($form = data_submitted()) && confirm_sesskey()) {
     // Save processors enabled/disabled status.
     foreach ($allprocessors as $processor) {
         $enabled = isset($form->{$processor->name});
+        if ($enabled != $processor->enabled) {
+            add_to_config_log($processor->name, $processor->enabled, $enabled, 'core');
+        }
         \core_message\api::update_processor_status($processor, $enabled);
     }
 
     foreach ($newpreferences as $name => $value) {
+        $old = isset($preferences->$name) ? $preferences->$name : '';
+
+        if ($old != $value) {
+            add_to_config_log($name, $old, $value, 'core');
+        }
+
         set_config($name, $value, 'message');
     }
     $transaction->allow_commit();
index 709e28c..c0b5572 100644 (file)
@@ -34,11 +34,12 @@ $error = optional_param('error', '', PARAM_RAW);
 if ($error) {
     $message = optional_param('error_description', '', PARAM_RAW);
     if ($message) {
-        print_error($message);
+        $SESSION->loginerrormsg = $message;
+        redirect(new moodle_url(get_login_url()));
     } else {
-        print_error($error);
+        $SESSION->loginerrormsg = $error;
+        redirect(new moodle_url(get_login_url()));
     }
-    die();
 }
 
 // The authorization code generated by the authorization server.
@@ -53,5 +54,6 @@ if (isset($params['sesskey']) and confirm_sesskey($params['sesskey'])) {
     $redirecturl->param('oauth2code', $code);
     redirect($redirecturl);
 } else {
-    print_error('invalidsesskey');
+    $SESSION->loginerrormsg = get_string('invalidsesskey', 'error');
+    redirect(new moodle_url(get_login_url()));
 }
index b4401c4..b43aa77 100644 (file)
@@ -52,8 +52,14 @@ if ($data = $form->get_data()) {
     $message = get_string('purgecachesfinished', 'admin');
 }
 
+// Redirect and/or show notification message confirming cache(s) were purged.
 if (isset($message)) {
-    redirect($returnurl, $message);
+    if (!$PAGE->url->compare($returnurl, URL_MATCH_BASE)) {
+        redirect($returnurl, $message);
+    }
+
+    // We are already on the purge caches page, add the notification.
+    \core\notification::add($message, \core\output\notification::NOTIFY_INFO);
 }
 
 // Otherwise, show a form to actually purge the caches.
index 86d198e..2037a63 100644 (file)
@@ -343,6 +343,11 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) {
 
     // Import defaults section.
     $temp->add(new admin_setting_heading('importsettings', new lang_string('importsettings', 'backup'), ''));
+    $temp->add(new admin_setting_configcheckbox_with_lock(
+            'backup/backup_import_permissions',
+            new lang_string('generalpermissions', 'backup'),
+            new lang_string('configgeneralpermissions', 'backup'),
+            array('value' => 0, 'locked' => 0)));
     $temp->add(new admin_setting_configcheckbox_with_lock('backup/backup_import_activities', new lang_string('generalactivities','backup'), new lang_string('configgeneralactivities','backup'), array('value'=>1, 'locked'=>0)));
     $temp->add(new admin_setting_configcheckbox_with_lock('backup/backup_import_blocks', new lang_string('generalblocks','backup'), new lang_string('configgeneralblocks','backup'), array('value'=>1, 'locked'=>0)));
     $temp->add(new admin_setting_configcheckbox_with_lock('backup/backup_import_filters', new lang_string('generalfilters','backup'), new lang_string('configgeneralfilters','backup'), array('value'=>1, 'locked'=>0)));
@@ -517,6 +522,9 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) {
     $temp->add(new admin_setting_configcheckbox_with_lock('restore/restore_general_role_assignments',
         new lang_string('generalroleassignments', 'backup'),
         new lang_string('configrestoreroleassignments', 'backup'), array('value' => 1, 'locked' => 0)));
+    $temp->add(new admin_setting_configcheckbox_with_lock('restore/restore_general_permissions',
+        new lang_string('generalpermissions', 'backup'),
+        new lang_string('configrestorepermissions', 'backup'), array('value' => 1, 'locked' => 0)));
     $temp->add(new admin_setting_configcheckbox_with_lock('restore/restore_general_activities',
         new lang_string('generalactivities', 'backup'),
         new lang_string('configrestoreactivities', 'backup'), array('value' => 1, 'locked' => 0)));
index 364544a..6172ef5 100644 (file)
@@ -188,7 +188,12 @@ class cohort_role_assignments_table extends table_sql {
                    FROM {tool_cohortroles} uca
                    JOIN {user} u ON u.id = uca.userid
                    JOIN {cohort} c ON c.id = uca.cohortid";
-        $params = array();
+
+        // Check if any additional filtering is required.
+        [$sqlwhere, $params] = $this->get_sql_where();
+        if ($sqlwhere) {
+            $sql .= " WHERE {$sqlwhere}";
+        }
 
         // Add order by if needed.
         if (!$count && $sqlsort = $this->get_sql_sort()) {
index cc9947c..3396ddb 100644 (file)
@@ -86,8 +86,8 @@ if ($removeid) {
 
     $title = get_string('existingcohortroles', 'tool_cohortroles');
     echo $output->heading($title);
-    $url = new moodle_url('/admin/tool/cohortroles/index.php');
-    $table = new tool_cohortroles\output\cohort_role_assignments_table(uniqid(), $url);
+
+    $table = new tool_cohortroles\output\cohort_role_assignments_table('cohort-role-assignments', $pageurl);
     echo $table->out(50, true);
 
     echo $output->spacer();
diff --git a/admin/tool/dataprivacy/amd/build/contactdpo.min.js b/admin/tool/dataprivacy/amd/build/contactdpo.min.js
new file mode 100644 (file)
index 0000000..7351478
Binary files /dev/null and b/admin/tool/dataprivacy/amd/build/contactdpo.min.js differ
diff --git a/admin/tool/dataprivacy/amd/build/contactdpo.min.js.map b/admin/tool/dataprivacy/amd/build/contactdpo.min.js.map
new file mode 100644 (file)
index 0000000..6445af7
Binary files /dev/null and b/admin/tool/dataprivacy/amd/build/contactdpo.min.js.map differ
index a6ba759..b1b7317 100644 (file)
Binary files a/admin/tool/dataprivacy/amd/build/myrequestactions.min.js and b/admin/tool/dataprivacy/amd/build/myrequestactions.min.js differ
index c9f78ed..4dfdc98 100644 (file)
Binary files a/admin/tool/dataprivacy/amd/build/myrequestactions.min.js.map and b/admin/tool/dataprivacy/amd/build/myrequestactions.min.js.map differ
diff --git a/admin/tool/dataprivacy/amd/src/contactdpo.js b/admin/tool/dataprivacy/amd/src/contactdpo.js
new file mode 100644 (file)
index 0000000..50417a8
--- /dev/null
@@ -0,0 +1,67 @@
+// 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/>.
+
+/**
+ * Javascript module for contacting the site DPO
+ *
+ * @module      tool_dataprivacy/contactdpo
+ * @package     tool_dataprivacy
+ * @copyright   2021 Paul Holden <paulh@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+import ModalForm from 'core_form/modalform';
+import Notification from 'core/notification';
+import {get_string as getString} from 'core/str';
+import {add as addToast} from 'core/toast';
+
+const SELECTORS = {
+    CONTACT_DPO: '[data-action="contactdpo"]',
+};
+
+/**
+ * Initialize module
+ */
+export const init = () => {
+    const triggerElement = document.querySelector(SELECTORS.CONTACT_DPO);
+
+    triggerElement.addEventListener('click', event => {
+        event.preventDefault();
+
+        const modalForm = new ModalForm({
+            modalConfig: {
+                title: getString('contactdataprotectionofficer', 'tool_dataprivacy'),
+            },
+            formClass: 'tool_dataprivacy\\form\\contactdpo',
+            saveButtonText: getString('send', 'tool_dataprivacy'),
+            returnFocus: triggerElement,
+        });
+
+        // Show a toast notification when the form is submitted.
+        modalForm.addEventListener(modalForm.events.FORM_SUBMITTED, event => {
+            if (event.detail.result) {
+                getString('requestsubmitted', 'tool_dataprivacy').then(addToast).catch();
+            } else {
+                const warningMessages = event.detail.warnings.map(warning => warning.message);
+                Notification.addNotification({
+                    type: 'error',
+                    message: warningMessages.join('<br>')
+                });
+            }
+        });
+
+        modalForm.show();
+    });
+};
index 54f94fb..b75b386 100644 (file)
  * @copyright  2018 Jun Pataleta
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-define([
-    'jquery',
-    'core/ajax',
-    'core/notification',
-    'core/str',
-    'core/modal_factory',
-    'core/modal_events',
-    'core/templates',
-    'core/pending'],
-function($, Ajax, Notification, Str, ModalFactory, ModalEvents, Templates, Pending) {
 
-    /**
-     * List of action selectors.
-     *
-     * @type {{CANCEL_REQUEST: string}}
-     * @type {{CONTACT_DPO: string}}
-     */
-    var ACTIONS = {
-        CANCEL_REQUEST: '[data-action="cancel"]',
-        CONTACT_DPO: '[data-action="contactdpo"]',
-    };
+import Ajax from 'core/ajax';
+import Notification from 'core/notification';
+import Pending from 'core/pending';
+import {get_strings as getStrings} from 'core/str';
 
-    /**
-     * MyRequestActions class.
-     */
-    var MyRequestActions = function() {
-        this.registerEvents();
-    };
+const SELECTORS = {
+    CANCEL_REQUEST: '[data-action="cancel"][data-requestid]',
+};
 
-    /**
-     * Register event listeners.
-     */
-    MyRequestActions.prototype.registerEvents = function() {
-        $(ACTIONS.CANCEL_REQUEST).click(function(e) {
-            e.preventDefault();
-
-            var requestId = $(this).data('requestid');
-            var stringkeys = [
-                {
-                    key: 'cancelrequest',
-                    component: 'tool_dataprivacy'
-                },
-                {
-                    key: 'cancelrequestconfirmation',
-                    component: 'tool_dataprivacy'
-                }
-            ];
-
-            Str.get_strings(stringkeys).then(function(langStrings) {
-                var title = langStrings[0];
-                var confirmMessage = langStrings[1];
-                return ModalFactory.create({
-                    title: title,
-                    body: confirmMessage,
-                    type: ModalFactory.types.SAVE_CANCEL
-                }).then(function(modal) {
-                    modal.setSaveButtonText(title);
-
-                    // Handle save event.
-                    modal.getRoot().on(ModalEvents.save, function() {
-                        // Cancel the request.
-                        var params = {
-                            'requestid': requestId
-                        };
-
-                        var request = {
-                            methodname: 'tool_dataprivacy_cancel_data_request',
-                            args: params
-                        };
-
-                        Ajax.call([request])[0].done(function(data) {
-                            if (data.result) {
-                                window.location.reload();
-                            } else {
-                                Notification.addNotification({
-                                    message: data.warnings[0].message,
-                                    type: 'error'
-                                });
-                            }
-                        }).fail(Notification.exception);
-                    });
-
-                    // Handle hidden event.
-                    modal.getRoot().on(ModalEvents.hidden, function() {
-                        // Destroy when hidden.
-                        modal.destroy();
-                    });
-
-                    return modal;
-                });
-            }).done(function(modal) {
-                // Show the modal!
-                modal.show();
-
-            }).fail(Notification.exception);
-        });
-
-        $(ACTIONS.CONTACT_DPO).click(function(e) {
-            var pendingPromise = new Pending('dataprivacy/crud:initModal:contactdpo');
-            e.preventDefault();
+/**
+ * Initialize module
+ */
+export const init = () => {
+    document.addEventListener('click', event => {
+        const triggerElement = event.target.closest(SELECTORS.CANCEL_REQUEST);
+        if (triggerElement === null) {
+            return;
+        }
 
-            var replyToEmail = $(this).data('replytoemail');
+        event.preventDefault();
 
-            var keys = [
-                {
-                    key: 'contactdataprotectionofficer',
-                    component: 'tool_dataprivacy'
-                },
-                {
-                    key: 'send',
-                    component: 'tool_dataprivacy'
-                },
-            ];
+        const requiredStrings = [
+            {key: 'cancelrequest', component: 'tool_dataprivacy'},
+            {key: 'cancelrequestconfirmation', component: 'tool_dataprivacy'},
+        ];
 
-            var sendButtonText = '';
-            Str.get_strings(keys).then(function(langStrings) {
-                var modalTitle = langStrings[0];
-                sendButtonText = langStrings[1];
-                var context = {
-                    'replytoemail': replyToEmail
+        getStrings(requiredStrings).then(([cancelRequest, cancelConfirm]) => {
+            return Notification.confirm(cancelRequest, cancelConfirm, cancelRequest, null, () => {
+                const pendingPromise = new Pending('tool/dataprivacy:cancelRequest');
+                const request = {
+                    methodname: 'tool_dataprivacy_cancel_data_request',
+                    args: {requestid: triggerElement.dataset.requestid}
                 };
-                return ModalFactory.create({
-                    title: modalTitle,
-                    body: Templates.render('tool_dataprivacy/contact_dpo', context),
-                    type: ModalFactory.types.SAVE_CANCEL,
-                    large: true
-                });
-            }).then(function(modal) {
-                modal.setSaveButtonText(sendButtonText);
-
-                // Show the modal!
-                modal.show();
 
-                // Handle send event.
-                modal.getRoot().on(ModalEvents.save, function(e) {
-                    var message = $('#message').val().trim();
-                    if (message.length === 0) {
-                        e.preventDefault();
-                        // Show validation error when the message is empty.
-                        $('[data-region="messageinput"]').addClass('has-danger notifyproblem');
-                        $('#id_error_message').removeAttr('hidden');
+                Ajax.call([request])[0].then(response => {
+                    if (response.result) {
+                        window.location.reload();
                     } else {
-                        // Send the message.
-                        sendMessageToDPO(message);
+                        Notification.addNotification({
+                            type: 'error',
+                            message: response.warnings[0].message
+                        });
                     }
-                });
-
-                // Handle hidden event.
-                modal.getRoot().on(ModalEvents.hidden, function() {
-                    // Destroy when hidden.
-                    modal.destroy();
-                });
-
-                return;
-            }).then(pendingPromise.resolve)
-            .catch(Notification.exception);
-        });
-    };
-
-    /**
-     * Send message to the Data Protection Officer.
-     *
-     * @param {String} message The message to send.
-     */
-    function sendMessageToDPO(message) {
-        var request = {
-            methodname: 'tool_dataprivacy_contact_dpo',
-            args: {
-                message: message
-            }
-        };
-
-        var requestType = 'success';
-        Ajax.call([request])[0].then(function(data) {
-            if (data.result) {
-                return Str.get_string('requestsubmitted', 'tool_dataprivacy');
-            }
-            requestType = 'error';
-            return data.warnings.join('<br>');
-
-        }).done(function(message) {
-            Notification.addNotification({
-                message: message,
-                type: requestType
+                    return pendingPromise.resolve();
+                }).catch(Notification.exception);
             });
-
-        }).fail(Notification.exception);
-    }
-
-    return /** @alias module:tool_dataprivacy/myrequestactions */ {
-        // Public variables and functions.
-
-        /**
-         * Initialise the unified user filter.
-         *
-         * @method init
-         * @return {MyRequestActions}
-         */
-        'init': function() {
-            return new MyRequestActions();
-        }
-    };
-});
+        }).catch();
+    });
+};
index 92dd398..30d2e6d 100644 (file)
@@ -212,7 +212,8 @@ class external extends external_api {
                 $warnings[] = [
                     'item' => $dpo->id,
                     'warningcode' => 'errorsendingtodpo',
-                    'message' => get_string('errorsendingmessagetodpo', 'tool_dataprivacy')
+                    'message' => get_string('errorsendingmessagetodpo', 'tool_dataprivacy',
+                        fullname($dpo))
                 ];
             }
         }
diff --git a/admin/tool/dataprivacy/classes/form/contactdpo.php b/admin/tool/dataprivacy/classes/form/contactdpo.php
new file mode 100644 (file)
index 0000000..dc17291
--- /dev/null
@@ -0,0 +1,100 @@
+<?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/>.
+
+
+namespace tool_dataprivacy\form;
+
+use context;
+use context_user;
+use moodle_exception;
+use moodle_url;
+use core_form\dynamic_form;
+use tool_dataprivacy\api;
+use tool_dataprivacy\external;
+
+/**
+ * Contact DPO modal form
+ *
+ * @package    tool_dataprivacy
+ * @copyright  2021 Paul Holden <paulh@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class contactdpo extends dynamic_form {
+
+    /**
+     * Form definition
+     */
+    protected function definition() {
+        global $USER;
+
+        $mform = $this->_form;
+
+        $mform->addElement('static', 'replyto', get_string('replyto', 'tool_dataprivacy'), s($USER->email));
+
+        $mform->addElement('textarea', 'message', get_string('message', 'tool_dataprivacy'), 'cols="60" rows="8"');
+        $mform->setType('message', PARAM_TEXT);
+        $mform->addRule('message', get_string('required'), 'required', null, 'client');
+    }
+
+    /**
+     * Return form context
+     *
+     * @return context
+     */
+    protected function get_context_for_dynamic_submission(): context {
+        global $USER;
+
+        return context_user::instance($USER->id);
+    }
+
+    /**
+     * Check if current user has access to this form, otherwise throw exception
+     *
+     * @throws moodle_exception
+     */
+    protected function check_access_for_dynamic_submission(): void {
+        if (!api::can_contact_dpo()) {
+            throw new moodle_exception('errorcontactdpodisabled', 'tool_dataprivacy');
+        }
+    }
+
+    /**
+     * Process the form submission, used if form was submitted via AJAX
+     *
+     * @return array
+     */
+    public function process_dynamic_submission() {
+        return external::contact_dpo($this->get_data()->message);
+    }
+
+    /**
+     * Load in existing data as form defaults (not applicable)
+     */
+    public function set_data_for_dynamic_submission(): void {
+        return;
+    }
+
+    /**
+     * Returns url to set in $PAGE->set_url() when form is being rendered or submitted via AJAX
+     *
+     * @return moodle_url
+     */
+    protected function get_page_url_for_dynamic_submission(): moodle_url {
+        global $USER;
+
+        return new moodle_url('/user/profile.php', ['id' => $USER->id]);
+    }
+}
index 8032906..5d3f4cb 100644 (file)
@@ -55,14 +55,11 @@ class renderer extends plugin_renderer_base {
     /**
      * Render the contact DPO link.
      *
-     * @param string $replytoemail The Reply-to email address
      * @return string The HTML for the link.
-     * @throws coding_exception
      */
-    public function render_contact_dpo_link($replytoemail) {
+    public function render_contact_dpo_link() {
         $params = [
             'data-action' => 'contactdpo',
-            'data-replytoemail' => $replytoemail,
         ];
         return html_writer::link('#', get_string('contactdataprotectionofficer', 'tool_dataprivacy'), $params);
     }
index 46d7af2..53ce860 100644 (file)
@@ -135,6 +135,7 @@ $string['effectiveretentionperioduser'] = '{$a} (since the last time the user ac
 $string['emailsalutation'] = 'Dear {$a},';
 $string['errorcannotrequestdeleteforself'] = 'You don\'t have permission to create deletion request for yourself.';
 $string['errorcannotrequestdeleteforother'] = 'You don\'t have permission to create deletion request for this user.';
+$string['errorcontactdpodisabled'] = 'Contacting the privacy officer is disabled';
 $string['errorinvalidrequestcomments'] = 'The comments field may contain plain text only.';
 $string['errorinvalidrequestcreationmethod'] = 'Invalid request creation method!';
 $string['errorinvalidrequeststatus'] = 'Invalid request status!';
index 5b5f28a..25091fd 100644 (file)
@@ -54,10 +54,12 @@ function tool_dataprivacy_myprofile_navigation(tree $tree, $user, $iscurrentuser
     // Contact data protection officer link.
     if (\tool_dataprivacy\api::can_contact_dpo() && $iscurrentuser) {
         $renderer = $PAGE->get_renderer('tool_dataprivacy');
-        $content = $renderer->render_contact_dpo_link($USER->email);
+        $content = $renderer->render_contact_dpo_link();
         $node = new core_user\output\myprofile\node('privacyandpolicies', 'contactdpo', null, null, null, $content);
         $category->add_node($node);
-        $PAGE->requires->js_call_amd('tool_dataprivacy/myrequestactions', 'init');
+
+        // Require our Javascript module to handle contact DPO interaction.
+        $PAGE->requires->js_call_amd('tool_dataprivacy/contactdpo', 'init');
 
         $url = new moodle_url('/admin/tool/dataprivacy/mydatarequests.php');
         $node = new core_user\output\myprofile\node('privacyandpolicies', 'datarequests',
diff --git a/admin/tool/dataprivacy/templates/contact_dpo.mustache b/admin/tool/dataprivacy/templates/contact_dpo.mustache
deleted file mode 100644 (file)
index d3a0e86..0000000
+++ /dev/null
@@ -1,58 +0,0 @@
-{{!
-    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_dataprivacy/contact_dpo
-
-    The purpose of this template is to enable the user to contact the site's DPO via email.
-
-    Classes required for JS:
-    * none
-
-    Data attributes required for JS:
-    * none
-
-    Context variables required for this template:
-    * userid int The user's ID.
-    * email string The user's email address.
-
-    Example context (json):
-    {
-        "userid": 1,
-        "replytoemail": "martha@example.com"
-    }
-}}
-<div class="container">
-    <div class="row mb-2">
-        <label class="col-md-3 col-form-label">{{#str}}replyto, tool_dataprivacy{{/str}}</label>
-        <div class="col-md-9 col-form-label">{{replytoemail}}</div>
-    </div>
-    <div class="row" data-region="messageinput">
-        <label for="message" class="col-md-3 col-form-label">
-            {{#str}}message, tool_dataprivacy{{/str}}
-            <span class="float-sm-right text-nowrap">
-            <abbr class="initialism text-danger" title="{{#str}}required{{/str}}">{{#pix}}req, core, {{#str}}required{{/str}}{{/pix}}</abbr>
-        </span>
-        </label>
-        <div class="col-md-9">
-            <textarea class="form-control" id="message" cols="60" rows="8"></textarea>
-            <div class="form-control-feedback" id="id_error_message" hidden="hidden">
-                {{#str}}required, moodle{{/str}}
-            </div>
-        </div>
-    </div>
-</div>
-
index 528d1f6..28f5de8 100644 (file)
@@ -8,19 +8,21 @@ Feature: Contact the privacy officer
     Given the following "users" exist:
       | username | firstname | lastname | email          |
       | student1 | Student   | 1        | s1@example.com |
-    And I log in as "admin"
-    And I set the following administration settings values:
-      | contactdataprotectionofficer | 1 |
-    And I log out
 
   @javascript
   Scenario: Contacting the privacy officer
-    Given I log in as "student1"
+    Given the following config values are set as admin:
+      | contactdataprotectionofficer | 1 | tool_dataprivacy |
+    When I log in as "student1"
     And I follow "Profile" in the user menu
-    And I should see "Contact the privacy officer"
     And I click on "Contact the privacy officer" "link"
     And I set the field "Message" to "Hello DPO!"
     And I click on "Send" "button" in the "Contact the privacy officer" "dialogue"
-    And I should see "Your request has been submitted to the privacy officer"
+    Then I should see "Your request has been submitted to the privacy officer"
     And I click on "Data requests" "link"
     And I should see "Hello DPO!" in the "General inquiry" "table_row"
+
+  Scenario: Contacting the privacy officer when not enabled
+    When I log in as "student1"
+    And I follow "Profile" in the user menu
+    Then "Contact the privacy officer" "link" should not exist
diff --git a/admin/tool/dataprivacy/tests/behat/my_data_requests.feature b/admin/tool/dataprivacy/tests/behat/my_data_requests.feature
new file mode 100644 (file)
index 0000000..15de9cd
--- /dev/null
@@ -0,0 +1,26 @@
+@tool @tool_dataprivacy
+Feature: Manage my own data requests
+  In order to manage my own data requests
+  As a user
+  I need to be able to view and cancel all my data requests
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email          |
+      | student1 | Student   | 1        | s1@example.com |
+    And the following config values are set as admin:
+      | contactdataprotectionofficer | 1 | tool_dataprivacy |
+
+  @javascript
+  Scenario: Cancel my own data request
+    Given I log in as "student1"
+    And I follow "Profile" in the user menu
+    And I click on "Contact the privacy officer" "link"
+    And I set the field "Message" to "Hello DPO!"
+    And I click on "Send" "button" in the "Contact the privacy officer" "dialogue"
+    And I should see "Your request has been submitted to the privacy officer"
+    When I click on "Data requests" "link"
+    And I open the action menu in "Hello DPO!" "table_row"
+    And I choose "Cancel" in the open action menu
+    And I click on "Cancel request" "button" in the "Cancel request" "dialogue"
+    Then I should see "Cancelled" in the "Hello DPO!" "table_row"
index 863aaed..cac07d9 100644 (file)
     This template does not have an example context because it includes ajax functionality.
 }}
 <div data-region="user-competency-full-info" data-node="user-competency" data-competencyid="{{usercompetency.competencyid}}" data-userid="{{usercompetency.userid}}" data-region-id="{{uniqid}}">
-<div data-region="competency-summary">
-    {{#competency}}
-        {{> tool_lp/competency_summary }}
-    {{/competency}}
-    <dl>
-        {{#usercompetency}}
-        <dt>{{#str}}reviewstatus, tool_lp{{/str}}</dt>
-        <dd data-region="user-competency-status">{{statusname}}
+    <div data-region="competency-summary">
+        {{#competency}}
+            {{> tool_lp/competency_summary }}
+        {{/competency}}
+        <dl>
+            {{#usercompetency}}
+            <dt>{{#str}}reviewstatus, tool_lp{{/str}}</dt>
+            <dd data-region="user-competency-status">{{statusname}}
 
-            {{#isstatusinreview}}
-                - {{reviewer.fullname}}
-            {{/isstatusinreview}}
+                {{#isstatusinreview}}
+                    - {{reviewer.fullname}}
+                {{/isstatusinreview}}
 
-            {{#isrequestreviewallowed}}<button class="btn btn-secondary" data-action="request-review">{{#str}}requestreview, tool_lp{{/str}}</button>{{/isrequestreviewallowed}}
-            {{#iscancelreviewrequestallowed}}<button class="btn btn-secondary" data-action="cancel-review-request">{{#str}}cancelreviewrequest, tool_lp{{/str}}</button>{{/iscancelreviewrequestallowed}}
-            {{#isstartreviewallowed}}<button class="btn btn-secondary" data-action="start-review">{{#str}}startreview, tool_lp{{/str}}</button>{{/isstartreviewallowed}}
-            {{#isstopreviewallowed}}<button class="btn btn-secondary" data-action="stop-review">{{#str}}stopreview, tool_lp{{/str}}</button>{{/isstopreviewallowed}}
-        </dd>
-        <dt>{{#str}}proficient, tool_lp{{/str}}</dt>
-        <dd>
-            <span class="badge {{#proficiency}}badge-success{{/proficiency}}{{^proficiency}}badge-danger{{/proficiency}} float-left">
-                {{proficiencyname}}
-            </span>
-        </dd>
-        <dt>{{#str}}rating, tool_lp{{/str}}</dt>
-        <dd>{{gradename}}
-            {{#cangrade}}
-                <button class="btn btn-secondary" id="rate_{{uniqid}}">{{#str}}rate, tool_lp{{/str}}</button>
-            {{/cangrade}}
-        </dd>
-        {{#js}}
-        require(['jquery', 'tool_lp/grade_user_competency_inline', 'tool_lp/user_competency_info', 'tool_lp/user_competency_workflow'], function($, mod, info, UserCompWorkflow) {
+                {{#isrequestreviewallowed}}<button class="btn btn-secondary" data-action="request-review">{{#str}}requestreview, tool_lp{{/str}}</button>{{/isrequestreviewallowed}}
+                {{#iscancelreviewrequestallowed}}<button class="btn btn-secondary" data-action="cancel-review-request">{{#str}}cancelreviewrequest, tool_lp{{/str}}</button>{{/iscancelreviewrequestallowed}}
+                {{#isstartreviewallowed}}<button class="btn btn-secondary" data-action="start-review">{{#str}}startreview, tool_lp{{/str}}</button>{{/isstartreviewallowed}}
+                {{#isstopreviewallowed}}<button class="btn btn-secondary" data-action="stop-review">{{#str}}stopreview, tool_lp{{/str}}</button>{{/isstopreviewallowed}}
+            </dd>
+            <dt>{{#str}}proficient, tool_lp{{/str}}</dt>
+            <dd>
+                <span class="badge {{#proficiency}}badge-success{{/proficiency}}{{^proficiency}}badge-danger{{/proficiency}} float-left">
+                    {{proficiencyname}}
+                </span>
+            </dd>
+            <dt>{{#str}}rating, tool_lp{{/str}}</dt>
+            <dd>{{gradename}}
+                {{#cangrade}}
+                    <button class="btn btn-secondary" id="rate_{{uniqid}}">{{#str}}rate, tool_lp{{/str}}</button>
+                {{/cangrade}}
+            </dd>
+            {{#js}}
+            require(['jquery', 'tool_lp/grade_user_competency_inline', 'tool_lp/user_competency_info', 'tool_lp/user_competency_workflow'], function($, mod, info, UserCompWorkflow) {
 
-            var competencyElement = $('[data-region-id="{{uniqid}}"]');
-            var infoReloader = new info(competencyElement, '{{competency.competency.id}}', '{{user.id}}');
+                var competencyElement = $('[data-region-id="{{uniqid}}"]');
+                var infoReloader = new info(competencyElement, '{{competency.competency.id}}', '{{user.id}}');
 
-            var ucw = new UserCompWorkflow();
-            ucw.registerEvents('[data-region="user-competency-status"]');
-            ucw.on('status-changed', infoReloader.reload.bind(infoReloader));
-            ucw.on('error-occured', infoReloader.reload.bind(infoReloader));
+                var ucw = new UserCompWorkflow();
+                ucw.registerEvents('[data-region="user-competency-status"]');
+                ucw.on('status-changed', infoReloader.reload.bind(infoReloader));
+                ucw.on('error-occured', infoReloader.reload.bind(infoReloader));
 
-            var inlineGrader = new mod('#rate_{{uniqid}}', '{{competency.scaleid}}', '{{competency.competency.id}}', '{{user.id}}', '{{plan.id}}', '', '{{#str}}chooserating, tool_lp{{/str}}');
-            inlineGrader.on('competencyupdated', infoReloader.reload.bind(infoReloader));
-        });
-        {{/js}}
-        {{/usercompetency}}
-    </dl>
-    {{#commentarea}}
-        {{#canpostorhascomments}}
-            {{>tool_lp/comment_area}}
-        {{/canpostorhascomments}}
-    {{/commentarea}}
-    <dl data-region="evidence-listing">
-        <dt>{{#str}}evidence, tool_lp{{/str}}</dt>
-        <dd>
-            {{#evidence}}
-                {{> tool_lp/evidence_summary }}
-            {{/evidence}}
-            {{^evidence}}
-                <p>{{#str}}noevidence, tool_lp{{/str}}</p>
-            {{/evidence}}
-        </dd>
-    </dl>
+                var inlineGrader = new mod('#rate_{{uniqid}}', '{{competency.scaleid}}', '{{competency.competency.id}}', '{{user.id}}', '{{plan.id}}', '', '{{#str}}chooserating, tool_lp{{/str}}');
+                inlineGrader.on('competencyupdated', infoReloader.reload.bind(infoReloader));
+            });
+            {{/js}}
+            {{/usercompetency}}
+        </dl>
+        {{#commentarea}}
+            {{#canpostorhascomments}}
+                {{>tool_lp/comment_area}}
+            {{/canpostorhascomments}}
+        {{/commentarea}}
+        <dl data-region="evidence-listing">
+            <dt>{{#str}}evidence, tool_lp{{/str}}</dt>
+            <dd>
+                {{#evidence}}
+                    {{> tool_lp/evidence_summary }}
+                {{/evidence}}
+                {{^evidence}}
+                    <p>{{#str}}noevidence, tool_lp{{/str}}</p>
+                {{/evidence}}
+            </dd>
+        </dl>
+    </div>
 </div>
index cc5da40..6383ef2 100644 (file)
@@ -53,7 +53,7 @@ if ($action == 'edit') {
     if ($issuer) {
         $PAGE->navbar->add(get_string('editissuer', 'tool_oauth2', s($issuer->get('name'))));
     } else {
-        $PAGE->navbar->add(get_string('createnewservice', 'tool_oauth2') . get_string('custom_service', 'tool_oauth2'));
+        $PAGE->navbar->add(get_string('createnewservice', 'tool_oauth2') . ' ' . get_string('custom_service', 'tool_oauth2'));
     }
 
     $showrequireconfirm = false;
@@ -96,7 +96,7 @@ if ($mform && $mform->is_cancelled()) {
         if ($issuer) {
             echo $OUTPUT->heading(get_string('editissuer', 'tool_oauth2', s($issuer->get('name'))));
         } else {
-            echo $OUTPUT->heading(get_string('createnewservice', 'tool_oauth2') . get_string('custom_service', 'tool_oauth2'));
+            echo $OUTPUT->heading(get_string('createnewservice', 'tool_oauth2') . ' ' . get_string('custom_service', 'tool_oauth2'));
         }
         $mform->display();
         echo $OUTPUT->footer();
@@ -115,7 +115,7 @@ if ($mform && $mform->is_cancelled()) {
         redirect($PAGE->url, get_string('changessaved'), null, \core\output\notification::NOTIFY_SUCCESS);
     } else {
         echo $OUTPUT->header();
-        echo $OUTPUT->heading(get_string('createnewservice', 'tool_oauth2') . get_string($type . '_service', 'tool_oauth2'));
+        echo $OUTPUT->heading(get_string('createnewservice', 'tool_oauth2') . ' ' . get_string($type . '_service', 'tool_oauth2'));
         $mform->display();
         echo $OUTPUT->footer();
     }
@@ -130,9 +130,9 @@ if ($mform && $mform->is_cancelled()) {
     $mform = new \tool_oauth2\form\issuer(null, ['persistent' => $issuer, 'type' => $type,
         'showrequireconfirm' => $showrequireconfirm]);
 
-    $PAGE->navbar->add(get_string('createnewservice', 'tool_oauth2') . get_string($type . '_service', 'tool_oauth2'));
+    $PAGE->navbar->add(get_string('createnewservice', 'tool_oauth2') . ' ' . get_string($type . '_service', 'tool_oauth2'));
     echo $OUTPUT->header();
-    echo $OUTPUT->heading(get_string('createnewservice', 'tool_oauth2') . get_string($type . '_service', 'tool_oauth2'));
+    echo $OUTPUT->heading(get_string('createnewservice', 'tool_oauth2') . ' ' . get_string($type . '_service', 'tool_oauth2'));
     $mform->display();
     echo $OUTPUT->footer();
 
@@ -199,7 +199,7 @@ if ($mform && $mform->is_cancelled()) {
     echo $renderer->issuers_table($issuers);
 
     echo $renderer->container_start();
-    echo get_string('createnewservice', 'tool_oauth2');
+    echo get_string('createnewservice', 'tool_oauth2') . ' ';
 
     // Google template.
     $docs = 'admin/tool/oauth2/issuers/google';
index 938c576..9ff13af 100644 (file)
@@ -31,7 +31,7 @@ $string['connectsystemaccount'] = 'Connect to a system account';
 $string['createfromtemplate'] = 'Create an OAuth 2 service from a template';
 $string['createfromtemplatedesc'] = 'Choose one of the OAuth 2 service templates below to create an OAuth service with a valid configuration for one of the known service types. This will create the OAuth 2 service, with all the correct end points and parameters required for authentication, though you will still need to enter the client ID and secret for the new service before it can be used.';
 $string['createnewendpoint'] = 'Create new endpoint for issuer "{$a}"';
-$string['createnewservice'] = 'Create new service: ';
+$string['createnewservice'] = 'Create new service:';
 $string['createnewuserfieldmapping'] = 'Create new user field mapping for issuer "{$a}"';
 $string['custom_service'] = 'Custom';
 $string['deleteconfirm'] = 'Are you sure you want to delete the identity issuer "{$a}"? Any plugins relying on this issuer will stop working.';
@@ -53,7 +53,7 @@ $string['endpointurl_help'] = 'URL for this endpoint. Must use https:// protocol
 $string['endpointurl'] = 'URL';
 $string['facebook_service'] = 'Facebook';
 $string['google_service'] = 'Google';
-$string['imsobv2p1_service'] = 'IMS OBv2.1';
+$string['imsobv2p1_service'] = 'OpenBadges';
 $string['issuersetup'] = 'Detailed instructions on configuring the common OAuth 2 services';
 $string['issuersetuptype'] = 'Detailed instructions on setting up the {$a} OAuth 2 provider';
 $string['issueralloweddomains_help'] = 'If set, this setting is a comma separated list of domains that logins will be restricted to when using this provider.';
index 2eb3345..429d371 100644 (file)
@@ -142,29 +142,29 @@ Feature: Basic OAuth2 functionality
     And I should see "Identity issuer deleted"
     And I should not see "Testing service modified"
 
-  Scenario: Create, edit and delete standard service for IMS OBv2.1
-    Given I press "IMS OBv2.1"
-    And I should see "Create new service: IMS OBv2.1"
+  Scenario: Create, edit and delete standard service for OpenBadges
+    Given I press "OpenBadges"
+    And I should see "Create new service: OpenBadges"
     And I set the following fields to these values:
       | Client ID                  | thisistheclientid                         |
       | Client secret              | supersecret                               |
       | Service base URL           | https://dc.imsglobal.org/                 |
     When I press "Save changes"
     Then I should see "Changes saved"
-    And I should see "IMS OBv2.1"
-    And "Configured" "icon" should exist in the "IMS OBv2.1" "table_row"
-    And "Do not allow login" "icon" should exist in the "IMS OBv2.1" "table_row"
-    And "Service discovery successful" "icon" should exist in the "IMS OBv2.1" "table_row"
+    And I should see "OpenBadges"
+    And "Configured" "icon" should exist in the "OpenBadges" "table_row"
+    And "Do not allow login" "icon" should exist in the "OpenBadges" "table_row"
+    And "Service discovery successful" "icon" should exist in the "OpenBadges" "table_row"
     And the "src" attribute of "table.admintable th img" "css_element" should contain "IMS-Global-Logo.png"
-    And I click on "Configure endpoints" "link" in the "IMS OBv2.1" "table_row"
+    And I click on "Configure endpoints" "link" in the "OpenBadges" "table_row"
     And I should see "https://dc.imsglobal.org/.well-known/badgeconnect.json" in the "discovery_endpoint" "table_row"
     And I should see "authorization_endpoint"
     And I follow "OAuth 2 services"
-    And I click on "Configure user field mappings" "link" in the "IMS OBv2.1" "table_row"
+    And I click on "Configure user field mappings" "link" in the "OpenBadges" "table_row"
     And I should not see "given_name"
     And I should not see "middle_name"
     And I follow "OAuth 2 services"
-    And I click on "Edit" "link" in the "IMS OBv2.1" "table_row"
+    And I click on "Edit" "link" in the "OpenBadges" "table_row"
     And I set the following fields to these values:
       | Name                       | IMS Global                                |
     And I press "Save changes"
index 9d76b2e..01ebf39 100644 (file)
@@ -438,17 +438,7 @@ class manager {
         $filename = 'tour_export_' . $tour->get_id() . '_' . time() . '.json';
 
         // Force download.
-        header('Last-Modified: ' . gmdate('D, d M Y H:i:s', time()) . ' GMT');
-        header('Cache-Control: private, must-revalidate, pre-check=0, post-check=0, max-age=0');
-        header('Expires: ' . gmdate('D, d M Y H:i:s', 0) . 'GMT');
-        header('Pragma: no-cache');
-        header('Accept-Ranges: none');
-        header('Content-disposition: attachment; filename=' . $filename);
-        header('Content-length: ' . strlen($exportstring));
-        header('Content-type: text/calendar; charset=utf-8');
-
-        echo $exportstring;
-        die;
+        send_file($exportstring, $filename, 0, 0, true, true);
     }
 
     /**
index 6b0929e..3b0aced 100644 (file)
@@ -178,6 +178,33 @@ class prediction {
         \core\event\prediction_action_started::create($eventdata)->trigger();
     }
 
+    /**
+     * Get the executed actions.
+     *
+     * Actions could be filtered by actionname.
+     *
+     * @param array $actionnamefilter Limit the results obtained to this list of action names.
+     * @param int $userid the user id. Current user by default.
+     * @return array of actions.
+     */
+    public function get_executed_actions(array $actionnamefilter = null, int $userid = 0): array {
+        global $USER, $DB;
+
+        $conditions[] = "predictionid = :predictionid";
+        $params['predictionid'] = $this->get_prediction_data()->id;
+        if (!$userid) {
+            $userid = $USER->id;
+        }
+        $conditions[] = "userid = :userid";
+        $params['userid'] = $userid;
+        if ($actionnamefilter) {
+            list($actionsql, $actionparams) = $DB->get_in_or_equal($actionnamefilter, SQL_PARAMS_NAMED);
+            $conditions[] = "actionname $actionsql";
+            $params = $params + $actionparams;
+        }
+        return $DB->get_records_select('analytics_prediction_actions', implode(' AND ', $conditions), $params);
+    }
+
     /**
      * format_calculations
      *
index f8f2866..8aa6c24 100644 (file)
@@ -112,6 +112,86 @@ class analytics_prediction_actions_testcase extends advanced_testcase {
         $this->assertEquals(2, $DB->count_records('analytics_prediction_actions'));
     }
 
+    /**
+     * Data provider for test_get_executed_actions.
+     *
+     * @return  array
+     */
+    public function execute_actions_provider(): array {
+        return [
+            'Empty actions with no filter' => [
+                [],
+                [],
+                0
+            ],
+            'Empty actions with filter' => [
+                [],
+                [\core_analytics\prediction::ACTION_FIXED],
+                0
+            ],
+            'Multiple actions with no filter' => [
+                [
+                    \core_analytics\prediction::ACTION_FIXED,
+                    \core_analytics\prediction::ACTION_FIXED,
+                    \core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED
+                ],
+                [],
+                3
+            ],
+            'Multiple actions applying filter' => [
+                [
+                    \core_analytics\prediction::ACTION_FIXED,
+                    \core_analytics\prediction::ACTION_FIXED,
+                    \core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED
+                ],
+                [\core_analytics\prediction::ACTION_FIXED],
+                2
+            ],
+            'Multiple actions not applying filter' => [
+                [
+                    \core_analytics\prediction::ACTION_FIXED,
+                    \core_analytics\prediction::ACTION_FIXED,
+                    \core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED
+                ],
+                [\core_analytics\prediction::ACTION_NOT_APPLICABLE],
+                0
+            ],
+            'Multiple actions with multiple filter' => [
+                [
+                    \core_analytics\prediction::ACTION_FIXED,
+                    \core_analytics\prediction::ACTION_FIXED,
+                    \core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED
+                ],
+                [\core_analytics\prediction::ACTION_FIXED, \core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED],
+                3
+            ],
+        ];
+    }
+
+    /**
+     * Tests for get_executed_actions() function.
+     *
+     * @dataProvider    execute_actions_provider
+     * @param   array   $actionstoexecute    An array of actions to execute
+     * @param   array   $actionnamefilter   Actions to filter
+     * @param   int     $returned             Number of actions returned
+     *
+     * @covers \core_analytics\prediction::get_executed_actions
+     */
+    public function test_get_executed_actions(array $actionstoexecute, array $actionnamefilter, int $returned) {
+
+        $this->setUser($this->teacher2);
+        list($ignored, $predictions) = $this->model->get_predictions($this->context, true);
+        $prediction = reset($predictions);
+        $target = $this->model->get_target();
+        foreach($actionstoexecute as $action) {
+            $prediction->action_executed($action, $target);
+        }
+
+        $filteredactions = $prediction->get_executed_actions($actionnamefilter);
+        $this->assertCount($returned, $filteredactions);
+    }
+
     /**
      * test_get_predictions
      */
index b23200c..0798670 100644 (file)
@@ -11,6 +11,8 @@ information provided here is intended especially for developers.
   by updating the lib/db/analytics.php file and bumping the core version.
 * Final deprecation - get_analysables(). Please see get_analysables_interator() instead.
   get_analysables_iterator() needs to be overridden by the child class.
+* A new function get_executed_actions() has been added to \core_analytics\prediction class
+  to get all (or filtered by action name) executed actions of a prediction
 
 === 3.8 ===
 
index a57094f..0f4c121 100644 (file)
@@ -52,5 +52,5 @@ Feature: availability_completion
     Then I should not see "Page 2" in the "region-main" "region"
 
     # Mark page 1 complete
-    When I click on ".togglecompletion .icon" "css_element"
+    When I toggle the manual completion state of "Page 1"
     Then I should see "Page 2" in the "region-main" "region"
index d2ac8c5..2804661 100644 (file)
@@ -125,6 +125,8 @@ class backup_course_task extends backup_task {
             $this->add_step(new backup_course_logs_structure_step('course_logs', 'logs.xml'));
             // New log stores.
             $this->add_step(new backup_course_logstores_structure_step('course_logstores', 'logstores.xml'));
+            // Last access to course logs.
+            $this->add_step(new backup_course_loglastaccess_structure_step('course_loglastaccess', 'loglastaccess.xml'));
         }
 
         // Generate the course competencies.
index 478ae2a..673b58b 100644 (file)
@@ -99,6 +99,13 @@ class backup_root_task extends backup_task {
         $this->add_setting($roleassignments);
         $users->add_dependency($roleassignments);
 
+        // Define permission.
+        if ($this->plan->get_mode() == backup::MODE_IMPORT) {
+            $permissions = new backup_permissions_setting('permissions', base_setting::IS_BOOLEAN, false);
+            $permissions->set_ui(new backup_setting_ui_checkbox($permissions, get_string('rootsettingpermissions', 'backup')));
+            $this->add_setting($permissions);
+        }
+
         // Define activities
         $activities = new backup_activities_setting('activities', base_setting::IS_BOOLEAN, true);
         $activities->set_ui(new backup_setting_ui_checkbox($activities, get_string('rootsettingactivities', 'backup')));
index 59ea252..ca9224f 100644 (file)
@@ -65,6 +65,12 @@ class backup_filename_setting extends backup_generic_setting {
  */
 class backup_users_setting extends backup_generic_setting {}
 
+/**
+ * root setting to control if backup will include permission information by roles
+ */
+class backup_permissions_setting extends backup_generic_setting {
+}
+
 /**
  * root setting to control if backup will include group information depends on @backup_users_setting
  *
index 4339b0c..2a77b03 100644 (file)
@@ -1639,6 +1639,55 @@ class backup_course_logstores_structure_step extends backup_structure_step {
     }
 }
 
+/**
+ * Structure step in charge of constructing the loglastaccess.xml file for the course logs.
+ *
+ * This backup step will backup the logs of the user_lastaccess table.
+ */
+class backup_course_loglastaccess_structure_step extends backup_structure_step {
+
+    /**
+     *  This function creates the structures for the loglastaccess.xml file.
+     *  Expected structure would look like this.
+     *  <loglastaccesses>
+     *      <loglastaccess id=2>
+     *          <userid>5</userid>
+     *          <timeaccess>1616887341</timeaccess>
+     *      </loglastaccess>
+     *  </loglastaccesses>
+     *
+     * @return backup_nested_element
+     */
+    protected function define_structure() {
+
+        // To know if we are including userinfo.
+        $userinfo = $this->get_setting_value('users');
+
+        // Define the structure of logstores container.
+        $lastaccesses = new backup_nested_element('lastaccesses');
+        $lastaccess = new backup_nested_element('lastaccess', array('id'), array('userid', 'timeaccess'));
+
+        // Define build tree.
+        $lastaccesses->add_child($lastaccess);
+
+        // This element should only happen if we are including user info.
+        if ($userinfo) {
+            // Define sources.
+            $lastaccess->set_source_sql('
+                SELECT id, userid, timeaccess
+                  FROM {user_lastaccess}
+                 WHERE courseid = ?',
+                array(backup::VAR_COURSEID));
+
+            // Define userid annotation to user.
+            $lastaccess->annotate_ids('user', 'userid');
+        }
+
+        // Return the root element (lastaccessess).
+        return $lastaccesses;
+    }
+}
+
 /**
  * Structure step in charge of constructing the logstores.xml file for the activity logs.
  *
index b0b6b76..83da7e1 100644 (file)
@@ -93,6 +93,8 @@ class restore_final_task extends restore_task {
             $this->add_step(new restore_course_logs_structure_step('course_logs', 'course/logs.xml'));
             // New log stores.
             $this->add_step(new restore_course_logstores_structure_step('course_logstores', 'course/logstores.xml'));
+            // Last access to course logs.
+            $this->add_step(new restore_course_loglastaccess_structure_step('course_loglastaccess', 'course/loglastaccess.xml'));
         }
 
         // Review all the executed tasks having one after_restore method
index 3b37acc..40345c1 100644 (file)
@@ -146,6 +146,19 @@ class restore_root_task extends restore_task {
         $this->add_setting($roleassignments);
         $users->add_dependency($roleassignments);
 
+        // Define permissions.
+        $defaultvalue = false;                      // Safer default.
+        $changeable = false;
+        // Enable when available, or key doesn't exist (backward compatibility).
+        if (!array_key_exists('permissions', $rootsettings) || !empty($rootsettings['permissions'])) {
+            $defaultvalue = true;
+            $changeable = true;
+        }
+        $permissions = new restore_permissions_setting('permissions', base_setting::IS_BOOLEAN, $defaultvalue);
+        $permissions->set_ui(new backup_setting_ui_checkbox($permissions, get_string('rootsettingpermissions', 'backup')));
+        $permissions->get_ui()->set_changeable($changeable);
+        $this->add_setting($permissions);
+
         // Define activitites
         $defaultvalue = false;                      // Safer default
         $changeable = false;
index 61124de..fb9e064 100644 (file)
@@ -43,6 +43,12 @@ class restore_generic_setting extends root_backup_setting {}
  */
 class restore_users_setting extends restore_generic_setting {}
 
+/**
+ * root setting to control if restore will create override permission information by roles
+ */
+class restore_permissions_setting extends restore_generic_setting {
+}
+
 /**
  * root setting to control if restore will create groups/grouping information. Depends on @restore_users_setting
  *
index 237d993..7e51a1f 100644 (file)
@@ -2058,7 +2058,9 @@ class restore_ras_and_caps_structure_step extends restore_structure_step {
         if ($this->get_setting_value('role_assignments')) {
             $paths[] = new restore_path_element('assignment', '/roles/role_assignments/assignment');
         }
-        $paths[] = new restore_path_element('override', '/roles/role_overrides/override');
+        if ($this->get_setting_value('permissions')) {
+            $paths[] = new restore_path_element('override', '/roles/role_overrides/override');
+        }
 
         return $paths;
     }
@@ -3428,6 +3430,81 @@ class restore_course_logstores_structure_step extends restore_structure_step {
     }
 }
 
+/**
+ * Structure step in charge of restoring the loglastaccess.xml file for the course logs.
+ *
+ * This restore step will rebuild the table for user_lastaccess table.
+ */
+class restore_course_loglastaccess_structure_step extends restore_structure_step {
+
+    /**
+     * Conditionally decide if this step should be executed.
+     *
+     * This function checks the following parameter:
+     *
+     *   1. the loglastaccess.xml file exists
+     *
+     * @return bool true is safe to execute, false otherwise
+     */
+    protected function execute_condition() {
+        // Check it is included in the backup.
+        $fullpath = $this->task->get_taskbasepath();
+        $fullpath = rtrim($fullpath, '/') . '/' . $this->filename;
+        if (!file_exists($fullpath)) {
+            // Not found, can't restore loglastaccess.xml information.
+            return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * Return the elements to be processed on restore of loglastaccess.
+     *
+     * @return restore_path_element[] array of elements to be processed on restore.
+     */
+    protected function define_structure() {
+
+        $paths = array();
+        // To know if we are including userinfo.
+        $userinfo = $this->get_setting_value('users');
+
+        if ($userinfo) {
+            $paths[] = new restore_path_element('lastaccess', '/lastaccesses/lastaccess');
+        }
+        // Return the paths wrapped.
+        return $paths;
+    }
+
+    /**
+     * Process the 'lastaccess' elements.
+     *
+     * @param array $data element data
+     */
+    protected function process_lastaccess($data) {
+        global $DB;
+
+        $data = (object)$data;
+
+        $data->courseid = $this->get_courseid();
+        if (!$data->userid = $this->get_mappingid('user', $data->userid)) {
+            return; // Nothing to do, not able to find the user to set the lastaccess time.
+        }
+
+        // Check if record does exist.
+        $exists = $DB->get_record('user_lastaccess', array('courseid' => $data->courseid, 'userid' => $data->userid));
+        if ($exists) {
+            // If the time of last access of the restore is newer, then replace and update.
+            if ($exists->timeaccess < $data->timeaccess) {
+                $exists->timeaccess = $data->timeaccess;
+                $DB->update_record('user_lastaccess', $exists);
+            }
+        } else {
+            $DB->insert_record('user_lastaccess', $data);
+        }
+    }
+}
+
 /**
  * Structure step in charge of restoring the logstores.xml file for the activity logs.
  *
@@ -4065,9 +4142,30 @@ class restore_contentbankcontent_structure_step extends restore_structure_step {
         $exists = $DB->record_exists('contentbank_content', $params);
         if (!$exists) {
             $params['configdata'] = $data->configdata;
+            $params['timemodified'] = time();
+
+            // Trying to map users. Users cannot always be mapped, e.g. when copying.
             $params['usercreated'] = $this->get_mappingid('user', $data->usercreated);
+            if (!$params['usercreated']) {
+                // Leave the content creator unchanged when we are restoring the same site.
+                // Otherwise use current user id.
+                if ($this->task->is_samesite()) {
+                    $params['usercreated'] = $data->usercreated;
+                } else {
+                    $params['usercreated'] = $this->task->get_userid();
+                }
+            }
             $params['usermodified'] = $this->get_mappingid('user', $data->usermodified);
-            $params['timemodified'] = time();
+            if (!$params['usermodified']) {
+                // Leave the content modifier unchanged when we are restoring the same site.
+                // Otherwise use current user id.
+                if ($this->task->is_samesite()) {
+                    $params['usermodified'] = $data->usermodified;
+                } else {
+                    $params['usermodified'] = $this->task->get_userid();
+                }
+            }
+
             $newitemid = $DB->insert_record('contentbank_content', $params);
             $this->set_mapping('contentbank_content', $oldid, $newitemid, true);
         }
index e4b71c8..98fdbb3 100644 (file)
@@ -60,6 +60,7 @@ class core_backup_cleanup_task_testcase extends advanced_testcase {
             $user->id
         );
         $controller->execute_plan();
+        $controller->destroy(); // Unset all structures, close files...
         return $controller->get_backupid();
     }
 
diff --git a/backup/tests/backup_restore_base_testcase.php b/backup/tests/backup_restore_base_testcase.php
new file mode 100644 (file)
index 0000000..8b12296
--- /dev/null
@@ -0,0 +1,123 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Backup restore base tests.
+ *
+ * @package   core_backup
+ * @copyright Tomo Tsuyuki <tomotsuyuki@catalyst-au.net>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php');
+require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php');
+
+/**
+ * Basic testcase class for backup / restore functionality.
+ */
+abstract class core_backup_backup_restore_base_testcase extends advanced_testcase {
+
+    /**
+     * Setup test data.
+     */
+    protected function setUp(): void {
+        $this->resetAfterTest();
+        $this->setAdminUser();
+    }
+
+    /**
+     * Backup the course by general mode.
+     *
+     * @param  stdClass $course Course for backup.
+     * @return string Hash string ID from the backup.
+     * @throws coding_exception
+     * @throws moodle_exception
+     */
+    protected function perform_backup($course): string {
+        global $CFG, $USER;
+
+        $coursecontext = context_course::instance($course->id);
+
+        // Start backup process.
+        $bc = new backup_controller(backup::TYPE_1COURSE, $course->id, backup::FORMAT_MOODLE,
+                backup::INTERACTIVE_NO, backup::MODE_GENERAL, $USER->id);
+        $bc->execute_plan();
+        $backupid = $bc->get_backupid();
+        $bc->destroy();
+
+        // Get the backup file.
+        $fs = get_file_storage();
+        $files = $fs->get_area_files($coursecontext->id, 'backup', 'course', false, 'id ASC');
+        $backupfile = reset($files);
+
+        // Extract backup file.
+        $path = $CFG->tempdir . DIRECTORY_SEPARATOR . "backup" . DIRECTORY_SEPARATOR . $backupid;
+
+        $fp = get_file_packer('application/vnd.moodle.backup');
+        $fp->extract_to_pathname($backupfile, $path);
+
+        return $backupid;
+    }
+
+    /**
+     * Restore from backupid to course.
+     *
+     * @param  string   $backupid Hash string ID from backup.
+     * @param  stdClass $course Course which is restored for.
+     * @throws restore_controller_exception
+     */
+    protected function perform_restore($backupid, $course): void {
+        global $USER;
+
+        // Set up restore.
+        $rc = new restore_controller($backupid, $course->id,
+                backup::INTERACTIVE_NO, backup::MODE_GENERAL, $USER->id, backup::TARGET_EXISTING_ADDING);
+        // Execute restore.
+        $rc->execute_precheck();
+        $rc->execute_plan();
+        $rc->destroy();
+    }
+
+    /**
+     * Import course from course1 to course2.
+     *
+     * @param stdClass $course1 Course to be backuped up.
+     * @param stdClass $course2 Course to be restored.
+     * @throws restore_controller_exception
+     */
+    protected function perform_import($course1, $course2): void {
+        global $USER;
+
+        // Start backup process.
+        $bc = new backup_controller(backup::TYPE_1COURSE, $course1->id, backup::FORMAT_MOODLE,
+                backup::INTERACTIVE_NO, backup::MODE_IMPORT, $USER->id);
+        $backupid = $bc->get_backupid();
+        $bc->execute_plan();
+        $bc->destroy();
+
+        // Set up restore.
+        $rc = new restore_controller($backupid, $course2->id,
+                backup::INTERACTIVE_NO, backup::MODE_SAMESITE, $USER->id, backup::TARGET_EXISTING_ADDING);
+        // Execute restore.
+        $rc->execute_precheck();
+        $rc->execute_plan();
+        $rc->destroy();
+    }
+
+}
diff --git a/backup/tests/backup_restore_permission_test.php b/backup/tests/backup_restore_permission_test.php
new file mode 100644 (file)
index 0000000..42929a4
--- /dev/null
@@ -0,0 +1,156 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Backup restore permission tests.
+ *
+ * @package   core_backup
+ * @copyright Tomo Tsuyuki <tomotsuyuki@catalyst-au.net>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once('backup_restore_base_testcase.php');
+require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php');
+require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php');
+
+/**
+ * Testcase class for permission backup / restore functionality.
+ */
+class core_backup_backup_restore_permission_testcase extends core_backup_backup_restore_base_testcase {
+
+    /** @var stdClass A test course which is restored/imported from. */
+    protected $course1;
+
+    /** @var stdClass A test course which is restored/imported to. */
+    protected $course2;
+
+    /** @var stdClass A user for using in this test. */
+    protected $user;
+
+    /** @var string Capability name for using in this test. */
+    protected $capabilityname;
+
+    /** @var context_course Context instance for course1. */
+    protected $course1context;
+
+    /** @var context_course Context instance for course2. */
+    protected $course2context;
+
+    /**
+     * Setup test data.
+     */
+    protected function setUp(): void {
+        global $DB;
+
+        parent::setUp();
+        // Create a course with some availability data set.
+        $generator = $this->getDataGenerator();
+        $this->course1 = $generator->create_course();
+        $this->course1context = context_course::instance($this->course1->id);
+        $this->course2 = $generator->create_course();
+        $this->course2context = context_course::instance($this->course2->id);
+        $this->capabilityname = 'enrol/manual:enrol';
+        $this->user = $generator->create_user();
+
+        // Set additional permission for course 1.
+        $teacherrole = $DB->get_record('role', ['shortname' => 'teacher'], '*', MUST_EXIST);
+        role_change_permission($teacherrole->id, $this->course1context, $this->capabilityname, CAP_ALLOW);
+
+        // Enrol to the courses.
+        $generator->enrol_user($this->user->id, $this->course1->id, $teacherrole->id);
+        $generator->enrol_user($this->user->id, $this->course2->id, $teacherrole->id);
+    }
+
+    /**
+     * Test having settings.
+     */
+    public function test_having_settings(): void {
+        $this->assertEquals(0, get_config('backup', 'backup_import_permissions'));
+        $this->assertEquals(1, get_config('restore', 'restore_general_permissions'));
+    }
+
+    /**
+     * Test for restore with permission.
+     */
+    public function test_backup_restore_with_permission(): void {
+
+        // Set default setting to restore with permission.
+        set_config('restore_general_permissions', 1, 'restore');
+
+        // Confirm course1 has the capability for the user.
+        $this->assertTrue(has_capability($this->capabilityname, $this->course1context, $this->user));
+
+        // Confirm course2 does not have the capability for the user.
+        $this->assertFalse(has_capability($this->capabilityname, $this->course2context, $this->user));
+
+        // Perform backup and restore.
+        $backupid = $this->perform_backup($this->course1);
+        $this->perform_restore($backupid, $this->course2);
+
+        // Confirm course2 has the capability for the user.
+        $this->assertTrue(has_capability($this->capabilityname, $this->course2context, $this->user));
+    }
+
+    /**
+     * Test for backup / restore without restore permission.
+     */
+    public function test_backup_restore_without_permission(): void {
+
+        // Set default setting to restore without permission.
+        set_config('restore_general_permissions', 0, 'restore');
+
+        // Perform backup and restore.
+        $backupid = $this->perform_backup($this->course1);
+        $this->perform_restore($backupid, $this->course2);
+
+        // Confirm course2 does not have the capability for the user.
+        $this->assertFalse(has_capability($this->capabilityname, $this->course2context, $this->user));
+    }
+
+    /**
+     * Test for import with permission.
+     */
+    public function test_backup_import_with_permission(): void {
+
+        // Set default setting to restore with permission.
+        set_config('backup_import_permissions', 1, 'backup');
+
+        // Perform import.
+        $this->perform_import($this->course1, $this->course2);
+
+        // Confirm course2 does not have the capability for the user.
+        $this->assertTrue(has_capability($this->capabilityname, $this->course2context, $this->user));
+    }
+
+    /**
+     * Test for import without permission.
+     */
+    public function test_backup_import_without_permission(): void {
+
+        // Set default setting to restore without permission.
+        set_config('backup_import_permissions', 0, 'backup');
+
+        // Perform import.
+        $this->perform_import($this->course1, $this->course2);
+
+        // Confirm course2 does not have the capability for the user.
+        $this->assertFalse(has_capability($this->capabilityname, $this->course2context, $this->user));
+    }
+
+}
index 3d55558..825f55b 100644 (file)
@@ -1,7 +1,13 @@
 This files describes API changes in /backup/*,
 information provided here is intended especially for developers.
 
-=== 4.0 ===
+=== 3.11 ===
+
+ * New setting called "Include override permissions" has been implemented. The default
+   settings is OFF for import, and ON for restore.
+
+=== 3.10 ===
+
  * Local plugins can now hook into a backup and restore process of grade items by
    using define_grade_item_plugin_structure method (See MDL-69418).
 
index e5c0163..7b5cbd4 100644 (file)
@@ -577,6 +577,7 @@ abstract class backup_controller_dbops extends backup_dbops {
                         'backup_import_blocks'             => 'blocks',
                         'backup_import_filters'            => 'filters',
                         'backup_import_calendarevents'     => 'calendarevents',
+                        'backup_import_permissions'        => 'permissions',
                         'backup_import_questionbank'       => 'questionbank',
                         'backup_import_groups'             => 'groups',
                         'backup_import_competencies'       => 'competencies',
index 216da03..4cc7ee2 100644 (file)
@@ -146,6 +146,7 @@ abstract class restore_controller_dbops extends restore_dbops {
             'restore_general_users'              => 'users',
             'restore_general_enrolments'         => 'enrolments',
             'restore_general_role_assignments'   => 'role_assignments',
+            'restore_general_permissions'        => 'permissions',
             'restore_general_activities'         => 'activities',
             'restore_general_blocks'             => 'blocks',
             'restore_general_filters'            => 'filters',
index eb7507f..0417651 100644 (file)
@@ -1,11 +1,22 @@
-@core @core_backup @core_contentbank
+@core @core_backup @core_contentbank @core_h5p @contenttype_h5p @_file_upload @javascript
 Feature: Import course content bank content
   In order to import content from a course contentbank
   As a teacher
   I need to confirm that errors will not happen
 
   Background:
-    Given the following "courses" exist:
+    Given I log in as "admin"
+    And I am on site homepage
+    And I turn editing mode on
+    And I add the "Navigation" block if not present
+    And I configure the "Navigation" block
+    And I set the following fields to these values:
+      | Page contexts | Display throughout the entire site |
+    And I press "Save changes"
+    And I navigate to "H5P > Manage H5P content types" in site administration
+    And I upload "h5p/tests/fixtures/ipsums.h5p" file to "H5P content type" filemanager
+    And I click on "Upload H5P content types" "button" in the "#fitem_id_uploadlibraries" "css_element"
+    And the following "courses" exist:
       | fullname | shortname | category |
       | Course 1 | C1        | 0        |
       | Course 2 | C2        | 0        |
@@ -17,29 +28,36 @@ Feature: Import course content bank content
       | teacher1 | C1 | editingteacher |
       | teacher1 | C2 | editingteacher |
     And the following "contentbank content" exist:
-      | contextlevel | reference | contenttype     | user     | contentname |
-      | Course       | C1        | contenttype_h5p | teacher1 | ipsums.h5p  |
+      | contextlevel | reference | contenttype     | user     | contentname | filepath                        |
+      | Course       | C1        | contenttype_h5p | teacher1 | ipsums.h5p  | /h5p/tests/fixtures/ipsums.h5p  |
+    And I log out
     And I log in as "teacher1"
 
   Scenario: Import content bank content to another course
     Given I am on "Course 2" course homepage
+    And I expand "Site pages" node
     And I click on "Content bank" "link"
     And I should not see "ipsums.h5p"
     When I import "Course 1" course into "Course 2" course using this options:
+    And I expand "Site pages" node
     And I click on "Content bank" "link"
     Then I should see "ipsums.h5p"
     And I am on "Course 1" course homepage
+    And I expand "Site pages" node
     And I click on "Content bank" "link"
     And I should see "ipsums.h5p"
 
   Scenario: User could configure not to import content bank
     Given I am on "Course 2" course homepage
+    And I expand "Site pages" node
     And I click on "Content bank" "link"
     And I should not see "ipsums.h5p"
     When I import "Course 1" course into "Course 2" course using this options:
       | Initial | Include content bank content | 0 |
+    And I expand "Site pages" node
     And I click on "Content bank" "link"
     Then I should not see "ipsums.h5p"
     And I am on "Course 1" course homepage
+    And I expand "Site pages" node
     And I click on "Content bank" "link"
     And I should see "ipsums.h5p"
index 5adde7f..52c1452 100644 (file)
@@ -4,19 +4,21 @@ Feature: Import course's contents into another course
   As a teacher
   I need to import a course contents into another course selecting what I want to import
 
-  Scenario: Import course's contents to another course
+  Background:
     Given the following "courses" exist:
       | fullname | shortname | category |
-      | Course 1 | C1 | 0 |
-      | Course 2 | C2 | 0 |
+      | Course 1 | C1        | 0        |
+      | Course 2 | C2        | 0        |
     And the following "users" exist:
-      | username | firstname | lastname | email |
-      | teacher1 | Teacher | 1 | teacher1@example.com |
+      | username | firstname | lastname | email                |
+      | teacher1 | Teacher   | 1        | teacher1@example.com |
     And the following "course enrolments" exist:
-      | user | course | role |
-      | teacher1 | C1 | editingteacher |
-      | teacher1 | C2 | editingteacher |
-    And I log in as "teacher1"
+      | user     | course | role           |
+      | teacher1 | C1     | editingteacher |
+      | teacher1 | C2     | editingteacher |
+
+  Scenario: Import course's contents to another course
+    Given I log in as "teacher1"
     And I am on "Course 1" course homepage with editing mode on
     And I add a "Database" to section "1" and I fill the form with:
       | Name | Test database name |
@@ -32,3 +34,26 @@ Feature: Import course's contents into another course
     And I should see "Test forum name"
     And I should see "Comments" in the "Comments" "block"
     And I should see "Recent blog entries"
+
+  Scenario: Import process with permission option
+    Given the following "permission overrides" exist:
+      | capability         | permission | role    | contextlevel | reference |
+      | enrol/manual:enrol | Allow      | teacher | Course       | C1        |
+    And I log in as "teacher1"
+    When I import "Course 1" course into "Course 2" course using this options:
+      | Initial | Include override permissions | 1 |
+    And I navigate to "Users > Permissions" in current page administration
+    Then I should see "Non-editing teacher (1)"
+    And I set the field "Advanced role override" to "Non-editing teacher (1)"
+    And I press "Go"
+    And "enrol/manual:enrol" capability has "Allow" permission
+
+  Scenario: Import process without permission option
+    Given the following "permission overrides" exist:
+      | capability         | permission | role    | contextlevel | reference |
+      | enrol/manual:enrol | Allow      | teacher | Course       | C1        |
+    And I log in as "teacher1"
+    When I import "Course 1" course into "Course 2" course using this options:
+      | Initial | Include override permissions | 0 |
+    And I navigate to "Users > Permissions" in current page administration
+    Then I should see "Non-editing teacher (0)"
index d1bae45..fb32d18 100644 (file)
@@ -244,3 +244,29 @@ Feature: Restore Moodle 2 course backups
     And I should not see "Topic 16"
     And I should see "Test URL name" in the "Topic 3" "section"
     And I should see "Test forum name" in the "Topic 1" "section"
+
+  @javascript
+  Scenario: Restore a backup with override permission
+    Given the following "permission overrides" exist:
+      | capability         | permission | role           | contextlevel | reference |
+      | enrol/manual:enrol | Allow      | teacher        | Course       | C1        |
+    And I backup "Course 1" course using this options:
+      | Confirmation | Filename | test_backup.mbz |
+    When I restore "test_backup.mbz" backup into a new course using this options:
+      | Settings | Include override permissions | 1 |
+    Then I navigate to "Users > Permissions" in current page administration
+    And I should see "Non-editing teacher (1)"
+    And I set the field "Advanced role override" to "Non-editing teacher (1)"
+    And "enrol/manual:enrol" capability has "Allow" permission
+
+  @javascript
+  Scenario: Restore a backup without override permission
+    Given the following "permission overrides" exist:
+      | capability         | permission | role           | contextlevel | reference |
+      | enrol/manual:enrol | Allow      | teacher        | Course       | C1        |
+    And I backup "Course 1" course using this options:
+      | Confirmation | Filename | test_backup.mbz |
+    When I restore "test_backup.mbz" backup into a new course using this options:
+      | Settings | Include override permissions | 0 |
+    Then I navigate to "Users > Permissions" in current page administration
+    And I should see "Non-editing teacher (0)"
index ff77f49..0dceb33 100644 (file)
@@ -43,6 +43,10 @@ if ($persistedissuer) {
     $returnurl = new moodle_url('/badges/backpack-connect.php',
         ['action' => 'authorization', 'sesskey' => sesskey(), 'backpackid' => $backpackid]);
 
+    // If scope is not passed as parameter, use the issuer supported scopes.
+    if (empty($scope)) {
+        $scope = $issuer->get('scopessupported');
+    }
     $client = new core_badges\oauth2\client($issuer, $returnurl, $scope, $externalbackpack);
     if ($client) {
         if (!$client->is_logged_in()) {
index b5d10e0..3d63276 100644 (file)
@@ -56,12 +56,12 @@ class external_backpack extends \moodleform {
         $mform->addElement('text', 'backpackapiurl',  get_string('backpackapiurl', 'core_badges'));
         $mform->setType('backpackapiurl', PARAM_URL);
         $mform->addRule('backpackapiurl', null, 'required', null, 'client');
-        $mform->addRule('backpackapiurl', get_string('maximumchars', '', 255), 'maxlength', 50, 'client');
+        $mform->addRule('backpackapiurl', get_string('maximumchars', '', 255), 'maxlength', 255, 'client');
 
         $mform->addElement('text', 'backpackweburl', get_string('backpackweburl', 'core_badges'));
         $mform->setType('backpackweburl', PARAM_URL);
         $mform->addRule('backpackweburl', null, 'required', null, 'client');
-        $mform->addRule('backpackweburl', get_string('maximumchars', '', 255), 'maxlength', 50, 'client');
+        $mform->addRule('backpackweburl', get_string('maximumchars', '', 255), 'maxlength', 255, 'client');
 
         $apiversions = badges_get_badge_api_versions();
         $mform->addElement('select', 'apiversion', get_string('apiversion', 'core_badges'), $apiversions);
index 575985b..8e635bc 100644 (file)
@@ -222,6 +222,7 @@ class client extends \core\oauth2\client {
         $callbackurl = self::callback_url();
 
         if ($granttype == 'authorization_code') {
+            $this->basicauth = true;
             $params = array('code' => $code,
                 'grant_type' => $granttype,
                 'redirect_uri' => $callbackurl->out(false),
@@ -236,7 +237,7 @@ class client extends \core\oauth2\client {
             );
         }
         if ($this->basicauth) {
-            $idsecret = urlencode($this->clientid) . ':' . urlencode($this->clientsecret);
+            $idsecret = $this->clientid . ':' . $this->clientsecret;
             $this->setHeader('Authorization: Basic ' . base64_encode($idsecret));
         } else {
             $params['client_id'] = $this->clientid;
@@ -244,11 +245,13 @@ class client extends \core\oauth2\client {
         }
         // 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');
+            $debuginfo = !empty($this->error) ? $this->error : $response;
+            throw new moodle_exception('oauth2refreshtokenerror', 'core_error', '', $this->info['http_code'], $debuginfo);
         }
 
+        $r = json_decode($response);
+
         if (is_null($r)) {
             throw new moodle_exception("Could not decode JSON token response");
         }
index 1e0bb80..81290eb 100644 (file)
@@ -240,13 +240,23 @@ class award_criteria_cohort extends award_criteria {
             return array($join, $where, $params);
         } else {
             // User is a member of ALL of the specified cohorts.
-            $join = " LEFT JOIN {cohort_members} cm ON cm.userid = u.id";
+            $join = ' LEFT JOIN {cohort_members} cm ON cm.userid = u.id';
+            $where = ' AND (';
             $i = 0;
             foreach ($this->params as $param) {
-                $i++;
-                $where = ' AND cm.cohortid = :cohortid'.$i;
+                if ($i == 0) {
+                    $where .= 'cm.cohortid = :cohortid'.$i;
+                } else {
+                    $where .= ' OR cm.cohortid = :cohortid'.$i;
+                }
                 $params['cohortid'.$i] = $param['cohort'];
+                $i++;
             }
+            $where .= ')
+                GROUP BY u.id, bi.badgeid
+                HAVING COUNT(cm.cohortid) = :cohortcount';
+            $params['cohortcount'] = $i;
+
             return array($join, $where, $params);
         }
     }
index 1afadf4..ba16434 100644 (file)
@@ -350,18 +350,15 @@ class core_badges_renderer extends plugin_renderer_base {
             if (!empty($CFG->badges_allowexternalbackpack) && ($expiration > $now)
                 && $userbackpack = badges_get_user_backpack($USER->id)) {
 
-                $assertion = null;
                 if (badges_open_badges_backpack_api($userbackpack->id) == OPEN_BADGES_V2P1) {
                     $assertion = new moodle_url('/badges/backpack-export.php', array('hash' => $ibadge->hash));
                 } else {
                     $assertion = new moodle_url('/badges/backpack-add.php', array('hash' => $ibadge->hash));
                 }
 
-                if (!is_null(assertion)) {
-                    $attributes = ['class' => 'btn btn-secondary m-1', 'role' => 'button'];
-                    $tobackpack = html_writer::link($assertion, get_string('addtobackpack', 'badges'), $attributes);
-                    $output .= $tobackpack;
-                }
+                $attributes = ['class' => 'btn btn-secondary m-1', 'role' => 'button'];
+                $tobackpack = html_writer::link($assertion, get_string('addtobackpack', 'badges'), $attributes);
+                $output .= $tobackpack;
             }
         }
         $output .= html_writer::end_tag('div');
index fa0baa7..3231db1 100644 (file)
@@ -624,35 +624,127 @@ class badgeslib_test extends advanced_testcase {
     }
 
     /**
-     * Test badges observer when cohort_member_added event is fired.
+     * Test badges observer when cohort_member_added event is fired and user required to belong to any cohort.
+     *
+     * @covers award_criteria_cohort
      */
-    public function test_badges_observer_cohort_criteria_review() {
+    public function test_badges_observer_any_cohort_criteria_review() {
         global $CFG;
 
         require_once("$CFG->dirroot/cohort/lib.php");
 
-        $cohort = $this->getDataGenerator()->create_cohort();
+        $cohort1 = $this->getDataGenerator()->create_cohort();
+        $cohort2 = $this->getDataGenerator()->create_cohort();
 
         $this->preventResetByRollback(); // Messaging is not compatible with transactions.
+
         $badge = new badge($this->badgeid);
         $this->assertFalse($badge->is_issued($this->user->id));
+        $this->assertSame(0, $badge->review_all_criteria()); // Verify award_criteria_cohort->get_completed_criteria_sql().
 
         // Set up the badge criteria.
         $criteriaoverall = award_criteria::build(array('criteriatype' => BADGE_CRITERIA_TYPE_OVERALL, 'badgeid' => $badge->id));
         $criteriaoverall->save(array('agg' => BADGE_CRITERIA_AGGREGATION_ANY));
         $criteriaoverall1 = award_criteria::build(array('criteriatype' => BADGE_CRITERIA_TYPE_COHORT, 'badgeid' => $badge->id));
-        $criteriaoverall1->save(array('agg' => BADGE_CRITERIA_AGGREGATION_ANY, 'cohort_cohorts' => array('0' => $cohort->id)));
-
-        // Make the badge active.
+        $criteriaoverall1->save(array('agg' => BADGE_CRITERIA_AGGREGATION_ANY,
+            'cohort_cohorts' => array('0' => $cohort1->id, '1' => $cohort2->id)));
         $badge->set_status(BADGE_STATUS_ACTIVE);
 
+        // Reload it to contain criteria.
+        $badge = new badge($this->badgeid);
+        $this->assertFalse($badge->is_issued($this->user->id));
+        $this->assertSame(0, $badge->review_all_criteria()); // Verify award_criteria_cohort->get_completed_criteria_sql().
+
         // Add the user to the cohort.
-        cohort_add_member($cohort->id, $this->user->id);
+        cohort_add_member($cohort2->id, $this->user->id);
+        $this->assertDebuggingCalled();
 
         // Verify that the badge was awarded.
-        $this->assertDebuggingCalled();
         $this->assertTrue($badge->is_issued($this->user->id));
+        // As the badge has been awarded to user because core_badges_observer been called when the member has been added to the
+        // cohort, there are no other users that can award this badge.
+        $this->assertSame(0, $badge->review_all_criteria()); // Verify award_criteria_cohort->get_completed_criteria_sql().
+    }
+
+    /**
+     * Test badges observer when cohort_member_added event is fired and user required to belong to multiple (all) cohorts.
+     *
+     * @covers award_criteria_cohort
+     */
+    public function test_badges_observer_all_cohort_criteria_review() {
+        global $CFG;
+
+        require_once("$CFG->dirroot/cohort/lib.php");
+
+        $cohort1 = $this->getDataGenerator()->create_cohort();
+        $cohort2 = $this->getDataGenerator()->create_cohort();
+        $cohort3 = $this->getDataGenerator()->create_cohort();
+
+        // Add user2 to cohort1 and cohort3.
+        $user2 = $this->getDataGenerator()->create_user();
+        cohort_add_member($cohort3->id, $user2->id);
+        cohort_add_member($cohort1->id, $user2->id);
+
+        // Add user3 to cohort1, cohort2 and cohort3.
+        $user3 = $this->getDataGenerator()->create_user();
+        cohort_add_member($cohort1->id, $user3->id);
+        cohort_add_member($cohort2->id, $user3->id);
+        cohort_add_member($cohort3->id, $user3->id);
+
+        $this->preventResetByRollback(); // Messaging is not compatible with transactions.
+
+        // Cohort criteria are used in site badges.
+        $badge = new badge($this->badgeid);
 
+        $this->assertFalse($badge->is_issued($this->user->id));
+        $this->assertSame(0, $badge->review_all_criteria()); // Verify award_criteria_cohort->get_completed_criteria_sql().
+
+        // Set up the badge criteria.
+        $criteriaoverall = award_criteria::build(array('criteriatype' => BADGE_CRITERIA_TYPE_OVERALL, 'badgeid' => $badge->id));
+        $criteriaoverall->save(array('agg' => BADGE_CRITERIA_AGGREGATION_ANY));
+        $criteriaoverall1 = award_criteria::build(array('criteriatype' => BADGE_CRITERIA_TYPE_COHORT, 'badgeid' => $badge->id));
+        $criteriaoverall1->save(array('agg' => BADGE_CRITERIA_AGGREGATION_ALL,
+            'cohort_cohorts' => array('0' => $cohort1->id, '1' => $cohort2->id, '2' => $cohort3->id)));
+        $badge->set_status(BADGE_STATUS_ACTIVE);
+
+        // Reload it to contain criteria.
+        $badge = new badge($this->badgeid);
+
+        // Verify that the badge was not awarded yet (ALL cohorts are needed and review_all_criteria has to be called).
+        $this->assertFalse($badge->is_issued($this->user->id));
+        $this->assertFalse($badge->is_issued($user2->id));
+        $this->assertFalse($badge->is_issued($user3->id));
+
+        // Verify that after calling review_all_criteria, users with the criteria (user3) award the badge instantly.
+        $this->assertSame(1, $badge->review_all_criteria()); // Verify award_criteria_cohort->get_completed_criteria_sql().
+        $this->assertFalse($badge->is_issued($this->user->id));
+        $this->assertFalse($badge->is_issued($user2->id));
+        $this->assertTrue($badge->is_issued($user3->id));
+        $this->assertDebuggingCalled();
+
+        // Add the user to the cohort1.
+        cohort_add_member($cohort1->id, $this->user->id);
+
+        // Verify that the badge was not awarded yet (ALL cohorts are needed).
+        $this->assertFalse($badge->is_issued($this->user->id));
+        $this->assertSame(0, $badge->review_all_criteria()); // Verify award_criteria_cohort->get_completed_criteria_sql().
+
+        // Add the user to the cohort3.
+        cohort_add_member($cohort3->id, $this->user->id);
+
+        // Verify that the badge was not awarded yet (ALL cohorts are needed).
+        $this->assertFalse($badge->is_issued($this->user->id));
+        $this->assertSame(0, $badge->review_all_criteria()); // Verify award_criteria_cohort->get_completed_criteria_sql().
+
+        // Add user to cohort2.
+        cohort_add_member($cohort2->id, $this->user->id);
+        $this->assertDebuggingCalled();
+
+        // Verify that the badge was awarded (ALL cohorts).
+        $this->assertTrue($badge->is_issued($this->user->id));
+        // As the badge has been awarded to user because core_badges_observer been called when the member has been added to the
+        // cohort, there are no other users that can award this badge.
+        $this->assertSame(0, $badge->review_all_criteria()); // Verify award_criteria_cohort->get_completed_criteria_sql().
     }
 
     /**
index 313a9a5..7324e75 100644 (file)
@@ -242,7 +242,7 @@ Feature: Award badges
     And I click on "Course 1" "link" in the "region-main" "region"
     Then I should not see "badges"
     And I am on "Course 1" course homepage
-    And I click on "Not completed: Test assignment name" "icon"
+    And I toggle the manual completion state of "Test assignment name"
     And I follow "Profile" in the user menu
     And I click on "Course 1" "link" in the "region-main" "region"
     Then I should see "Course Badge"
@@ -296,7 +296,7 @@ Feature: Award badges
     And I click on "Course 1" "link" in the "region-main" "region"
     Then I should not see "badges"
     And I am on "Course 1" course homepage
-    And I click on "Not completed: Test assignment name" "icon"
+    And I toggle the manual completion state of "Test assignment name"
     And I log out
     # Completion cron won't mark the whole course completed unless the
     # individual criteria was marked completed more than a second ago. So
index 55f9254..96bb361 100644 (file)
@@ -53,7 +53,8 @@ Feature: Award badges based on activity completion
   Scenario: Student earns a badge using activity completion, but does not get passing grade
     When I log in as "student1"
     And I am on "Course 1" course homepage
-    And the "Test quiz name" "quiz" activity with "auto" completion should be marked as not complete
+    And the "Receive a grade" completion condition of "Test quiz name" is displayed as "failed"
+    And the "Receive a pass grade or complete all available attempts" completion condition of "Test quiz name" is displayed as "todo"
     And I follow "Test quiz name"
     And I press "Re-attempt quiz"
     And I set the field "False" to "1"
index 99ad750..40707e5 100644 (file)
@@ -156,7 +156,7 @@ class completion_criteria_grade extends completion_criteria {
      * @return string
      */
     public function get_type_title() {
-        return get_string('grade');
+        return get_string('gradenoun');
     }
 
     /**
index 893b79f..7ebe22a 100644 (file)
@@ -153,6 +153,162 @@ class behat_completion extends behat_base {
         );
     }
 
+    /**
+     * Checks if the activity with specified name is maked as complete.
+     *
+     * @Given /^the "(?P<conditionname>(?:[^"]|\\")*)" completion condition of "(?P<activityname>(?:[^"]|\\")*)" is displayed as "(?P<completionstatus>(?:[^"]|\\")*)"$/
+     * @param string $conditionname The completion condition text.
+     * @param string $activityname The activity name.
+     * @param string $completionstatus The completion status. Must be either of the following: 'todo', 'done', 'failed'.
+     */
+    public function activity_completion_condition_displayed_as(string $conditionname, string $activityname,
+            string $completionstatus): void {
+
+        if (!in_array($completionstatus, ['todo', 'done', 'failed'])) {
+            throw new coding_exception('Invalid completion status. It must be of type "todo", "done", or "failed".');
+        }
+
+        $text = get_string("completion_automatic:$completionstatus", 'core_course') . ' ' . $conditionname;
+
+        $conditionslistlabel = get_string('completionrequirements', 'core_course', $activityname);
+        $selector = "div[aria-label='$conditionslistlabel']";
+
+        $this->execute("behat_general::assert_element_contains_text", [$text, $selector, "css_element"]);
+    }
+
+    /**
+     * Checks if the activity with specified name is maked as complete.
+     *
+     * @Given /^the "(?P<conditionname>(?:[^"]|\\")*)" completion condition of "(?P<activityname>(?:[^"]|\\")*)" overridden by "(?P<username>(?:[^"]|\\")*)" is displayed as "(?P<completionstatus>(?:[^"]|\\")*)"$/
+     * @param string $conditionname The completion condition text.
+     * @param string $activityname The activity name.
+     * @param string $username The full name of the user overriding the student's activity completion.
+     * @param string $completionstatus The override completion status. Must be either of the following: 'todo', 'done'.
+     */
+    public function overridden_activity_completion_condition_displayed_as(string $conditionname, string $activityname,
+            string $username, string $completionstatus): void {
+        if (!in_array($completionstatus, ['todo', 'done'])) {
+            throw new coding_exception('Invalid override completion status. It must be of type "todo" or "done".');
+        }
+
+        $conditionlabel = get_string('completion_setby:auto:' . $completionstatus, 'core_course', (object)[
+            'condition' => $conditionname,
+            'setby' => $username,
+        ]);
+        $conditionbadge = "span[aria-label='$conditionlabel']";
+
+        $conditionslistlabel = get_string('completionrequirements', 'core_course', $activityname);
+        $completionconditions = "div[aria-label='$conditionslistlabel']";
+
+        $params = [$conditionbadge, 'css_element', $completionconditions, 'css_element'];
+        $this->execute("behat_general::should_exist_in_the", $params);
+    }
+
+    /**
+     * Checks the manual completion state of an activity.
+     *
+     * @Given /^the manual completion button of "(?P<activityname>(?:[^"]|\\")*)" is displayed as "(?P<completionstatus>(?:[^"]|\\")*)"$/
+     * @param string $activityname The activity name.
+     * @param string $completionstatus The completion status shown on the manual completion button.
+     *                                 Must be either 'Mark as done' or 'Done'.
+     */
+    public function manual_completion_button_displayed_as(string $activityname, string $completionstatus): void {
+        if (!in_array($completionstatus, ['Mark as done', 'Done'])) {
+            throw new coding_exception('Invalid completion status. It must be "Mark as done" or "Done".');
+        }
+
+        $langstringkey = $completionstatus === 'Done' ? 'done' : 'markdone';
+        $conditionslistlabel = get_string('completion_manual:aria:' . $langstringkey, 'core_course', $activityname);
+        $selector = "button[aria-label='$conditionslistlabel']";
+
+        $this->execute("behat_general::assert_element_contains_text", [$completionstatus, $selector, "css_element"]);
+    }
+
+    /**
+     * Checks the manual completion state of an activity.
+     *
+     * @Given /^the manual completion button of "(?P<activityname>(?:[^"]|\\")*)" overridden by "(?P<username>(?:[^"]|\\")*)" is displayed as "(?P<completionstatus>(?:[^"]|\\")*)"$/
+     * @param string $activityname The activity name.
+     * @param string $username The full name of the user overriding the student's activity completion.
+     * @param string $completionstatus The completion status shown on the manual completion button.
+     *                                 Must be either 'Mark as done' or 'Done'.
+     */
+    public function overridden_manual_completion_button_displayed_as(string $activityname, string $username,
+            string $completionstatus): void {
+        if (!in_array($completionstatus, ['Mark as done', 'Done'])) {
+            throw new coding_exception('Invalid completion status. It must be "Mark as done" or "Done".');
+        }
+
+        $langstringkey = $completionstatus === 'Done' ? 'done' : 'markdone';
+        $conditionslistlabel = get_string('completion_setby:manual:' . $langstringkey, 'core_course', (object)[
+            'activityname' => $activityname,
+            'setby' => $username,
+        ]);
+        $selector = "button[aria-label='$conditionslistlabel']";
+
+        $this->execute("behat_general::assert_element_contains_text", [$completionstatus, $selector, "css_element"]);
+    }
+
+    /**
+     * Toggles the manual completion button for a given activity.
+     *
+     * @Given /^I toggle the manual completion state of "(?P<activityname>(?:[^"]|\\")*)"$/
+     * @param string $activityname The activity name.
+     */
+    public function toggle_the_manual_completion_state(string $activityname): void {
+        $selector = "button[data-action=toggle-manual-completion][data-activityname='{$activityname}']";
+
+        $this->execute("behat_general::i_click_on", [$selector, "css_element"]);
+    }
+
+    /**
+     * Check that the activity does show completion information.
+     *
+     * @Given /^there should be no completion information shown for "(?P<activityname>(?:[^"]|\\")*)"$/
+     * @param string $activityname The activity name.
+     */
+    public function there_should_be_no_completion_for_activity(string $activityname): void {
+        $containerselector = "div[data-region=activity-information][data-activityname='$activityname']";
+        try {
+            $this->find('css_element', $containerselector);
+        } catch (ElementNotFoundException $e) {
+            // If activity information container does not exist (activity dates not shown, completion info not shown), all good.
+            return;
+        }
+
+        // Otherwise, ensure that the completion information does not exist.
+        $elementselector = "div[data-region=completion-info]";
+        $params = [$elementselector, "css_element", $containerselector, "css_element"];
+        $this->execute("behat_general::should_not_exist_in_the", $params);
+    }
+
+    /**
+     * Check that the manual completion button for the activity is disabled.
+     *
+     * @Given /^the manual completion button for "(?P<activityname>(?:[^"]|\\")*)" should be disabled$/
+     * @param string $activityname The activity name.
+     */
+    public function the_manual_completion_button_for_activity_should_be_disabled(string $activityname): void {
+        $selector = "div[data-activityname='$activityname'] button";
+
+        $params = [$selector, "css_element"];
+        $this->execute("behat_general::the_element_should_be_disabled", $params);
+    }
+
+    /**
+     * Check that the activity has the given automatic completion condition.
+     *
+     * @Given /^"(?P<activityname>(?:[^"]|\\")*)" should have the "(?P<conditionname>(?:[^"]|\\")*)" completion condition$/
+     * @param string $activityname The activity name.
+     * @param string $conditionname The automatic condition name.
+     */
+    public function activity_should_have_the_completion_condition(string $activityname, string $conditionname): void {
+        $containerselector = "div[data-region=activity-information][data-activityname='$activityname']";
+
+        $params = [$conditionname, $containerselector, 'css_element'];
+        $this->execute("behat_general::assert_element_contains_text", $params);
+    }
+
     /**
      * Checks if the activity with specified name shows a information completion checkbox (i.e. showing the completion tracking
      * configuration).
@@ -2,7 +2,7 @@
 Feature: Show activity completion status or activity completion configuration on the course page
   In order to understand the configuration or status of an activity's completion
   As a user
-  I want to see an appropriate checkbox icon besides the activity
+  I need to see the appropriate completion information for each activity in the course homepage
 
   Background:
     Given the following "courses" exist:
@@ -39,27 +39,27 @@ Feature: Show activity completion status or activity completion configuration on
     Given I log in as "student1"
     And I am on "Course 1" course homepage
     Then I should see "Your progress"
-    And the "Test forum name" "Forum" activity with "manual" completion shows a status completion checkbox
-    And the "Test assignment name" "Assign" activity with "auto" completion shows a status completion checkbox
-    And the "Test quiz name" "Quiz" activity does not show any completion checkbox
+    And the manual completion button of "Test forum name" is displayed as "Mark as done"
+    And the "View" completion condition of "Test assignment name" is displayed as "todo"
+    And there should be no completion information shown for "Test quiz name"
 
   Scenario: Show completion configuration to editing teachers
     Given I log in as "teacher1"
     And I am on "Course 1" course homepage
     Then I should not see "Your progress"
-    And the "Test forum name" "Forum" activity with "manual" completion shows a configuration completion checkbox
-    And the "Test assignment name" "Assign" activity with "auto" completion shows a configuration completion checkbox
-    And the "Test quiz name" "Quiz" activity does not show any completion checkbox
+    And the manual completion button for "Test forum name" should be disabled
+    And "Test assignment name" should have the "View" completion condition
+    And there should be no completion information shown for "Test quiz name"
     And I am on "Course 1" course homepage with editing mode on
     And I should not see "Your progress"
-    And the "Test forum name" "Forum" activity with "manual" completion shows a configuration completion checkbox
-    And the "Test assignment name" "Assign" activity with "auto" completion shows a configuration completion checkbox
-    And the "Test quiz name" "Quiz" activity does not show any completion checkbox
+    And the manual completion button for "Test forum name" should be disabled
+    And "Test assignment name" should have the "View" completion condition
+    And there should be no completion information shown for "Test quiz name"
 
   Scenario: Show completion configuration to non-editing teachers
     Given I log in as "teacher2"
     And I am on "Course 1" course homepage
     Then I should not see "Your progress"
-    And the "Test forum name" "Forum" activity with "manual" completion shows a configuration completion checkbox
-    And the "Test assignment name" "Assign" activity with "auto" completion shows a configuration completion checkbox
-    And the "Test quiz name" "Quiz" activity does not show any completion checkbox
+    And the manual completion button for "Test forum name" should be disabled
+    And "Test assignment name" should have the "View" completion condition
+    And there should be no completion information shown for "Test quiz name"
index 3ab7d2f..783690b 100644 (file)
@@ -21,13 +21,16 @@ Feature: Allow teachers to edit the visibility of completion conditions in a cou
 
   Scenario: Completion condition displaying for manual and auto completion
     Given I log in as "teacher1"
-    And I am on "Course 1" course homepage
+    When I am on "Course 1" course homepage
+    # The manual completion toggle button should be always displayed in both course homepage and activity view.
+    Then the manual completion button for "Test choice manual" should be disabled
     And I follow "Test choice manual"
-    And I should see "Mark as done"
+    And the manual completion button for "Test choice manual" should be disabled
+    # Automatic completion conditions should be displayed on both activity view page and course homepage if show completion conditions is enabled.
     And I am on "Course 1" course homepage
-    When I follow "Test choice auto"
-    Then I should see "Make a choice" in the "[data-region=completionrequirements]" "css_element"
-    # TODO MDL-70821: Check completion conditions display on course homepage.
+    And "Test choice auto" should have the "Make a choice" completion condition
+    And I follow "Test choice auto"
+    And "Test choice auto" should have the "Make a choice" completion condition
 
   Scenario: Completion condition displaying setting can be disabled at course level
     Given I log in as "teacher1"
@@ -36,12 +39,16 @@ Feature: Allow teachers to edit the visibility of completion conditions in a cou
     When I set the following fields to these values:
       | Show completion conditions | No |
     And I click on "Save and display" "button"
+    # Automatic completion conditions should not be displayed on the course homepage if show completion conditions is disabled.
+    And there should be no completion information shown for "Test choice auto"
+    # Completion conditions are always shown in the module's view page.
     And I follow "Test choice auto"
-     # Completion conditions are always shown in the module's view page.
-    Then I should see "Make a choice" in the "[data-region=completionrequirements]" "css_element"
+    Then "Test choice auto" should have the "Make a choice" completion condition
+    # The manual completion toggle button should be always displayed in both course homepage and activity view.
     And I am on "Course 1" course homepage
+    And the manual completion button for "Test choice manual" should be disabled
     And I follow "Test choice manual"
-    And I should see "Mark as done"
+    And the manual completion button for "Test choice manual" should be disabled
 
   Scenario: Default show completion conditions value in course form when default show completion conditions admin setting is set to No
     Given I log in as "admin"
index f6b0826..83f8df5 100644 (file)
@@ -31,8 +31,8 @@ Feature: Allow students to manually mark an activity as complete
     And I log out
     And I log in as "student1"
     And I am on "Course 1" course homepage
-    When I click on "Not completed: Test forum name. Select to mark as complete." "icon"
-    Then the "Test forum name" "forum" activity with "manual" completion should be marked as complete
+    When I toggle the manual completion state of "Test forum name"
+    Then the manual completion button of "Test forum name" is displayed as "Done"
     And I log out
     And I log in as "teacher1"
     And I am on "Course 1" course homepage
index f65c9fa..f1550de 100644 (file)
@@ -45,7 +45,7 @@ Feature: Restrict sections availability through completion or grade conditions
     And I am on "Course 1" course homepage
     Then I should see "Not available unless: The activity Test label is marked complete"
     And I should not see "Test page name"
-    And I click on "Not completed: Test label. Select to mark as complete." "icon"
+    And I toggle the manual completion state of "Test label"
     And I should see "Test page name"
     And I should not see "Not available unless: The activity Test label is marked complete"
 
index dc0dbed..f9657dd 100644 (file)
@@ -1,6 +1,25 @@
 This files describes API changes in /completion/* - completion,
 information provided here is intended especially for developers.
 
+=== 3.11 ===
+* New Behat steps for activity completion in the behat_completion class:
+  - activity_completion_condition_displayed_as()
+    - Given the "<Completion condition>" completion condition of "<Activity name>" is displayed as "<Status>"
+  - activity_should_have_the_completion_condition()
+    - Given "<Activity name>" should have the "<Condition name>" completion condition
+  - manual_completion_button_displayed_as()
+    - Given the manual completion button of "<Activity name>" is displayed as "<Status>"
+  - the_manual_completion_button_for_activity_should_be_disabled()
+    - Given the manual completion button for "<Activity name>" should be disabled
+  - there_should_be_no_completion_for_activity()
+    - Given there should be no completion information shown for "<Activity name>"
+  - toggle_the_manual_completion_state()
+    - Given I toggle the manual completion state of "<Activity name>"
+  - overridden_manual_completion_button_displayed_as
+    - Given the manual completion button of "<Activity name>" overridden by "<User>" is displayed as "<Status>"
+  - overridden_activity_completion_condition_displayed_as
+    - Given the "<Completion condition>" completion condition of "<Activity name>" overridden by "<User>" is displayed as "<Status>"
+
 === 3.7 ===
  * External function core_completion_external::get_activities_completion_status new returns the following additional field:
    - valueused (indicates whether the completion state affects the availability of other content)
diff --git a/contentbank/amd/build/upload.min.js b/contentbank/amd/build/upload.min.js
new file mode 100644 (file)
index 0000000..9df927d
Binary files /dev/null and b/contentbank/amd/build/upload.min.js differ
diff --git a/contentbank/amd/build/upload.min.js.map b/contentbank/amd/build/upload.min.js.map
new file mode 100644 (file)
index 0000000..c14d2d7
Binary files /dev/null and b/contentbank/amd/build/upload.min.js.map differ
diff --git a/contentbank/amd/src/upload.js b/contentbank/amd/src/upload.js
new file mode 100644 (file)
index 0000000..fb1f7c8
--- /dev/null
@@ -0,0 +1,52 @@
+// 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/>.
+
+/**
+ * Module to handle AJAX interactions with content bank upload files.
+ *
+ * @module     core_contentbank/upload
+ * @copyright  2021 Sara Arjona <sara@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+import ModalForm from 'core_form/modalform';
+import {get_string as getString} from 'core/str';
+
+/**
+ * Initialize upload files to the content bank form as Modal form.
+ *
+ * @param {String} elementSelector
+ * @param {String} formClass
+ * @param {Integer} contextId
+ * @param {Integer} contentId
+ */
+export const initModal = (elementSelector, formClass, contextId, contentId) => {
+    const element = document.querySelector(elementSelector);
+    element.addEventListener('click', function(e) {
+        e.preventDefault();
+        const form = new ModalForm({
+            formClass,
+            args: {
+                contextid: contextId,
+                id: contentId,
+            },
+            modalConfig: {title: getString('upload', 'contentbank')},
+            returnFocus: e.target,
+        });
+        form.addEventListener(form.events.FORM_SUBMITTED, (event) => {
+            document.location = event.detail.returnurl;
+        });
+        form.show();
+    });
+};
diff --git a/contentbank/classes/form/upload_files.php b/contentbank/classes/form/upload_files.php
new file mode 100644 (file)
index 0000000..bbd729c
--- /dev/null
@@ -0,0 +1,228 @@
+<?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/>.
+
+namespace core_contentbank\form;
+
+/**
+ * Upload files to content bank form
+ *
+ * @package    core_contentbank
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class upload_files extends \core_form\dynamic_form {
+
+    /**
+     * Add elements to this form.
+     */
+    public function definition() {
+        $mform = $this->_form;
+
+        $mform->addElement('hidden', 'contextid');
+        $mform->setType('contextid', PARAM_INT);
+
+        $mform->addElement('hidden', 'id');
+        $mform->setType('id', PARAM_INT);
+
+        $mform->addElement('filepicker', 'file', get_string('file', 'core_contentbank'), null, $this->get_options());
+        $mform->addHelpButton('file', 'file', 'core_contentbank');
+        $mform->addRule('file', null, 'required');
+    }
+
+    /**
+     * Validate incoming data.
+     *
+     * @param array $data
+     * @param array $files
+     * @return array
+     */
+    public function validation($data, $files) {
+        $errors = array();
+        $draftitemid = $data['file'];
+        $options = $this->get_options();
+        if (file_is_draft_area_limit_reached($draftitemid, $options['areamaxbytes'])) {
+            $errors['file'] = get_string('userquotalimit', 'error');
+        }
+        return $errors;
+    }
+
+    /**
+     * Check if current user has access to this form, otherwise throw exception
+     *
+     * Sometimes permission check may depend on the action and/or id of the entity.
+     * If necessary, form data is available in $this->_ajaxformdata or
+     * by calling $this->optional_param()
+     */
+    protected function check_access_for_dynamic_submission(): void {
+        require_capability('moodle/contentbank:upload', $this->get_context_for_dynamic_submission());
+
+        // Check the context used by the content bank is allowed.
+        $cb = new \core_contentbank\contentbank();
+        if (!$cb->is_context_allowed($this->get_context_for_dynamic_submission())) {
+            throw new \moodle_exception('contextnotallowed', 'core_contentbank');
+        }
+
+        // If $id is defined, the file content will be replaced (instead of uploading a new one).
+        // Check that the user has the right permissions to replace this content file.
+        $id = $this->optional_param('id', null, PARAM_INT);
+        if ($id) {
+            $content = $cb->get_content_from_id($id);
+            $contenttype = $content->get_content_type_instance();
+            if (!$contenttype->can_manage($content) || !$contenttype->can_upload()) {
+                throw new \moodle_exception('nopermissions', 'error', '', null, get_string('replacecontent', 'contentbank'));
+            }
+        }
+    }
+
+    /**
+     * Returns form context
+     *
+     * If context depends on the form data, it is available in $this->_ajaxformdata or
+     * by calling $this->optional_param()
+     *
+     * @return \context
+     */
+    protected function get_context_for_dynamic_submission(): \context {
+        $contextid = $this->optional_param('contextid', null, PARAM_INT);
+        return \context::instance_by_id($contextid, MUST_EXIST);
+    }
+
+    /**
+     * File upload options
+     *
+     * @return array
+     * @throws \coding_exception
+     */
+    protected function get_options(): array {
+        global $CFG;
+
+        $maxbytes = $CFG->userquota;
+        $maxareabytes = $CFG->userquota;
+        if (has_capability('moodle/user:ignoreuserquota', $this->get_context_for_dynamic_submission())) {
+            $maxbytes = USER_CAN_IGNORE_FILE_SIZE_LIMITS;
+            $maxareabytes = FILE_AREA_MAX_BYTES_UNLIMITED;
+        }
+
+        $cb = new \core_contentbank\contentbank();
+        $id = $this->optional_param('id', null, PARAM_INT);
+        if ($id) {
+            $content = $cb->get_content_from_id($id);
+            $contenttype = $content->get_content_type_instance();
+            $extensions = $contenttype->get_manageable_extensions();
+            $acceptedtypes = implode(',', $extensions);
+        } else {
+            $acceptedtypes = $cb->get_supported_extensions_as_string($this->get_context_for_dynamic_submission());
+        }
+
+        return ['subdirs' => 1, 'maxbytes' => $maxbytes, 'maxfiles' => -1, 'accepted_types' => $acceptedtypes,
+            'areamaxbytes' => $maxareabytes];
+    }
+
+    /**
+     * Process the form submission, used if form was submitted via AJAX
+     *
+     * This method can return scalar values or arrays that can be json-encoded, they will be passed to the caller JS.
+     *
+     * Submission data can be accessed as: $this->get_data()
+     *
+     * @return mixed
+     */
+    public function process_dynamic_submission() {
+        global $USER;
+
+        // Get the file and create the content based on it.
+        $usercontext = \context_user::instance($USER->id);
+        $fs = get_file_storage();
+        $files = $fs->get_area_files($usercontext->id, 'user', 'draft', $this->get_data()->file, 'itemid, filepath,
+            filename', false);
+        if (!empty($files)) {
+            $file = reset($files);
+            $cb = new \core_contentbank\contentbank();
+            try {
+                if ($this->get_data()->id) {
+                    $content = $cb->get_content_from_id($this->get_data()->id);
+                    $contenttype = $content->get_content_type_instance();
+                    $content = $contenttype->replace_content($file, $content);
+                } else {
+                    $content = $cb->create_content_from_file($this->get_context_for_dynamic_submission(), $USER->id, $file);
+                }
+                $params = ['id' => $content->get_id(), 'contextid' => $this->get_context_for_dynamic_submission()->id];
+                $url = new \moodle_url('/contentbank/view.php', $params);
+            } catch (\Exception $e) {
+                // Redirect to the right page (depending on if content is new or existing) and display an error.
+                if ($this->get_data()->id) {
+                    $content = $cb->get_content_from_id($this->get_data()->id);
+                    $params = [
+                        'id' => $content->get_id(),
+                        'contextid' => $this->get_context_for_dynamic_submission()->id,
+                        'errormsg' => 'notvalidpackage',
+                    ];
+                    $url = new \moodle_url('/contentbank/view.php', $params);
+                } else {
+                    $url = new \moodle_url('/contentbank/index.php', [
+                        'contextid' => $this->get_context_for_dynamic_submission()->id,
+                        'errormsg' => 'notvalidpackage'],
+                    );
+                }
+            }
+
+            return ['returnurl' => $url->out(false)];
+        }
+
+        return null;
+    }
+
+    /**
+     * Load in existing data as form defaults
+     *
+     * Can be overridden to retrieve existing values from db by entity id and also
+     * to preprocess editor and filemanager elements
+     *
+     * Example:
+     *     $this->set_data(get_entity($this->_ajaxformdata['id']));
+     */
+    public function set_data_for_dynamic_submission(): void {
+        $data = (object)[
+            'contextid' => $this->optional_param('contextid', null, PARAM_INT),
+            'id' => $this->optional_param('id', null, PARAM_INT),
+        ];
+        $this->set_data($data);
+    }
+
+    /**
+     * Returns url to set in $PAGE->set_url() when form is being rendered or submitted via AJAX
+     *
+     * This is used in the form elements sensitive to the page url, such as Atto autosave in 'editor'
+     *
+     * If the form has arguments (such as 'id' of the element being edited), the URL should
+     * also have respective argument.
+     *
+     * @return \moodle_url
+     */
+    protected function get_page_url_for_dynamic_submission(): \moodle_url {
+        $params = ['contextid' => $this->get_context_for_dynamic_submission()->id];
+
+        $id = $this->optional_param('id', null, PARAM_INT);
+        if ($id) {
+            $url = '/contentbank/view.php';
+            $params['id'] = $id;
+        } else {
+            $url = '/contentbank/index.php';
+        }
+
+        return new \moodle_url($url, $params);
+    }
+}
index 4670423..ea41ffb 100644 (file)
@@ -24,9 +24,6 @@
 
 namespace contenttype_h5p;
 
-use stdClass;
-use html_writer;
-
 /**
  * H5P Content manager class
  *
@@ -36,4 +33,59 @@ use html_writer;
  */
 class content extends \core_contentbank\content {
 
+    /**
+     * Returns user has access permission for the content itself.
+     * If the H5P content-type library is disabled, the user won't have access to it.
+     *
+     * @return bool     True if content could be accessed. False otherwise.
+     */
+    public function is_view_allowed(): bool {
+        // Force H5P content to be deployed.
+        $fileurl = $this->get_file_url();
+        // Skip capability check when creating the H5P content (because it has been created by trusted users).
+        $h5pplayer = new \core_h5p\player($fileurl, new \stdClass(), true, '', true);
+        // Flush error messages.
+        $h5pplayer->get_messages();
+
+        // Check if the H5P entry has been created and if the main library is enabled.
+        $file = $this->get_file();
+        if (!empty($file)) {
+            $h5p = \core_h5p\api::get_content_from_pathnamehash($file->get_pathnamehash());
+            if (empty($h5p)) {
+                // If there is no H5P entry for this content, it won't be displayed unless the user has the manageanycontent
+                // capability. Reasons for contents without a proper H5P entry in DB:
+                // - Invalid H5P package (it won't be never deployed).
+                // - Disabled content-type library (it can't be deployed so there is no way to know the mainlibraryid).
+                $context = \context::instance_by_id($this->content->contextid);
+                if (!has_capability('moodle/contentbank:manageanycontent', $context)) {
+                    return false;
+                }
+            } else if (!\core_h5p\api::is_library_enabled((object) ['id' => $h5p->mainlibraryid])) {
+                // If the main library is disabled, it won't be displayed.
+                return false;
+            }
+        }
+
+        return parent::is_view_allowed();
+    }
+
+    /**
+     * Import a file as a valid content.
+     * Before importing the file, this method will check if the file is a valid H5P package. If it's not valid, it will thrown
+     * an exception.
+     *
+     * @throws \file_exception If file operations fail
+     * @param \stored_file $file File to store in the content file area.
+     * @return \stored_file|null the stored content file or null if the file is discarted.
+     */
+    public function import_file(\stored_file $file): ?\stored_file {
+        // The H5P content can be only deployed if the author of the .h5p file can update libraries or if all the
+        // content-type libraries exist, to avoid users without the h5p:updatelibraries capability upload malicious content.
+        $onlyupdatelibs = !\core_h5p\helper::can_update_library($file);
+
+        if (!\core_h5p\api::is_valid_package($file, $onlyupdatelibs)) {
+            throw new \file_exception('invalidpackage');
+        }
+        return parent::import_file($file);
+    }
 }
index e16a30e..eda31d9 100644 (file)
@@ -148,22 +148,25 @@ class contenttype extends \core_contentbank\contenttype {
         $types = [];
         $h5pfilestorage = new file_storage();
         foreach ($h5pcontenttypes as $h5pcontenttype) {
-            $library = [
-                'name' => $h5pcontenttype->machine_name,
-                'majorVersion' => $h5pcontenttype->major_version,
-                'minorVersion' => $h5pcontenttype->minor_version,
-            ];
-            $key = H5PCore::libraryToString($library);
-            $type = new stdClass();
-            $type->key = $key;
-            $type->typename = $h5pcontenttype->title;
-            $type->typeeditorparams = 'library=' . $key;
-            $type->typeicon = $h5pfilestorage->get_icon_url(
-                $h5pcontenttype->id,
-                $h5pcontenttype->machine_name,
-                $h5pcontenttype->major_version,
-                $h5pcontenttype->minor_version);
-            $types[] = $type;
+            if ($h5pcontenttype->enabled) {
+                // Only enabled content-types will be displayed.
+                $library = [
+                    'name' => $h5pcontenttype->machine_name,
+                    'majorVersion' => $h5pcontenttype->major_version,
+                    'minorVersion' => $h5pcontenttype->minor_version,
+                ];
+                $key = H5PCore::libraryToString($library);
+                $type = new stdClass();
+                $type->key = $key;
+                $type->typename = $h5pcontenttype->title;
+                $type->typeeditorparams = 'library=' . $key;
+                $type->typeicon = $h5pfilestorage->get_icon_url(
+                    $h5pcontenttype->id,
+                    $h5pcontenttype->machine_name,
+                    $h5pcontenttype->major_version,
+                    $h5pcontenttype->minor_version);
+                $types[] = $type;
+            }
         }
 
         return $types;
index bc5202c..c91828e 100644 (file)
@@ -1,4 +1,4 @@
-@core @core_contentbank @contenttype_h5p @_file_upload @_switch_iframe @javascript
+@core @core_contentbank @core_h5p @contenttype_h5p @_file_upload @_switch_iframe @javascript
 Feature: Replace H5P file from an existing content
   In order to replace an H5P content from the content bank
   As an admin
index 5e3bcbe..29959c5 100644 (file)
@@ -1,4 +1,4 @@
-@core @core_contentbank @contenttype_h5p @_file_upload @_switch_iframe @javascript
+@core @core_contentbank @core_h5p @contenttype_h5p @_file_upload @_switch_iframe @javascript
 Feature: H5P file upload to content bank for admins
   In order import new H5P content to content bank
   As an admin
diff --git a/contentbank/contenttype/h5p/tests/behat/disable_contenttypes.feature b/contentbank/contenttype/h5p/tests/behat/disable_contenttypes.feature
new file mode 100644 (file)
index 0000000..fa2e8b0
--- /dev/null
@@ -0,0 +1,101 @@
+@core @core_contentbank @core_h5p @contenttype_h5p @_file_upload @javascript
+Feature: Disable H5P content-types from the content bank
+  In order to disable H5P content-types
+  As an admin
+  I need to be able to check they are not displayed in the content bank
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | teacher1 | Teacher   | 1        | teacher1@example.com |
+    And the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1        | 0        |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | teacher1 | C1     | editingteacher |
+    And the following "contentbank contents" exist:
+      | contextlevel | reference | contenttype     | user     | contentname       | filepath                              |
+      | Course       | C1        | contenttype_h5p | admin    | filltheblanks     | /h5p/tests/fixtures/filltheblanks.h5p |
+      | Course       | C1        | contenttype_h5p | admin    | accordion         | /h5p/tests/fixtures/ipsums.h5p        |
+      | Course       | C1        | contenttype_h5p | admin    | invalidh5p        | /h5p/tests/fixtures/h5ptest.zip       |
+    And I log in as "admin"
+    And I am on "Course 1" course homepage with editing mode on
+    And I add the "Navigation" block if not present
+    And I log out
+
+  Scenario: Teachers cannot view disabled or invalid content-types
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I expand "Site pages" node
+    And I click on "Content bank" "link"
+    And I should see "accordion"
+    And I should see "filltheblanks"
+    And I should not see "invalidh5p"
+    And I log out
+    And I log in as "admin"
+    And I navigate to "H5P > Manage H5P content types" in site administration
+    And I click on "Disable" "link" in the "Accordion" "table_row"
+    And I log out
+    When I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I expand "Site pages" node
+    And I click on "Content bank" "link"
+    Then I should not see "accordion"
+    And I should see "filltheblanks"
+    And I should not see "invalidh5p"
+
+  Scenario: Admins cannot view disabled content-types
+    Given I log in as "admin"
+    And I am on "Course 1" course homepage
+    And I expand "Site pages" node
+    And I click on "Content bank" "link"
+    And I should see "accordion"
+    And I should see "filltheblanks"
+    And I should see "invalidh5p"
+    And I navigate to "H5P > Manage H5P content types" in site administration
+    And I click on "Disable" "link" in the "Accordion" "table_row"
+    When I am on "Course 1" course homepage
+    And I expand "Site pages" node
+    And I click on "Content bank" "link"
+    Then I should not see "accordion"
+    And I should see "filltheblanks"
+    And I should see "invalidh5p"
+
+  Scenario: Teachers cannot create disabled content-types
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I expand "Site pages" node
+    And I click on "Content bank" "link"
+    And I click on "[data-action=Add-content]" "css_element"
+    And I should see "Accordion"
+    And I should see "Fill in the Blanks"
+    And I log out
+    And I log in as "admin"
+    And I navigate to "H5P > Manage H5P content types" in site administration
+    And I click on "Disable" "link" in the "Accordion" "table_row"
+    And I log out
+    When I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I expand "Site pages" node
+    And I click on "Content bank" "link"
+    And I click on "[data-action=Add-content]" "css_element"
+    Then I should not see "Accordion"
+    And I should see "Fill in the Blanks"
+
+  Scenario: Admins cannot create disabled content-types
+    Given I log in as "admin"
+    And I am on "Course 1" course homepage
+    And I expand "Site pages" node
+    And I click on "Content bank" "link"
+    And I click on "[data-action=Add-content]" "css_element"
+    And I should see "Accordion"
+    And I should see "Fill in the Blanks"
+    And I navigate to "H5P > Manage H5P content types" in site administration
+    And I click on "Disable" "link" in the "Accordion" "table_row"
+    When I am on "Course 1" course homepage
+    And I expand "Site pages" node
+    And I click on "Content bank" "link"
+    And I click on "[data-action=Add-content]" "css_element"
+    Then I should not see "Accordion"
+    And I should see "Fill in the Blanks"
index 505b977..aff59f1 100644 (file)
@@ -1,11 +1,17 @@
-@core @core_contentbank @contenttype_h5p @_file_upload @javascript
+@core @core_contentbank @core_h5p @contenttype_h5p @_file_upload @javascript
 Feature: Manage H5P content from the content bank
   In order to manage H5P content in the content bank
   As an admin
   I need to be able to edit any H5P content in the content bank
 
   Background:
-    Given the following "users" exist:
+    Given I log in as "admin"
+    And I navigate to "H5P > Manage H5P content types" in site administration
+    And I upload "h5p/tests/fixtures/filltheblanks.h5p" file to "H5P content type" filemanager
+    And I click on "Upload H5P content types" "button" in the "#fitem_id_uploadlibraries" "css_element"
+    And I upload "h5p/tests/fixtures/ipsums.h5p" file to "H5P content type" filemanager
+    And I click on "Upload H5P content types" "button" in the "#fitem_id_uploadlibraries" "css_element"
+    And the following "users" exist:
       | username | firstname | lastname | email                |
       | teacher1 | Teacher   | 1        | teacher1@example.com |
     And the following "courses" exist:
@@ -15,10 +21,9 @@ Feature: Manage H5P content from the content bank
       | user     | course | role           |
       | teacher1 | C1     | editingteacher |
     And the following "contentbank content" exist:
-      | contextlevel | reference | contenttype     | user     | contentname       |
-      | Course       | C1        | contenttype_h5p | admin    | filltheblanks.h5p |
-      | Course       | C1        | contenttype_h5p | teacher1 | ipsums.h5p        |
-    And I log in as "admin"
+      | contextlevel | reference | contenttype     | user     | contentname       | filepath                                |
+      | Course       | C1        | contenttype_h5p | admin    | filltheblanks.h5p | /h5p/tests/fixtures/filltheblanks.h5p   |
+      | Course       | C1        | contenttype_h5p | teacher1 | ipsums.h5p        | /h5p/tests/fixtures/ipsums.h5p          |
     And I am on "Course 1" course homepage with editing mode on
     And I add the "Navigation" block if not present
     And I log out
index a08ad1b..d994b67 100644 (file)
@@ -1,11 +1,17 @@
-@core @core_contentbank @contenttype_h5p @_file_upload @_switch_iframe @javascript
+@core @core_contentbank @core_h5p @contenttype_h5p @_file_upload @_switch_iframe @javascript
 Feature: Replace H5P file from an existing content requires special capabilities
   In order replace an H5P content from the content bank
   As a teacher
   I need to be able to replace the content only if certain capabilities are allowed
 
   Background:
-    Given the following "users" exist:
+    Given I log in as "admin"
+    And I navigate to "H5P > Manage H5P content types" in site administration
+    And I upload "h5p/tests/fixtures/ipsums.h5p" file to "H5P content type" filemanager
+    And I click on "Upload H5P content types" "button" in the "#fitem_id_uploadlibraries" "css_element"
+    And I upload "h5p/tests/fixtures/filltheblanks.h5p" file to "H5P content type" filemanager
+    And I click on "Upload H5P content types" "button" in the "#fitem_id_uploadlibraries" "css_element"
+    And the following "users" exist:
       | username | firstname | lastname | email                |
       | teacher1 | Teacher   | 1        | teacher1@example.com |
     And the following "categories" exist:
@@ -21,6 +27,7 @@ Feature: Replace H5P file from an existing content requires special capabilities
       | contextlevel | reference | contenttype     | user     | contentname       | filepath                              |
       | Course       | C1        | contenttype_h5p | admin    | admincontent      | /h5p/tests/fixtures/ipsums.h5p        |
       | Course       | C1        | contenttype_h5p | teacher1 | teachercontent    | /h5p/tests/fixtures/filltheblanks.h5p |
+    And I log out
     And I log in as "teacher1"
     And I am on "Course 1" course homepage with editing mode on
     And I add the "Navigation" block if not present
index 48f6e22..488fde8 100644 (file)
@@ -1,4 +1,4 @@
-@core @core_contentbank @contenttype_h5p @_file_upload @_switch_iframe @javascript
+@core @core_contentbank @core_h5p @contenttype_h5p @_file_upload @_switch_iframe @javascript
 Feature: H5P file upload to content bank for non admins
   In order import new H5P content to content bank
   As an admin
@@ -37,7 +37,14 @@ Feature: H5P file upload to content bank for non admins
     Then I should see "Content bank"