Merge branch 'MDL-63394-master' of git://github.com/jleyva/moodle
authorDavid Monllao <davidm@moodle.com>
Mon, 19 Nov 2018 18:42:06 +0000 (19:42 +0100)
committerDavid Monllao <davidm@moodle.com>
Mon, 19 Nov 2018 18:42:06 +0000 (19:42 +0100)
329 files changed:
admin/lock.php [new file with mode: 0644]
admin/settings/development.php
admin/tool/dataprivacy/tests/behat/contact_privacy_officer.feature
admin/tool/dataprivacy/tests/behat/dataexport.feature
admin/tool/dataprivacy/tests/behat/manage_categories.feature
admin/tool/dataprivacy/tests/behat/manage_data_requests.feature
admin/tool/dataprivacy/tests/behat/manage_purposes.feature
admin/tool/lp/styles.css
admin/tool/lp/tests/behat/framework_crud.feature
admin/tool/lp/tests/behat/plan_crud.feature
admin/tool/lp/tests/behat/template_crud.feature
admin/tool/lp/tests/behat/user_evidence_crud.feature
admin/tool/monitor/lib.php
admin/tool/policy/tests/behat/acceptances.feature
admin/tool/recyclebin/tests/behat/basic_functionality.feature
admin/tool/usertours/amd/build/tour.min.js
admin/tool/usertours/amd/src/tour.js
admin/tool/usertours/tests/behat/tour_navigation.feature [new file with mode: 0644]
admin/tool/usertours/thirdpartylibs.xml
admin/tool/xmldb/actions/edit_field/edit_field.class.php
admin/tool/xmldb/actions/edit_index/edit_index.class.php
admin/tool/xmldb/actions/edit_key/edit_key.class.php
admin/tool/xmldb/actions/edit_table/edit_table.class.php
admin/tool/xmldb/actions/edit_xml_file/edit_xml_file.class.php
admin/tool/xmldb/actions/view_reserved_words/view_reserved_words.class.php
admin/tool/xmldb/actions/view_structure_php/view_structure_php.class.php
admin/tool/xmldb/actions/view_structure_sql/view_structure_sql.class.php
admin/tool/xmldb/actions/view_table_php/view_table_php.class.php
admin/tool/xmldb/actions/view_table_sql/view_table_sql.class.php
auth/cas/auth.php
availability/condition/group/tests/behat/availability_group.feature
badges/competency_form.php
badges/edit_form.php
badges/endorsement.php
badges/endorsement_form.php
badges/related_form.php
badges/renderer.php
blocks/myoverview/amd/build/view.min.js
blocks/myoverview/amd/src/view.js
blocks/myoverview/block_myoverview.php
blocks/myoverview/classes/output/main.php
blocks/myoverview/classes/privacy/provider.php
blocks/myoverview/lang/en/block_myoverview.php
blocks/myoverview/lib.php
blocks/myoverview/templates/courses-view.mustache
blocks/myoverview/tests/behat/block_myoverview_pagelimit_persistence.feature [new file with mode: 0644]
blocks/myoverview/tests/privacy_test.php
blocks/recentlyaccesseditems/classes/privacy/provider.php
blocks/recentlyaccesseditems/tests/privacy_test.php [new file with mode: 0644]
blocks/timeline/amd/build/event_list.min.js
blocks/timeline/amd/build/view_dates.min.js
blocks/timeline/amd/src/event_list.js
blocks/timeline/amd/src/view_dates.js
blocks/timeline/block_timeline.php
blocks/timeline/classes/output/main.php
blocks/timeline/classes/privacy/provider.php
blocks/timeline/lang/en/block_timeline.php
blocks/timeline/lib.php
blocks/timeline/templates/view.mustache
blocks/timeline/tests/behat/block_timeline_pagelimit_persistence.feature [new file with mode: 0644]
blocks/timeline/tests/privacy_test.php
calendar/templates/event_item.mustache
calendar/templates/month_detailed.mustache
calendar/tests/behat/calendar.feature
cohort/lib.php
course/classes/category.php
course/classes/management/helper.php
course/classes/privacy/provider.php
course/externallib.php
course/tests/privacy_test.php
favourites/classes/local/service/user_favourite_service.php
favourites/classes/privacy/provider.php
favourites/tests/privacy_test.php
favourites/tests/service_test.php
filter/activitynames/tests/filter_test.php [new file with mode: 0644]
filter/glossary/tests/filter_test.php
grade/edit/tree/index.php
grade/export/lib.php
grade/export/ods/grade_export_ods.php
grade/export/txt/grade_export_txt.php
grade/export/xls/grade_export_xls.php
grade/export/xml/grade_export_xml.php
grade/tests/export_test.php [new file with mode: 0644]
lang/en/admin.php
lang/en/course.php
lang/en/message.php
lang/en/moodle.php
lang/en/role.php
lib/accesslib.php
lib/amd/build/auto_rows.min.js
lib/amd/build/key_codes.min.js
lib/amd/build/paged_content.min.js
lib/amd/build/paged_content_factory.min.js
lib/amd/src/auto_rows.js
lib/amd/src/key_codes.js
lib/amd/src/paged_content.js
lib/amd/src/paged_content_factory.js
lib/badgeslib.php
lib/behat/classes/partial_named_selector.php
lib/blocklib.php
lib/classes/message/manager.php
lib/classes/oauth2/access_token.php [new file with mode: 0644]
lib/classes/oauth2/client.php
lib/classes/output/icon_system_fontawesome.php
lib/classes/user.php
lib/db/access.php
lib/db/install.xml
lib/db/services.php
lib/db/upgrade.php
lib/editor/atto/plugins/image/yui/build/moodle-atto_image-button/moodle-atto_image-button-debug.js
lib/editor/atto/plugins/image/yui/build/moodle-atto_image-button/moodle-atto_image-button-min.js
lib/editor/atto/plugins/image/yui/build/moodle-atto_image-button/moodle-atto_image-button.js
lib/editor/atto/plugins/image/yui/src/button/js/button.js
lib/editor/atto/plugins/link/yui/build/moodle-atto_link-button/moodle-atto_link-button-debug.js
lib/editor/atto/plugins/link/yui/build/moodle-atto_link-button/moodle-atto_link-button-min.js
lib/editor/atto/plugins/link/yui/build/moodle-atto_link-button/moodle-atto_link-button.js
lib/editor/atto/plugins/link/yui/src/button/js/button.js
lib/filebrowser/file_info_context_course.php
lib/filterlib.php
lib/navigationlib.php
lib/oauthlib.php
lib/outputrenderers.php
lib/outputrequirementslib.php
lib/phpunit/bootstrap.php
lib/phpunit/classes/advanced_testcase.php
lib/setuplib.php
lib/templates/loading.mustache
lib/tests/accesslib_has_capability_test.php [new file with mode: 0644]
lib/tests/behat/locking.feature [new file with mode: 0644]
lib/tests/messagelib_test.php
lib/tests/moodlelib_test.php
lib/tests/session_manager_test.php
lib/upgrade.txt
login/index.php
message/amd/build/message_drawer.min.js [new file with mode: 0644]
message/amd/build/message_drawer_events.min.js [new file with mode: 0644]
message/amd/build/message_drawer_helper.min.js [new file with mode: 0644]
message/amd/build/message_drawer_lazy_load_list.min.js [new file with mode: 0644]
message/amd/build/message_drawer_router.min.js [new file with mode: 0644]
message/amd/build/message_drawer_routes.min.js [new file with mode: 0644]
message/amd/build/message_drawer_view_contact.min.js [new file with mode: 0644]
message/amd/build/message_drawer_view_contacts.min.js [new file with mode: 0644]
message/amd/build/message_drawer_view_contacts_section.min.js [new file with mode: 0644]
message/amd/build/message_drawer_view_contacts_section_contacts.min.js [new file with mode: 0644]
message/amd/build/message_drawer_view_contacts_section_requests.min.js [new file with mode: 0644]
message/amd/build/message_drawer_view_conversation.min.js [new file with mode: 0644]
message/amd/build/message_drawer_view_conversation_constants.min.js [new file with mode: 0644]
message/amd/build/message_drawer_view_conversation_patcher.min.js [new file with mode: 0644]
message/amd/build/message_drawer_view_conversation_renderer.min.js [new file with mode: 0644]
message/amd/build/message_drawer_view_conversation_state_manager.min.js [new file with mode: 0644]
message/amd/build/message_drawer_view_group_info.min.js [new file with mode: 0644]
message/amd/build/message_drawer_view_overview.min.js [new file with mode: 0644]
message/amd/build/message_drawer_view_overview_section.min.js [new file with mode: 0644]
message/amd/build/message_drawer_view_overview_section_favourites.min.js [new file with mode: 0644]
message/amd/build/message_drawer_view_overview_section_group_messages.min.js [new file with mode: 0644]
message/amd/build/message_drawer_view_overview_section_messages.min.js [new file with mode: 0644]
message/amd/build/message_drawer_view_search.min.js [new file with mode: 0644]
message/amd/build/message_drawer_view_settings.min.js [new file with mode: 0644]
message/amd/build/message_popover.min.js [new file with mode: 0644]
message/amd/build/message_repository.min.js
message/amd/src/message_drawer.js [new file with mode: 0644]
message/amd/src/message_drawer_events.js [new file with mode: 0644]
message/amd/src/message_drawer_helper.js [new file with mode: 0644]
message/amd/src/message_drawer_lazy_load_list.js [new file with mode: 0644]
message/amd/src/message_drawer_router.js [new file with mode: 0644]
message/amd/src/message_drawer_routes.js [new file with mode: 0644]
message/amd/src/message_drawer_view_contact.js [new file with mode: 0644]
message/amd/src/message_drawer_view_contacts.js [new file with mode: 0644]
message/amd/src/message_drawer_view_contacts_section.js [new file with mode: 0644]
message/amd/src/message_drawer_view_contacts_section_contacts.js [new file with mode: 0644]
message/amd/src/message_drawer_view_contacts_section_requests.js [new file with mode: 0644]
message/amd/src/message_drawer_view_conversation.js [new file with mode: 0644]
message/amd/src/message_drawer_view_conversation_constants.js [new file with mode: 0644]
message/amd/src/message_drawer_view_conversation_patcher.js [new file with mode: 0644]
message/amd/src/message_drawer_view_conversation_renderer.js [new file with mode: 0644]
message/amd/src/message_drawer_view_conversation_state_manager.js [new file with mode: 0644]
message/amd/src/message_drawer_view_group_info.js [new file with mode: 0644]
message/amd/src/message_drawer_view_overview.js [new file with mode: 0644]
message/amd/src/message_drawer_view_overview_section.js [new file with mode: 0644]
message/amd/src/message_drawer_view_overview_section_favourites.js [new file with mode: 0644]
message/amd/src/message_drawer_view_overview_section_group_messages.js [new file with mode: 0644]
message/amd/src/message_drawer_view_overview_section_messages.js [new file with mode: 0644]
message/amd/src/message_drawer_view_search.js [new file with mode: 0644]
message/amd/src/message_drawer_view_settings.js [new file with mode: 0644]
message/amd/src/message_popover.js [new file with mode: 0644]
message/amd/src/message_repository.js
message/classes/api.php
message/classes/helper.php
message/classes/output/messagearea/contact.php
message/classes/privacy/provider.php
message/edit.php
message/externallib.php
message/index.php
message/lib.php
message/output/popup/amd/build/message_popover_controller.min.js [deleted file]
message/output/popup/amd/src/message_popover_controller.js [deleted file]
message/output/popup/lib.php
message/output/popup/templates/message_popover.mustache [deleted file]
message/output/popup/tests/behat/message_popover_preferences.feature [deleted file]
message/output/popup/tests/behat/message_popover_unread.feature [deleted file]
message/templates/message_drawer.mustache [new file with mode: 0644]
message/templates/message_drawer_contacts_list.mustache [new file with mode: 0644]
message/templates/message_drawer_contacts_list_item_placeholder.mustache [new file with mode: 0644]
message/templates/message_drawer_conversations_list.mustache [new file with mode: 0644]
message/templates/message_drawer_conversations_list_item_placeholder.mustache [new file with mode: 0644]
message/templates/message_drawer_icon_back.mustache [new file with mode: 0644]
message/templates/message_drawer_icon_forward.mustache [new file with mode: 0644]
message/templates/message_drawer_lazy_load_list.mustache [new file with mode: 0644]
message/templates/message_drawer_messages_list.mustache [new file with mode: 0644]
message/templates/message_drawer_messages_list_item_placeholder.mustache [new file with mode: 0644]
message/templates/message_drawer_non_contacts_list.mustache [new file with mode: 0644]
message/templates/message_drawer_view_contact_body.mustache [new file with mode: 0644]
message/templates/message_drawer_view_contact_body_content.mustache [new file with mode: 0644]
message/templates/message_drawer_view_contacts_body.mustache [new file with mode: 0644]
message/templates/message_drawer_view_contacts_body_section_contacts.mustache [new file with mode: 0644]
message/templates/message_drawer_view_contacts_body_section_requests.mustache [new file with mode: 0644]
message/templates/message_drawer_view_contacts_body_section_requests_list.mustache [new file with mode: 0644]
message/templates/message_drawer_view_contacts_header.mustache [new file with mode: 0644]
message/templates/message_drawer_view_contacts_section.mustache [new file with mode: 0644]
message/templates/message_drawer_view_conversation_body.mustache [new file with mode: 0644]
message/templates/message_drawer_view_conversation_body_confirm_dialogue.mustache [new file with mode: 0644]
message/templates/message_drawer_view_conversation_body_day.mustache [new file with mode: 0644]
message/templates/message_drawer_view_conversation_body_day_placeholder.mustache [new file with mode: 0644]
message/templates/message_drawer_view_conversation_body_message.mustache [new file with mode: 0644]
message/templates/message_drawer_view_conversation_body_messages.mustache [new file with mode: 0644]
message/templates/message_drawer_view_conversation_body_placeholder.mustache [new file with mode: 0644]
message/templates/message_drawer_view_conversation_footer.mustache [new file with mode: 0644]
message/templates/message_drawer_view_conversation_footer_content.mustache [new file with mode: 0644]
message/templates/message_drawer_view_conversation_footer_edit_mode.mustache [new file with mode: 0644]
message/templates/message_drawer_view_conversation_footer_placeholder.mustache [new file with mode: 0644]
message/templates/message_drawer_view_conversation_footer_require_contact.mustache [new file with mode: 0644]
message/templates/message_drawer_view_conversation_footer_require_unblock.mustache [new file with mode: 0644]
message/templates/message_drawer_view_conversation_footer_unable_to_message.mustache [new file with mode: 0644]
message/templates/message_drawer_view_conversation_header.mustache [new file with mode: 0644]
message/templates/message_drawer_view_conversation_header_content_type_private.mustache [new file with mode: 0644]
message/templates/message_drawer_view_conversation_header_content_type_private_no_controls.mustache [new file with mode: 0644]
message/templates/message_drawer_view_conversation_header_content_type_public.mustache [new file with mode: 0644]
message/templates/message_drawer_view_conversation_header_edit_mode.mustache [new file with mode: 0644]
message/templates/message_drawer_view_conversation_header_placeholder.mustache [new file with mode: 0644]
message/templates/message_drawer_view_group_info_body.mustache [new file with mode: 0644]
message/templates/message_drawer_view_group_info_body_content.mustache [new file with mode: 0644]
message/templates/message_drawer_view_group_info_participants_list.mustache [new file with mode: 0644]
message/templates/message_drawer_view_group_info_participants_list_item_placeholder.mustache [new file with mode: 0644]
message/templates/message_drawer_view_overview_body.mustache [new file with mode: 0644]
message/templates/message_drawer_view_overview_header.mustache [new file with mode: 0644]
message/templates/message_drawer_view_overview_section.mustache [new file with mode: 0644]
message/templates/message_drawer_view_overview_section_favourites.mustache [new file with mode: 0644]
message/templates/message_drawer_view_overview_section_group_messages.mustache [new file with mode: 0644]
message/templates/message_drawer_view_overview_section_messages.mustache [new file with mode: 0644]
message/templates/message_drawer_view_search_body.mustache [new file with mode: 0644]
message/templates/message_drawer_view_search_header.mustache [new file with mode: 0644]
message/templates/message_drawer_view_search_results_content.mustache [new file with mode: 0644]
message/templates/message_drawer_view_search_results_content_placeholder.mustache [new file with mode: 0644]
message/templates/message_drawer_view_settings_body.mustache [new file with mode: 0644]
message/templates/message_drawer_view_settings_header.mustache [new file with mode: 0644]
message/templates/message_popover.mustache [new file with mode: 0644]
message/tests/api_test.php
message/tests/behat/behat_message.php
message/tests/behat/delete_all_messages.feature [deleted file]
message/tests/behat/delete_messages.feature [deleted file]
message/tests/behat/manage_contacts.feature [deleted file]
message/tests/behat/reply_message.feature [deleted file]
message/tests/behat/search_messages.feature [deleted file]
message/tests/behat/search_users.feature [deleted file]
message/tests/behat/update_messaging_preferences.feature [deleted file]
message/tests/behat/view_messages.feature [deleted file]
message/tests/externallib_test.php
message/tests/privacy_provider_test.php
mod/assign/classes/event/course_module_viewed.php [new file with mode: 0644]
mod/assign/externallib.php
mod/assign/locallib.php
mod/assign/tests/events_test.php
mod/assign/tests/externallib_test.php
mod/forum/db/access.php
mod/forum/version.php
mod/quiz/report/statistics/report.php
mod/quiz/styles.css
mod/quiz/tests/behat/editing_add.feature
pix/i/sendmessage.png [new file with mode: 0644]
pix/i/sendmessage.svg [new file with mode: 0644]
pix/i/trash.png [new file with mode: 0644]
pix/i/trash.svg [new file with mode: 0644]
pix/i/window_close.png [new file with mode: 0644]
pix/i/window_close.svg [new file with mode: 0644]
pix/t/sendmessage.png [new file with mode: 0644]
pix/t/sendmessage.svg [new file with mode: 0644]
privacy/classes/local/request/moodle_content_writer.php
privacy/templates/htmlpage.mustache
question/tests/behat/edit_questions_standard_tags.feature
question/type/ddimageortext/tests/behat/add.feature
question/type/ddmarker/tests/behat/add.feature
tag/tests/behat/edit_tag.feature
theme/boost/scss/moodle/blocks.scss
theme/boost/scss/moodle/core.scss
theme/boost/scss/moodle/icons.scss
theme/boost/scss/moodle/message.scss
theme/boost/style/moodle.css
theme/boost/templates/columns1.mustache
theme/boost/templates/columns2.mustache
theme/bootstrapbase/layout/columns1.php
theme/bootstrapbase/layout/columns2.php
theme/bootstrapbase/layout/columns3.php
theme/bootstrapbase/less/moodle/blocks.less
theme/bootstrapbase/less/moodle/bs4-compat.less
theme/bootstrapbase/less/moodle/message.less
theme/bootstrapbase/less/moodle/user.less
theme/bootstrapbase/style/moodle.css
theme/bootstrapbase/templates/core_calendar/minicalendar_day_link.mustache
theme/bootstrapbase/templates/core_message/message_drawer.mustache [new file with mode: 0644]
theme/bootstrapbase/templates/core_message/message_drawer_icon_back.mustache [new file with mode: 0644]
theme/bootstrapbase/templates/core_message/message_drawer_icon_forward.mustache [new file with mode: 0644]
theme/bootstrapbase/templates/core_message/message_drawer_view_contacts_body.mustache [new file with mode: 0644]
theme/bootstrapbase/templates/core_message/message_drawer_view_conversation_body_message.mustache [new file with mode: 0644]
theme/bootstrapbase/templates/core_message/message_drawer_view_conversation_footer_content.mustache [new file with mode: 0644]
theme/bootstrapbase/templates/core_message/message_drawer_view_conversation_footer_edit_mode.mustache [new file with mode: 0644]
theme/bootstrapbase/templates/core_message/message_drawer_view_conversation_header_content_type_private.mustache [new file with mode: 0644]
theme/bootstrapbase/templates/core_message/message_drawer_view_conversation_header_content_type_private_no_controls.mustache [new file with mode: 0644]
theme/bootstrapbase/templates/core_message/message_drawer_view_conversation_header_content_type_public.mustache [new file with mode: 0644]
theme/bootstrapbase/templates/core_message/message_drawer_view_group_info_body_content.mustache [new file with mode: 0644]
theme/bootstrapbase/templates/core_message/message_drawer_view_overview_header.mustache [new file with mode: 0644]
theme/bootstrapbase/templates/core_message/message_drawer_view_overview_section.mustache [new file with mode: 0644]
theme/bootstrapbase/templates/core_message/message_drawer_view_search_header.mustache [new file with mode: 0644]
theme/clean/layout/columns1.php
theme/clean/layout/columns2.php
theme/clean/layout/columns3.php
user/tests/behat/edit_user_enrolment.feature
user/tests/behat/view_full_profile.feature
user/tests/userlib_test.php
version.php

diff --git a/admin/lock.php b/admin/lock.php
new file mode 100644 (file)
index 0000000..3041ded
--- /dev/null
@@ -0,0 +1,96 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * This file is used to display a categories sub categories, external pages, and settings.
+ *
+ * @package    admin
+ * @copyright  2018 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require_once('../config.php');
+require_once("{$CFG->libdir}/adminlib.php");
+
+$contextid = required_param('id', PARAM_INT);
+$confirm = optional_param('confirm', null, PARAM_INT);
+$returnurl = optional_param('returnurl', null, PARAM_LOCALURL);
+
+$PAGE->set_url('/admin/lock.php', ['id' => $contextid]);
+
+list($context, $course, $cm) = get_context_info_array($contextid);
+
+$parentcontext = $context->get_parent_context();
+if ($parentcontext && !empty($parentcontext->locked)) {
+    // Can't make changes to a context whose parent is locked.
+    throw new \coding_exception('Not sure how you got here');
+}
+
+if ($course) {
+    $isfrontpage = ($course->id == SITEID);
+} else {
+    $isfrontpage = false;
+    $course = $SITE;
+}
+
+require_login($course, false, $cm);
+require_capability('moodle/site:managecontextlocks', $context);
+
+$PAGE->set_pagelayout('admin');
+$PAGE->navigation->clear_cache();
+
+$a = (object) [
+    'contextname' => $context->get_context_name(),
+];
+
+if (null !== $confirm && confirm_sesskey()) {
+    $context->set_locked(!empty($confirm));
+
+    if ($context->locked) {
+        $lockmessage = get_string('managecontextlocklocked', 'admin', $a);
+    } else {
+        $lockmessage = get_string('managecontextlockunlocked', 'admin', $a);
+    }
+
+    if (empty($returnurl)) {
+        $returnurl = $context->get_url();
+    } else {
+        $returnurl = new moodle_url($returnurl);
+    }
+    redirect($returnurl, $lockmessage);
+}
+
+$heading = get_string('managecontextlock', 'admin');
+$PAGE->set_title($heading);
+$PAGE->set_heading($heading);
+
+echo $OUTPUT->header();
+
+if ($context->locked) {
+    $confirmstring = get_string('confirmcontextunlock', 'admin', $a);
+    $target = 0;
+} else {
+    $confirmstring = get_string('confirmcontextlock', 'admin', $a);
+    $target = 1;
+}
+
+$confirmurl = new \moodle_url($PAGE->url, ['confirm' => $target]);
+if (!empty($returnurl)) {
+    $confirmurl->param('returnurl', $returnurl);
+}
+
+echo $OUTPUT->confirm($confirmstring, $confirmurl, $context->get_url());
+echo $OUTPUT->footer();
index cc2a620..61d82c3 100644 (file)
@@ -16,6 +16,16 @@ if ($hassiteconfig) { // speedup for non-admins, add all caps used on this page
 
     $temp->add(new admin_setting_configexecutable('pathtosassc', new lang_string('pathtosassc', 'admin'), new lang_string('pathtosassc_help', 'admin'), ''));
 
+    $temp->add(new admin_setting_configcheckbox('contextlocking', new lang_string('contextlocking', 'core_admin'),
+        new lang_string('contextlocking_desc', 'core_admin'), 0));
+
+    $temp->add(new admin_setting_configcheckbox(
+            'contextlockappliestoadmin',
+            new lang_string('contextlockappliestoadmin', 'core_admin'),
+            new lang_string('contextlockappliestoadmin_desc', 'core_admin'),
+            1
+        ));
+
     $temp->add(new admin_setting_configcheckbox('forceclean', new lang_string('forceclean', 'core_admin'),
         new lang_string('forceclean_desc', 'core_admin'), 0));
 
index e015d19..528d1f6 100644 (file)
@@ -20,7 +20,7 @@ Feature: Contact the privacy officer
     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 press "Send"
+    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"
     And I click on "Data requests" "link"
     And I should see "Hello DPO!" in the "General inquiry" "table_row"
index aa5c46a..1947f62 100644 (file)
@@ -46,7 +46,7 @@ Feature: Data export from the privacy API
     And I reload the page
     And I should see "Download ready" in the "Victim User 1" "table_row"
     And I open the action menu in "Victim User 1" "table_row"
-    And following "Download" should download between "1" and "100000" bytes
+    And following "Download" should download between "1" and "135000" bytes
     And the following config values are set as admin:
       | privacyrequestexpiry | 1 | tool_dataprivacy |
     And I wait "1" seconds
@@ -81,7 +81,7 @@ Feature: Data export from the privacy API
     And I reload the page
     And I should see "Download ready" in the "Export all of my personal data" "table_row"
     And I open the action menu in "Victim User 1" "table_row"
-    And following "Download" should download between "1" and "100000" bytes
+    And following "Download" should download between "1" and "135000" bytes
 
     And the following config values are set as admin:
       | privacyrequestexpiry | 1 | tool_dataprivacy |
@@ -118,7 +118,7 @@ Feature: Data export from the privacy API
     And I reload the page
     And I should see "Download ready" in the "Victim User 1" "table_row"
     And I open the action menu in "Victim User 1" "table_row"
-    And following "Download" should download between "1" and "100000" bytes
+    And following "Download" should download between "1" and "135000" bytes
 
     And the following config values are set as admin:
       | privacyrequestexpiry | 1 | tool_dataprivacy |
index fb230d7..c6f7f0e 100644 (file)
@@ -12,7 +12,7 @@ Feature: Manage data categories
     And I press "Add category"
     And I set the field "Name" to "Category 1"
     And I set the field "Description" to "Category 1 description"
-    When I press "Save"
+    When I click on "Save" "button" in the "Delete category" "dialogue"
     Then I should see "Category 1" in the "List of data categories" "table"
     And I should see "Category 1 description" in the "Category 1" "table_row"
 
@@ -30,5 +30,5 @@ Feature: Manage data categories
     And I choose "Delete" in the open action menu
     And I should see "Delete category"
     And I should see "Are you sure you want to delete the category 'Category 1'?"
-    When I press "Delete"
+    When I click on "Delete" "button" in the "Confirm" "dialogue"
     Then I should not see "Category 1" in the "List of data categories" "table"
index cd1bc2a..bf839ed 100644 (file)
@@ -21,14 +21,14 @@ Feature: Manage data requests
     And I should see "Contact the privacy officer"
     And I click on "Contact the privacy officer" "link"
     And I set the field "Message" to "Hi PO! Can others access my information on your site?"
-    And I press "Send"
+    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"
     And I log out
     And I log in as "student2"
     And I follow "Profile" in the user menu
     And I click on "Contact the privacy officer" "link"
     And I set the field "Message" to "Dear Mr. Privacy Officer, I'd like to know more about GDPR. Thanks!"
-    And I press "Send"
+    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"
     And I log out
     When I log in as "admin"
index ff02925..772c590 100644 (file)
@@ -52,5 +52,5 @@ Feature: Manage data storage purposes
     And I choose "Delete" in the open action menu
     And I should see "Delete purpose"
     And I should see "Are you sure you want to delete the purpose 'Purpose 1'?"
-    When I press "Delete"
+    When I click on "Delete" "button" in the "Confirm" "dialogue"
     Then I should not see "Purpose 1" in the "List of data purposes" "table"
index 73e7313..77339bb 100644 (file)
@@ -222,10 +222,6 @@ input[type="checkbox"].tool_lp_scale_proficient {
     display: none;
 }
 
-.user-competency-course-navigation {
-    width: 240px;
-}
-
 .user-competency-course-navigation span {
     max-width: 100%;
     overflow: hidden;
index 998a950..df34c22 100644 (file)
@@ -61,10 +61,10 @@ Feature: Manage competency frameworks
     And "Confirm" "dialogue" should be visible
     And "Delete" "button" should exist in the "Confirm" "dialogue"
     And "Cancel" "button" should exist in the "Confirm" "dialogue"
-    And I click on "Cancel" "button"
+    And I click on "Cancel" "button" in the "Confirm" "dialogue"
     And I click on "Delete" of edit menu in the "Science Year-4" row
     And "Confirm" "dialogue" should be visible
-    When I click on "Delete" "button"
+    When I click on "Delete" "button" in the "Confirm" "dialogue"
     Then I should not see "Science Year-4"
     And I should not see "sc-y-4"
 
index f1ff7dd..d2ca869 100644 (file)
@@ -124,9 +124,9 @@ Feature: Manage plearning plan
     And "Confirm" "dialogue" should be visible
     And "Delete" "button" should exist in the "Confirm" "dialogue"
     And "Cancel" "button" should exist in the "Confirm" "dialogue"
-    And I click on "Cancel" "button"
+    And I click on "Cancel" "button" in the "Confirm" "dialogue"
     And I click on "Delete" of edit menu in the "Science plan Year-4" row
     And "Confirm" "dialogue" should be visible
-    When I click on "Delete" "button"
+    When I click on "Delete" "button" in the "Confirm" "dialogue"
     And I wait until the page is ready
     Then I should not see "Science plan Year-4"
index e2f806f..0156243 100644 (file)
@@ -52,9 +52,9 @@ Feature: Manage plearning plan templates
     And "Confirm" "dialogue" should be visible
     And "Delete" "button" should exist in the "Confirm" "dialogue"
     And "Cancel" "button" should exist in the "Confirm" "dialogue"
-    And I click on "Cancel" "button"
+    And I click on "Cancel" "button" in the "Confirm" "dialogue"
     And I click on "Delete" of edit menu in the "Science template Year-4" row
     And "Confirm" "dialogue" should be visible
-    When I click on "Delete" "button"
+    When I click on "Delete" "button" in the "Confirm" "dialogue"
     And I wait until the page is ready
     Then I should not see "Science template Year-4"
index e39707a..6effaae 100644 (file)
@@ -53,10 +53,10 @@ Feature: Manage evidence of prior learning
     And I follow "Evidence of prior learning"
     And I should see "List of evidence"
     And I click on "Delete" of edit menu in the "Evidence-4" row
-    And I click on "Cancel" "button"
+    And I click on "Cancel" "button" in the "Confirm" "dialogue"
     And I click on "Delete" of edit menu in the "Evidence-4" row
     And "Confirm" "dialogue" should be visible
-    When I click on "Delete" "button"
+    When I click on "Delete" "button" in the "Confirm" "dialogue"
     And I wait until the page is ready
     Then I should not see "Evidence-4"
 
index c7493ad..58078ec 100644 (file)
@@ -125,7 +125,15 @@ function tool_monitor_get_user_courses() {
         $options[0] = get_string('site');
     }
 
-    $fields = 'fullname, visible, ctxid, ctxpath, ctxdepth, ctxlevel, ctxinstance';
+    $fieldlist = array_merge(
+            [
+                'fullname',
+                'visible',
+            ],
+            array_values(context_helper::get_preload_record_columns('c'))
+        );
+
+    $fields = implode(', ', $fieldlist);
     if ($courses = get_user_capability_course('tool/monitor:subscribe', null, true, $fields, $orderby)) {
         foreach ($courses as $course) {
             context_helper::preload_from_record($course);
index da4749f..b35a20e 100644 (file)
@@ -283,7 +283,7 @@ Feature: Viewing acceptances reports and accepting on behalf of other users
     And I press "Consent"
     And I should see "Accepting policy"
     And I should see "One"
-    And I press "Cancel"
+    And I click on "Cancel" "button" in the "Accepting policy" "dialogue"
     And I should not see "Accepting policy"
     And I click on "Select" "checkbox" in the "User Two" "table_row"
     And I press "Consent"
index 5edb043..4d0c7f2 100644 (file)
@@ -95,7 +95,7 @@ Feature: Basic recycle bin functionality
     And I navigate to "Recycle bin" in current page administration
     When I click on "Delete" "link"
     Then I should see "Are you sure you want to delete the selected item from the recycle bin?"
-    And I press "Cancel"
+    And I click on "Cancel" "button" in the "Confirmation" "dialogue"
     And I should see "Test assign"
     And I click on "Delete" "link"
     And I press "Yes"
@@ -120,7 +120,7 @@ Feature: Basic recycle bin functionality
     And I should see "Test assign 2"
     When I click on "Delete all" "link"
     Then I should see "Are you sure you want to delete all items from the recycle bin?"
-    And I press "Cancel"
+    And I click on "Cancel" "button" in the "Confirmation" "dialogue"
     And I should see "Test assign 1"
     And I should see "Test assign 2"
     And I click on "Delete all" "link"
index 8912559..7b9e567 100644 (file)
Binary files a/admin/tool/usertours/amd/build/tour.min.js and b/admin/tool/usertours/amd/build/tour.min.js differ
index a8b963b..65e48a7 100644 (file)
@@ -640,18 +640,6 @@ Tour.prototype.processStepListeners = function (stepConfig) {
         args: ['click', $.proxy(this.hide, this)]
     },
 
-    // Click out and hide tour without backdrop.
-    {
-        node: $('body'),
-        args: ['click', $.proxy(function (e) {
-            // Handle click in or click out tour content,
-            // if click out, hide tour.
-            if (!this.currentStepNode.is(e.target) && $(e.target).closest('[data-role="flexitour-step"]').length === 0) {
-                this.hide();
-            }
-        }, this)]
-    },
-
     // Keypresses.
     {
         node: $('body'),
diff --git a/admin/tool/usertours/tests/behat/tour_navigation.feature b/admin/tool/usertours/tests/behat/tour_navigation.feature
new file mode 100644 (file)
index 0000000..a3c1855
--- /dev/null
@@ -0,0 +1,24 @@
+@tool @tool_usertours
+Feature: Steps can be navigated within a tour
+  In order to use a tour effectively
+  As a user
+  I can navigate its steps
+
+  @javascript
+  Scenario: Clicking on items in the page should not end the tour
+    Given I log in as "admin"
+    And I add a new user tour with:
+      | Name                | Calendar tour |
+      | Description         | Calendar tour |
+      | Apply to URL match  | /my/% |
+      | Tour is enabled     | 1 |
+    And I add steps to the "Calendar tour" tour:
+      | targettype   | Block        | Title             | Content |
+      | Block        | Calendar     | Calendar events   | This is the calendar block |
+    And I change window size to "large"
+    And I follow "Dashboard" in the user menu
+    And I wait until the page is ready
+    And I should see "This is the calendar block"
+    When I click on ".block_calendar_month .calendar-controls .next" "css_element"
+    And I wait until the page is ready
+    Then I should see "Calendar events"
index c6d1186..8f1c4f5 100644 (file)
@@ -4,7 +4,7 @@
     <location>amd/src/tour.js</location>
     <name>Flexitour</name>
     <license>GPLv3</license>
-    <version>0.12.2</version>
+    <version>0.12.3</version>
     <licenseversion>3</licenseversion>
   </library>
 </libraries>
index 99d0ce5..d48fe8e 100644 (file)
@@ -125,7 +125,9 @@ class edit_field extends XMLDBAction {
             $o.= '      <tr valign="top"><td><label for="name" accesskey="n">Name:</label></td><td colspan="2"><input name="name" type="text" size="'.xmldb_field::NAME_MAX_LENGTH.'" maxlength="'.xmldb_field::NAME_MAX_LENGTH.'" id="name" value="' . s($field->getName()) . '" /></td></tr>';
         }
         // XMLDB field comment
-        $o.= '      <tr valign="top"><td><label for="comment" accesskey="c">Comment:</label></td><td colspan="2"><textarea name="comment" rows="3" cols="80" id="comment">' . s($field->getComment()) . '</textarea></td></tr>';
+        $o .= '      <tr valign="top"><td><label for="comment" accesskey="c">Comment:</label></td><td colspan="2">
+                     <textarea name="comment" rows="3" cols="80" id="comment" class="form-control">' .
+                     s($field->getComment()) . '</textarea></td></tr>';
         // xmldb_field Type
         $typeoptions = array (XMLDB_TYPE_INTEGER => $field->getXMLDBTypeName(XMLDB_TYPE_INTEGER),
                               XMLDB_TYPE_NUMBER  => $field->getXMLDBTypeName(XMLDB_TYPE_NUMBER),
@@ -167,7 +169,8 @@ class edit_field extends XMLDBAction {
         $o.= '      <tr valign="top"><td><label for="default" accesskey="d">Default:</label></td>';
         $o.= '        <td colspan="2"><input type="text" name="default" size="30" maxlength="80" id="default" value="' . s($field->getDefault()) . '" /></td></tr>';
         // Change button
-        $o.= '      <tr valign="top"><td>&nbsp;</td><td colspan="2"><input type="submit" value="' .$this->str['change'] . '" /></td></tr>';
+        $o .= '      <tr valign="top"><td>&nbsp;</td><td colspan="2"><input type="submit" value="' . $this->str['change'] .
+                     '" class="btn btn-secondary" /></td></tr>';
         $o.= '    </table>';
         $o.= '</div></form>';
         // Calculate the buttons
index a72b1c5..ed8d894 100644 (file)
@@ -115,7 +115,9 @@ class edit_index extends XMLDBAction {
         }
         $o.= '      <tr valign="top"><td><label for="name" accesskey="n">Name:</label></td><td colspan="2"><input name="name" type="text" size="'.xmldb_field::NAME_MAX_LENGTH.'" id="name"' . $disabled . ' value="' . s($index->getName()) . '" /></td></tr>';
         // XMLDB key comment
-        $o.= '      <tr valign="top"><td><label for="comment" accesskey="c">Comment:</label></td><td colspan="2"><textarea name="comment" rows="3" cols="80" id="comment">' . s($index->getComment()) . '</textarea></td></tr>';
+        $o .= '      <tr valign="top"><td><label for="comment" accesskey="c">Comment:</label></td><td colspan="2">
+                     <textarea name="comment" rows="3" cols="80" id="comment" class="form-control">' .
+                     s($index->getComment()) . '</textarea></td></tr>';
         // xmldb_index Type
         $typeoptions = array (0 => 'not unique',
                               1 => 'unique');
@@ -129,7 +131,8 @@ class edit_index extends XMLDBAction {
         $o.= '      <tr valign="top"><td><label for="hints" accesskey="h">Hints:</label></td>';
         $o.= '        <td colspan="2"><input name="hints" type="text" size="40" maxlength="80" id="hints" value="' . s(implode(', ', $index->getHints())) . '" /></td></tr>';
         // Change button
-        $o.= '      <tr valign="top"><td>&nbsp;</td><td colspan="2"><input type="submit" value="' .$this->str['change'] . '" /></td></tr>';
+        $o .= '      <tr valign="top"><td>&nbsp;</td><td colspan="2"><input type="submit" value="' .
+                     $this->str['change'] . '" class="btn btn-secondary"/></td></tr>';
         $o.= '    </table>';
         $o.= '</div></form>';
         // Calculate the buttons
index 6e32acf..9e78589 100644 (file)
@@ -115,7 +115,9 @@ class edit_key extends XMLDBAction {
         }
         $o.= '      <tr valign="top"><td><label for="name" accesskey="n">Name:</label></td><td colspan="2"><input name="name" type="text" size="'.xmldb_field::NAME_MAX_LENGTH.'" id="name"' . $disabled . ' value="' . s($key->getName()) . '" /></td></tr>';
         // XMLDB key comment
-        $o.= '      <tr valign="top"><td><label for="comment" accesskey="c">Comment:</label></td><td colspan="2"><textarea name="comment" rows="3" cols="80" id="comment">' . s($key->getComment()) . '</textarea></td></tr>';
+        $o .= '      <tr valign="top"><td><label for="comment" accesskey="c">Comment:</label></td><td colspan="2">
+                     <textarea name="comment" rows="3" cols="80" id="comment" class="form-control">' .
+                     s($key->getComment()) . '</textarea></td></tr>';
         // xmldb_key Type
         $typeoptions = array (XMLDB_KEY_PRIMARY => $key->getXMLDBKeyName(XMLDB_KEY_PRIMARY),
                               XMLDB_KEY_UNIQUE  => $key->getXMLDBKeyName(XMLDB_KEY_UNIQUE),
@@ -139,7 +141,8 @@ class edit_key extends XMLDBAction {
         $o.= '      <tr valign="top"><td><label for="reffields" accesskey="t">Reffields:</label></td>';
         $o.= '        <td colspan="2"><input name="reffields" type="text" size="40" maxlength="80" id="reffields" value="' . s(implode(', ', $key->getRefFields())) . '" /></td></tr>';
         // Change button
-        $o.= '      <tr valign="top"><td>&nbsp;</td><td colspan="2"><input type="submit" value="' .$this->str['change'] . '" /></td></tr>';
+        $o .= '      <tr valign="top"><td>&nbsp;</td><td colspan="2"><input type="submit" value="' .
+                     $this->str['change'] . '" class="btn btn-secondary"/></td></tr>';
         $o.= '    </table>';
         $o.= '</div></form>';
         // Calculate the buttons
index 00ed43a..5cd4bcd 100644 (file)
@@ -132,8 +132,11 @@ class edit_table extends XMLDBAction {
         } else {
             $o.= '      <tr valign="top"><td><label for="name" accesskey="p">Name:</label></td><td><input name="name" type="text" size="'.xmldb_table::NAME_MAX_LENGTH.'" maxlength="'.xmldb_table::NAME_MAX_LENGTH.'" id="name" value="' . s($table->getName()) . '" /></td></tr>';
         }
-        $o.= '      <tr valign="top"><td><label for="comment" accesskey="c">Comment:</label></td><td><textarea name="comment" rows="3" cols="80" id="comment">' . s($table->getComment()) . '</textarea></td></tr>';
-        $o.= '      <tr valign="top"><td>&nbsp;</td><td><input type="submit" value="' .$this->str['change'] . '" /></td></tr>';
+        $o .= '      <tr valign="top"><td><label for="comment" accesskey="c">Comment:</label></td><td>
+                     <textarea name="comment" rows="3" cols="80" id="comment" class="form-control">' .
+                     s($table->getComment()) . '</textarea></td></tr>';
+        $o .= '      <tr valign="top"><td>&nbsp;</td><td><input type="submit" value="' . $this->str['change'] .
+                     '" class="btn btn-secondary"/></td></tr>';
         $o.= '    </table>';
         $o.= '</div></form>';
         // Calculate the pending changes / save message
index 45df3aa..115ca27 100644 (file)
@@ -119,8 +119,11 @@ class edit_xml_file extends XMLDBAction {
                 $o .= '    <table id="formelements">';
                 $o.= '      <tr valign="top"><td>Path:</td><td>' . s($structure->getPath()) . '</td></tr>';
                 $o.= '      <tr valign="top"><td>Version:</td><td>' . s($structure->getVersion()) . '</td></tr>';
-                $o.= '      <tr valign="top"><td><label for="comment" accesskey="c">Comment:</label></td><td><textarea name="comment" rows="3" cols="80" id="comment">' . $structure->getComment() . '</textarea></td></tr>';
-                $o.= '      <tr><td>&nbsp;</td><td><input type="submit" value="' .$this->str['change'] . '" /></td></tr>';
+                $o .= '      <tr valign="top"><td><label for="comment" accesskey="c">Comment:</label></td><td>
+                             <textarea name="comment" rows="3" cols="80" id="comment" class="form-control">' .
+                             $structure->getComment() . '</textarea></td></tr>';
+                $o .= '      <tr><td>&nbsp;</td><td><input type="submit" value="' . $this->str['change'] .
+                             '"class="btn btn-secondary" /></td></tr>';
                 $o.= '    </table>';
                 $o.= '</div></form>';
                 // Calculate the pending changes / save message
index 2d40ea1..a363384 100644 (file)
@@ -114,7 +114,7 @@ class view_reserved_words extends XMLDBAction {
         // The textarea showing all the reserved words
         $o.= '    <table id="formelements" class="boxaligncenter" cellpadding="5">';
         $o.= '      <tr><td align="center">' . $this->str['listreservedwords'].'</td></tr>';
-        $o.= '      <tr><td><textarea cols="80" rows="32">';
+        $o .= '      <tr><td><textarea cols="80" rows="32" class="form-control">';
         $o.= s(implode(', ', array_keys($reserved_words)));
         $o.= '</textarea></td></tr>';
         $o.= '    </table>';
index 99d4524..91618c3 100644 (file)
@@ -119,7 +119,7 @@ class view_structure_php extends XMLDBAction {
         $o.= '    </table>';
         $o.= '</div></form>';
         $o.= '    <table id="phpcode" class="boxaligncenter" cellpadding="5">';
-        $o.= '      <tr><td><textarea cols="80" rows="32">';
+        $o .= '      <tr><td><textarea cols="80" rows="32" class="form-control">';
         // Based on current params, call the needed function
         switch ($commandparam) {
             case 'create_table':
index 377b915..bb81347 100644 (file)
@@ -86,7 +86,7 @@ class view_structure_sql extends XMLDBAction {
         $o = $b;
 
         $o.= '    <table id="formelements" class="boxaligncenter" cellpadding="5">';
-        $o.= '      <tr><td><textarea cols="80" rows="32">';
+        $o .= '      <tr><td><textarea cols="80" rows="32" class="form-control">';
         // Get an array of statements
         if ($starr = $DB->get_manager()->generator->getCreateStructureSQL($structure)) {
             $starr = $dbman->generator->getEndedStatements($starr);
index d337572..c316432 100644 (file)
@@ -169,7 +169,7 @@ class view_table_php extends XMLDBAction {
         $o.= '</div></form>';
 
         $o.= '    <table id="phpcode" class="boxaligncenter" cellpadding="5">';
-        $o.= '      <tr><td><textarea cols="80" rows="32">';
+        $o .= '      <tr><td><textarea cols="80" rows="32" class="form-control">';
         // Check we have selected some field/key/index from the popup
         if ($fieldkeyindexparam == 'fieldshead' || $fieldkeyindexparam == 'keyshead' || $fieldkeyindexparam == 'indexeshead') {
             $o.= s($this->str['selectonefieldkeyindex']);
index a3f2566..8a9e04a 100644 (file)
@@ -93,7 +93,7 @@ class view_table_sql extends XMLDBAction {
         $o = $b;
 
         $o.= '    <table id="formelements" class="boxaligncenter" cellpadding="5">';
-        $o.= '      <tr><td><textarea cols="80" rows="32">';
+        $o .= '      <tr><td><textarea cols="80" rows="32" class="form-control">';
 
         // Get an array of statements
         if ($starr = $DB->get_manager()->generator->getCreateTableSQL($table)) {
index d94c396..5eb8434 100644 (file)
@@ -154,6 +154,7 @@ class auth_plugin_cas extends auth_plugin_ldap {
             $frm = new stdClass();
             $frm->username = phpCAS::getUser();
             $frm->password = 'passwdCas';
+            $frm->logintoken = \core\session\manager::get_login_token();
 
             // Redirect to a course if multi-auth is activated, authCAS is set to CAS and the courseid is specified.
             if ($this->config->multiauth && !empty($courseid)) {
@@ -167,6 +168,7 @@ class auth_plugin_cas extends auth_plugin_ldap {
             $frm = new stdClass();
             $frm->username = 'guest';
             $frm->password = 'guest';
+            $frm->logintoken = \core\session\manager::get_login_token();
             return;
         }
 
index ceae232..c5e0729 100644 (file)
@@ -44,7 +44,7 @@ Feature: availability_group
     Then "Group" "button" should exist in the "Add restriction..." "dialogue"
 
     # Page P1 any group.
-    Given I click on "Group" "button"
+    Given I click on "Group" "button" in the "Add restriction..." "dialogue"
     And I set the field "Group" to "(Any group)"
     And I click on ".availability-item .availability-eye img" "css_element"
     And I set the following fields to these values:
@@ -61,7 +61,7 @@ Feature: availability_group
       | Page content | x  |
     And I expand all fieldsets
     And I click on "Add restriction..." "button"
-    And I click on "Group" "button"
+    And I click on "Group" "button" in the "Add restriction..." "dialogue"
     And I set the field "Group" to "G1"
     And I click on ".availability-item .availability-eye img" "css_element"
     And I click on "Save and return to course" "button"
@@ -74,7 +74,7 @@ Feature: availability_group
       | Page content | x  |
     And I expand all fieldsets
     And I click on "Add restriction..." "button"
-    And I click on "Group" "button"
+    And I click on "Group" "button" in the "Add restriction..." "dialogue"
     And I set the field "Group" to "G2"
     And I click on ".availability-item .availability-eye img" "css_element"
     And I click on "Save and return to course" "button"
index 7150eb9..64557fa 100644 (file)
@@ -76,4 +76,19 @@ class competency_alignment_form extends moodleform {
             }
         }
     }
+
+    /**
+     * Validate the data from the form.
+     *
+     * @param  array $data form data
+     * @param  array $files form files
+     * @return array An array of error messages.
+     */
+    public function validation($data, $files) {
+        $errors = parent::validation($data, $files);
+        if (!empty($data['targeturl']) && !preg_match('@^https?://.+@', $data['targeturl'])) {
+            $errors['targeturl'] = get_string('invalidurl', 'badges');
+        }
+        return $errors;
+    }
 }
\ No newline at end of file
index c21bf5a..4343985 100644 (file)
@@ -211,6 +211,10 @@ class edit_details_form extends moodleform {
             $errors['name'] = get_string('error:duplicatename', 'badges');
         }
 
+        if ($data['imageauthorurl'] && !preg_match('@^https?://.+@', $data['imageauthorurl'])) {
+            $errors['imageauthorurl'] = get_string('invalidurl', 'badges');
+        }
+
         return $errors;
     }
 }
index ec3e145..d7b2345 100644 (file)
@@ -63,6 +63,10 @@ $output = $PAGE->get_renderer('core', 'badges');
 $msg = optional_param('msg', '', PARAM_TEXT);
 $emsg = optional_param('emsg', '', PARAM_TEXT);
 
+if ($msg !== '') {
+    $msg = get_string($msg, 'badges');
+}
+
 echo $OUTPUT->header();
 echo $OUTPUT->heading(print_badge_image($badge, $context, 'small') . ' ' . $badge->name);
 
index f4bc231..532420d 100644 (file)
@@ -86,6 +86,12 @@ class endorsement_form extends moodleform {
         if ($data['issueremail'] && !validate_email($data['issueremail'])) {
             $errors['issueremail'] = get_string('invalidemail');
         }
+        if ($data['issuerurl'] && !preg_match('@^https?://.+@', $data['issuerurl'])) {
+            $errors['issuerurl'] = get_string('invalidurl', 'badges');
+        }
+        if ($data['claimid'] && !preg_match('@^https?://.+@', $data['claimid'])) {
+            $errors['claimid'] = get_string('invalidurl', 'badges');
+        }
         return $errors;
     }
 }
\ No newline at end of file
index 8e7cd56..31d74f4 100644 (file)
@@ -82,9 +82,12 @@ class edit_relatedbadge_form extends moodleform {
         $sql = "SELECT b.id, b.name, b.version, b.language, b.type
                   FROM {badge} b
                  WHERE b.id <> :badgeid
-                       AND b.id NOT IN (SELECT br.relatedbadgeid
-                  FROM {badge_related} br WHERE br.badgeid = :badgeid1)";
-        $params = array('badgeid' => $badge->id, 'badgeid1' => $badge->id);
+                       AND b.id NOT IN (
+                            SELECT DISTINCT b.id
+                              FROM {badge_related} br
+                              JOIN {badge} b ON (br.relatedbadgeid = b.id OR br.badgeid = b.id)
+                             WHERE (br.badgeid = :badgeid2 OR br.relatedbadgeid = :badgeid3) AND b.id != :badgeid4)";
+        $params = ['badgeid' => $badge->id, 'badgeid2' => $badge->id, 'badgeid3' => $badge->id, 'badgeid4' => $badge->id];
         if ($badge->type == BADGE_TYPE_COURSE) {
             $sql .= " AND (b.courseid = :courseid OR b.type = :badgetype)";
             $params['courseid'] = $badge->courseid;
index 5810e57..998e091 100644 (file)
@@ -113,13 +113,13 @@ class core_badges_renderer extends plugin_renderer_base {
                     'type' => 'submit',
                     'name' => 'award',
                     'value' => $this->output->larrow() . ' ' . get_string('award', 'badges'),
-                    'class' => 'actionbutton')
+                    'class' => 'actionbutton btn btn-secondary')
                 );
         $actioncell->text .= html_writer::empty_tag('input', array(
                     'type' => 'submit',
                     'name' => 'revoke',
                     'value' => get_string('revoke', 'badges') . ' ' . $this->output->rarrow(),
-                    'class' => 'actionbutton')
+                    'class' => 'actionbutton btn btn-secondary')
                 );
         $actioncell->text .= html_writer::end_tag('div', array());
         $actioncell->attributes['class'] = 'actions';
@@ -150,8 +150,7 @@ class core_badges_renderer extends plugin_renderer_base {
         $dl[get_string('description', 'badges')] = $badge->description;
         $dl[get_string('createdon', 'search')] = userdate($badge->timecreated);
         $dl[get_string('badgeimage', 'badges')] = print_badge_image($badge, $context, 'large');
-        $dl[get_string('imageauthorname', 'badges')] =
-            html_writer::link($badge->imageauthorname, $badge->imageauthorname, array('target' => '_blank'));
+        $dl[get_string('imageauthorname', 'badges')] = $badge->imageauthorname;
         $dl[get_string('imageauthoremail', 'badges')] =
             html_writer::tag('a', $badge->imageauthoremail, array('href' => 'mailto:' . $badge->imageauthoremail));
         $dl[get_string('imageauthorurl', 'badges')] =
@@ -349,16 +348,25 @@ class core_badges_renderer extends plugin_renderer_base {
         $output .= $this->output->heading(get_string('badgedetails', 'badges'), 3);
         $dl = array();
         $dl[get_string('name')] = $badge->name;
-        $dl[get_string('version', 'badges')] = $badge->version;
+        if (!empty($badge->version)) {
+            $dl[get_string('version', 'badges')] = $badge->version;
+        }
         $dl[get_string('language')] = $languages[$badge->language];
         $dl[get_string('description', 'badges')] = $badge->description;
-        $dl[get_string('imageauthorname', 'badges')] =
-            html_writer::link($badge->imageauthorname, $badge->imageauthorname, array('target' => '_blank'));
-        $dl[get_string('imageauthoremail', 'badges')] =
-            html_writer::tag('a', $badge->imageauthoremail, array('href' => 'mailto:' . $badge->imageauthoremail));
-        $dl[get_string('imageauthorurl', 'badges')] =
-            html_writer::link($badge->imageauthorurl, $badge->imageauthorurl, array('target' => '_blank'));
-        $dl[get_string('imagecaption', 'badges')] = $badge->imagecaption;
+        if (!empty($badge->imageauthorname)) {
+            $dl[get_string('imageauthorname', 'badges')] = $badge->imageauthorname;
+        }
+        if (!empty($badge->imageauthoremail)) {
+            $dl[get_string('imageauthoremail', 'badges')] =
+                    html_writer::tag('a', $badge->imageauthoremail, array('href' => 'mailto:' . $badge->imageauthoremail));
+        }
+        if (!empty($badge->imageauthorurl)) {
+            $dl[get_string('imageauthorurl', 'badges')] =
+                    html_writer::link($badge->imageauthorurl, $badge->imageauthorurl, array('target' => '_blank'));
+        }
+        if (!empty($badge->imagecaption)) {
+            $dl[get_string('imagecaption', 'badges')] = $badge->imagecaption;
+        }
 
         if ($badge->type == BADGE_TYPE_COURSE && isset($badge->courseid)) {
             $coursename = $DB->get_field('course', 'fullname', array('id' => $badge->courseid));
@@ -405,28 +413,27 @@ class core_badges_renderer extends plugin_renderer_base {
 
         $dl[get_string('evidence', 'badges')] = get_string('completioninfo', 'badges') . html_writer::alist($items, array(), 'ul');
         $output .= $this->definition_list($dl);
-        $output .= self::print_badge_endorsement($badge);
+        $endorsement = $badge->get_endorsement();
+        if (!empty($endorsement)) {
+            $output .= self::print_badge_endorsement($badge);
+        }
         $relatedbadges = $badge->get_related_badges();
-        $output .= $this->heading(get_string('relatedbages', 'badges'), 3);
         if (!empty($relatedbadges)) {
+            $output .= $this->heading(get_string('relatedbages', 'badges'), 3);
             $items = array();
             foreach ($relatedbadges as $related) {
                 $items[] = $related->name;
             }
             $output .= html_writer::alist($items, array(), 'ul');
-        } else {
-            $output .= get_string('norelated', 'badges');
         }
-        $output .= $this->heading(get_string('alignment', 'badges'), 3);
         $competencies = $badge->get_alignment();
         if (!empty($competencies)) {
+            $output .= $this->heading(get_string('alignment', 'badges'), 3);
             $items = array();
             foreach ($competencies as $competency) {
                 $items[] = html_writer::link($competency->targeturl, $competency->targetname, array('target' => '_blank'));
             }
             $output .= html_writer::alist($items, array(), 'ul');
-        } else {
-            $output .= get_string('noalignment', 'badges');
         }
         $output .= html_writer::end_tag('div');
 
@@ -725,8 +732,10 @@ class core_badges_renderer extends plugin_renderer_base {
         }
 
         if (has_capability('moodle/badges:configuredetails', $context)) {
-            $related = $DB->count_records_sql("SELECT COUNT(br.badgeid)
-                      FROM {badge_related} br WHERE br.badgeid = :badgeid", array('badgeid' => $badgeid));
+            $sql = "SELECT COUNT(br.badgeid)
+                      FROM {badge_related} br
+                     WHERE (br.badgeid = :badgeid OR br.relatedbadgeid = :badgeid2)";
+            $related = $DB->count_records_sql($sql, ['badgeid' => $badgeid, 'badgeid2' => $badgeid]);
             $row[] = new tabobject('brelated',
                 new moodle_url('/badges/related.php', array('id' => $badgeid)),
                 get_string('brelated', 'badges', $related)
@@ -1048,10 +1057,11 @@ class core_badges_renderer extends plugin_renderer_base {
         $output .= $this->heading(get_string('endorsement', 'badges'), 3);
         if (!empty($endorsement)) {
             $dl[get_string('issuername', 'badges')] = $endorsement->issuername;
-            $dl[get_string('issueremail', 'badges')] = $endorsement->issueremail;
+            $dl[get_string('issueremail', 'badges')] =
+                html_writer::tag('a', $endorsement->issueremail, array('href' => 'mailto:' . $endorsement->issueremail));
             $dl[get_string('issuerurl', 'badges')] = html_writer::link($endorsement->issuerurl, $endorsement->issuerurl,
                 array('target' => '_blank'));
-            $dl[get_string('dateawarded', 'badges')] = date('c', $endorsement->dateissued);
+            $dl[get_string('dateawarded', 'badges')] = userdate($endorsement->dateissued);
             $dl[get_string('claimid', 'badges')] = html_writer::link($endorsement->claimid, $endorsement->claimid,
             array('target' => '_blank'));
             $dl[get_string('claimcomment', 'badges')] = $endorsement->claimcomment;
@@ -1504,4 +1514,4 @@ class badge_competencies_alignment implements renderable
         $this->alignments = $alignments;
         $this->currentbadgeid = $currentbadgeid;
     }
-}
\ No newline at end of file
+}
index 0212118..dbce038 100644 (file)
Binary files a/blocks/myoverview/amd/build/view.min.js and b/blocks/myoverview/amd/build/view.min.js differ
index 62f44a8..f9d0e1d 100644 (file)
@@ -31,7 +31,8 @@ define(
     'core/notification',
     'core/templates',
     'core_course/events',
-    'block_myoverview/selectors'
+    'block_myoverview/selectors',
+    'core/paged_content_events',
 ],
 function(
     $,
@@ -42,7 +43,8 @@ function(
     Notification,
     Templates,
     CourseEvents,
-    Selectors
+    Selectors,
+    PagedContentEvents
 ) {
 
     var SELECTORS = {
@@ -75,6 +77,8 @@ function(
 
     var lastLimit = 0;
 
+    var namespace = null;
+
     /**
      * Get filter values from DOM.
      *
@@ -95,6 +99,7 @@ function(
     var DEFAULT_PAGED_CONTENT_CONFIG = {
         ignoreControlWhileLoading: true,
         controlPlacementBottom: true,
+        persistentLimitKey: 'block_myoverview_user_paging_preference'
     };
 
     /**
@@ -377,17 +382,58 @@ function(
         }
     };
 
+    /**
+     * Return the callback to be passed to the subscribe event
+     *
+     * @param {Number} limit The paged limit that is passed through the event
+     */
+    var setLimit = function(limit) {
+        this.find(Selectors.courseView.region).attr('data-paging', limit);
+    };
+
     /**
      * Intialise the paged list and cards views on page load.
+     * Returns an array of paged contents that we would like to handle here
+     *
+     * @param {object} root The root element for the courses view
+     * @param {string} namespace The namespace for all the events attached
+     */
+    var registerPagedEventHandlers = function(root, namespace) {
+        var event = namespace + PagedContentEvents.SET_ITEMS_PER_PAGE_LIMIT;
+        PubSub.subscribe(event, setLimit.bind(root));
+    };
+
+    /**
+     * Intialise the courses list and cards views on page load.
      *
      * @param {object} root The root element for the courses view.
      * @param {object} content The content element for the courses view.
      */
     var initializePagedContent = function(root) {
+        namespace = "block_myoverview_" + root.attr('id') + "_" + Math.random();
+
+        var itemsPerPage = NUMCOURSES_PERPAGE;
+        var pagingLimit = parseInt(root.find(Selectors.courseView.region).attr('data-paging'), 10);
+        if (pagingLimit) {
+            itemsPerPage = NUMCOURSES_PERPAGE.map(function(value) {
+                var active = false;
+                if (value == pagingLimit) {
+                    active = true;
+                }
+
+                return {
+                    value: value,
+                    active: active
+                };
+            });
+        }
+
         var filters = getFilterValues(root);
+        var config = $.extend({}, DEFAULT_PAGED_CONTENT_CONFIG);
+        config.eventNamespace = namespace;
 
         var pagedContentPromise = PagedContentFactory.createWithLimit(
-            NUMCOURSES_PERPAGE,
+            itemsPerPage,
             function(pagesData, actions) {
                 var promises = [];
 
@@ -471,10 +517,11 @@ function(
 
                 return promises;
             },
-            DEFAULT_PAGED_CONTENT_CONFIG
+            config
         );
 
         pagedContentPromise.then(function(html, js) {
+            registerPagedEventHandlers(root, namespace);
             return Templates.replaceNodeContents(root.find(Selectors.courseView.region), html, js);
         }).catch(Notification.exception);
     };
@@ -556,12 +603,12 @@ function(
         lastPage = 0;
         courseOffset = 0;
 
+        initializePagedContent(root);
+
         if (!root.attr('data-init')) {
             registerEventListeners(root);
             root.attr('data-init', true);
         }
-
-        initializePagedContent(root);
     };
 
     /**
index cbd2f9d..85c208e 100644 (file)
@@ -52,8 +52,9 @@ class block_myoverview extends block_base {
         $group = get_user_preferences('block_myoverview_user_grouping_preference');
         $sort = get_user_preferences('block_myoverview_user_sort_preference');
         $view = get_user_preferences('block_myoverview_user_view_preference');
+        $paging = get_user_preferences('block_myoverview_user_paging_preference');
 
-        $renderable = new \block_myoverview\output\main($group, $sort, $view);
+        $renderable = new \block_myoverview\output\main($group, $sort, $view, $paging);
         $renderer = $this->page->get_renderer('block_myoverview');
 
         $this->content = new stdClass();
index 7cd6d1e..eb28dce 100644 (file)
@@ -59,6 +59,13 @@ class main implements renderable, templatable {
      */
     private $view;
 
+    /**
+     * Store the paging preference
+     *
+     * @var string String matching the paging constants defined in myoverview/lib.php
+     */
+    private $paging;
+
     /**
      * main constructor.
      * Initialize the user preferences
@@ -67,10 +74,11 @@ class main implements renderable, templatable {
      * @param string $sort Sort user preference
      * @param string $view Display user preference
      */
-    public function __construct($grouping, $sort, $view) {
+    public function __construct($grouping, $sort, $view, $paging) {
         $this->grouping = $grouping ? $grouping : BLOCK_MYOVERVIEW_GROUPING_ALL;
         $this->sort = $sort ? $sort : BLOCK_MYOVERVIEW_SORTING_TITLE;
         $this->view = $view ? $view : BLOCK_MYOVERVIEW_VIEW_CARD;
+        $this->paging = $paging ? $paging : BLOCK_MYOVERVIEW_PAGING_12;
     }
 
     /**
@@ -101,7 +109,8 @@ class main implements renderable, templatable {
             'nocoursesimg' => $nocoursesurl,
             'grouping' => $this->grouping,
             'sort' => $this->sort == BLOCK_MYOVERVIEW_SORTING_TITLE ? 'fullname' : 'ul.timeaccess desc',
-            'view' => $this->view
+            'view' => $this->view,
+            'paging' => $this->paging
         ];
 
         $preferences = $this->get_preferences_as_booleans();
index be95345..729bbce 100644 (file)
@@ -49,6 +49,8 @@ class provider implements \core_privacy\local\metadata\provider, user_preference
         $collection->add_user_preference('block_myoverview_user_view_preference', 'privacy:metadata:overviewviewpreference');
         $collection->add_user_preference('block_myoverview_user_grouping_preference',
             'privacy:metadata:overviewgroupingpreference');
+        $collection->add_user_preference('block_myoverview_user_paging_preference',
+            'privacy:metadata:overviewpagingpreference');
         return $collection;
     }
     /**
@@ -94,5 +96,13 @@ class provider implements \core_privacy\local\metadata\provider, user_preference
                 );
             }
         }
+
+        $preference = get_user_preferences('block_myoverview_user_paging_preference', null, $userid);
+        if (isset($preference)) {
+            \core_privacy\local\request\writer::export_user_preference('block_myoverview',
+                'block_myoverview_user_paging_preference',
+                $preference,
+                get_string('privacy:metadata:overviewpagingpreference', 'block_myoverview'));
+        }
     }
 }
\ No newline at end of file
index 7a7e2e2..3bd0631 100644 (file)
@@ -60,6 +60,7 @@ $string['pluginname'] = 'Course overview';
 $string['privacy:metadata:overviewsortpreference'] = 'The Course overview block sort preference.';
 $string['privacy:metadata:overviewviewpreference'] = 'The Course overview block view preference.';
 $string['privacy:metadata:overviewgroupingpreference'] = 'The Course overview block grouping preference.';
+$string['privacy:metadata:overviewpagingpreference'] = 'The Course overview block paging preference.';
 $string['removefromfavourites'] = 'Unstar this course';
 $string['summary'] = 'Summary';
 $string['title'] = 'Title';
index 3244e4c..ed7334a 100644 (file)
@@ -48,6 +48,13 @@ define('BLOCK_MYOVERVIEW_VIEW_CARD', 'cards');
 define('BLOCK_MYOVERVIEW_VIEW_LIST', 'list');
 define('BLOCK_MYOVERVIEW_VIEW_SUMMARY', 'summary');
 
+/**
+ * Constants for the user paging preferences
+ */
+define('BLOCK_MYOVERVIEW_PAGING_12', 12);
+define('BLOCK_MYOVERVIEW_PAGING_24', 24);
+define('BLOCK_MYOVERVIEW_PAGING_48', 48);
+
 /**
  * Get the current user preferences that are available
  *
@@ -95,5 +102,16 @@ function block_myoverview_user_preferences() {
         'default' => 'none'
     );
 
+    $preferences['block_myoverview_user_paging_preference'] = array(
+        'null' => NULL_NOT_ALLOWED,
+        'default' => BLOCK_MYOVERVIEW_PAGING_12,
+        'type' => PARAM_INT,
+        'choices' => array(
+            BLOCK_MYOVERVIEW_PAGING_12,
+            BLOCK_MYOVERVIEW_PAGING_24,
+            BLOCK_MYOVERVIEW_PAGING_48
+        )
+    );
+
     return $preferences;
 }
index c5be1e8..d58425c 100644 (file)
@@ -33,6 +33,7 @@
     data-grouping="{{grouping}}"
     data-sort="{{sort}}"
     data-prev-display="{{view}}"
+    data-paging="{{paging}}"
     data-nocoursesimg="{{nocoursesimg}}">
     <div data-region="course-view-content">
         <div data-region="courses-loading-placeholder">
diff --git a/blocks/myoverview/tests/behat/block_myoverview_pagelimit_persistence.feature b/blocks/myoverview/tests/behat/block_myoverview_pagelimit_persistence.feature
new file mode 100644 (file)
index 0000000..4135c0a
--- /dev/null
@@ -0,0 +1,57 @@
+@block @block_myoverview @javascript
+Feature: The my overview block allows users to persistence of their page limits
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email                | idnumber |
+      | student1 | Student   | X        | student1@example.com | S1       |
+    And the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1        | 0        |
+      | Course 2 | C2        | 0        |
+      | Course 3 | C3        | 0        |
+      | Course 4 | C4        | 0        |
+      | Course 5 | C5        | 0        |
+      | Course 6 | C6        | 0        |
+      | Course 7 | C7        | 0        |
+      | Course 8 | C8        | 0        |
+      | Course 9 | C9        | 0        |
+      | Course 10 | C10        | 0        |
+      | Course 11 | C11        | 0        |
+      | Course 12 | C12        | 0        |
+      | Course 13 | C13        | 0        |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | student1 | C1 | student |
+      | student1 | C2 | student |
+      | student1 | C3 | student |
+      | student1 | C4 | student |
+      | student1 | C5 | student |
+      | student1 | C6 | student |
+      | student1 | C7 | student |
+      | student1 | C8 | student |
+      | student1 | C9 | student |
+      | student1 | C10 | student |
+      | student1 | C11 | student |
+      | student1 | C12 | student |
+      | student1 | C13 | student |
+
+  Scenario: Toggle the page limit between page reloads
+    Given I log in as "student1"
+    When I click on "Show 12 items per page" "button" in the "Course overview" "block"
+    And I click on "24" "link"
+    Then I should see "Course 9"
+    And I reload the page
+    Then I should see "Course 9"
+    And I should see "24" in the "[data-action='limit-toggle']" "css_element"
+    And I log out
+
+  Scenario: Toggle the page limit between grouping changes
+    Given I log in as "student1"
+    When I click on "Show 12 items per page" "button" in the "Course overview" "block"
+    And I click on "24" "link"
+    And I click on "All" "button" in the "Course overview" "block"
+    And I click on "In progress" "link" in the "Course overview" "block"
+    Then I should see "Course 9"
+    And I should see "24" in the "[data-action='limit-toggle']" "css_element"
+    And I log out
index 2ccd331..5b5b488 100644 (file)
@@ -50,14 +50,17 @@ class block_myoverview_privacy_testcase extends \core_privacy\tests\provider_tes
      *
      * @dataProvider user_preference_provider
      */
-    public function test_export_user_preferences($type, $value) {
+    public function test_export_user_preferences($type, $value, $expected) {
         $this->resetAfterTest();
         $user = $this->getDataGenerator()->create_user();
         set_user_preference($type, $value, $user);
         provider::export_user_preferences($user->id);
         $writer = writer::with_context(\context_system::instance());
         $blockpreferences = $writer->get_user_preferences('block_myoverview');
-        $this->assertEquals(get_string($value, 'block_myoverview'), $blockpreferences->{$type}->value);
+        if (!$expected) {
+            $expected = get_string($value, 'block_myoverview');
+        }
+        $this->assertEquals($expected, $blockpreferences->{$type}->value);
     }
 
     /**
@@ -67,15 +70,16 @@ class block_myoverview_privacy_testcase extends \core_privacy\tests\provider_tes
      */
     public function user_preference_provider() {
         return array(
-            array('block_myoverview_user_sort_preference', 'lastaccessed'),
-            array('block_myoverview_user_sort_preference', 'title'),
-            array('block_myoverview_user_grouping_preference', 'all'),
-            array('block_myoverview_user_grouping_preference', 'inprogress'),
-            array('block_myoverview_user_grouping_preference', 'future'),
-            array('block_myoverview_user_grouping_preference', 'past'),
-            array('block_myoverview_user_view_preference', 'card'),
-            array('block_myoverview_user_view_preference', 'list'),
-            array('block_myoverview_user_view_preference', 'summary')
+            array('block_myoverview_user_sort_preference', 'lastaccessed', ''),
+            array('block_myoverview_user_sort_preference', 'title', ''),
+            array('block_myoverview_user_grouping_preference', 'all', ''),
+            array('block_myoverview_user_grouping_preference', 'inprogress', ''),
+            array('block_myoverview_user_grouping_preference', 'future', ''),
+            array('block_myoverview_user_grouping_preference', 'past', ''),
+            array('block_myoverview_user_view_preference', 'card', ''),
+            array('block_myoverview_user_view_preference', 'list', ''),
+            array('block_myoverview_user_view_preference', 'summary', ''),
+            array('block_myoverview_user_paging_preference', 12, 12)
         );
     }
 
index f56589b..7eb7235 100644 (file)
@@ -29,7 +29,9 @@ defined('MOODLE_INTERNAL') || die();
 use \core_privacy\local\metadata\collection;
 use \core_privacy\local\request\transform;
 use \core_privacy\local\request\contextlist;
+use \core_privacy\local\request\userlist;
 use \core_privacy\local\request\approved_contextlist;
+use \core_privacy\local\request\approved_userlist;
 use \core_privacy\local\request\writer;
 
 /**
@@ -39,7 +41,10 @@ use \core_privacy\local\request\writer;
  * @copyright  2018 Victor Deniz <victor@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class provider implements \core_privacy\local\metadata\provider, \core_privacy\local\request\subsystem\provider {
+class provider implements
+        \core_privacy\local\metadata\provider,
+        \core_privacy\local\request\core_userlist_provider,
+        \core_privacy\local\request\plugin\provider {
 
     /**
      * Returns information about the user data stored in this component.
@@ -69,14 +74,36 @@ class provider implements \core_privacy\local\metadata\provider, \core_privacy\l
      */
     public static function get_contexts_for_userid(int $userid) : contextlist {
         $params = ['userid' => $userid, 'contextuser' => CONTEXT_USER];
-        $sql = "SELECT id
-                  FROM {context}
-                 WHERE instanceid = :userid and contextlevel = :contextuser";
+        $sql = "SELECT c.id
+                  FROM {context} c
+                  JOIN {block_recentlyaccesseditems} b
+                    ON b.userid = c.instanceid
+                 WHERE c.instanceid = :userid
+                   AND c.contextlevel = :contextuser";
         $contextlist = new contextlist();
         $contextlist->add_from_sql($sql, $params);
         return $contextlist;
     }
 
+    /**
+     * Get the list of users within a specific context.
+     *
+     * @param userlist $userlist The userlist containing the list of users who have data in this context/plugin combination.
+     */
+    public static function get_users_in_context(userlist $userlist) {
+        global $DB;
+
+        $context = $userlist->get_context();
+
+        if (!$context instanceof \context_user) {
+            return;
+        }
+
+        if ($DB->record_exists('block_recentlyaccesseditems', ['userid' => $context->instanceid])) {
+            $userlist->add_user($context->instanceid);
+        }
+    }
+
     /**
      * Export all user data for the specified user, in the specified contexts.
      *
@@ -148,4 +175,19 @@ class provider implements \core_privacy\local\metadata\provider, \core_privacy\l
             }
         }
     }
-}
\ No newline at end of file
+
+    /**
+     * Delete multiple users within a single context.
+     *
+     * @param approved_userlist $userlist The approved context and user information to delete information for.
+     */
+    public static function delete_data_for_users(approved_userlist $userlist) {
+        global $DB;
+
+        $context = $userlist->get_context();
+
+        if ($context instanceof \context_user && in_array($context->instanceid, $userlist->get_userids())) {
+            $DB->delete_records('block_recentlyaccesseditems', ['userid' => $context->instanceid]);
+        }
+    }
+}
diff --git a/blocks/recentlyaccesseditems/tests/privacy_test.php b/blocks/recentlyaccesseditems/tests/privacy_test.php
new file mode 100644 (file)
index 0000000..8c5ab69
--- /dev/null
@@ -0,0 +1,438 @@
+<?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/>.
+
+/**
+ * Block recentlyaccesseditems privacy provider tests.
+ *
+ * @package    block_recentlyaccesseditems
+ * @copyright  2018 Michael Hawkins <michaelh@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @since      Moodle 3.6
+ */
+defined('MOODLE_INTERNAL') || die();
+
+use \block_recentlyaccesseditems\privacy\provider;
+use \core_privacy\local\request\approved_contextlist;
+use \core_privacy\local\request\approved_userlist;
+
+/**
+ * Block Recently accessed items privacy provider tests.
+ *
+ * @package    block_recentlyaccesseditems
+ * @copyright  2018 Michael Hawkins <michaelh@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @since      Moodle 3.6
+ */
+class block_recentlyaccesseditems_privacy_testcase extends \core_privacy\tests\provider_testcase {
+
+    /**
+     * Test getting the context for the user ID related to this plugin.
+     */
+    public function test_get_contexts_for_userid() {
+        $this->resetAfterTest();
+        $generator = $this->getDataGenerator();
+
+        $student = $generator->create_user();
+        $studentcontext = context_user::instance($student->id);
+        $teacher = $generator->create_user();
+        $teachercontext = context_user::instance($teacher->id);
+
+        // Enrol users in course and add course items.
+        $course = $generator->create_course();
+        $generator->enrol_user($student->id, $course->id, 'student');
+        $generator->enrol_user($teacher->id, $course->id, 'teacher');
+        $forum = $generator->create_module('forum', ['course' => $course]);
+        $chat = $generator->create_module('chat', ['course' => $course]);
+
+        // Check nothing is found before block is populated.
+        $contextlist1 = provider::get_contexts_for_userid($student->id);
+        $this->assertCount(0, $contextlist1);
+        $contextlist2 = provider::get_contexts_for_userid($teacher->id);
+        $this->assertCount(0, $contextlist2);
+
+        // Generate some recent activity for both users.
+        $this->setUser($student);
+        $event = \mod_forum\event\course_module_viewed::create(['context' => context_module::instance($forum->cmid),
+                    'objectid' => $forum->id]);
+        $event->trigger();
+
+        $this->setUser($teacher);
+        $event = \mod_chat\event\course_module_viewed::create(['context' => context_module::instance($chat->cmid),
+                    'objectid' => $chat->id]);
+        $event->trigger();
+
+        // Ensure provider only fetches the users's own context.
+        $contextlist1 = provider::get_contexts_for_userid($student->id);
+        $this->assertCount(1, $contextlist1);
+        $this->assertEquals($studentcontext, $contextlist1->current());
+
+        $contextlist2 = provider::get_contexts_for_userid($teacher->id);
+        $this->assertCount(1, $contextlist2);
+        $this->assertEquals($teachercontext, $contextlist2->current());
+    }
+
+    /**
+     * Test getting users in the context ID related to this plugin.
+     */
+    public function test_get_users_in_context() {
+        $this->resetAfterTest();
+        $generator = $this->getDataGenerator();
+        $component = 'block_recentlyaccesseditems';
+
+        $student = $generator->create_user();
+        $studentcontext = context_user::instance($student->id);
+        $teacher = $generator->create_user();
+        $teachercontext = context_user::instance($teacher->id);
+
+        // Enrol users in course and add course items.
+        $course = $generator->create_course();
+        $generator->enrol_user($student->id, $course->id, 'student');
+        $generator->enrol_user($teacher->id, $course->id, 'teacher');
+        $forum = $generator->create_module('forum', ['course' => $course]);
+        $chat = $generator->create_module('chat', ['course' => $course]);
+
+        // Check nothing is found before block is populated.
+        $userlist1 = new \core_privacy\local\request\userlist($studentcontext, $component);
+        provider::get_users_in_context($userlist1);
+        $this->assertCount(0, $userlist1);
+        $userlist2 = new \core_privacy\local\request\userlist($teachercontext, $component);
+        provider::get_users_in_context($userlist2);
+        $this->assertCount(0, $userlist2);
+
+        // Generate some recent activity for both users.
+        $this->setUser($student);
+        $event = \mod_forum\event\course_module_viewed::create(['context' => context_module::instance($forum->cmid),
+                    'objectid' => $forum->id]);
+        $event->trigger();
+        $event = \mod_chat\event\course_module_viewed::create(['context' => context_module::instance($chat->cmid),
+                    'objectid' => $chat->id]);
+        $event->trigger();
+
+        $this->setUser($teacher);
+        $event = \mod_forum\event\course_module_viewed::create(['context' => context_module::instance($forum->cmid),
+                    'objectid' => $forum->id]);
+        $event->trigger();
+        $event = \mod_chat\event\course_module_viewed::create(['context' => context_module::instance($chat->cmid),
+                    'objectid' => $chat->id]);
+        $event->trigger();
+
+        // Ensure provider only fetches the user whose user context is checked.
+        $userlist1 = new \core_privacy\local\request\userlist($studentcontext, $component);
+        provider::get_users_in_context($userlist1);
+        $this->assertCount(1, $userlist1);
+        $this->assertEquals($student, $userlist1->current());
+
+        $userlist2 = new \core_privacy\local\request\userlist($teachercontext, $component);
+        provider::get_users_in_context($userlist2);
+        $this->assertCount(1, $userlist2);
+        $this->assertEquals($teacher, $userlist2->current());
+    }
+
+    /**
+     * Test fetching information about user data stored.
+     */
+    public function test_get_metadata() {
+        $collection = new \core_privacy\local\metadata\collection('block_recentlyaccesseditems');
+        $newcollection = provider::get_metadata($collection);
+        $itemcollection = $newcollection->get_collection();
+        $this->assertCount(1, $itemcollection);
+
+        $table = reset($itemcollection);
+        $this->assertEquals('block_recentlyaccesseditems', $table->get_name());
+
+        $privacyfields = $table->get_privacy_fields();
+        $this->assertCount(4, $privacyfields);
+        $this->assertArrayHasKey('userid', $privacyfields);
+        $this->assertArrayHasKey('courseid', $privacyfields);
+        $this->assertArrayHasKey('cmid', $privacyfields);
+        $this->assertArrayHasKey('timeaccess', $privacyfields);
+
+        $this->assertEquals('privacy:metadata:block_recentlyaccesseditemstablesummary', $table->get_summary());
+    }
+
+    /**
+     * Test exporting data for an approved contextlist.
+     */
+    public function test_export_user_data() {
+        global $DB;
+
+        $this->resetAfterTest();
+        $generator = $this->getDataGenerator();
+        $component = 'block_recentlyaccesseditems';
+
+        $student = $generator->create_user();
+        $studentcontext = context_user::instance($student->id);
+
+        // Enrol user in course and add course items.
+        $course = $generator->create_course();
+        $generator->enrol_user($student->id, $course->id, 'student');
+        $forum = $generator->create_module('forum', ['course' => $course]);
+        $chat = $generator->create_module('chat', ['course' => $course]);
+
+        // Generate some recent activity.
+        $this->setUser($student);
+        $event = \mod_forum\event\course_module_viewed::create(['context' => context_module::instance($forum->cmid),
+                'objectid' => $forum->id]);
+        $event->trigger();
+        $event = \mod_chat\event\course_module_viewed::create(['context' => context_module::instance($chat->cmid),
+                'objectid' => $chat->id]);
+        $event->trigger();
+
+        // Confirm data is present.
+        $params = [
+            'courseid' => $course->id,
+            'userid' => $student->id,
+        ];
+
+        $result = $DB->count_records('block_recentlyaccesseditems', $params);
+        $this->assertEquals(2, $result);
+
+        // Export data for student.
+        $approvedlist = new approved_contextlist($student, $component, [$studentcontext->id]);
+        provider::export_user_data($approvedlist);
+
+        // Confirm student's data is exported.
+        $writer = \core_privacy\local\request\writer::with_context($studentcontext);
+        $this->assertTrue($writer->has_any_data());
+    }
+
+    /**
+     * Test deleting data for all users within an approved contextlist.
+     */
+    public function test_delete_data_for_all_users_in_context() {
+        global $DB;
+
+        $this->resetAfterTest();
+        $generator = $this->getDataGenerator();
+
+        $student = $generator->create_user();
+        $studentcontext = context_user::instance($student->id);
+        $teacher = $generator->create_user();
+
+        // Enrol users in course and add course items.
+        $course = $generator->create_course();
+        $generator->enrol_user($student->id, $course->id, 'student');
+        $generator->enrol_user($teacher->id, $course->id, 'teacher');
+        $forum = $generator->create_module('forum', ['course' => $course]);
+        $chat = $generator->create_module('chat', ['course' => $course]);
+
+        // Generate some recent activity for both users.
+        $users = [$student, $teacher];
+        foreach ($users as $user) {
+            $this->setUser($user);
+            $event = \mod_forum\event\course_module_viewed::create(['context' => context_module::instance($forum->cmid),
+                        'objectid' => $forum->id]);
+            $event->trigger();
+            $event = \mod_chat\event\course_module_viewed::create(['context' => context_module::instance($chat->cmid),
+                        'objectid' => $chat->id]);
+            $event->trigger();
+        }
+
+        // Confirm data is present for both users.
+        $params = [
+            'courseid' => $course->id,
+            'userid' => $student->id,
+        ];
+
+        $result = $DB->count_records('block_recentlyaccesseditems', $params);
+        $this->assertEquals(2, $result);
+        $params['userid'] = $teacher->id;
+        $result = $DB->count_records('block_recentlyaccesseditems', $params);
+        $this->assertEquals(2, $result);
+
+        // Attempt system context deletion (should have no effect).
+        $systemcontext = context_system::instance();
+        provider::delete_data_for_all_users_in_context($systemcontext);
+
+        $params = ['courseid' => $course->id];
+        $result = $DB->count_records('block_recentlyaccesseditems', $params);
+        $this->assertEquals(4, $result);
+
+        // Delete all data in student context.
+        provider::delete_data_for_all_users_in_context($studentcontext);
+
+        // Confirm only student data is deleted.
+        $params = [
+            'courseid' => $course->id,
+            'userid' => $student->id,
+        ];
+        $result = $DB->count_records('block_recentlyaccesseditems', $params);
+        $this->assertEquals(0, $result);
+
+        $params['userid'] = $teacher->id;
+        $result = $DB->count_records('block_recentlyaccesseditems', $params);
+        $this->assertEquals(2, $result);
+    }
+
+    /**
+     * Test deleting data within an approved contextlist for a user.
+     */
+    public function test_delete_data_for_user() {
+        global $DB;
+
+        $this->resetAfterTest();
+        $generator = $this->getDataGenerator();
+        $component = 'block_recentlyaccesseditems';
+
+        $student = $generator->create_user();
+        $studentcontext = context_user::instance($student->id);
+        $teacher = $generator->create_user();
+        $teachercontext = context_user::instance($teacher->id);
+
+        // Enrol users in course and add course items.
+        $course = $generator->create_course();
+        $generator->enrol_user($student->id, $course->id, 'student');
+        $generator->enrol_user($teacher->id, $course->id, 'teacher');
+        $forum = $generator->create_module('forum', ['course' => $course]);
+        $chat = $generator->create_module('chat', ['course' => $course]);
+
+        // Generate some recent activity for both users.
+        $users = [$student, $teacher];
+        foreach ($users as $user) {
+            $this->setUser($user);
+            $event = \mod_forum\event\course_module_viewed::create(['context' => context_module::instance($forum->cmid),
+                        'objectid' => $forum->id]);
+            $event->trigger();
+            $event = \mod_chat\event\course_module_viewed::create(['context' => context_module::instance($chat->cmid),
+                        'objectid' => $chat->id]);
+            $event->trigger();
+        }
+
+        // Confirm data is present for both users.
+        $params = [
+            'courseid' => $course->id,
+            'userid' => $student->id,
+        ];
+
+        $result = $DB->count_records('block_recentlyaccesseditems', $params);
+        $this->assertEquals(2, $result);
+        $params['userid'] = $teacher->id;
+        $result = $DB->count_records('block_recentlyaccesseditems', $params);
+        $this->assertEquals(2, $result);
+
+        // Attempt system context deletion (should have no effect).
+        $systemcontext = context_system::instance();
+        $approvedlist = new approved_contextlist($teacher, $component, [$systemcontext->id]);
+        provider::delete_data_for_user($approvedlist);
+
+        $params = ['courseid' => $course->id];
+        $result = $DB->count_records('block_recentlyaccesseditems', $params);
+        $this->assertEquals(4, $result);
+
+        // Attempt to delete teacher data in student user context (should have no effect).
+        $approvedlist = new approved_contextlist($teacher, $component, [$studentcontext->id]);
+        provider::delete_data_for_user($approvedlist);
+
+        $result = $DB->count_records('block_recentlyaccesseditems', $params);
+        $this->assertEquals(4, $result);
+
+        // Delete teacher data in their own user context.
+        $approvedlist = new approved_contextlist($teacher, $component, [$teachercontext->id]);
+        provider::delete_data_for_user($approvedlist);
+
+        // Confirm only teacher data is deleted.
+        $params = [
+            'courseid' => $course->id,
+            'userid' => $student->id,
+        ];
+
+        $result = $DB->count_records('block_recentlyaccesseditems', $params);
+        $this->assertEquals(2, $result);
+
+        $params['userid'] = $teacher->id;
+        $result = $DB->count_records('block_recentlyaccesseditems', $params);
+        $this->assertEquals(0, $result);
+    }
+
+    /**
+     * Test deleting data within a context for an approved userlist.
+     */
+    public function test_delete_data_for_users() {
+        global $DB;
+
+        $this->resetAfterTest();
+        $generator = $this->getDataGenerator();
+        $component = 'block_recentlyaccesseditems';
+
+        $student = $generator->create_user();
+        $studentcontext = context_user::instance($student->id);
+        $teacher = $generator->create_user();
+        $teachercontext = context_user::instance($teacher->id);
+
+        // Enrol users in course and add course items.
+        $course = $generator->create_course();
+        $generator->enrol_user($student->id, $course->id, 'student');
+        $generator->enrol_user($teacher->id, $course->id, 'teacher');
+        $forum = $generator->create_module('forum', ['course' => $course]);
+        $chat = $generator->create_module('chat', ['course' => $course]);
+
+        // Generate some recent activity for all users.
+        $users = [$student, $teacher];
+        foreach ($users as $user) {
+            $this->setUser($user);
+            $event = \mod_forum\event\course_module_viewed::create(['context' => context_module::instance($forum->cmid),
+                        'objectid' => $forum->id]);
+            $event->trigger();
+            $event = \mod_chat\event\course_module_viewed::create(['context' => context_module::instance($chat->cmid),
+                        'objectid' => $chat->id]);
+            $event->trigger();
+        }
+
+        // Confirm data is present for all 3 users.
+        $params = [
+            'courseid' => $course->id,
+            'userid' => $student->id,
+        ];
+
+        $result = $DB->count_records('block_recentlyaccesseditems', $params);
+        $this->assertEquals(2, $result);
+        $params['userid'] = $teacher->id;
+        $result = $DB->count_records('block_recentlyaccesseditems', $params);
+        $this->assertEquals(2, $result);
+
+        // Attempt system context deletion (should have no effect).
+        $systemcontext = context_system::instance();
+        $approvedlist = new approved_userlist($systemcontext, $component, [$student->id, $teacher->id]);
+        provider::delete_data_for_users($approvedlist);
+
+        $params = ['courseid' => $course->id];
+        $result = $DB->count_records('block_recentlyaccesseditems', $params);
+        $this->assertEquals(4, $result);
+
+        // Attempt to delete data in another user's context (should have no effect).
+        $approvedlist = new approved_userlist($studentcontext, $component, [$teacher->id]);
+        provider::delete_data_for_users($approvedlist);
+
+        $result = $DB->count_records('block_recentlyaccesseditems', $params);
+        $this->assertEquals(4, $result);
+
+        // Delete users' data in teacher's context.
+        $approvedlist = new approved_userlist($teachercontext, $component, [$student->id, $teacher->id]);
+        provider::delete_data_for_users($approvedlist);
+
+        // Confirm only teacher data is deleted.
+        $params = [
+            'courseid' => $course->id,
+            'userid' => $student->id,
+        ];
+
+        $result = $DB->count_records('block_recentlyaccesseditems', $params);
+        $this->assertEquals(2, $result);
+        $params['userid'] = $teacher->id;
+        $result = $DB->count_records('block_recentlyaccesseditems', $params);
+        $this->assertEquals(0, $result);
+    }
+}
index cd209c5..025c596 100644 (file)
Binary files a/blocks/timeline/amd/build/event_list.min.js and b/blocks/timeline/amd/build/event_list.min.js differ
index b29cb60..dc99182 100644 (file)
Binary files a/blocks/timeline/amd/build/view_dates.min.js and b/blocks/timeline/amd/build/view_dates.min.js differ
index dfd1517..b6d2a43 100644 (file)
@@ -300,6 +300,7 @@ function(
      * @param {Number} daysOffset How many days (from midnight) to offset the results from
      * @param {int|undefined} daysLimit How many dates (from midnight) to limit the result to
      * @param {string} paginationAriaLabel String to set as the aria label for the pagination bar.
+     * @param {object} additionalConfig Additional config options to pass to pagedContentFactory
      * @return {object} jQuery promise.
      */
     var createPagedContent = function(
@@ -310,7 +311,8 @@ function(
         courseId,
         daysOffset,
         daysLimit,
-        paginationAriaLabel
+        paginationAriaLabel,
+        additionalConfig
     ) {
         // Remember the last event id we loaded on each page because we can't
         // use the offset value since the backend can skip events if the user doesn't
@@ -318,7 +320,7 @@ function(
         // based on the last seen event id.
         var lastIds = {'1': 0};
         var hasContent = false;
-        var config = $.extend({}, DEFAULT_PAGED_CONTENT_CONFIG);
+        var config = $.extend({}, DEFAULT_PAGED_CONTENT_CONFIG, additionalConfig);
 
         return Str.get_string(
                 'ariaeventlistpagelimit',
@@ -396,8 +398,9 @@ function(
      * @param {int|array} pageLimit A single limit or list of limits as options for the paged content
      * @param {object} preloadedPages An object of preloaded page data. Page number as key, data promise as value.
      * @param {string} paginationAriaLabel String to set as the aria label for the pagination bar.
+     * @param {object} additionalConfig Additional config options to pass to pagedContentFactory
      */
-    var init = function(root, pageLimit, preloadedPages, paginationAriaLabel) {
+    var init = function(root, pageLimit, preloadedPages, paginationAriaLabel, additionalConfig) {
         root = $(root);
 
         // Create a promise that will be resolved once the first set of page
@@ -425,7 +428,8 @@ function(
         }
 
         // Created the paged content element.
-        createPagedContent(pageLimit, preloadedPages, midnight, firstLoad, courseId, daysOffset, daysLimit, paginationAriaLabel)
+        return createPagedContent(pageLimit, preloadedPages, midnight, firstLoad, courseId, daysOffset, daysLimit,
+                paginationAriaLabel, additionalConfig)
             .then(function(html, js) {
                 html = $(html);
                 // Hide the content for now.
index b18d21a..f411c47 100644 (file)
@@ -25,18 +25,64 @@ define(
 [
     'jquery',
     'core/str',
-    'block_timeline/event_list'
+    'block_timeline/event_list',
+    'core/pubsub',
+    'core/paged_content_events'
 ],
 function(
     $,
     Str,
-    EventList
+    EventList,
+    PubSub,
+    PagedContentEvents
 ) {
 
     var SELECTORS = {
         EVENT_LIST_CONTAINER: '[data-region="event-list-container"]',
     };
 
+    var DEFAULT_PAGE_LIMIT = [5, 10, 25];
+
+    /**
+     * Generate a paged content array of limits taking into account user preferences
+     *
+     * @param {object} root The root element for the timeline dates view.
+     * @return {array} Array of limit objects
+     */
+    var getPagingLimits = function(root) {
+        var limitPref = parseInt(root.data('limit'), 10);
+        var isDefaultSet = false;
+        var limits = DEFAULT_PAGE_LIMIT.map(function(value) {
+            if (limitPref == value) {
+                isDefaultSet = true;
+            }
+
+            return {
+                value: value,
+                active: limitPref == value
+            };
+        });
+
+        if (!isDefaultSet) {
+            limits[0].active = true;
+        }
+
+        return limits;
+    };
+
+    /**
+     * Setup the listeners for the timeline block
+     *
+     * @param {string} root view dates container
+     * @param {string} namespace The namespace for the paged content
+     */
+    var registerEventListeners = function(root, namespace) {
+        var event = namespace + PagedContentEvents.SET_ITEMS_PER_PAGE_LIMIT;
+        PubSub.subscribe(event, function(limit) {
+            $(root).data('limit', limit);
+        });
+    };
+
     /**
      * Initialise the event list and being loading the events.
      *
@@ -44,14 +90,22 @@ function(
      */
     var load = function(root) {
         var eventListContainer = root.find(SELECTORS.EVENT_LIST_CONTAINER);
+        var namespace = $(eventListContainer).attr('id') + "user_block_timeline" + Math.random();
+        registerEventListeners(root, namespace);
+
+        var limits = getPagingLimits(root);
+        var config = {
+            persistentLimitKey: "block_timeline_user_limit_preference",
+            eventNamespace: namespace
+        };
         Str.get_string('ariaeventlistpaginationnavdates', 'block_timeline')
             .then(function(string) {
-                EventList.init(eventListContainer, [5, 10, 25], {}, string);
+                EventList.init(eventListContainer, limits, {}, string, config);
                 return string;
             })
             .catch(function() {
                 // Ignore if we can't load the string. Still init the event list.
-                EventList.init(eventListContainer, [5, 10, 25]);
+                EventList.init(eventListContainer, limits, {}, "", config);
             });
     };
 
@@ -65,7 +119,7 @@ function(
         root = $(root);
         if (root.hasClass('active')) {
             load(root);
-            root.attr('data-seen', true);
+            root.data('seen', true);
         }
     };
 
@@ -79,7 +133,7 @@ function(
         root.removeAttr('data-seen');
         if (root.hasClass('active')) {
             load(root);
-            root.attr('data-seen', true);
+            root.data('seen', true);
         }
     };
 
@@ -89,9 +143,9 @@ function(
      * @param {object} root The root element for the timeline courses view.
      */
     var shown = function(root) {
-        if (!root.attr('data-seen')) {
+        if (!root.data('seen')) {
             load(root);
-            root.attr('data-seen', true);
+            root.data('seen', true);
         }
     };
 
index 03e7230..ad7aaa2 100644 (file)
@@ -52,8 +52,9 @@ class block_timeline extends block_base {
 
         $sort = get_user_preferences('block_timeline_user_sort_preference');
         $filter = get_user_preferences('block_timeline_user_filter_preference');
+        $limit = get_user_preferences('block_timeline_user_limit_preference');
 
-        $renderable = new \block_timeline\output\main($sort, $filter);
+        $renderable = new \block_timeline\output\main($sort, $filter, $limit);
         $renderer = $this->page->get_renderer('block_timeline');
 
         $this->content = (object) [
index 218eb46..fd555e0 100644 (file)
@@ -54,15 +54,22 @@ class main implements renderable, templatable {
      */
     public $order;
 
+    /**
+     * @var string The current limit preference
+     */
+    public $limit;
+
     /**
      * main constructor.
      *
      * @param string $order Constant sort value from ../timeline/lib.php
-     * @param string $filter Constant sort value from ../timeline/lib.php
+     * @param string $filter Constant filter value from ../timeline/lib.php
+     * @param string $limit Constant limit value from ../timeline/lib.php
      */
-    public function __construct($order, $filter) {
+    public function __construct($order, $filter, $limit) {
         $this->order = $order ? $order : BLOCK_TIMELINE_SORT_BY_DATES;
         $this->filter = $filter ? $filter : BLOCK_TIMELINE_FILTER_BY_7_DAYS;
+        $this->limit = $limit ? $limit : BLOCK_TIMELINE_ACTIVITIES_LIMIT_DEFAULT;
     }
 
     /**
@@ -165,6 +172,7 @@ class main implements renderable, templatable {
             'hasdaysoffset' => true,
             'hasdayslimit' => $offsets['dayslimit'] !== false ,
             'nodayslimit' => $offsets['dayslimit'] === false ,
+            'limit' => $this->limit
         ];
         return array_merge($contextvariables, $filters, $offsets);
     }
index 3be0998..ed1df85 100644 (file)
@@ -44,6 +44,7 @@ class provider implements \core_privacy\local\metadata\provider, \core_privacy\l
     public static function get_metadata(collection $collection) : collection {
         $collection->add_user_preference('block_timeline_user_sort_preference', 'privacy:metadata:timelinesortpreference');
         $collection->add_user_preference('block_timeline_user_filter_preference', 'privacy:metadata:timelinefilterpreference');
+        $collection->add_user_preference('block_timeline_user_limit_preference', 'privacy:metadata:timelinelimitpreference');
         return $collection;
     }
 
@@ -68,5 +69,13 @@ class provider implements \core_privacy\local\metadata\provider, \core_privacy\l
                     get_string('privacy:metadata:timelinefilterpreference', 'block_timeline')
             );
         }
+
+        $preference = get_user_preferences('block_timeline_user_limit_preference', null, $userid);
+        if (isset($preference)) {
+            \core_privacy\local\request\writer::export_user_preference('block_timeline', 'block_timeline_user_limit_preference',
+                    $preference,
+                    get_string('privacy:metadata:timelinelimitpreference', 'block_timeline')
+            );
+        }
     }
 }
index 12dae0d..b45d3ff 100644 (file)
@@ -49,3 +49,4 @@ $string['timeline'] = 'Timeline';
 $string['viewcourse'] = 'View course';
 $string['privacy:metadata:timelinesortpreference'] = 'The user sort preference for the timeline block.';
 $string['privacy:metadata:timelinefilterpreference'] = 'The user day filter preference for the timeline block.';
+$string['privacy:metadata:timelinelimitpreference'] = 'The user page limit preference for the timeline block.';
index 26e8783..712684e 100644 (file)
@@ -38,6 +38,7 @@ define('BLOCK_TIMELINE_FILTER_BY_7_DAYS', 'next7days');
 define('BLOCK_TIMELINE_FILTER_BY_30_DAYS', 'next30days');
 define('BLOCK_TIMELINE_FILTER_BY_3_MONTHS', 'next3months');
 define('BLOCK_TIMELINE_FILTER_BY_6_MONTHS', 'next6months');
+define('BLOCK_TIMELINE_ACTIVITIES_LIMIT_DEFAULT', 5);
 
 /**
  * Returns the name of the user preferences as well as the details this plugin uses.
@@ -66,5 +67,11 @@ function block_timeline_user_preferences() {
         )
     );
 
+    $preferences['block_timeline_user_limit_preference'] = array(
+        'null' => NULL_NOT_ALLOWED,
+        'default' => BLOCK_TIMELINE_ACTIVITIES_LIMIT_DEFAULT,
+        'type' => PARAM_INT
+    );
+
     return $preferences;
 }
index acb5f78..ce96132 100644 (file)
         "next3months": false,
         "next6months": false,
         "daysoffset": -14,
-        "dayslimit": false
+        "dayslimit": false,
+        "limit": 0
     }
 }}
 <div data-region="timeline-view">
     <div class="tab-content">
-        <div class="tab-pane {{#sorttimelinedates}}active show{{/sorttimelinedates}} fade" data-region="view-dates" id="view_dates_{{uniqid}}">
+        <div class="tab-pane {{#sorttimelinedates}}active show{{/sorttimelinedates}} fade" data-limit="{{limit}}" data-region="view-dates" id="view_dates_{{uniqid}}">
             {{> block_timeline/view-dates }}
         </div>
         <div
diff --git a/blocks/timeline/tests/behat/block_timeline_pagelimit_persistence.feature b/blocks/timeline/tests/behat/block_timeline_pagelimit_persistence.feature
new file mode 100644 (file)
index 0000000..76518f5
--- /dev/null
@@ -0,0 +1,63 @@
+@block @block_timeline @javascript
+Feature: The timeline block allows user persistence of their page limits
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email                | idnumber |
+      | student1 | Student   | 1        | student1@example.com | S1       |
+    And the following "courses" exist:
+      | fullname | shortname | category | startdate                   | enddate         |
+      | Course 1 | C1        | 0        | ##1 month ago##             | ##15 days ago## |
+      | Course 2 | C2        | 0        | ##yesterday##               | ##tomorrow## |
+      | Course 3 | C3        | 0        | ##first day of next month## | ##last day of next month## |
+      | Course 4 | C4        | 0        | ##first day of next month## | ##last day of next month## |
+      | Course 5 | C5        | 0        | ##first day of next month## | ##last day of next month## |
+      | Course 6 | C6        | 0        | ##first day of next month## | ##last day of next month## |
+    And the following "activities" exist:
+      | activity | course | idnumber  | name            | intro                   | timeopen      | timeclose     |
+      | choice   | C2     | choice1   | Test choice 1   | Test choice description | ##yesterday## | ##tomorrow##  |
+      | choice   | C1     | choice2   | Test choice 2   | Test choice description | ##1 month ago## | ##15 days ago##  |
+      | choice   | C3     | choice3   | Test choice 3   | Test choice description | ##first day of +5 months## | ##last day of +5 months##  |
+      | feedback | C2     | feedback1 | Test feedback 1 | Test feedback description | ##yesterday## | ##tomorrow##  |
+      | feedback | C1     | feedback2 | Test feedback 2 | Test feedback description | ##first day of +10 months## | ##last day of +10 months##  |
+      | feedback | C3     | feedback3 | Test feedback 3 | Test feedback description | ##first day of +5 months## | ##last day of +5 months## |
+    And the following "activities" exist:
+      | activity | course | idnumber  | name            | intro                   | timeopen      | duedate     |
+      | assign   | C1     | assign1   | Test assign 1   | Test assign description | ##1 month ago## | ##yesterday##  |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | student1 | C1 | student |
+      | student1 | C2 | student |
+      | student1 | C3 | student |
+      | student1 | C4 | student |
+      | student1 | C5 | student |
+      | student1 | C6 | student |
+
+  Scenario: Toggle the page limit 5 - 25
+    Given I log in as "student1"
+    And I click on "Next 30 days" "button" in the "Timeline" "block"
+    And I click on "All" "link" in the "Timeline" "block"
+    And I click on "Sort" "button" in the "Timeline" "block"
+    And I click on "Sort by dates" "link" in the "Timeline" "block"
+    When I click on "5" "button" in the "Timeline" "block"
+    And I click on "25" "link"
+    Then I should see "Test feedback 2" in the "Timeline" "block"
+    And I reload the page
+    Then I should see "Test feedback 2" in the "Timeline" "block"
+    And I log out
+
+  Scenario: Toggle the page limit 25 - 5
+    Given I log in as "student1"
+    And I click on "Next 30 days" "button" in the "Timeline" "block"
+    And I click on "All" "link" in the "Timeline" "block"
+    And I click on "Sort" "button" in the "Timeline" "block"
+    And I click on "Sort by dates" "link" in the "Timeline" "block"
+    When I click on "5" "button" in the "Timeline" "block"
+    And I click on "25" "link"
+    And I should see "Test feedback 2" in the "Timeline" "block"
+    And I click on "25" "button" in the "Timeline" "block"
+    And I click on "5" "link" in the ".dropdown-menu.show" "css_element"
+    Then I should not see "Test feedback 2" in the "Timeline" "block"
+    And I reload the page
+    And I should not see "Test feedback 2" in the "Timeline" "block"
+    And I log out
index 0aacf3e..787a346 100644 (file)
@@ -49,62 +49,39 @@ class block_timeline_privacy_testcase extends \core_privacy\tests\provider_testc
     }
 
     /**
-     * Test that the preference courses is exported properly.
+     * Test the export_user_preferences given different inputs
+     *
+     * @param string $type The name of the user preference to get/set
+     * @param string $value The value you are storing
+     * @param string $expected The expected value override
+     *
+     * @dataProvider user_preference_provider
      */
-    public function test_export_user_preferences_date_sort_preference() {
+    public function test_export_user_preferences($type, $value, $expected) {
         $this->resetAfterTest();
-
-        $user = $this->getDataGenerator()->create_user();
-        set_user_preference('block_timeline_user_sort_preference', 'sortbydates', $user);
-
-        provider::export_user_preferences($user->id);
-        $writer = writer::with_context(\context_system::instance());
-        $blockpreferences = $writer->get_user_preferences('block_timeline');
-        $this->assertEquals('Sort by dates', $blockpreferences->block_timeline_user_sort_preference->value);
-    }
-
-    /**
-     * Test that the preference timeline is exported properly.
-     */
-    public function test_export_user_preferences_course_sort_preference() {
-        $this->resetAfterTest();
-
         $user = $this->getDataGenerator()->create_user();
-        set_user_preference('block_timeline_user_sort_preference', 'sortbycourses', $user);
-
+        set_user_preference($type, $value, $user);
         provider::export_user_preferences($user->id);
         $writer = writer::with_context(\context_system::instance());
         $blockpreferences = $writer->get_user_preferences('block_timeline');
-        $this->assertEquals('Sort by courses', $blockpreferences->block_timeline_user_sort_preference->value);
+        if (!$expected) {
+            $expected = get_string($value, 'block_timeline');
+        }
+        $this->assertEquals($expected, $blockpreferences->{$type}->value);
     }
 
     /**
-     * Test that the preference timeline is exported properly.
+     * Create an array of valid user preferences for the timeline block.
+     *
+     * @return array Array of valid user preferences.
      */
-    public function test_export_user_preferences_7day_filter_preference() {
-        $this->resetAfterTest();
-
-        $user = $this->getDataGenerator()->create_user();
-        set_user_preference('block_timeline_user_filter_preference', 'next7days', $user);
-
-        provider::export_user_preferences($user->id);
-        $writer = writer::with_context(\context_system::instance());
-        $blockpreferences = $writer->get_user_preferences('block_timeline');
-        $this->assertEquals('Next 7 days', $blockpreferences->block_timeline_user_filter_preference->value);
-    }
-
-    /**
-     * Test that the preference timeline is exported properly.
-     */
-    public function test_export_user_preferences_all_filter_preference() {
-        $this->resetAfterTest();
-
-        $user = $this->getDataGenerator()->create_user();
-        set_user_preference('block_timeline_user_filter_preference', 'all', $user);
-
-        provider::export_user_preferences($user->id);
-        $writer = writer::with_context(\context_system::instance());
-        $blockpreferences = $writer->get_user_preferences('block_timeline');
-        $this->assertEquals('All', $blockpreferences->block_timeline_user_filter_preference->value);
+    public function user_preference_provider() {
+        return array(
+            array('block_timeline_user_sort_preference', 'sortbydates', ''),
+            array('block_timeline_user_sort_preference', 'sortbycourses', ''),
+            array('block_timeline_user_sort_preference', 'next7days', ''),
+            array('block_timeline_user_sort_preference', 'all', ''),
+            array('block_timeline_user_limit_preference', 5, 5),
+        );
     }
 }
index 109d86c..f1fec54 100644 (file)
@@ -58,7 +58,7 @@
             </div>
             {{#icon}}<div class="d-inline-block mt-1 align-top">{{#pix}} {{key}}, {{component}}, {{alttext}} {{/pix}}</div>{{/icon}}
             <div class="d-inline-block">
-                <h3 class="name d-inline-block">{{name}}</h3>
+                <h3 class="name d-inline-block">{{{name}}}</h3>
                 <span class="date pull-xs-right m-r-1">{{{formattedtime}}}</span>
                 <div class="location">{{#location}}{{{location}}}{{/location}}</div>
             </div>
index 9231fa8..fbf3fde 100644 (file)
@@ -83,7 +83,7 @@
                                     <ul>
                                         {{#events}}
                                         {{#underway}}
-                                            <li class="events-underway">[{{name}}]</li>
+                                            <li class="events-underway">[{{{name}}}]</li>
                                         {{/underway}}
                                         {{^underway}}
                                             <li data-region="event-item"
                                                         &nbsp;
                                                     </span>
                                                     {{> core_calendar/event_icon}}
-                                                    <span class="eventname">{{name}}</span>
+                                                    <span class="eventname">{{{name}}}</span>
                                                 </a>
                                             </li>
                                         {{/underway}}
index 5483edb..22aed84 100644 (file)
@@ -106,7 +106,7 @@ Feature: Perform basic calendar functionality
     And I am on "Course 1" course homepage
     When I follow "This month"
     And I click on "Really awesome event!" "link"
-    And I click on "Delete" "button"
+    And I click on "Delete" "button" in the "Really awesome event!" "dialogue"
     And I click on "Delete event" "button"
     And I wait to be redirected
     Then I should not see "Really awesome event!"
@@ -146,8 +146,8 @@ Feature: Perform basic calendar functionality
     Then I should see "Test choice opens"
     And I should see "Test choice closes"
     When I click on "Test choice opens" "link"
-    Then "Delete" "button" should not exist
-    And "Edit" "button" should not exist
+    Then "Delete" "button" should not exist in the "Test choice opens" "dialogue"
+    And "Edit" "button" should not exist in the "Test choice opens" "dialogue"
     And I should see "Course event"
     When I click on "Go to activity" "link"
     And I wait to be redirected
@@ -155,8 +155,8 @@ Feature: Perform basic calendar functionality
     And I am on "Course 1" course homepage
     And I follow "This month"
     When I click on "Test choice closes" "link"
-    Then "Delete" "button" should not exist
-    And "Edit" "button" should not exist
+    Then "Delete" "button" should not exist in the "Test choice closes" "dialogue"
+    And "Edit" "button" should not exist in the "Test choice closes" "dialogue"
     And I should see "Course event"
     When I click on "Go to activity" "link"
     And I wait to be redirected
index a76246e..e5a991a 100644 (file)
@@ -634,4 +634,4 @@ function cohort_get_list_of_themes() {
         }
     }
     return $themes;
-}
\ No newline at end of file
+}
index bf4bfd6..2880675 100644 (file)
@@ -238,6 +238,7 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
                 $record->visible = 1;
                 $record->depth = 0;
                 $record->path = '';
+                $record->locked = 0;
                 self::$coursecat0 = new self($record);
             }
             return self::$coursecat0;
@@ -2458,6 +2459,7 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
         $context = $this->get_context();
         $a['xi'] = $context->id;
         $a['xp'] = $context->path;
+        $a['xl'] = $context->locked;
         return $a;
     }
 
@@ -2486,6 +2488,7 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
         $record->ctxdepth = $record->depth + 1;
         $record->ctxlevel = CONTEXT_COURSECAT;
         $record->ctxinstance = $record->id;
+        $record->ctxlocked = $a['xl'];
         return new self($record, true);
     }
 
index 2374883..0befd64 100644 (file)
@@ -168,6 +168,8 @@ class helper {
      * @return array
      */
     public static function get_category_listitem_actions(\core_course_category $category) {
+        global $CFG;
+
         $manageurl = new \moodle_url('/course/management.php', array('categoryid' => $category->id));
         $baseurl = new \moodle_url($manageurl, array('sesskey' => \sesskey()));
         $actions = array();
@@ -280,6 +282,28 @@ class helper {
             );
         }
 
+        // Context locking.
+        if (!empty($CFG->contextlocking) && has_capability('moodle/site:managecontextlocks', $category->get_context())) {
+            $parentcontext = $category->get_context()->get_parent_context();
+            if (empty($parentcontext) || !$parentcontext->locked) {
+                if ($category->get_context()->locked) {
+                    $lockicon = 'i/unlock';
+                    $lockstring = get_string('managecontextunlock', 'admin');
+                } else {
+                    $lockicon = 'i/lock';
+                    $lockstring = get_string('managecontextlock', 'admin');
+                }
+                $actions['managecontextlock'] = [
+                    'url' => new \moodle_url('/admin/lock.php', [
+                            'id' => $category->get_context()->id,
+                            'returnurl' => $manageurl->out_as_local_url(false),
+                        ]),
+                    'icon' => new \pix_icon($lockicon, $lockstring),
+                    'string' => $lockstring,
+                ];
+            }
+        }
+
         // Cohorts.
         if ($category->can_review_cohorts()) {
             $actions['cohorts'] = array(
index a0808c1..edeb50c 100644 (file)
@@ -55,6 +55,7 @@ class provider implements
      */
     public static function get_metadata(collection $collection) : collection {
         $collection->add_subsystem_link('core_completion', [], 'privacy:metadata:completionsummary');
+        $collection->add_subsystem_link('core_favourites', [], 'privacy:metadata:favouritessummary');
         $collection->add_user_preference('coursecat_management_perpage', 'privacy:perpage');
         return $collection;
     }
@@ -75,6 +76,9 @@ class provider implements
         $params['contextcourse'] = CONTEXT_COURSE;
         $contextlist = new contextlist();
         $contextlist->add_from_sql($sql, $params);
+
+        \core_favourites\privacy\provider::add_contexts_for_userid($contextlist, $userid, 'core_course', 'courses');
+
         return $contextlist;
     }
 
@@ -86,11 +90,12 @@ class provider implements
     public static function get_users_in_context(userlist $userlist) {
         $context = $userlist->get_context();
 
-        if (!is_a($context, \context_course::class)) {
+        if (!$context instanceof \context_course) {
             return;
         }
 
         \core_completion\privacy\provider::add_course_completion_users_to_userlist($userlist);
+        \core_favourites\privacy\provider::add_userids_for_context($userlist, 'courses');
     }
 
     /**
@@ -111,10 +116,18 @@ class provider implements
                 WHERE ctx.id $select";
 
         $courses = $DB->get_recordset_sql($sql, $params);
+
         foreach ($courses as $course) {
             $coursecompletion = \core_completion\privacy\provider::get_course_completion_info($contextlist->get_user(), $course);
             writer::with_context(\context_course::instance($course->id))->export_data(
                     [get_string('privacy:completionpath', 'course')], (object) $coursecompletion);
+            // Get user's favourites information for the particular course.
+            $coursefavourite = \core_favourites\privacy\provider::get_favourites_info_for_user($contextlist->get_user()->id,
+                    \context_course::instance($course->id), 'core_course', 'courses', $course->id);
+            if ($coursefavourite) { // If the course has been favourited by the user, include it in the export.
+                writer::with_context(\context_course::instance($course->id))->export_data(
+                        [get_string('privacy:favouritespath', 'course')], (object) $coursefavourite);
+            }
         }
         $courses->close();
     }
@@ -217,10 +230,14 @@ class provider implements
      */
     public static function delete_data_for_all_users_in_context(\context $context) {
         // Check what context we've been delivered.
-        if ($context->contextlevel == CONTEXT_COURSE) {
-            // Delete course completion data.
-            \core_completion\privacy\provider::delete_completion(null, $context->instanceid);
+        if (!$context instanceof \context_course) {
+            return;
         }
+        // Delete course completion data.
+        \core_completion\privacy\provider::delete_completion(null, $context->instanceid);
+        // Delete course favourite data.
+        \core_favourites\privacy\provider::delete_favourites_for_all_users($context, 'core_course',
+                'courses');
     }
 
     /**
@@ -230,9 +247,13 @@ class provider implements
      */
     public static function delete_data_for_user(approved_contextlist $contextlist) {
         foreach ($contextlist as $context) {
-            if ($context->contextlevel == CONTEXT_COURSE) {
+            // Check what context we've been delivered.
+            if ($context instanceof \context_course) {
                 // Delete course completion data.
                 \core_completion\privacy\provider::delete_completion($contextlist->get_user(), $context->instanceid);
+                // Delete course favourite data.
+                \core_favourites\privacy\provider::delete_favourites_for_user($contextlist, 'core_course',
+                    'courses');
             }
         }
     }
@@ -243,12 +264,15 @@ class provider implements
      * @param   approved_userlist       $userlist The approved context and user information to delete information for.
      */
     public static function delete_data_for_users(approved_userlist $userlist) {
-        global $DB;
         $context = $userlist->get_context();
 
-        if ($context->contextlevel == CONTEXT_COURSE) {
-            // Delete course completion data.
-            \core_completion\privacy\provider::delete_completion_by_approved_userlist($userlist, $context->instanceid);
+        // Check what context we've been delivered.
+        if (!$context instanceof \context_course) {
+            return;
         }
+        // Delete course completion data.
+        \core_completion\privacy\provider::delete_completion_by_approved_userlist($userlist, $context->instanceid);
+        // Delete course favourite data.
+        \core_favourites\privacy\provider::delete_favourites_for_userlist($userlist, 'courses');
     }
 }
index 8d59976..81e21ab 100644 (file)
@@ -3823,12 +3823,14 @@ class core_course_external extends external_api {
 
             $warning = [];
 
-            $favouriteexists = $ufservice->favourite_exists('core_course', 'courses', $course['id'], \context_system::instance());
+            $favouriteexists = $ufservice->favourite_exists('core_course', 'courses', $course['id'],
+                    \context_course::instance($course['id']));
 
             if ($course['favourite']) {
                 if (!$favouriteexists) {
                     try {
-                        $ufservice->create_favourite('core_course', 'courses', $course['id'], \context_system::instance());
+                        $ufservice->create_favourite('core_course', 'courses', $course['id'],
+                                \context_course::instance($course['id']));
                     } catch (Exception $e) {
                         $warning['courseid'] = $course['id'];
                         if ($e instanceof moodle_exception) {
@@ -3849,7 +3851,8 @@ class core_course_external extends external_api {
             } else {
                 if ($favouriteexists) {
                     try {
-                        $ufservice->delete_favourite('core_course', 'courses', $course['id'], \context_system::instance());
+                        $ufservice->delete_favourite('core_course', 'courses', $course['id'],
+                                \context_course::instance($course['id']));
                     } catch (Exception $e) {
                         $warning['courseid'] = $course['id'];
                         if ($e instanceof moodle_exception) {
index 25f60f4..512a8d8 100644 (file)
@@ -27,6 +27,8 @@ defined('MOODLE_INTERNAL') || die();
 global $CFG;
 require_once($CFG->dirroot . '/completion/tests/fixtures/completion_creation.php');
 
+use \core_privacy\local\request\transform;
+
 /**
  * Unit tests for course/classes/privacy/policy
  *
@@ -43,11 +45,45 @@ class core_course_privacy_testcase extends \core_privacy\tests\provider_testcase
      */
     public function test_get_contexts_for_userid() {
         $this->resetAfterTest();
-        $user = $this->getDataGenerator()->create_user();
+
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+
+        // Make sure contexts are not being returned for user1.
+        $contextlist = \core_course\privacy\provider::get_contexts_for_userid($user1->id);
+        $this->assertCount(0, $contextlist->get_contextids());
+
+        // Make sure contexts are not being returned for user2.
+        $contextlist = \core_course\privacy\provider::get_contexts_for_userid($user2->id);
+        $this->assertCount(0, $contextlist->get_contextids());
+
+        // Create course completion data for user1.
         $this->create_course_completion();
-        $this->complete_course($user);
-        $contextlist = \core_course\privacy\provider::get_contexts_for_userid($user->id);
-        $this->assertEquals($this->coursecontext->id, $contextlist->current()->id);
+        $this->complete_course($user1);
+
+        // Make sure the course context is being returned for user1.
+        $contextlist = \core_course\privacy\provider::get_contexts_for_userid($user1->id);
+        $expected = [$this->coursecontext->id];
+        $actual = $contextlist->get_contextids();
+        $this->assertCount(1, $actual);
+        $this->assertEquals($expected, $actual);
+
+        // Make sure contexts are still not being returned for user2.
+        $contextlist = \core_course\privacy\provider::get_contexts_for_userid($user2->id);
+        $this->assertCount(0, $contextlist->get_contextids());
+
+        // User2 has a favourite course.
+        $user2context = \context_user::instance($user2->id);
+        $ufservice = \core_favourites\service_factory::get_service_for_user_context($user2context);
+        $ufservice->create_favourite('core_course', 'courses', $this->coursecontext->instanceid,
+            $this->coursecontext);
+
+        // Make sure the course context is being returned for user2.
+        $contextlist = \core_course\privacy\provider::get_contexts_for_userid($user2->id);
+        $expected = [$this->coursecontext->id];
+        $actual = $contextlist->get_contextids();
+        $this->assertCount(1, $actual);
+        $this->assertEquals($expected, $actual);
     }
 
     /**
@@ -60,6 +96,7 @@ class core_course_privacy_testcase extends \core_privacy\tests\provider_testcase
         $user1 = $this->getDataGenerator()->create_user();
         $user2 = $this->getDataGenerator()->create_user();
         $user3 = $this->getDataGenerator()->create_user();
+        $user4 = $this->getDataGenerator()->create_user();
 
         // User1 and user2 complete course.
         $this->create_course_completion();
@@ -69,15 +106,32 @@ class core_course_privacy_testcase extends \core_privacy\tests\provider_testcase
         // User3 is enrolled but has not completed course.
         $this->getDataGenerator()->enrol_user($user3->id, $this->course->id, 'student');
 
-        // Ensure only users that have course completion are returned.
+        // User4 has a favourited course.
+        $systemcontext = \context_system::instance();
+        $user4ctx = \context_user::instance($user4->id);
+        $ufservice = \core_favourites\service_factory::get_service_for_user_context($user4ctx);
+        $ufservice->create_favourite('core_course', 'courses', $this->coursecontext->instanceid,
+                $this->coursecontext);
+
+        // Ensure only users that have course completion or favourites are returned.
         $userlist = new \core_privacy\local\request\userlist($this->coursecontext, $component);
         \core_course\privacy\provider::get_users_in_context($userlist);
-        $expected = [$user1->id, $user2->id];
+        $expected = [
+            $user1->id,
+            $user2->id,
+            $user4->id
+        ];
         $actual = $userlist->get_userids();
         sort($expected);
         sort($actual);
-        $this->assertCount(2, $actual);
+        $this->assertCount(3, $actual);
         $this->assertEquals($expected, $actual);
+
+        // Ensure that users are not being returned in other contexts than the course context.
+        $userlist = new \core_privacy\local\request\userlist($systemcontext, $component);
+        \core_course\privacy\provider::get_users_in_context($userlist);
+        $actual = $userlist->get_userids();
+        $this->assertCount(0, $actual);
     }
 
     /**
@@ -85,6 +139,7 @@ class core_course_privacy_testcase extends \core_privacy\tests\provider_testcase
      */
     public function test_export_user_data() {
         $this->resetAfterTest();
+
         $user = $this->getDataGenerator()->create_user();
         $this->create_course_completion();
         $this->complete_course($user);
@@ -95,6 +150,22 @@ class core_course_privacy_testcase extends \core_privacy\tests\provider_testcase
         $completiondata = $writer->get_data([get_string('privacy:completionpath', 'course')]);
         $this->assertEquals('In progress', $completiondata->status);
         $this->assertCount(2, $completiondata->criteria);
+
+        // User has a favourite course.
+        $usercontext = \context_user::instance($user->id);
+        $ufservice = \core_favourites\service_factory::get_service_for_user_context($usercontext);
+        $favourite = $ufservice->create_favourite('core_course', 'courses',
+                $this->coursecontext->instanceid, $this->coursecontext);
+
+        // Ensure that user's favourites data in the course context is being exported.
+        $writer = \core_privacy\local\request\writer::with_context($this->coursecontext);
+        \core_course\privacy\provider::export_user_data($approvedlist);
+        $favouritedata = $writer->get_data([get_string('privacy:favouritespath', 'course')]);
+
+        $this->assertEquals(transform::yesno(true), $favouritedata->starred);
+        $this->assertEquals('', $favouritedata->ordering);
+        $this->assertEquals(transform::datetime($favourite->timecreated), $favouritedata->timecreated);
+        $this->assertEquals(transform::datetime($favourite->timemodified), $favouritedata->timemodified);
     }
 
     /**
@@ -165,52 +236,169 @@ class core_course_privacy_testcase extends \core_privacy\tests\provider_testcase
      */
     public function test_delete_data_for_all_users_in_context() {
         global $DB;
+
         $this->resetAfterTest();
+
         $user1 = $this->getDataGenerator()->create_user();
         $user2 = $this->getDataGenerator()->create_user();
         $this->create_course_completion();
+
+        $systemcontext = \context_system::instance();
+        $user1ctx = \context_user::instance($user1->id);
+        $user2ctx = \context_user::instance($user2->id);
+        // User1 and user2 have a favourite course.
+        $ufservice1 = \core_favourites\service_factory::get_service_for_user_context($user1ctx);
+        $ufservice1->create_favourite('core_course', 'courses', $this->coursecontext->instanceid,
+                $this->coursecontext);
+        $ufservice2 = \core_favourites\service_factory::get_service_for_user_context($user2ctx);
+        $ufservice2->create_favourite('core_course', 'courses', $this->coursecontext->instanceid,
+                $this->coursecontext);
+
+        // Ensure only users that have course favourites are returned in the course context (user1 and user2).
+        $userlist = new \core_privacy\local\request\userlist($this->coursecontext, 'core_course');
+        \core_course\privacy\provider::get_users_in_context($userlist);
+        $actual = $userlist->get_userids();
+        $this->assertCount(2, $actual);
+
+        // Ensure the users does not have a course completion data.
+        $records = $DB->get_records('course_modules_completion');
+        $this->assertCount(0, $records);
+        $records = $DB->get_records('course_completion_crit_compl');
+        $this->assertCount(0, $records);
+
+        // Create course completions for user1 and users.
         $this->complete_course($user1);
         $this->complete_course($user2);
         $records = $DB->get_records('course_modules_completion');
         $this->assertCount(2, $records);
         $records = $DB->get_records('course_completion_crit_compl');
         $this->assertCount(2, $records);
+
+        // Delete data for all users in a context different than the course context (system context).
+        \core_course\privacy\provider::delete_data_for_all_users_in_context($systemcontext);
+
+        // Ensure the data in the course context has not been deleted.
+        $userlist = new \core_privacy\local\request\userlist($this->coursecontext, 'core_course');
+        \core_course\privacy\provider::get_users_in_context($userlist);
+        $actual = $userlist->get_userids();
+        $this->assertCount(2, $actual);
+
+        // Delete data for all users in the course context.
         \core_course\privacy\provider::delete_data_for_all_users_in_context($this->coursecontext);
+
+        // Ensure the completion data has been removed in the course context.
         $records = $DB->get_records('course_modules_completion');
         $this->assertCount(0, $records);
         $records = $DB->get_records('course_completion_crit_compl');
         $this->assertCount(0, $records);
+
+        // Ensure that users are not returned after the deletion in the course context.
+        $userlist = new \core_privacy\local\request\userlist($this->coursecontext, 'core_course');
+        \core_course\privacy\provider::get_users_in_context($userlist);
+        $actual = $userlist->get_userids();
+        $this->assertCount(0, $actual);
     }
 
     /**
      * Test deleting data for only one user.
      */
     public function test_delete_data_for_user() {
-        global $DB;
         $this->resetAfterTest();
+
         $user1 = $this->getDataGenerator()->create_user();
         $user2 = $this->getDataGenerator()->create_user();
+        $user3 = $this->getDataGenerator()->create_user();
+
+        // Create course completion for user1.
         $this->create_course_completion();
         $this->complete_course($user1);
-        $this->complete_course($user2);
-        $records = $DB->get_records('course_modules_completion');
-        $this->assertCount(2, $records);
-        $records = $DB->get_records('course_completion_crit_compl');
-        $this->assertCount(2, $records);
+
+        // Ensure user1 is returned in the course context.
+        $userlist = new \core_privacy\local\request\userlist($this->coursecontext, 'core_course');
+        \core_course\privacy\provider::get_users_in_context($userlist);
+        $actual = $userlist->get_userids();
+        $expected = [$user1->id];
+        $this->assertCount(1, $actual);
+        $this->assertEquals($expected, $actual);
+
+        // User2 and user3 have a favourite course.
+        $systemcontext = \context_system::instance();
+        $user2ctx = \context_user::instance($user2->id);
+        $user3ctx = \context_user::instance($user3->id);
+        $ufservice2 = \core_favourites\service_factory::get_service_for_user_context($user2ctx);
+        $ufservice2->create_favourite('core_course', 'courses', $this->coursecontext->instanceid,
+                $this->coursecontext);
+        $ufservice3 = \core_favourites\service_factory::get_service_for_user_context($user3ctx);
+        $ufservice3->create_favourite('core_course', 'courses', $this->coursecontext->instanceid,
+                $this->coursecontext);
+
+        // Ensure user1, user2 and user3 are returned in the course context.
+        $userlist = new \core_privacy\local\request\userlist($this->coursecontext, 'core_course');
+        \core_course\privacy\provider::get_users_in_context($userlist);
+        $actual = $userlist->get_userids();
+        $expected = [
+            $user1->id,
+            $user2->id,
+            $user3->id
+        ];
+        sort($expected);
+        sort($actual);
+        $this->assertCount(3, $actual);
+        $this->assertEquals($expected, $actual);
+
+        // Delete user1's data in the course context.
         $approvedlist = new \core_privacy\local\request\approved_contextlist($user1, 'core_course',
                 [$this->coursecontext->id]);
         \core_course\privacy\provider::delete_data_for_user($approvedlist);
-        $records = $DB->get_records('course_modules_completion');
-        $this->assertCount(1, $records);
-        $records = $DB->get_records('course_completion_crit_compl');
-        $this->assertCount(1, $records);
+
+        // Ensure user1's data is deleted and only user2 and user3 are returned in the course context.
+        $userlist = new \core_privacy\local\request\userlist($this->coursecontext, 'core_course');
+        \core_course\privacy\provider::get_users_in_context($userlist);
+        $actual = $userlist->get_userids();
+        $expected = [
+            $user2->id,
+            $user3->id
+        ];
+        sort($expected);
+        sort($actual);
+        $this->assertEquals($expected, $actual);
+
+        // Delete user2's data in a context different than the course context (system context).
+        $approvedlist = new \core_privacy\local\request\approved_contextlist($user2, 'core_course',
+                [$systemcontext->id]);
+        \core_course\privacy\provider::delete_data_for_user($approvedlist);
+
+        // Ensure user2 and user3 are still returned in the course context.
+        $userlist = new \core_privacy\local\request\userlist($this->coursecontext, 'core_course');
+        \core_course\privacy\provider::get_users_in_context($userlist);
+        $actual = $userlist->get_userids();
+        $expected = [
+            $user2->id,
+            $user3->id
+        ];
+        sort($expected);
+        sort($actual);
+        $this->assertEquals($expected, $actual);
+
+        // Delete user2's data in the course context.
+        $approvedlist = new \core_privacy\local\request\approved_contextlist($user2, 'core_course',
+                [$this->coursecontext->id]);
+        \core_course\privacy\provider::delete_data_for_user($approvedlist);
+
+        // Ensure user2's is deleted and user3 is still returned in the course context.
+        $userlist = new \core_privacy\local\request\userlist($this->coursecontext, 'core_course');
+        \core_course\privacy\provider::get_users_in_context($userlist);
+        $actual = $userlist->get_userids();
+        $expected = [
+            $user3->id
+        ];
+        $this->assertEquals($expected, $actual);
     }
 
     /**
      * Test deleting data within a context for an approved userlist.
      */
     public function test_delete_data_for_users() {
-        global $DB;
         $this->resetAfterTest();
 
         $component = 'core_course';
@@ -221,26 +409,67 @@ class core_course_privacy_testcase extends \core_privacy\tests\provider_testcase
         $this->create_course_completion();
         $this->complete_course($user1);
         $this->complete_course($user2);
-        $this->complete_course($user3);
 
-        // Ensure records exist for all users before delete.
-        $records = $DB->get_records('course_modules_completion');
-        $this->assertCount(3, $records);
-        $records = $DB->get_records('course_completion_crit_compl');
-        $this->assertCount(3, $records);
+        // Ensure user1, user2 are returned in the course context.
+        $userlist = new \core_privacy\local\request\userlist($this->coursecontext, 'core_course');
+        \core_course\privacy\provider::get_users_in_context($userlist);
+        $actual = $userlist->get_userids();
+        $expected = [
+            $user1->id,
+            $user2->id
+        ];
+        sort($expected);
+        sort($actual);
+        $this->assertCount(2, $actual);
+        $this->assertEquals($expected, $actual);
+
+        $systemcontext = \context_system::instance();
+        // User3 has a favourite course.
+        $user3ctx = \context_user::instance($user3->id);
+        $ufservice = \core_favourites\service_factory::get_service_for_user_context($user3ctx);
+        $ufservice->create_favourite('core_course', 'courses', $this->coursecontext->instanceid,
+                $this->coursecontext);
+
+        // Ensure user1, user2 and user3 are now returned in the course context.
+        $userlist = new \core_privacy\local\request\userlist($this->coursecontext, 'core_course');
+        \core_course\privacy\provider::get_users_in_context($userlist);
+        $actual = $userlist->get_userids();
+        $expected = [
+            $user1->id,
+            $user2->id,
+            $user3->id
+        ];
+        sort($expected);
+        sort($actual);
+        $this->assertCount(3, $actual);
+        $this->assertEquals($expected, $actual);
 
+        // Delete data for user1 and user3 in the course context.
         $approveduserids = [$user1->id, $user3->id];
         $approvedlist = new \core_privacy\local\request\approved_userlist($this->coursecontext, $component, $approveduserids);
         \core_course\privacy\provider::delete_data_for_users($approvedlist);
 
-        // Ensure content is only deleted for approved userlist.
-        $records = $DB->get_records('course_modules_completion');
-        $this->assertCount(1, $records);
-        $record = reset($records);
-        $this->assertEquals($user2->id, $record->userid);
-        $records = $DB->get_records('course_completion_crit_compl');
-        $this->assertCount(1, $records);
-        $record = reset($records);
-        $this->assertEquals($user2->id, $record->userid);
+        // Ensure user1 and user3 are deleted and user2 is still returned in the course context.
+        $userlist = new \core_privacy\local\request\userlist($this->coursecontext, 'core_course');
+        \core_course\privacy\provider::get_users_in_context($userlist);
+        $actual = $userlist->get_userids();
+        $expected = [$user2->id];
+        $this->assertCount(1, $actual);
+        $this->assertEquals($expected, $actual);
+
+        // Try to delete user2's data in a context different than course (system context).
+        $approveduserids = [$user2->id];
+        $approvedlist = new \core_privacy\local\request\approved_userlist($systemcontext, $component, $approveduserids);
+        \core_course\privacy\provider::delete_data_for_users($approvedlist);
+
+        // Ensure user2 is still returned in the course context.
+        $userlist = new \core_privacy\local\request\userlist($this->coursecontext, 'core_course');
+        \core_course\privacy\provider::get_users_in_context($userlist);
+        $actual = $userlist->get_userids();
+        $expected = [
+            $user2->id
+        ];
+        $this->assertCount(1, $actual);
+        $this->assertEquals($expected, $actual);
     }
 }
index 16718b8..a63692a 100644 (file)
@@ -156,4 +156,49 @@ class user_favourite_service {
             ]
         );
     }
+
+    /**
+     * Get the favourite.
+     *
+     * @param string $component the frankenstyle component name.
+     * @param string $itemtype the type of the favourited item.
+     * @param int $itemid the id of the item which was favourited (not the favourite's id).
+     * @param \context $context the context of the item which was favourited.
+     * @return favourite|null
+     */
+    public function get_favourite(string $component, string $itemtype, int $itemid, \context $context) {
+        try {
+            return $this->repo->find_favourite(
+                $this->userid,
+                $component,
+                $itemtype,
+                $itemid,
+                $context->id
+            );
+        } catch (\dml_missing_record_exception $e) {
+            return null;
+        }
+    }
+
+    /**
+     * Count the favourite by item type.
+     *
+     * @param string $component the frankenstyle component name.
+     * @param string $itemtype the type of the favourited item.
+     * @param \context|null $context the context of the item which was favourited.
+     * @return favourite|null
+     */
+    public function count_favourites_by_type(string $component, string $itemtype, \context $context = null) {
+        $criteria = [
+            'userid' => $this->userid,
+            'component' => $component,
+            'itemtype' => $itemtype
+        ];
+
+        if ($context) {
+            $criteria['contextid'] = $context->id;
+        }
+
+        return $this->repo->count_by($criteria);
+    }
 }
index 0d94739..44a076f 100644 (file)
@@ -29,6 +29,7 @@ defined('MOODLE_INTERNAL') || die();
 use \core_privacy\local\metadata\collection;
 use \core_privacy\local\request\context;
 use \core_privacy\local\request\approved_contextlist;
+use \core_privacy\local\request\transform;
 
 /**
  * Privacy class for requesting user data.
@@ -36,7 +37,10 @@ use \core_privacy\local\request\approved_contextlist;
  * @copyright  2018 Jake Dallimore <jrhdallimore@gmail.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class provider implements \core_privacy\local\metadata\provider, \core_privacy\local\request\subsystem\plugin_provider {
+class provider implements
+        \core_privacy\local\metadata\provider,
+        \core_privacy\local\request\subsystem\plugin_provider,
+        \core_privacy\local\request\shared_userlist_provider {
 
     /**
      * Returns metadata about this system.
@@ -86,13 +90,82 @@ class provider implements \core_privacy\local\metadata\provider, \core_privacy\l
                   FROM {favourite} f
                  WHERE userid = :userid
                    AND component = :component";
+
+        $params = ['userid' => $userid, 'component' => $component];
+
         if (!is_null($itemtype)) {
             $sql .= " AND itemtype = :itemtype";
+            $params['itemtype'] = $itemtype;
         }
-        $params = ['userid' => $userid, 'component' => $component, 'itemtype' => $itemtype];
+
         $contextlist->add_from_sql($sql, $params);
     }
 
+    /**
+     * Add users to a userlist who have favourites within the specified context.
+     *
+     * @param \core_privacy\local\request\userlist $userlist The userlist to add the users to.
+     * @param string $itemtype the type of the favourited items.
+     * @return void
+     */
+    public static function add_userids_for_context(\core_privacy\local\request\userlist $userlist,
+                                                   string $itemtype = null) {
+        if (empty($userlist)) {
+            return;
+        }
+
+        $params = [
+            'contextid' => $userlist->get_context()->id,
+            'component' => $userlist->get_component()
+        ];
+
+        $sql = "SELECT userid
+                  FROM {favourite}
+                 WHERE contextid = :contextid
+                       AND component = :component";
+
+        if (!is_null($itemtype)) {
+            $sql .= " AND itemtype = :itemtype";
+            $params['itemtype'] = $itemtype;
+        }
+
+        $userlist->add_from_sql('userid', $sql, $params);
+    }
+
+    /**
+     * Get favourites data for the specified user in the specified component, item type and item ID.
+     *
+     * @param int $userid The id of the user in scope.
+     * @param \context $context The context to which data is scoped.
+     * @param string $component The favourite's component name.
+     * @param string $itemtype The favourite's item type.
+     * @param int $itemid The favourite's item ID.
+     * @return array|null
+     */
+    public static function get_favourites_info_for_user(int $userid, \context $context,
+                                                        string $component, string $itemtype, int $itemid) {
+        global $DB;
+
+        $params = [
+            'userid' => $userid,
+            'component' => $component,
+            'itemtype' => $itemtype,
+            'itemid' => $itemid,
+            'contextid' => $context->id
+        ];
+
+        if (!$favourited = $DB->get_record('favourite', $params)) {
+            return;
+        }
+
+        return [
+            'starred' => transform::yesno(true),
+            'ordering' => $favourited->ordering,
+            'timecreated' => transform::datetime($favourited->timecreated),
+            'timemodified' => transform::datetime($favourited->timemodified)
+        ];
+    }
+
     /**
      * Delete all favourites for all users in the specified contexts, and component area.
      *
@@ -114,6 +187,39 @@ class provider implements \core_privacy\local\metadata\provider, \core_privacy\l
         $DB->delete_records_select('favourite', $select, $params);
     }
 
+    /**
+     * Delete all favourites for the specified users in the specified context, component area and item type.
+     *
+     * @param \core_privacy\local\request\approved_userlist $userlist The approved contexts and user information
+     * to delete information for.
+     * @param string $itemtype The favourite's itemtype.
+     * @throws \dml_exception if any errors are encountered during deletion.
+     */
+    public static function delete_favourites_for_userlist(\core_privacy\local\request\approved_userlist $userlist,
+                                                          string $itemtype) {
+        global $DB;
+
+        $userids = $userlist->get_userids();
+
+        if (empty($userids)) {
+            return;
+        }
+
+        $context = $userlist->get_context();
+        list($usersql, $userparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
+
+        $params = [
+            'component' => $userlist->get_component(),
+            'itemtype' => $itemtype,
+            'contextid' => $context->id
+        ];
+
+        $params += $userparams;
+        $select = "component = :component AND itemtype = :itemtype AND contextid = :contextid AND userid $usersql";
+
+        $DB->delete_records_select('favourite', $select, $params);
+    }
+
     /**
      * Delete all favourites for the specified user, in the specified contexts.
      *
index 409282b..7507689 100644 (file)
@@ -27,6 +27,7 @@ defined('MOODLE_INTERNAL') || die();
 
 use \core_privacy\tests\provider_testcase;
 use \core_favourites\privacy\provider;
+use \core_privacy\local\request\transform;
 
 /**
  * Unit tests for favourites/classes/privacy/provider
@@ -65,25 +66,25 @@ class privacy_test extends provider_testcase {
         $ufservice1 = \core_favourites\service_factory::get_service_for_user_context($user1context);
         $ufservice2 = \core_favourites\service_factory::get_service_for_user_context($user2context);
         $systemcontext = context_system::instance();
-        $ufservice1->create_favourite('core_course', 'course', $course1context->instanceid, $systemcontext);
-        $ufservice1->create_favourite('core_course', 'course', $course2context->instanceid, $systemcontext);
-        $ufservice2->create_favourite('core_course', 'course', $course2context->instanceid, $systemcontext);
-        $this->assertCount(2, $ufservice1->find_favourites_by_type('core_course', 'course'));
-        $this->assertCount(1, $ufservice2->find_favourites_by_type('core_course', 'course'));
+        $ufservice1->create_favourite('core_course', 'courses', $course1context->instanceid, $systemcontext);
+        $ufservice1->create_favourite('core_course', 'courses', $course2context->instanceid, $systemcontext);
+        $ufservice2->create_favourite('core_course', 'courses', $course2context->instanceid, $systemcontext);
+        $this->assertCount(2, $ufservice1->find_favourites_by_type('core_course', 'courses'));
+        $this->assertCount(1, $ufservice2->find_favourites_by_type('core_course', 'courses'));
 
         // Now, just for variety, let's assume you can favourite a course at user context, and do so for user1.
-        $ufservice1->create_favourite('core_course', 'course', $course1context->instanceid, $user1context);
+        $ufservice1->create_favourite('core_course', 'courses', $course1context->instanceid, $user1context);
 
         // Now, ask the favourites privacy api to export contexts for favourites of the type we just created, for user1.
         $contextlist = new \core_privacy\local\request\contextlist();
-        \core_favourites\privacy\provider::add_contexts_for_userid($contextlist, $user1->id, 'core_course', 'course');
+        \core_favourites\privacy\provider::add_contexts_for_userid($contextlist, $user1->id, 'core_course', 'courses');
 
         // Verify we have two contexts in the list for user1.
         $this->assertCount(2, $contextlist->get_contextids());
 
         // And verify we only have the system context returned for user2.
         $contextlist = new \core_privacy\local\request\contextlist();
-        \core_favourites\privacy\provider::add_contexts_for_userid($contextlist, $user2->id, 'core_course', 'course');
+        \core_favourites\privacy\provider::add_contexts_for_userid($contextlist, $user2->id, 'core_course', 'courses');
         $this->assertCount(1, $contextlist->get_contextids());
     }
 
@@ -96,19 +97,19 @@ class privacy_test extends provider_testcase {
         // Favourite 2 courses for user1 and 1 course for user2, all at the user context.
         $ufservice1 = \core_favourites\service_factory::get_service_for_user_context($user1context);
         $ufservice2 = \core_favourites\service_factory::get_service_for_user_context($user2context);
-        $ufservice1->create_favourite('core_course', 'course', $course1context->instanceid, $user1context);
-        $ufservice1->create_favourite('core_course', 'course', $course2context->instanceid, $user1context);
-        $ufservice2->create_favourite('core_course', 'course', $course2context->instanceid, $user2context);
-        $this->assertCount(2, $ufservice1->find_favourites_by_type('core_course', 'course'));
-        $this->assertCount(1, $ufservice2->find_favourites_by_type('core_course', 'course'));
+        $ufservice1->create_favourite('core_course', 'courses', $course1context->instanceid, $user1context);
+        $ufservice1->create_favourite('core_course', 'courses', $course2context->instanceid, $user1context);
+        $ufservice2->create_favourite('core_course', 'courses', $course2context->instanceid, $user2context);
+        $this->assertCount(2, $ufservice1->find_favourites_by_type('core_course', 'courses'));
+        $this->assertCount(1, $ufservice2->find_favourites_by_type('core_course', 'courses'));
 
         // Now, delete the favourites for user1 only.
         $approvedcontextlist = new \core_privacy\local\request\approved_contextlist($user1, 'core_course', [$user1context->id]);
-        provider::delete_favourites_for_user($approvedcontextlist, 'core_course', 'course');
+        provider::delete_favourites_for_user($approvedcontextlist, 'core_course', 'courses');
 
         // Verify that we have no favourite courses for user1 but that the records are in tact for user2.
-        $this->assertCount(0, $ufservice1->find_favourites_by_type('core_course', 'course'));
-        $this->assertCount(1, $ufservice2->find_favourites_by_type('core_course', 'course'));
+        $this->assertCount(0, $ufservice1->find_favourites_by_type('core_course', 'courses'));
+        $this->assertCount(1, $ufservice2->find_favourites_by_type('core_course', 'courses'));
     }
 
     public function test_delete_favourites_for_all_users() {
@@ -134,4 +135,170 @@ class privacy_test extends provider_testcase {
         $this->assertCount(0, $ufservice1->find_favourites_by_type('core_course', 'modules'));
         $this->assertCount(1, $ufservice2->find_favourites_by_type('core_course', 'modules'));
     }
+
+    /**
+     * Test confirming that user ID's of favourited items can be added to the userlist.
+     */
+    public function test_add_userids_for_context() {
+        list($user1, $user2, $user1context, $user2context, $course1context, $course2context) = $this->set_up_courses_and_users();
+
+        // Favourite 2 courses for user1 and 1 course for user2, all at the site context.
+        $ufservice1 = \core_favourites\service_factory::get_service_for_user_context($user1context);
+        $ufservice2 = \core_favourites\service_factory::get_service_for_user_context($user2context);
+        $systemcontext = context_system::instance();
+        $ufservice1->create_favourite('core_course', 'courses', $course1context->instanceid, $systemcontext);
+        $ufservice1->create_favourite('core_course', 'courses', $course2context->instanceid, $systemcontext);
+        $ufservice2->create_favourite('core_course', 'courses', $course2context->instanceid, $systemcontext);
+        $this->assertCount(2, $ufservice1->find_favourites_by_type('core_course', 'courses'));
+        $this->assertCount(1, $ufservice2->find_favourites_by_type('core_course', 'courses'));
+
+        // Now, just for variety, let's assume you can favourite a course at user context, and do so for user1.
+        $ufservice1->create_favourite('core_course', 'courses', $course1context->instanceid, $user1context);
+
+        // Now, ask the favourites privacy api to export userids for favourites of the type we just created, in the system context.
+        $userlist = new \core_privacy\local\request\userlist($systemcontext, 'core_course');
+        provider::add_userids_for_context($userlist, 'courses');
+        // Verify we have two userids in the list for system context.
+        $this->assertCount(2, $userlist->get_userids());
+        $expected = [
+            $user1->id,
+            $user2->id
+        ];
+        $this->assertEquals($expected, $userlist->get_userids(), '', 0.0, 10, true);
+
+        // Ask the favourites privacy api to export userids for favourites of the type we just created, in the user1 context.
+        $userlist = new \core_privacy\local\request\userlist($user1context, 'core_course');
+        provider::add_userids_for_context($userlist, 'courses');
+        // Verify we have one userid in the list for user1 context.
+        $this->assertCount(1, $userlist->get_userids());
+        $expected = [$user1->id];
+        $this->assertEquals($expected, $userlist->get_userids());
+
+        // Ask the favourites privacy api to export userids for favourites of the type we just created, in the user2 context.
+        $userlist = new \core_privacy\local\request\userlist($user2context, 'core_favourites');
+        provider::add_userids_for_context($userlist, 'core_course', 'courses');
+        // Verify we do not have any userids in the list for user2 context.
+        $this->assertCount(0, $userlist->get_userids());
+    }
+
+    /**
+     * Test deletion of user favourites based on an approved_userlist, component area and item type.
+     */
+    public function test_delete_favourites_for_userlist() {
+        list($user1, $user2, $user1context, $user2context, $course1context, $course2context) = $this->set_up_courses_and_users();
+
+        // Favourite 2 courses for user1 and 1 course for user2.
+        $systemcontext = context_system::instance();
+        $ufservice1 = \core_favourites\service_factory::get_service_for_user_context($user1context);
+        $ufservice2 = \core_favourites\service_factory::get_service_for_user_context($user2context);
+        $ufservice1->create_favourite('core_course', 'courses', $course1context->instanceid, $systemcontext);
+        $ufservice1->create_favourite('core_course', 'courses', $course2context->instanceid, $user1context);
+        $ufservice2->create_favourite('core_course', 'courses', $course2context->instanceid, $systemcontext);
+        $this->assertCount(2, $ufservice1->find_favourites_by_type('core_course', 'courses'));
+        $this->assertCount(1, $ufservice2->find_favourites_by_type('core_course', 'courses'));
+
+        // Ask the favourites privacy api to export userids for favourites of the type we just created, in the system context.
+        $userlist1 = new \core_privacy\local\request\userlist($systemcontext, 'core_course');
+        provider::add_userids_for_context($userlist1, 'courses');
+        // Verify we have two userids in the list for system context.
+        $this->assertCount(2, $userlist1->get_userids());
+
+        // Ask the favourites privacy api to export userids for favourites of the type we just created, in the user1 context.
+        $userlist2 = new \core_privacy\local\request\userlist($user1context, 'core_course');
+        provider::add_userids_for_context($userlist2, 'courses');
+        // Verify we have one userid in the list for user1 context.
+        $this->assertCount(1, $userlist2->get_userids());
+
+        // Now, delete the favourites for user1 only in the system context.
+        $approveduserlist = new \core_privacy\local\request\approved_userlist($systemcontext, 'core_course',
+                [$user1->id]);
+        provider::delete_favourites_for_userlist($approveduserlist, 'courses');
+        // Ensure user1's data was deleted and user2 is still returned for system context.
+        $userlist1 = new \core_privacy\local\request\userlist($systemcontext, 'core_course');
+        provider::add_userids_for_context($userlist1, 'courses');
+        $this->assertCount(1, $userlist1->get_userids());
+        // Verify that user2 is still in the list for system context.
+        $expected = [$user2->id];
+        $this->assertEquals($expected, $userlist1->get_userids());
+        // Verify that the data of user1 was not deleted in the user1context.
+        $userlist2 = new \core_privacy\local\request\userlist($user1context, 'core_course');
+        provider::add_userids_for_context($userlist2, 'courses');
+        $expected = [$user1->id];
+        $this->assertEquals($expected, $userlist2->get_userids());
+
+        // Now, delete the favourites for user2 only in the user1 context.
+        // Make sure favourites are only being deleted in the right context.
+        $approveduserlist = new \core_privacy\local\request\approved_userlist($user1context, 'core_course',
+                [$user2->id]);
+        provider::delete_favourites_for_userlist($approveduserlist, 'courses');
+        // Verify we have one userid in the list for system context.
+        $userlist2 = new \core_privacy\local\request\userlist($systemcontext, 'core_course');
+        provider::add_userids_for_context($userlist2, 'courses');
+        $this->assertCount(1, $userlist2->get_userids());
+        // Verify that user2 is still in the list for system context.
+        $expected = [$user2->id];
+        $this->assertEquals($expected, $userlist2->get_userids());
+
+        // Verify that user1 is still present in the list for user1 context.
+        $userlist3 = new \core_privacy\local\request\userlist($user1context, 'core_course');
+        provider::add_userids_for_context($userlist3, 'courses');
+        $this->assertCount(1, $userlist3->get_userids());
+        // Verify that user1 is still in the list for user1 context.
+        $expected = [$user1->id];
+        $this->assertEquals($expected, $userlist3->get_userids());
+    }
+
+    /**
+     * Test fetching the favourites data for a specified user in a specified component, item type and item ID.
+     */
+    public function test_get_favourites_info_for_user() {
+        list($user1, $user2, $user1context, $user2context, $course1context, $course2context) = $this->set_up_courses_and_users();
+
+        // Favourite 2 courses for user1 and 1 course for user2.
+        $ufservice1 = \core_favourites\service_factory::get_service_for_user_context($user1context);
+        $ufservice2 = \core_favourites\service_factory::get_service_for_user_context($user2context);
+        $coursefavourite1 = $ufservice1->create_favourite('core_course', 'courses',
+                $course1context->instanceid, $course1context);
+        $this->waitForSecond();
+        $coursefavourite2 = $ufservice1->create_favourite('core_course', 'courses',
+                $course2context->instanceid, $course2context);
+        $this->waitForSecond();
+        $coursefavourite3 = $ufservice2->create_favourite('core_course', 'courses',
+                $course2context->instanceid, $course2context);
+        $this->assertCount(2, $ufservice1->find_favourites_by_type('core_course', 'courses'));
+        $this->assertCount(1, $ufservice2->find_favourites_by_type('core_course', 'courses'));
+
+        // Get the favourites info for user1 in the course1 context.
+        $favouriteinfo1 = (object) provider::get_favourites_info_for_user($user1->id, $course1context,
+                'core_course', 'courses', $course1context->instanceid);
+        // Ensure the correct data has been returned.
+        $this->assertEquals(transform::yesno(true), $favouriteinfo1->starred);
+        $this->assertEquals('', $favouriteinfo1->ordering);
+        $this->assertEquals(transform::datetime($coursefavourite1->timecreated), $favouriteinfo1->timecreated);
+        $this->assertEquals(transform::datetime($coursefavourite1->timemodified), $favouriteinfo1->timemodified);
+
+        // Get the favourites info for user1 in the course2 context.
+        $favouriteinfo2 = (object) provider::get_favourites_info_for_user($user1->id, $course2context,
+                'core_course', 'courses', $course2context->instanceid);
+        // Ensure the correct data has been returned.
+        $this->assertEquals(transform::yesno(true), $favouriteinfo2->starred);
+        $this->assertEquals('', $favouriteinfo2->ordering);
+        $this->assertEquals(transform::datetime($coursefavourite2->timecreated), $favouriteinfo2->timecreated);
+        $this->assertEquals(transform::datetime($coursefavourite2->timemodified), $favouriteinfo2->timemodified);
+
+        // Get the favourites info for user2 in the course2 context.
+        $favouriteinfo3 = (object) provider::get_favourites_info_for_user($user2->id, $course2context,
+                'core_course', 'courses', $course2context->instanceid);
+        // Ensure the correct data has been returned.
+        $this->assertEquals(transform::yesno(true), $favouriteinfo3->starred);
+        $this->assertEquals('', $favouriteinfo3->ordering);
+        $this->assertEquals(transform::datetime($coursefavourite3->timecreated), $favouriteinfo3->timecreated);
+        $this->assertEquals(transform::datetime($coursefavourite3->timemodified), $favouriteinfo3->timemodified);
+
+        // Get the favourites info for user2 in the course1 context (user2 has not favourited course1).
+        $favouriteinfo4 = provider::get_favourites_info_for_user($user2->id, $course1context,
+                'core_course', 'courses', $course1context->instanceid);
+        // Ensure that data has not been returned.
+        $this->assertEmpty($favouriteinfo4);
+    }
 }
index 939e544..5ead879 100644 (file)
@@ -111,7 +111,7 @@ class user_favourite_service_testcase extends advanced_testcase {
                         return $fakerow;
                     }
                 }
-                throw new \moodle_exception("Item not found");
+                throw new \dml_missing_record_exception("Item not found");
             })
         );
         $mockrepo->expects($this->any())
@@ -127,16 +127,17 @@ class user_favourite_service_testcase extends advanced_testcase {
             })
         );
         $mockrepo->expects($this->any())
-            ->method('exists_by')
+            ->method('count_by')
             ->will($this->returnCallback(function(array $criteria) use (&$mockstore) {
+                $count = 0;
                 // Check the mockstore for all objects with properties matching the key => val pairs in $criteria.
                 foreach ($mockstore as $index => $mockrow) {
                     $mockrowarr = (array)$mockrow;
                     if (array_diff($criteria, $mockrowarr) == []) {
-                        return true;
+                        $count++;
                     }
                 }
-                return false;
+                return $count;
             })
         );
         $mockrepo->expects($this->any())
@@ -149,6 +150,19 @@ class user_favourite_service_testcase extends advanced_testcase {
                 }
             })
         );
+        $mockrepo->expects($this->any())
+            ->method('exists_by')
+            ->will($this->returnCallback(function(array $criteria) use (&$mockstore) {
+                // Check the mockstore for all objects with properties matching the key => val pairs in $criteria.
+                foreach ($mockstore as $index => $mockrow) {
+                    $mockrowarr = (array)$mockrow;
+                    if (array_diff($criteria, $mockrowarr) == []) {
+                        return true;
+                    }
+                }
+                return false;
+            })
+        );
         return $mockrepo;
     }
 
@@ -352,4 +366,67 @@ class user_favourite_service_testcase extends advanced_testcase {
             )
         );
     }
+
+    /**
+     * Test confirming the behaviour of the get_favourite() method.
+     */
+    public function test_get_favourite() {
+        list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
+
+        // Get a user_favourite_service for the user.
+        $repo = $this->get_mock_repository([]);
+        $service = new \core_favourites\local\service\user_favourite_service($user1context, $repo);
+
+        // Favourite a course.
+        $fav1 = $service->create_favourite('core_course', 'course', $course1context->instanceid, $course1context);
+
+        $result = $service->get_favourite(
+            'core_course',
+            'course',
+            $course1context->instanceid,
+            $course1context
+        );
+        // Verify we can get the favourite.
+        $this->assertEquals($fav1->id, $result->id);
+
+        // And one that we know doesn't exist.
+        $this->assertNull(
+            $service->get_favourite(
+                'core_course',
+                'someothertype',
+                $course1context->instanceid,
+                $course1context
+            )
+        );
+    }
+
+    /**
+     * Test confirming the behaviour of the count_favourites_by_type() method.
+     */
+    public function test_count_favourites_by_type() {
+        list($user1context, $user2context, $course1context, $course2context) = $this->setup_users_and_courses();
+
+        // Get a user_favourite_service for the user.
+        $repo = $this->get_mock_repository([]);
+        $service = new \core_favourites\local\service\user_favourite_service($user1context, $repo);
+
+        $this->assertEquals(0, $service->count_favourites_by_type('core_course', 'course', $course1context));
+        // Favourite a course.
+        $service->create_favourite('core_course', 'course', $course1context->instanceid, $course1context);
+
+        $this->assertEquals(1, $service->count_favourites_by_type('core_course', 'course', $course1context));
+
+        // Favourite another course.
+        $service->create_favourite('core_course', 'course', $course2context->instanceid, $course1context);
+
+        $this->assertEquals(2, $service->count_favourites_by_type('core_course', 'course', $course1context));
+
+        // Favourite a course in another context.
+        $service->create_favourite('core_course', 'course', $course2context->instanceid, $course2context);
+
+        // Doesn't affect original context.
+        $this->assertEquals(2, $service->count_favourites_by_type('core_course', 'course', $course1context));
+        // Gets counted if we include all contexts.
+        $this->assertEquals(3, $service->count_favourites_by_type('core_course', 'course'));
+    }
 }
diff --git a/filter/activitynames/tests/filter_test.php b/filter/activitynames/tests/filter_test.php
new file mode 100644 (file)
index 0000000..403170e
--- /dev/null
@@ -0,0 +1,77 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Unit tests.
+ *
+ * @package filter_activitynames
+ * @category test
+ * @copyright 2018 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/filter/activitynames/filter.php'); // Include the code to test.
+
+/**
+ * Test case for the activity names auto-linking filter.
+ *
+ * @copyright 2018 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class filter_activitynames_filter_testcase extends advanced_testcase {
+
+    public function test_links() {
+        global $CFG;
+        $this->resetAfterTest(true);
+
+        // Create a test course.
+        $course = $this->getDataGenerator()->create_course();
+        $context = context_course::instance($course->id);
+
+        // Create two pages that will be linked to.
+        $page1 = $this->getDataGenerator()->create_module('page',
+                ['course' => $course->id, 'name' => 'Test 1']);
+        $page2 = $this->getDataGenerator()->create_module('page',
+                ['course' => $course->id, 'name' => 'Test (2)']);
+
+        // Format text with all three entries in HTML.
+        $html = '<p>Please read the two pages Test 1 and <i>Test (2)</i>.</p>';
+        $filtered = format_text($html, FORMAT_HTML, array('context' => $context));
+
+        // Find all the glossary links in the result.
+        $matches = [];
+        preg_match_all('~<a class="autolink" title="([^"]*)" href="[^"]*/mod/page/view.php\?id=([0-9]+)">([^<]*)</a>~',
+                $filtered, $matches);
+
+        // There should be 3 links links.
+        $this->assertEquals(2, count($matches[1]));
+
+        // Check text of title attribute.
+        $this->assertEquals($page1->name, $matches[1][0]);
+        $this->assertEquals($page2->name, $matches[1][1]);
+
+        // Check the ids in the links.
+        $this->assertEquals($page1->cmid, $matches[2][0]);
+        $this->assertEquals($page2->cmid, $matches[2][1]);
+
+        // Check the link text.
+        $this->assertEquals($page1->name, $matches[3][0]);
+        $this->assertEquals($page2->name, $matches[3][1]);
+    }
+}
index f22822c..a5441bb 100644 (file)
@@ -192,6 +192,52 @@ class filter_glossary_filter_testcase extends advanced_testcase {
         $this->assertEquals($glossary->name . ': normal', $matches[2][2]);
     }
 
+    /**
+     * Test brackets.
+     */
+    public function test_brackets() {
+        global $CFG;
+        $this->resetAfterTest(true);
+
+        // Enable glossary filter at top level.
+        filter_set_global_state('glossary', TEXTFILTER_ON);
+        $CFG->glossary_linkentries = 1;
+
+        // Create a test course.
+        $course = $this->getDataGenerator()->create_course();
+        $context = context_course::instance($course->id);
+
+        // Create a glossary.
+        $glossary = $this->getDataGenerator()->create_module('glossary',
+                array('course' => $course->id, 'mainglossary' => 1));
+
+        // Create two entries with ampersands and one normal entry.
+        /** @var mod_glossary_generator $generator */
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_glossary');
+        $simple = $generator->create_content($glossary, array('concept' => 'simple'));
+        $withbrackets = $generator->create_content($glossary, array('concept' => 'more complex (perhaps)'));
+        $test2 = $generator->create_content($glossary, array('concept' => 'Test (2)'));
+
+        // Format text with all three entries in HTML.
+        $html = '<p>Some thigns are simple. Others are more complex (perhaps). Test (2).</p>';
+        $filtered = format_text($html, FORMAT_HTML, array('context' => $context));
+
+        // Find all the glossary links in the result.
+        $matches = array();
+        preg_match_all('~eid=([0-9]+).*?title="(.*?)"~', $filtered, $matches);
+
+        // There should be 3 glossary links.
+        $this->assertEquals(3, count($matches[1]));
+        $this->assertEquals($simple->id, $matches[1][0]);
+        $this->assertEquals($withbrackets->id, $matches[1][1]);
+        $this->assertEquals($test2->id, $matches[1][2]);
+
+        // Check text and escaping of title attribute.
+        $this->assertEquals($glossary->name . ': simple', $matches[2][0]);
+        $this->assertEquals($glossary->name . ': more complex (perhaps)', $matches[2][1]);
+        $this->assertEquals($glossary->name . ': Test (2)', $matches[2][2]);
+    }
+
     public function test_exclude_excludes_link_to_entry_with_alias() {
         global $CFG, $GLOSSARY_EXCLUDEENTRY;
 
index b898b99..aa58497 100644 (file)
@@ -22,6 +22,8 @@
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
+define('NO_OUTPUT_BUFFERING', true); // The progress bar may be used here.
+
 require_once '../../../config.php';
 require_once $CFG->dirroot.'/grade/lib.php';
 require_once $CFG->dirroot.'/grade/report/lib.php'; // for preferences
index 6dabe7a..c05d31a 100644 (file)
@@ -282,10 +282,24 @@ abstract class grade_export {
     /**
      * Returns formatted grade feedback
      * @param object $feedback object with properties feedback and feedbackformat
+     * @param object $grade Grade object with grade properties
      * @return string
      */
-    public function format_feedback($feedback) {
-        return strip_tags(format_text($feedback->feedback, $feedback->feedbackformat));
+    public function format_feedback($feedback, $grade = null) {
+        $string = $feedback->feedback;
+        if (!empty($grade)) {
+            // Rewrite links to get the export working for 36, refer MDL-63488.
+            $string = file_rewrite_pluginfile_urls(
+                $feedback->feedback,
+                'pluginfile.php',
+                $grade->get_context()->id,
+                GRADE_FILE_COMPONENT,
+                GRADE_FEEDBACK_FILEAREA,
+                $grade->id
+            );
+        }
+
+        return strip_tags(format_text($string, $feedback->feedbackformat));
     }
 
     /**
index 5454456..40bbd90 100644 (file)
@@ -116,7 +116,7 @@ class grade_export_ods extends grade_export {
 
                 // writing feedback if requested
                 if ($this->export_feedback) {
-                    $myxls->write_string($i, $j++, $this->format_feedback($userdata->feedbacks[$itemid]));
+                    $myxls->write_string($i, $j++, $this->format_feedback($userdata->feedbacks[$itemid], $grade));
                 }
             }
             // Time exported.
index 7ec6aa4..e262d93 100644 (file)
@@ -109,7 +109,7 @@ class grade_export_txt extends grade_export {
                 }
 
                 if ($this->export_feedback) {
-                    $exportdata[] = $this->format_feedback($userdata->feedbacks[$itemid]);
+                    $exportdata[] = $this->format_feedback($userdata->feedbacks[$itemid], $grade);
                 }
             }
             // Time exported.
index 83e720d..f81b0e2 100644 (file)
@@ -110,7 +110,7 @@ class grade_export_xls extends grade_export {
                 }
                 // writing feedback if requested
                 if ($this->export_feedback) {
-                    $myxls->write_string($i, $j++, $this->format_feedback($userdata->feedbacks[$itemid]));
+                    $myxls->write_string($i, $j++, $this->format_feedback($userdata->feedbacks[$itemid], $grade));
                 }
             }
             // Time exported.
index c91122b..659fa6b 100644 (file)
@@ -101,7 +101,7 @@ class grade_export_xml extends grade_export {
                 }
 
                 if ($this->export_feedback) {
-                    $feedbackstr = $this->format_feedback($userdata->feedbacks[$itemid]);
+                    $feedbackstr = $this->format_feedback($userdata->feedbacks[$itemid], $grade);
                     fwrite($handle,  "\t\t<feedback>$feedbackstr</feedback>\n");
                 }
                 fwrite($handle,  "\t</result>\n");
diff --git a/grade/tests/export_test.php b/grade/tests/export_test.php
new file mode 100644 (file)
index 0000000..b2cc79a
--- /dev/null
@@ -0,0 +1,173 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Unit tests for grade/report/lib.php.
+ *
+ * @package  core_grades
+ * @category phpunit
+ * @copyright   Andrew Nicols <andrew@nicols.co.uk>
+ * @license  http://www.gnu.org/copyleft/gpl.html GNU Public License
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot.'/grade/lib.php');
+require_once($CFG->dirroot.'/grade/export/lib.php');
+
+/**
+ * A test class used to test grade_report, the abstract grade report parent class
+ */
+class core_grade_export_test extends advanced_testcase {
+
+    /**
+     * Ensure that feedback is correct formatted. Test the default implementation of format_feedback
+     *
+     * @dataProvider    format_feedback_provider
+     * @param   string  $input The input string to test
+     * @param   int     $inputformat The format of the input string
+     * @param   string  $expected The expected result of the format.
+     */
+    public function test_format_feedback($input, $inputformat, $expected) {
+        $feedback = $this->getMockForAbstractClass(
+                \grade_export::class,
+                [],
+                '',
+                false
+            );
+
+        $this->assertEquals(
+            $expected,
+            $feedback->format_feedback((object) [
+                    'feedback' => $input,
+                    'feedbackformat' => $inputformat,
+                ])
+            );
+    }
+
+    /**
+     * Ensure that feedback is correctly formatted. Test augmented functionality to handle file links
+     */
+    public function test_format_feedback_with_grade() {
+        $this->resetAfterTest();
+        $dg = $this->getDataGenerator();
+        $c1 = $dg->create_course();
+        $u1 = $dg->create_user();
+        $gi1a = new grade_item($dg->create_grade_item(['courseid' => $c1->id]), false);
+        $gi1a->update_final_grade($u1->id, 1, 'test');
+        $contextid = $gi1a->get_context()->id;
+        $gradeid = $gi1a->id;
+
+        $tests = [
+            'Has server based image (HTML)' => [
+                '<p>See this reference: <img src="@@PLUGINFILE@@/test.img"></p>',
+                FORMAT_HTML,
+                "See this reference: "
+            ],
+            'Has server based image and more (HTML)' => [
+                '<p>See <img src="@@PLUGINFILE@@/test.img"> for <em>reference</em></p>',
+                FORMAT_HTML,
+                "See  for reference"
+            ],
+            'Has server based video and more (HTML)' => [
+                '<p>See <video src="@@PLUGINFILE@@/test.img">video of a duck</video> for <em>reference</em></p>',
+                FORMAT_HTML,
+                'See video of a duck for reference'
+            ],
+            'Has server based video with text and more (HTML)' => [
+                '<p>See <video src="@@PLUGINFILE@@/test.img">@@PLUGINFILE@@/test.img</video> for <em>reference</em></p>',
+                FORMAT_HTML,
+                "See https://www.example.com/moodle/pluginfile.php/$contextid/grade/feedback/$gradeid/test.img for reference"
+            ],
+            'Multiple videos (HTML)' => [
+                '<p>See <video src="@@PLUGINFILE@@/test.img">video of a duck</video> and '.
+                '<video src="http://example.com/myimage.jpg">video of a cat</video> for <em>reference</em></p>',
+                FORMAT_HTML,
+                'See video of a duck and video of a cat for reference'
+            ],
+        ];
+
+        $feedback = $this->getMockForAbstractClass(
+            \grade_export::class,
+            [],
+            '',
+            false
+        );
+
+        foreach ($tests as $key => $testdetails) {
+            $expected = $testdetails[2];
+            $input = $testdetails[0];
+            $inputformat = $testdetails[1];
+
+            $this->assertEquals(
+                $expected,
+                $feedback->format_feedback((object) [
+                    'feedback' => $input,
+                    'feedbackformat' => $inputformat,
+                ], $gi1a),
+                $key
+            );
+        }
+    }
+
+    /**
+     * Data provider for the format_feedback tests.
+     *
+     * @return  array
+     */
+    public function format_feedback_provider() : array {
+        return [
+            'Basic string (PLAIN)' => [
+                'This is an example string',
+                FORMAT_PLAIN,
+                'This is an example string',
+            ],
+            'Basic string (HTML)' => [
+                '<p>This is an example string</p>',
+                FORMAT_HTML,
+                'This is an example string',
+            ],
+            'Has image (HTML)' => [
+                '<p>See this reference: <img src="http://example.com/myimage.jpg"></p>',
+                FORMAT_HTML,
+                'See this reference: ',
+            ],
+            'Has image and more (HTML)' => [
+                '<p>See <img src="http://example.com/myimage.jpg"> for <em>reference</em></p>',
+                FORMAT_HTML,
+                'See  for reference',
+            ],
+            'Has video and more (HTML)' => [
+                '<p>See <video src="http://example.com/myimage.jpg">video of a duck</video> for <em>reference</em></p>',
+                FORMAT_HTML,
+                'See video of a duck for reference',
+            ],
+            'Multiple videos (HTML)' => [
+                '<p>See <video src="http://example.com/myimage.jpg">video of a duck</video> and '.
+                '<video src="http://example.com/myimage.jpg">video of a cat</video> for <em>reference</em></p>',
+                FORMAT_HTML,
+                'See video of a duck and video of a cat for reference'
+            ],
+            'HTML Looking tags in PLAIN' => [
+                'The way you have written the <img thing looks pretty fun >',
+                FORMAT_PLAIN,
+                'The way you have written the &lt;img thing looks pretty fun &gt;',
+            ],
+
+        ];
+    }
+}
index 11d79f3..e2f1ecd 100644 (file)
@@ -379,8 +379,14 @@ $string['configvisiblecourses'] = 'Display courses in hidden categories normally
 $string['configwarning'] = 'Be careful modifying these settings - strange values could cause problems.';
 $string['configyuicomboloading'] = 'This options enables combined file loading optimisation for YUI libraries. This setting should be enabled on production sites for performance reasons.';
 $string['confirmation'] = 'Confirmation';
+$string['confirmcontextlock'] = '{$a->contextname} is currently unfrozen. Freezing it will prevent any further changes. Are you sure you wish to continue?';
+$string['confirmcontextunlock'] = '{$a->contextname} is currently frozen. Unfreezing it will allow users to make changes. Are you sure you wish to continue?';
 $string['confirmdeletecomments'] = 'You are about to delete comments, are you sure?';
 $string['confirmed'] = 'Confirmed';
+$string['contextlocking'] = 'Context freezing';
+$string['contextlocking_desc'] = 'This setting allows you to freeze categories, courses, activites, and blocks within the site by removing all write-access to those locations.';
+$string['contextlockappliestoadmin'] = 'Context freezing applies to administrators';
+$string['contextlockappliestoadmin_desc'] = 'This setting allows administrators to make changes in any context which is frozen.';
 $string['cookiehttponly'] = 'Only http cookies';
 $string['cookiesecure'] = 'Secure cookies only';
 $string['country'] = 'Default country';
@@ -718,6 +724,10 @@ $string['maintenancemodeisscheduled'] = 'This site will be switched to maintenan
 $string['maintenancemodeisscheduledlong'] = 'This site will be switched to maintenance mode in {$a->hour} hours {$a->min} mins {$a->sec} secs';
 $string['maintfileopenerror'] = 'Error opening maintenance files!';
 $string['maintinprogress'] = 'Maintenance is in progress...';
+$string['managecontextlock'] = 'Freeze this context';
+$string['managecontextlocklocked'] = '{$a->contextname}, and all of its children are now frozen.';
+$string['managecontextlockunlocked'] = '{$a->contextname}, and all of its children are now unfrozen.';
+$string['managecontextunlock'] = 'Unfreeze this context';
 $string['manageformats'] = 'Manage course formats';
 $string['manageformatsgotosettings'] = 'Default format can be changed in {$a}';
 $string['managelang'] = 'Manage';
index bf8d76e..3e32f80 100644 (file)
@@ -28,4 +28,6 @@ $string['aria:favourite'] = 'Course is starred';
 $string['favourite'] = 'Starred course';
 $string['privacy:perpage'] = 'The number of courses to show per page.';
 $string['privacy:completionpath'] = 'Course completion';
+$string['privacy:favouritespath'] = 'Course starred information';
 $string['privacy:metadata:completionsummary'] = 'The course contains completion information about the user.';
+$string['privacy:metadata:favouritessummary'] = 'The course contains information relating to the course being starred by the user.';
index 670c6c5..ce9ad84 100644 (file)
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
+$string['acceptandaddcontact'] = 'Accept and add to contacts';
 $string['addcontact'] = 'Add contact';
+$string['addcontactconfirm'] = 'Are you sure you want to add {$a} to your contacts?';
 $string['addtoyourcontacts'] = 'Add to your contacts';
+$string['addtoyourcontactsandmessage'] = 'Add to your contacts and message';
+$string['addtofavourites'] = 'Add to favourites';
 $string['ago'] = '{$a} ago';
 $string['allusers'] = 'All messages from all users';
+$string['backto'] = 'Back to {$a}';
 $string['backupmessageshelp'] = 'If enabled, then instant messages will be included in SITE automated backups';
 $string['blockcontact'] = 'Block contact';
 $string['blockedusers'] = 'Blocked users';
+$string['blockuser'] = 'Block user';
+$string['blockuserconfirm'] = 'Are you sure you want to block {$a}?';
 $string['blocknoncontacts'] = 'Prevent non-contacts from messaging me';
 $string['canceledit'] = 'Cancel editing messages';
 $string['contactableprivacy'] = 'Accept messages from:';
@@ -37,13 +44,16 @@ $string['contactableprivacy_coursemember'] = 'My contacts and anyone in my cours
 $string['contactableprivacy_site'] = 'Anyone on the site';
 $string['contactblocked'] = 'Contact blocked';
 $string['contactrequests'] = 'Contact requests';
+$string['contactrequestsent'] = 'Contact request sent';
 $string['contacts'] = 'Contacts';
-$string['conversationdoesntexist'] = 'Conversation does not exist';
+$string['decline'] = 'Decline';
 $string['defaultmessageoutputs'] = 'Default message outputs';
 $string['defaults'] = 'Defaults';
-$string['deleteallconfirm'] = "Are you sure you would like to delete this entire conversation?";
+$string['deleteallconfirm'] = "Are you sure you would like to delete this entire conversation? This will not delete it for other conversation participants.";
 $string['deleteallmessages'] = "Delete all messages";
+$string['deleteconversation'] = "Delete conversation";
 $string['deleteselectedmessages'] = 'Delete selected messages';
+$string['deleteselectedmessagesconfirm'] = 'Are you sure you would like to delete the selected messages? This will not delete them for other conversation participants.';
 $string['disableall'] = 'Disable notifications';
 $string['disabled'] = 'Messaging is disabled on this site';
 $string['disallowed'] = 'Disallowed';
@@ -51,6 +61,7 @@ $string['editmessages'] = 'Edit messages';
 $string['emailtagline'] = 'This is a copy of a message sent to you at "{$a->sitename}". Go to {$a->url} to reply.';
 $string['enabled'] = 'Enabled';
 $string['errorcallingprocessor'] = 'Error calling defined output';
+$string['errorconversationdoesnotexist'] = 'Conversation does not exist';
 $string['errortranslatingdefault'] = 'Error translating default setting provided by plugin, using system defaults instead.';
 $string['eventgroupmessagesent'] = 'Group message sent';
 $string['eventnotificationviewed'] = 'Notification viewed';
@@ -65,8 +76,13 @@ $string['eventmessagesent'] = 'Message sent';
 $string['forced'] = 'Locked';
 $string['guestnoeditmessage'] = 'Guest user can not edit messaging options';
 $string['guestnoeditmessageother'] = 'Guest user can not edit other user messaging options';
+$string['groupinfo'] = 'Group info';
+$string['groupmessages'] = 'Group messages';
 $string['hidemessagewindow'] = 'Hide message window';
 $string['hidenotificationwindow'] = 'Hide notification window';
+$string['info'] = 'Info';
+$string['isnotinyourcontacts'] = '{$a} is not in your contacts';
+$string['loadmore'] = 'Load more';
 $string['loggedin'] = 'Online';
 $string['loggedin_help'] = 'Configure how you would like to receive notifications when you are logged into Moodle';
 $string['loggedindescription'] = 'When you are logged into Moodle';
@@ -79,34 +95,54 @@ $string['messagepreferences'] = 'Message preferences';
 $string['message'] = 'Message';
 $string['messagecontactrequestsnotification'] = '{$a} wants to be added as a contact';
 $string['messagecontactrequestsnotificationsubject'] = '{$a} wants to be added as a contact';
+$string['messagedrawerviewcontact'] = 'User details for {$a}';
+$string['messagedrawerviewcontacts'] = 'Message contacts';
+$string['messagedrawerviewconversation'] = 'Conversation with {$a}';
+$string['messagedrawerviewgroupinfo'] = 'Group details for {$a}';
+$string['messagedrawerviewoverview'] = 'Messages overview';
+$string['messagedrawerviewsearch'] = 'Messages search results for {$a}';
+$string['messagedrawerviewsettings'] = 'Message settings';
 $string['messagepreferences'] = 'Message preferences';
 $string['messages'] = 'Messages';
+$string['messagesselected:'] = 'Messages selected:';
 $string['messagingdatahasnotbeenmigrated'] = 'Your messages are temporarily unavailable due to upgrades in the messaging infrastructure. Please wait for them to be migrated.';
 $string['newonlymsg'] = 'Show only new';
 $string['newmessage'] = 'New message';
 $string['newmessagesearch'] = 'Select or search for a contact to send a new message.';
+$string['nofavourites'] = 'No favourites';
 $string['noframesjs'] = 'Use more accessible interface';
+$string['nocontactrequests'] = 'No contact requests';
 $string['nocontacts'] = 'No contacts';
+$string['nocontactsgetstarted'] = "Try searching for someone to add them as a contact";
+$string['nogroupmessages'] = 'No group messages';
 $string['nomessages'] = 'No messages';
 $string['nomessagesfound'] = 'No messages were found';
 $string['noreply'] = 'Do not reply to this message';
 $string['noncontacts'] = 'Non-contacts';
 $string['nonotifications'] = 'You have no notifications';
+$string['noparticipants'] = 'No participants';
 $string['notificationdatahasnotbeenmigrated'] = 'Your notifications are temporarily unavailable due to upgrades in the notification infrastructure. Please wait for them to be migrated.';
 $string['notificationwindow'] = 'Notification window';
 $string['notificationpreferences'] = 'Notification preferences';
 $string['notificationimage'] = 'Notification image';
 $string['notifications'] = 'Notifications';
+$string['notincontactsheading'] = '{$a} is not in your contacts';
+$string['notincontacts'] = 'You need to add {$a} to your contacts to be able to send them messages.';
+$string['numparticipants'] = '{$a} participants';
 $string['off'] = 'Off';
 $string['offline'] = 'Offline';
 $string['on'] = 'On';
 $string['online'] = 'Online';
+$string['otherparticipants'] = 'Other participants';
 $string['outputdisabled'] = 'Output disabled';
 $string['outputdoesnotexist'] = 'Message output does not exists';
 $string['outputenabled'] = 'Output enabled';
 $string['outputnotavailable'] = 'Not available';
 $string['outputnotconfigured'] = 'Not configured';
+$string['participants'] = 'Participants';
 $string['permitted'] = 'Permitted';
+$string['privacy'] = 'Privacy';
+$string['privacy_desc'] = 'You can restrict who can message you';
 $string['privacy:metadata:messages'] = 'Messages';
 $string['privacy:metadata:messages:conversationid'] = 'The ID of the conversation';
 $string['privacy:metadata:messages:fullmessage'] = 'The full message';
@@ -155,12 +191,18 @@ $string['privacy:metadata:preference:core_message_settings'] = 'Settings related
 $string['privacy:request:preference:set'] = 'The value of the setting \'{$a->name}\' was \'{$a->value}\'';
 $string['processorsettings'] = 'Processor settings';
 $string['removecontact'] = 'Remove contact';
+$string['removecontactconfirm'] = 'Are you sure you want to remove {$a} from your contacts?';
 $string['removecoursefilter'] = 'Remove filter for course {$a}';
 $string['removefromyourcontacts'] = 'Remove from your contacts';
+$string['removefromfavourites'] = 'Remove from favourites';
+$string['requirecontacttomessage'] = 'You need to request {$a} to add you as a contact to be able to message';
 $string['requiresconfiguration'] = 'Requires configuration';
 $string['searchforuser'] = 'Search for a user';
 $string['searchforuserorcourse'] = 'Search for a user or course';
 $string['searchmessages'] = 'Search messages';
+$string['searchnocontactsfound'] = 'No contacts found';
+$string['searchnomessagesfound'] = 'No messages found';
+$string['searchnononcontactsfound'] = 'No non contacts found';
 $string['searchcombined'] = 'Search people and messages';
 $string['seeall'] = 'See all';
 $string['selectmessagestodelete'] = 'Select messages to delete';
@@ -168,6 +210,7 @@ $string['selectnotificationtoview'] = 'Select from the list of notifications on
 $string['send'] = 'Send';
 $string['sendingvia'] = 'Sending "{$a->provider}" via "{$a->processor}"';
 $string['sendingviawhen'] = 'Sending "{$a->provider}" via "{$a->processor}" when {$a->state}';
+$string['sendcontactrequest'] = 'Send contact request';
 $string['sendmessage'] = 'Send message';
 $string['sendbulkmessage'] = 'Send message to {$a} people';
 $string['sendbulkmessagesingle'] = 'Send message to 1 person';
@@ -183,19 +226,30 @@ $string['shownotificationwindowwithcount'] = 'Show notification window with {$a}
 $string['togglenotificationmenu'] = 'Toggle notifications menu';
 $string['togglemessagemenu'] = 'Toggle messages menu';
 $string['touserdoesntexist'] = 'You can not send a message to a user id ({$a}) that doesn\'t exist';
+$string['unabletomessage'] = 'You are unable to message this user';
+$string['unblock'] = 'Unblock';
 $string['unblockcontact'] = 'Unblock contact';
+$string['unblockuser'] = 'Unblock user';
+$string['unblockuserconfirm'] = 'Are you sure you want to unblock {$a}?';
 $string['unknownuser'] = 'Unknown user';
 $string['unreadnotification'] = 'Unread notification: {$a}';
 $string['unreadnewgroupconversationmessage'] = 'New message from {$a->name} in {$a->conversationname}';
 $string['unreadnewmessage'] = 'New message from {$a}';
+$string['useentertosend'] = 'Use enter to send';
 $string['usercantbemessaged'] = 'You can\'t message {$a} due to their message preferences. Try adding them as a contact.';
+$string['userisblockingyou'] = 'This user has blocked you from sending messages to them';
+$string['userisblockingyounoncontact'] = '{$a} only accepts messages from their contacts.';
+$string['userwouldliketocontactyou'] = '{$a} would like to contact you';
 $string['viewfullnotification'] = 'View full notification';
 $string['viewinganotherusersmessagearea'] = 'You are viewing another user\'s message area.';
 $string['viewmessageswith'] = 'View messages with {$a}';
 $string['viewnotificationresource'] = 'Go to: {$a}';
 $string['viewunreadmessageswith'] = 'View unread messages with {$a}';
 $string['writeamessage'] = 'Write a message...';
+$string['wouldliketocontactyou'] = 'Would like to contact you';
 $string['you'] = 'You:';
+$string['youhaveblockeduser'] = 'You have blocked this user in the past';
+$string['yourcontactrequestpending'] = 'Your contact request is pending with {$a}';
 
 // Deprecated since Moodle 3.6.
 $string['eventmessagecontactblocked'] = 'Message contact blocked';
index 163e3d8..93ebebe 100644 (file)
@@ -808,6 +808,7 @@ $string['expandcategory'] = 'Expand {$a}';
 $string['explanation'] = 'Explanation';
 $string['explanationdigitalminor'] = 'This information is required to determine if your age is over the digital age of consent. This is the age when an individual can consent to terms and conditions and their data being legally stored and processed.';
 $string['extendperiod'] = 'Extended period';
+$string['favourites'] = 'Favourites';
 $string['failedloginattempts'] = '{$a->attempts} failed logins since your last login';
 $string['feedback'] = 'Feedback';
 $string['file'] = 'File';
@@ -1656,6 +1657,7 @@ $string['requestcourse'] = 'Request a course';
 $string['requestedby'] = 'Requested by';
 $string['requestedcourses'] = 'Requested courses';
 $string['requestreason'] = 'Reason for course request';
+$string['requests'] = 'Requests';
 $string['required'] = 'Required';
 $string['requirespayment'] = 'This course requires payment for access';
 $string['resendemail'] = 'Resend email';
index d0d2003..743bc48 100644 (file)
@@ -411,6 +411,7 @@ $string['site:maintenanceaccess'] = 'Access site while in maintenance mode';
 $string['site:manageallmessaging'] = 'Add, remove, block and unblock contacts for any user';
 $string['site:manageblocks'] = 'Manage blocks on a page';
 $string['site:messageanyuser'] = 'Bypass user privacy preferences for messaging any user';
+$string['site:managecontextlocks'] = 'Manage freezing of contexts';
 $string['site:mnetloginfromremote'] = 'Login from a remote application via MNet';
 $string['site:mnetlogintoremote'] = 'Roam to a remote application via MNet';
 $string['site:readallmessages'] = 'Read all messages on site';
index ae631cf..9d6dc4f 100644 (file)
@@ -478,6 +478,21 @@ function has_capability($capability, context $context, $user = null, $doanything
         }
     }
 
+    // Check whether context locking is enabled.
+    if (!empty($CFG->contextlocking)) {
+        if ($capinfo->captype === 'write' && $context->locked) {
+            // Context locking applies to any write capability in a locked context.
+            // It does not apply to moodle/site:managecontextlocks - this is to allow context locking to be unlocked.
+            if ($capinfo->name !== 'moodle/site:managecontextlocks') {
+                // It applies to all users who are not site admins.
+                // It also applies to site admins when contextlockappliestoadmin is set.
+                if (!is_siteadmin($userid) || !empty($CFG->contextlockappliestoadmin)) {
+                    return false;
+                }
+            }
+        }
+    }
+
     // somehow make sure the user is not deleted and actually exists
     if ($userid != 0) {
         if ($userid == $USER->id and isset($USER->deleted)) {
@@ -4727,6 +4742,15 @@ abstract class context extends stdClass implements IteratorAggregate {
      */
     protected $_depth;
 
+    /**
+     * Whether this context is locked or not.
+     *
+     * Can be accessed publicly through $context->locked.
+     *
+     * @var int
+     */
+    protected $_locked;
+
     /**
      * @var array Context caching info
      */
@@ -4862,22 +4886,40 @@ abstract class context extends stdClass implements IteratorAggregate {
      * @param stdClass $rec
      * @return void (modifies $rec)
      */
-     protected static function preload_from_record(stdClass $rec) {
-         if (empty($rec->ctxid) or empty($rec->ctxlevel) or !isset($rec->ctxinstance) or empty($rec->ctxpath) or empty($rec->ctxdepth)) {
-             // $rec does not have enough data, passed here repeatedly or context does not exist yet
-             return;
-         }
-
-         // note: in PHP5 the objects are passed by reference, no need to return $rec
-         $record = new stdClass();
-         $record->id           = $rec->ctxid;       unset($rec->ctxid);
-         $record->contextlevel = $rec->ctxlevel;    unset($rec->ctxlevel);
-         $record->instanceid   = $rec->ctxinstance; unset($rec->ctxinstance);
-         $record->path         = $rec->ctxpath;     unset($rec->ctxpath);
-         $record->depth        = $rec->ctxdepth;    unset($rec->ctxdepth);
-
-         return context::create_instance_from_record($record);
-     }
+    protected static function preload_from_record(stdClass $rec) {
+        $notenoughdata = false;
+        $notenoughdata = $notenoughdata || empty($rec->ctxid);
+        $notenoughdata = $notenoughdata || empty($rec->ctxlevel);
+        $notenoughdata = $notenoughdata || !isset($rec->ctxinstance);
+        $notenoughdata = $notenoughdata || empty($rec->ctxpath);
+        $notenoughdata = $notenoughdata || empty($rec->ctxdepth);
+        $notenoughdata = $notenoughdata || !isset($rec->ctxlocked);
+        if ($notenoughdata) {
+            // The record does not have enough data, passed here repeatedly or context does not exist yet.
+            if (isset($rec->ctxid) && !isset($rec->ctxlocked)) {
+                debugging('Locked value missing. Code is possibly not usings the getter properly.', DEBUG_DEVELOPER);
+            }
+            return;
+        }
+
+        $record = (object) [
+            'id'            => $rec->ctxid,
+            'contextlevel'  => $rec->ctxlevel,
+            'instanceid'    => $rec->ctxinstance,
+            'path'          => $rec->ctxpath,
+            'depth'         => $rec->ctxdepth,
+            'locked'        => $rec->ctxlocked,
+        ];
+
+        unset($rec->ctxid);
+        unset($rec->ctxlevel);
+        unset($rec->ctxinstance);
+        unset($rec->ctxpath);
+        unset($rec->ctxdepth);
+        unset($rec->ctxlocked);
+
+        return context::create_instance_from_record($record);
+    }
 
 
     // ====== magic methods =======
@@ -4898,11 +4940,18 @@ abstract class context extends stdClass implements IteratorAggregate {
      */
     public function __get($name) {
         switch ($name) {
-            case 'id':           return $this->_id;
-            case 'contextlevel': return $this->_contextlevel;
-            case 'instanceid':   return $this->_instanceid;
-            case 'path':         return $this->_path;
-            case 'depth':        return $this->_depth;
+            case 'id':
+                return $this->_id;
+            case 'contextlevel':
+                return $this->_contextlevel;
+            case 'instanceid':
+                return $this->_instanceid;
+            case 'path':
+                return $this->_path;
+            case 'depth':
+                return $this->_depth;
+            case 'locked':
+                return $this->is_locked();
 
             default:
                 debugging('Invalid context property accessed! '.$name);
@@ -4917,19 +4966,26 @@ abstract class context extends stdClass implements IteratorAggregate {
      */
     public function __isset($name) {
         switch ($name) {
-            case 'id':           return isset($this->_id);
-            case 'contextlevel': return isset($this->_contextlevel);
-            case 'instanceid':   return isset($this->_instanceid);
-            case 'path':         return isset($this->_path);
-            case 'depth':        return isset($this->_depth);
-
-            default: return false;
+            case 'id':
+                return isset($this->_id);
+            case 'contextlevel':
+                return isset($this->_contextlevel);
+            case 'instanceid':
+                return isset($this->_instanceid);
+            case 'path':
+                return isset($this->_path);
+            case 'depth':
+                return isset($this->_depth);
+            case 'locked':
+                // Locked is always set.
+                return true;
+            default:
+                return false;
         }
-
     }
 
     /**
-     * ALl properties are read only, sorry.
+     * All properties are read only, sorry.
      * @param string $name
      */
     public function __unset($name) {
@@ -4950,7 +5006,8 @@ abstract class context extends stdClass implements IteratorAggregate {
             'contextlevel' => $this->contextlevel,
             'instanceid'   => $this->instanceid,
             'path'         => $this->path,
-            'depth'        => $this->depth
+            'depth'        => $this->depth,
+            'locked'       => $this->locked,
         );
         return new ArrayIterator($ret);
     }
@@ -4969,6 +5026,12 @@ abstract class context extends stdClass implements IteratorAggregate {
         $this->_instanceid   = $record->instanceid;
         $this->_path         = $record->path;
         $this->_depth        = $record->depth;
+
+        if (isset($record->locked)) {
+            $this->_locked = $record->locked;
+        } else if (!during_initial_install() && !moodle_needs_upgrading()) {
+            debugging('Locked value missing. Code is possibly not usings the getter properly.', DEBUG_DEVELOPER);
+        }
     }
 
     /**
@@ -5011,12 +5074,13 @@ abstract class context extends stdClass implements IteratorAggregate {
         if ($dbfamily == 'mysql') {
             $updatesql = "UPDATE {context} ct, {context_temp} temp
                              SET ct.path     = temp.path,
-                                 ct.depth    = temp.depth
+                                 ct.depth    = temp.depth,
+                                 ct.locked   = temp.locked
                            WHERE ct.id = temp.id";
         } else if ($dbfamily == 'oracle') {
             $updatesql = "UPDATE {context} ct
-                             SET (ct.path, ct.depth) =
-                                 (SELECT temp.path, temp.depth
+                             SET (ct.path, ct.depth, ct.locked) =
+                                 (SELECT temp.path, temp.depth, temp.locked
                                     FROM {context_temp} temp
                                    WHERE temp.id=ct.id)
                            WHERE EXISTS (SELECT 'x'
@@ -5025,14 +5089,16 @@ abstract class context extends stdClass implements IteratorAggregate {
         } else if ($dbfamily == 'postgres' or $dbfamily == 'mssql') {
             $updatesql = "UPDATE {context}
                              SET path     = temp.path,
-                                 depth    = temp.depth
+                                 depth    = temp.depth,
+                                 locked   = temp.locked
                             FROM {context_temp} temp
                            WHERE temp.id={context}.id";
         } else {
             // sqlite and others
             $updatesql = "UPDATE {context}
                              SET path     = (SELECT path FROM {context_temp} WHERE id = {context}.id),
-                                 depth    = (SELECT depth FROM {context_temp} WHERE id = {context}.id)
+                                 depth    = (SELECT depth FROM {context_temp} WHERE id = {context}.id),
+                                 locked   = (SELECT locked FROM {context_temp} WHERE id = {context}.id)
                              WHERE id IN (SELECT id FROM {context_temp})";
         }
 
@@ -5118,6 +5184,27 @@ abstract class context extends stdClass implements IteratorAggregate {
         $trans->allow_commit();
     }
 
+    /**
+     * Set whether this context has been locked or not.
+     *
+     * @param   bool    $locked
+     * @return  $this
+     */
+    public function set_locked(bool $locked) {
+        global $DB;
+
+        if ($this->_locked == $locked) {
+            return $this;
+        }
+
+        $this->_locked = $locked;
+        $DB->set_field('context', 'locked', (int) $locked, ['id' => $this->id]);
+        $this->mark_dirty();
+        self::reset_caches();
+
+        return $this;
+    }
+
     /**
      * Remove all context path info and optionally rebuild it.
      *
@@ -5239,6 +5326,7 @@ abstract class context extends stdClass implements IteratorAggregate {
         $record->instanceid   = $instanceid;
         $record->depth        = 0;
         $record->path         = null; //not known before insert
+        $record->locked       = 0;
 
         $record->id = $DB->insert_record('context', $record);
 
@@ -5266,6 +5354,23 @@ abstract class context extends stdClass implements IteratorAggregate {
         throw new coding_exception('can not get name of abstract context');
     }
 
+    /**
+     * Whether the current context is locked.
+     *
+     * @return  bool
+     */
+    public function is_locked() {
+        if ($this->_locked) {
+            return true;
+        }
+
+        if ($parent = $this->get_parent_context()) {
+            return $parent->is_locked();
+        }
+
+        return false;
+    }
+
     /**
      * Returns the most relevant URL for this context.
      *
@@ -5724,7 +5829,14 @@ class context_helper extends context {
      * @return array (table.column=>alias, ...)
      */
     public static function get_preload_record_columns($tablealias) {
-        return array("$tablealias.id"=>"ctxid", "$tablealias.path"=>"ctxpath", "$tablealias.depth"=>"ctxdepth", "$tablealias.contextlevel"=>"ctxlevel", "$tablealias.instanceid"=>"ctxinstance");
+        return [
+            "$tablealias.id" => "ctxid",
+            "$tablealias.path" => "ctxpath",
+            "$tablealias.depth" => "ctxdepth",
+            "$tablealias.contextlevel" => "ctxlevel",
+            "$tablealias.instanceid" => "ctxinstance",
+            "$tablealias.locked" => "ctxlocked",
+        ];
     }
 
     /**
@@ -5737,7 +5849,12 @@ class context_helper extends context {
      * @return string
      */
     public static function get_preload_record_columns_sql($tablealias) {
-        return "$tablealias.id AS ctxid, $tablealias.path AS ctxpath, $tablealias.depth AS ctxdepth, $tablealias.contextlevel AS ctxlevel, $tablealias.instanceid AS ctxinstance";
+        return "$tablealias.id AS ctxid, " .
+               "$tablealias.path AS ctxpath, " .
+               "$tablealias.depth AS ctxdepth, " .
+               "$tablealias.contextlevel AS ctxlevel, " .
+               "$tablealias.instanceid AS ctxinstance, " .
+               "$tablealias.locked AS ctxlocked";
     }
 
     /**
@@ -5920,12 +6037,12 @@ class context_system extends context {
                 $record->instanceid   = 0;
                 $record->path         = '/'.SYSCONTEXTID;
                 $record->depth        = 1;
+                $record->locked       = 0;
                 context::$systemcontext = new context_system($record);
             }
             return context::$systemcontext;
         }
 
-
         try {
             // We ignore the strictness completely because system context must exist except during install.
             $record = $DB->get_record('context', array('contextlevel'=>CONTEXT_SYSTEM), '*', MUST_EXIST);
@@ -5943,7 +6060,8 @@ class context_system extends context {
             $record->contextlevel = CONTEXT_SYSTEM;
             $record->instanceid   = 0;
             $record->depth        = 1;
-            $record->path         = null; //not known before insert
+            $record->path         = null; // Not known before insert.
+            $record->locked       = 0;
 
             try {
                 if ($DB->count_records('context')) {
@@ -5976,6 +6094,10 @@ class context_system extends context {
             $DB->update_record('context', $record);
         }
 
+        if (empty($record->locked)) {
+            $record->locked = 0;
+        }
+
         if (!defined('SYSCONTEXTID')) {
             define('SYSCONTEXTID', $record->id);
         }
@@ -6056,6 +6178,18 @@ class context_system extends context {
             $DB->update_record('context', $record);
         }
     }
+
+    /**
+     * Set whether this context has been locked or not.
+     *
+     * @param   bool    $locked
+     * @return  $this
+     */<