Merge branch 'MDL-63253-master-adminsearchresults' of git://github.com/mudrd8mz/moodle
authorEloy Lafuente (stronk7) <stronk7@moodle.org>
Tue, 25 Sep 2018 20:56:11 +0000 (22:56 +0200)
committerEloy Lafuente (stronk7) <stronk7@moodle.org>
Tue, 25 Sep 2018 20:56:11 +0000 (22:56 +0200)
164 files changed:
.travis.yml
admin/roles/allow.php
admin/roles/classes/capability_table_with_risks.php
admin/roles/manage.php
admin/settings/appearance.php
admin/settings/courses.php
admin/settings/subsystems.php
admin/tool/dataprivacy/amd/build/defaultsactions.min.js [new file with mode: 0644]
admin/tool/dataprivacy/amd/build/events.min.js
admin/tool/dataprivacy/amd/build/requestactions.min.js
admin/tool/dataprivacy/amd/src/defaultsactions.js [new file with mode: 0644]
admin/tool/dataprivacy/amd/src/events.js
admin/tool/dataprivacy/amd/src/requestactions.js
admin/tool/dataprivacy/categories.php
admin/tool/dataprivacy/classes/api.php
admin/tool/dataprivacy/classes/data_registry.php
admin/tool/dataprivacy/classes/external.php
admin/tool/dataprivacy/classes/external/category_exporter.php
admin/tool/dataprivacy/classes/external/purpose_exporter.php
admin/tool/dataprivacy/classes/form/defaults.php [deleted file]
admin/tool/dataprivacy/classes/local/helper.php
admin/tool/dataprivacy/classes/output/data_registry_page.php
admin/tool/dataprivacy/classes/output/data_requests_page.php
admin/tool/dataprivacy/classes/output/data_requests_table.php
admin/tool/dataprivacy/classes/output/defaults_page.php [new file with mode: 0644]
admin/tool/dataprivacy/classes/page_helper.php
admin/tool/dataprivacy/classes/privacy/provider.php
admin/tool/dataprivacy/createdatarequest.php
admin/tool/dataprivacy/datadeletion.php
admin/tool/dataprivacy/dataregistry.php
admin/tool/dataprivacy/datarequests.php
admin/tool/dataprivacy/db/services.php
admin/tool/dataprivacy/defaults.php
admin/tool/dataprivacy/lang/en/tool_dataprivacy.php
admin/tool/dataprivacy/purposes.php
admin/tool/dataprivacy/styles.css
admin/tool/dataprivacy/templates/categories.mustache
admin/tool/dataprivacy/templates/category_purpose_form.mustache [new file with mode: 0644]
admin/tool/dataprivacy/templates/data_request_email.mustache
admin/tool/dataprivacy/templates/data_requests_bulk_actions.mustache [new file with mode: 0644]
admin/tool/dataprivacy/templates/defaults_display.mustache [new file with mode: 0644]
admin/tool/dataprivacy/templates/defaults_page.mustache [new file with mode: 0644]
admin/tool/dataprivacy/templates/delete_activity_defaults.mustache [new file with mode: 0644]
admin/tool/dataprivacy/templates/purposes.mustache
admin/tool/dataprivacy/tests/api_test.php
admin/tool/dataprivacy/tests/behat/behat_tool_dataprivacy.php [new file with mode: 0644]
admin/tool/dataprivacy/tests/behat/manage_data_requests.feature
admin/tool/dataprivacy/tests/behat/manage_defaults.feature [new file with mode: 0644]
admin/tool/dataprivacy/tests/external_test.php
admin/tool/dataprivacy/tests/generator/lib.php [new file with mode: 0644]
admin/tool/dataprivacy/version.php
admin/tool/policy/amd/build/acceptmodal.min.js
admin/tool/policy/amd/src/acceptmodal.js
admin/tool/policy/classes/acceptances_table.php
admin/tool/policy/tests/behat/acceptances.feature
backup/moodle2/backup_stepslib.php
backup/moodle2/restore_stepslib.php
backup/util/ui/tests/behat/duplicate_activities.feature
blocks/rss_client/classes/output/item.php
calendar/classes/external/event_exporter.php
calendar/templates/event_summary_body.mustache
config-dist.php
course/classes/category.php
course/edit.php
course/lib.php
course/tests/courselib_test.php
enrol/category/tests/plugin_test.php
enrol/tests/enrollib_test.php
filter/glossary/filter.php
filter/glossary/lang/en/filter_glossary.php
filter/glossary/tests/filter_test.php
filter/upgrade.txt
grade/tests/report_graderlib_test.php
group/assign.php
lang/en/admin.php
lang/en/moodle.php
lang/en/question.php
lib/accesslib.php
lib/behat/classes/util.php
lib/db/install.xml
lib/db/upgrade.php
lib/ddl/mssql_sql_generator.php
lib/ddl/mysql_sql_generator.php
lib/ddl/oracle_sql_generator.php
lib/ddl/sql_generator.php
lib/ddl/tests/ddl_test.php
lib/dml/moodle_database.php
lib/dml/tests/dml_test.php
lib/enrollib.php
lib/filterlib.php
lib/grouplib.php
lib/moodlelib.php
lib/phpunit/classes/util.php
lib/questionlib.php
lib/tests/accesslib_test.php
lib/tests/grouplib_test.php
lib/tests/questionlib_test.php
lib/upgrade.txt
lib/xmldb/xmldb_index.php
lib/xmldb/xmldb_table.php
message/tests/api_test.php
mod/assign/feedback/editpdf/classes/event/observer.php
mod/assign/feedback/editpdf/classes/task/convert_submissions.php
mod/assign/feedback/editpdf/db/install.xml
mod/assign/feedback/editpdf/db/upgrade.php
mod/assign/feedback/editpdf/tests/editpdf_test.php
mod/assign/feedback/editpdf/version.php
mod/book/lib.php
mod/book/tests/lib_test.php
mod/choice/lib.php
mod/choice/tests/lib_test.php
mod/feedback/analysis_to_excel.php
mod/feedback/tests/lib_test.php
mod/folder/lib.php
mod/folder/tests/lib_test.php
mod/forum/lib.php
mod/forum/tests/lib_test.php
mod/forum/tests/subscriptions_test.php
mod/glossary/lib.php
mod/glossary/tests/lib_test.php
npm-shrinkwrap.json
question/category.php
question/category_class.php
question/category_form.php
question/editlib.php
question/engine/questionattempt.php
question/engine/tests/helpers.php
question/format.php
question/format/gift/tests/behat/import_export.feature
question/format/upgrade.txt
question/format/xml/format.php
question/format/xml/tests/fixtures/categories_reverse_order.xml [new file with mode: 0644]
question/format/xml/tests/fixtures/category_with_description.xml [new file with mode: 0644]
question/format/xml/tests/fixtures/export_category.xml [new file with mode: 0644]
question/format/xml/tests/fixtures/nested_categories.xml [new file with mode: 0644]
question/format/xml/tests/fixtures/nested_categories_with_questions.xml [new file with mode: 0644]
question/format/xml/tests/fixtures/old_format_file.xml [new file with mode: 0644]
question/format/xml/tests/fixtures/truefalse.xml
question/format/xml/tests/qformat_xml_import_export_test.php [new file with mode: 0644]
question/format/xml/tests/xmlformat_test.php
question/tests/backup_test.php
question/tests/behat/question_categories_idnumber.feature [new file with mode: 0644]
question/tests/generator/lib.php
question/tests/generator_test.php
question/type/ddwtos/tests/questiontype_test.php
question/type/description/tests/behat/export.feature
question/type/edit_question_form.php
question/type/essay/tests/behat/export.feature
question/type/gapselect/tests/questiontype_test.php
question/type/match/tests/questiontype_test.php
question/type/missingtype/tests/missingtype_test.php
question/type/questiontypebase.php
question/type/shortanswer/tests/behat/export.feature
question/type/truefalse/tests/behat/export.feature
tag/tests/external_test.php
theme/boost/scss/moodle/bs4alphacompat.scss
theme/boost/style/moodle.css
user/classes/participants_table.php
user/index.php
user/lib.php
user/renderer.php
user/tests/behat/filter_participants.feature
user/tests/userlib_test.php
version.php

index 0b6690a..aa0db16 100644 (file)
@@ -263,7 +263,9 @@ script:
         grunt ;
         # Add all files to the git index and then run diff --cached to see all changes.
         # This ensures that we get the status of all files, including new files.
+        # We ignore npm-shrinkwrap.json to make the tasks immune to npm changes.
         git add . ;
+        git reset -- npm-shrinkwrap.json ;
         git diff --cached --exit-code ;
       fi
 
index 71e9bcb..88609cf 100644 (file)
@@ -46,7 +46,6 @@ $controller = new $classformode[$mode]();
 
 if (optional_param('submit', false, PARAM_BOOL) && data_submitted() && confirm_sesskey()) {
     $controller->process_submission();
-    $syscontext->mark_dirty();
     $event = null;
     // Create event depending on mode.
     switch ($mode) {
index db7e16b..4e597f4 100644 (file)
@@ -123,9 +123,6 @@ abstract class core_role_capability_table_with_risks extends core_role_capabilit
             assign_capability($changedcap, $this->permissions[$changedcap],
                 $this->roleid, $this->context->id, true);
         }
-
-        // Force accessinfo refresh for users visiting this context.
-        $this->context->mark_dirty();
     }
 
     public function display() {
index fb390ea..d2e703f 100644 (file)
@@ -85,12 +85,10 @@ switch ($action) {
             die;
         }
         if (!delete_role($roleid)) {
-            // The delete failed, but mark the context dirty in case.
-            $systemcontext->mark_dirty();
+            // The delete failed.
             print_error('cannotdeleterolewithid', 'error', $baseurl, $roleid);
         }
         // Deleted a role sitewide...
-        $systemcontext->mark_dirty();
         redirect($baseurl);
         break;
 
index db05c1d..caf9e8a 100644 (file)
@@ -32,9 +32,9 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) { // sp
         'customusermenuitems',
         new lang_string('customusermenuitems', 'admin'),
         new lang_string('configcustomusermenuitems', 'admin'),
-        'grades,grades|/grade/report/mygrades.php|grades
-messages,message|/message/index.php|message
-preferences,moodle|/user/preferences.php|preferences',
+        'grades,grades|/grade/report/mygrades.php|t/grades
+messages,message|/message/index.php|t/message
+preferences,moodle|/user/preferences.php|t/preferences',
         PARAM_RAW,
         '50',
         '10'
index 64ef150..cf9b286 100644 (file)
@@ -43,6 +43,12 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) {
             array('moodle/category:manage')
         )
     );
+    $ADMIN->add('courses',
+        new admin_externalpage('addnewcourse', new lang_string('addnewcourse'),
+            new moodle_url('/course/edit.php', array('category' => 0)),
+            array('moodle/category:manage')
+        )
+    );
     $ADMIN->add('courses',
         new admin_externalpage('restorecourse', new lang_string('restorecourse', 'admin'),
             new moodle_url('/backup/restorefile.php', array('contextid' => context_system::instance()->id)),
index f2b50d5..db26385 100644 (file)
@@ -15,7 +15,11 @@ if ($hassiteconfig) { // speedup for non-admins, add all caps used on this page
 
     $optionalsubsystems->add(new admin_setting_configcheckbox('messaging', new lang_string('messaging', 'admin'), new lang_string('configmessaging','admin'), 1));
 
-    $optionalsubsystems->add(new admin_setting_configcheckbox('messaginghidereadnotifications', new lang_string('messaginghidereadnotifications', 'admin'), new lang_string('configmessaginghidereadnotifications','admin'), 0));
+    $optionalsubsystems->add(new admin_setting_configcheckbox('messagingallusers',
+        new lang_string('messagingallusers', 'admin'),
+        new lang_string('configmessagingallusers', 'admin'),
+        0)
+    );
 
     $options = array(DAYSECS=>new lang_string('secondstotime86400'), WEEKSECS=>new lang_string('secondstotime604800'), 2620800=>new lang_string('nummonths', 'moodle', 1), 15724800=>new lang_string('nummonths', 'moodle', 6),0=>new lang_string('never'));
     $optionalsubsystems->add(new admin_setting_configselect('messagingdeletereadnotificationsdelay', new lang_string('messagingdeletereadnotificationsdelay', 'admin'), new lang_string('configmessagingdeletereadnotificationsdelay', 'admin'), 604800, $options));
diff --git a/admin/tool/dataprivacy/amd/build/defaultsactions.min.js b/admin/tool/dataprivacy/amd/build/defaultsactions.min.js
new file mode 100644 (file)
index 0000000..b8f0bc3
Binary files /dev/null and b/admin/tool/dataprivacy/amd/build/defaultsactions.min.js differ
index 0ecae4c..6c94ab3 100644 (file)
Binary files a/admin/tool/dataprivacy/amd/build/events.min.js and b/admin/tool/dataprivacy/amd/build/events.min.js differ
index c405d17..2260ee3 100644 (file)
Binary files a/admin/tool/dataprivacy/amd/build/requestactions.min.js and b/admin/tool/dataprivacy/amd/build/requestactions.min.js differ
diff --git a/admin/tool/dataprivacy/amd/src/defaultsactions.js b/admin/tool/dataprivacy/amd/src/defaultsactions.js
new file mode 100644 (file)
index 0000000..76cbb89
--- /dev/null
@@ -0,0 +1,315 @@
+// 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/>.
+
+/**
+ * AMD module for data registry defaults actions.
+ *
+ * @module     tool_dataprivacy/defaultsactions
+ * @package    tool_dataprivacy
+ * @copyright  2018 Jun Pataleta
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define([
+    'jquery',
+    'core/ajax',
+    'core/notification',
+    'core/str',
+    'core/modal_factory',
+    'core/modal_events',
+    'core/templates'],
+function($, Ajax, Notification, Str, ModalFactory, ModalEvents, Templates) {
+
+    /**
+     * List of action selectors.
+     *
+     * @type {{EDIT_LEVEL_DEFAULTS: string}}
+     * @type {{NEW_ACTIVITY_DEFAULTS: string}}
+     * @type {{EDIT_ACTIVITY_DEFAULTS: string}}
+     * @type {{DELETE_ACTIVITY_DEFAULTS: string}}
+     */
+    var ACTIONS = {
+        EDIT_LEVEL_DEFAULTS: '[data-action="edit-level-defaults"]',
+        NEW_ACTIVITY_DEFAULTS: '[data-action="new-activity-defaults"]',
+        EDIT_ACTIVITY_DEFAULTS: '[data-action="edit-activity-defaults"]',
+        DELETE_ACTIVITY_DEFAULTS: '[data-action="delete-activity-defaults"]'
+    };
+
+    /** @type {{INHERIT: Number}} **/
+    var INHERIT = -1;
+
+    /**
+     * DefaultsActions class.
+     */
+    var DefaultsActions = function() {
+        this.registerEvents();
+    };
+
+    /**
+     * Register event listeners.
+     */
+    DefaultsActions.prototype.registerEvents = function() {
+        $(ACTIONS.EDIT_LEVEL_DEFAULTS).click(function(e) {
+            e.preventDefault();
+
+            var button = $(this);
+            var contextLevel = button.data('contextlevel');
+            var category = button.data('category');
+            var purpose = button.data('purpose');
+
+            // Get options.
+            var requests = [
+                {methodname: 'tool_dataprivacy_get_category_options', args: {}},
+                {methodname: 'tool_dataprivacy_get_purpose_options', args: {}}
+            ];
+
+            var promises = Ajax.call(requests);
+            var titlePromise = Str.get_string('editdefaults', 'tool_dataprivacy', $('#defaults-header').text());
+            $.when(promises[0], promises[1], titlePromise).then(function(categoryResponse, purposeResponse, title) {
+                var categories = categoryResponse.options;
+                var purposes = purposeResponse.options;
+                showDefaultsFormModal(title, contextLevel, category, purpose, null, categories, purposes, null);
+
+                return true;
+            }).catch(Notification.exception);
+        });
+
+        $(ACTIONS.NEW_ACTIVITY_DEFAULTS).click(function(e) {
+            e.preventDefault();
+
+            var button = $(this);
+            var contextLevel = button.data('contextlevel');
+
+            // Get options.
+            var requests = [
+                {methodname: 'tool_dataprivacy_get_category_options', args: {}},
+                {methodname: 'tool_dataprivacy_get_purpose_options', args: {}},
+                {methodname: 'tool_dataprivacy_get_activity_options', args: {'nodefaults': true}}
+            ];
+
+            var promises = Ajax.call(requests);
+            var titlePromise = Str.get_string('addnewdefaults', 'tool_dataprivacy');
+
+            $.when(promises[0], promises[1], promises[2], titlePromise).then(
+                function(categoryResponse, purposeResponse, activityResponse, title) {
+                    var categories = categoryResponse.options;
+                    var purposes = purposeResponse.options;
+                    var activities = activityResponse.options;
+
+                    showDefaultsFormModal(title, contextLevel, null, null, null, categories, purposes, activities);
+
+                    return true;
+
+                }).catch(Notification.exception);
+            }
+        );
+
+        $(ACTIONS.EDIT_ACTIVITY_DEFAULTS).click(function(e) {
+            e.preventDefault();
+
+            var button = $(this);
+            var contextLevel = button.data('contextlevel');
+            var category = button.data('category');
+            var purpose = button.data('purpose');
+            var activity = button.data('activityname');
+
+            // Get options.
+            var requests = [
+                {methodname: 'tool_dataprivacy_get_category_options', args: {}},
+                {methodname: 'tool_dataprivacy_get_purpose_options', args: {}},
+                {methodname: 'tool_dataprivacy_get_activity_options', args: {}}
+            ];
+
+            var promises = Ajax.call(requests);
+            var titlePromise = Str.get_string('editmoduledefaults', 'tool_dataprivacy');
+
+            $.when(promises[0], promises[1], promises[2], titlePromise).then(
+                function(categoryResponse, purposeResponse, activityResponse, title) {
+                    var categories = categoryResponse.options;
+                    var purposes = purposeResponse.options;
+                    var activities = activityResponse.options;
+
+                    showDefaultsFormModal(title, contextLevel, category, purpose, activity, categories, purposes, activities);
+
+                    return true;
+
+                }).catch(Notification.exception);
+            }
+        );
+
+        $(ACTIONS.DELETE_ACTIVITY_DEFAULTS).click(function(e) {
+            e.preventDefault();
+
+            var button = $(this);
+            var contextLevel = button.data('contextlevel');
+            var activity = button.data('activityname');
+            var activityDisplayName = button.data('activitydisplayname');
+            // Set category and purpose to inherit (-1).
+            var category = INHERIT;
+            var purpose = INHERIT;
+
+            ModalFactory.create({
+                title: Str.get_string('deletedefaults', 'tool_dataprivacy', activityDisplayName),
+                body: Templates.render('tool_dataprivacy/delete_activity_defaults', {"activityname": activityDisplayName}),
+                type: ModalFactory.types.SAVE_CANCEL,
+                large: true
+            }).then(function(modal) {
+                modal.setSaveButtonText(Str.get_string('delete'));
+
+                // Handle save event.
+                modal.getRoot().on(ModalEvents.save, function() {
+                    setContextDefaults(contextLevel, category, purpose, activity, false);
+                });
+
+                // Handle hidden event.
+                modal.getRoot().on(ModalEvents.hidden, function() {
+                    // Destroy when hidden.
+                    modal.destroy();
+                });
+
+                modal.show();
+
+                return true;
+            }).catch(Notification.exception);
+        });
+    };
+
+    /**
+     * Prepares and renders the modal for setting the defaults for the given context level/plugin.
+     *
+     * @param {String} title The modal's title.
+     * @param {Number} contextLevel The context level to set defaults for.
+     * @param {Number} category The current category ID.
+     * @param {Number} purpose The current purpose ID.
+     * @param {String} activity The plugin name of the activity. Optional.
+     * @param {Array} categoryOptions The list of category options.
+     * @param {Array} purposeOptions The list of purpose options.
+     * @param {Array} activityOptions The list of activity options. Optional.
+     */
+    function showDefaultsFormModal(title, contextLevel, category, purpose, activity,
+                                   categoryOptions, purposeOptions, activityOptions) {
+
+        if (category !== null) {
+            categoryOptions.forEach(function(currentValue) {
+                if (currentValue.id === category) {
+                    currentValue.selected = true;
+                }
+            });
+        }
+
+        if (purpose !== null) {
+            purposeOptions.forEach(function(currentValue) {
+                if (currentValue.id === purpose) {
+                    currentValue.selected = true;
+                }
+            });
+        }
+
+        var templateContext = {
+            "contextlevel": contextLevel,
+            "categoryoptions": categoryOptions,
+            "purposeoptions": purposeOptions
+        };
+
+        // Check the activityOptions parameter that was passed.
+        if (activityOptions !== null && activityOptions.length) {
+            // Check the activity parameter that was passed.
+            if (activity === null) {
+                // We're setting a new defaults for a module.
+                templateContext.newactivitydefaults = true;
+
+            } else {
+                // Edit mode. Set selection.
+                activityOptions.forEach(function(currentValue) {
+                    if (activity === currentValue.name) {
+                        currentValue.selected = true;
+                    }
+                });
+            }
+
+            templateContext.modemodule = true;
+            templateContext.activityoptions = activityOptions;
+        }
+
+        ModalFactory.create({
+            title: title,
+            body: Templates.render('tool_dataprivacy/category_purpose_form', templateContext),
+            type: ModalFactory.types.SAVE_CANCEL,
+            large: true
+        }).then(function(modal) {
+
+            // Handle save event.
+            modal.getRoot().on(ModalEvents.save, function() {
+                var activity = $('#activity');
+                var activityVal = typeof activity !== 'undefined' ? activity.val() : null;
+                var override = $('#override');
+                var overrideVal = typeof override !== 'undefined' ? override.is(':checked') : false;
+
+                setContextDefaults($('#contextlevel').val(), $('#category').val(), $('#purpose').val(), activityVal, overrideVal);
+            });
+
+            // Handle hidden event.
+            modal.getRoot().on(ModalEvents.hidden, function() {
+                // Destroy when hidden.
+                modal.destroy();
+            });
+
+            modal.show();
+
+            return modal;
+        }).catch(Notification.exception);
+    }
+
+    /**
+     * Calls a the tool_dataprivacy_set_context_defaults WS function.
+     *
+     * @param {Number} contextLevel The context level.
+     * @param {Number} category The category ID.
+     * @param {Number} purpose The purpose ID.
+     * @param {String} activity The plugin name of the activity module.
+     * @param {Boolean} override Whether to override custom instances.
+     */
+    function setContextDefaults(contextLevel, category, purpose, activity, override) {
+        var request = {
+            methodname: 'tool_dataprivacy_set_context_defaults',
+            args: {
+                'contextlevel': contextLevel,
+                'category': category,
+                'purpose': purpose,
+                'override': override,
+                'activity': activity
+            }
+        };
+
+        Ajax.call([request])[0].done(function(data) {
+            if (data.result) {
+                window.location.reload();
+            }
+        });
+    }
+
+    return /** @alias module:tool_dataprivacy/defaultsactions */ {
+        // Public variables and functions.
+
+        /**
+         * Initialise the module.
+         *
+         * @method init
+         * @return {DefaultsActions}
+         */
+        'init': function() {
+            return new DefaultsActions();
+        }
+    };
+});
index 4e7ff77..cb1d9af 100644 (file)
@@ -25,7 +25,9 @@
 define([], function() {
     return {
         approve: 'tool_dataprivacy-data_request:approve',
+        bulkApprove: 'tool_dataprivacy-data_request:bulk_approve',
         deny: 'tool_dataprivacy-data_request:deny',
+        bulkDeny: 'tool_dataprivacy-data_request:bulk_deny',
         complete: 'tool_dataprivacy-data_request:complete'
     };
 });
index 4f3c406..37c4d92 100644 (file)
@@ -40,12 +40,38 @@ function($, Ajax, Notification, Str, ModalFactory, ModalEvents, Templates, Modal
      * @type {{DENY_REQUEST: string}}
      * @type {{VIEW_REQUEST: string}}
      * @type {{MARK_COMPLETE: string}}
+     * @type {{CHANGE_BULK_ACTION: string}}
+     * @type {{CONFIRM_BULK_ACTION: string}}
+     * @type {{SELECT_ALL: string}}
      */
     var ACTIONS = {
         APPROVE_REQUEST: '[data-action="approve"]',
         DENY_REQUEST: '[data-action="deny"]',
         VIEW_REQUEST: '[data-action="view"]',
-        MARK_COMPLETE: '[data-action="complete"]'
+        MARK_COMPLETE: '[data-action="complete"]',
+        CHANGE_BULK_ACTION: '[id="bulk-action"]',
+        CONFIRM_BULK_ACTION: '[id="confirm-bulk-action"]',
+        SELECT_ALL: '[data-action="selectall"]'
+    };
+
+    /**
+     * List of available bulk actions.
+     *
+     * @type {{APPROVE: number}}
+     * @type {{DENY: number}}
+     */
+    var BULK_ACTIONS = {
+        APPROVE: 1,
+        DENY: 2
+    };
+
+    /**
+     * List of selectors.
+     *
+     * @type {{SELECT_REQUEST: string}}
+     */
+    var SELECTORS = {
+        SELECT_REQUEST: '.selectrequests'
     };
 
     /**
@@ -103,12 +129,12 @@ function($, Ajax, Notification, Str, ModalFactory, ModalEvents, Templates, Modal
             }).then(function(modal) {
                 // Handle approve event.
                 modal.getRoot().on(DataPrivacyEvents.approve, function() {
-                    showConfirmation(DataPrivacyEvents.approve, requestId);
+                    showConfirmation(DataPrivacyEvents.approve, approveEventWsData(requestId));
                 });
 
                 // Handle deny event.
                 modal.getRoot().on(DataPrivacyEvents.deny, function() {
-                    showConfirmation(DataPrivacyEvents.deny, requestId);
+                    showConfirmation(DataPrivacyEvents.deny, denyEventWsData(requestId));
                 });
 
                 // Handle send event.
@@ -137,34 +163,158 @@ function($, Ajax, Notification, Str, ModalFactory, ModalEvents, Templates, Modal
             e.preventDefault();
 
             var requestId = $(this).data('requestid');
-            showConfirmation(DataPrivacyEvents.approve, requestId);
+            showConfirmation(DataPrivacyEvents.approve, approveEventWsData(requestId));
         });
 
         $(ACTIONS.DENY_REQUEST).click(function(e) {
             e.preventDefault();
 
             var requestId = $(this).data('requestid');
-            showConfirmation(DataPrivacyEvents.deny, requestId);
+            showConfirmation(DataPrivacyEvents.deny, denyEventWsData(requestId));
         });
 
         $(ACTIONS.MARK_COMPLETE).click(function(e) {
             e.preventDefault();
-            showConfirmation(DataPrivacyEvents.complete, $(this).data('requestid'));
+
+            var requestId = $(this).data('requestid');
+            showConfirmation(DataPrivacyEvents.complete, completeEventWsData(requestId));
+        });
+
+        $(ACTIONS.CONFIRM_BULK_ACTION).click(function() {
+            var requestIds = [];
+            var actionEvent = '';
+            var wsdata = {};
+            var bulkActionKeys = [
+                {
+                    key: 'selectbulkaction',
+                    component: 'tool_dataprivacy'
+                },
+                {
+                    key: 'selectdatarequests',
+                    component: 'tool_dataprivacy'
+                },
+                {
+                    key: 'ok'
+                }
+            ];
+
+            var bulkaction = parseInt($('#bulk-action').val());
+
+            if (bulkaction != BULK_ACTIONS.APPROVE && bulkaction != BULK_ACTIONS.DENY) {
+                Str.get_strings(bulkActionKeys).done(function(langStrings) {
+                    Notification.alert('', langStrings[0], langStrings[2]);
+                }).fail(Notification.exception);
+
+                return;
+            }
+
+            $(".selectrequests:checked").each(function() {
+                requestIds.push($(this).val());
+            });
+
+            if (requestIds.length < 1) {
+                Str.get_strings(bulkActionKeys).done(function(langStrings) {
+                    Notification.alert('', langStrings[1], langStrings[2]);
+                }).fail(Notification.exception);
+
+                return;
+            }
+
+            switch (bulkaction) {
+                case BULK_ACTIONS.APPROVE:
+                    actionEvent = DataPrivacyEvents.bulkApprove;
+                    wsdata = bulkApproveEventWsData(requestIds);
+                    break;
+                case BULK_ACTIONS.DENY:
+                    actionEvent = DataPrivacyEvents.bulkDeny;
+                    wsdata = bulkDenyEventWsData(requestIds);
+            }
+
+            showConfirmation(actionEvent, wsdata);
+        });
+
+        $(ACTIONS.SELECT_ALL).change(function(e) {
+            e.preventDefault();
+
+            var selectAll = $(this).is(':checked');
+            $(SELECTORS.SELECT_REQUEST).prop('checked', selectAll);
         });
     };
 
+    /**
+     * Return the webservice data for the approve request action.
+     *
+     * @param {Number} requestId The ID of the request.
+     * @return {Object}
+     */
+    function approveEventWsData(requestId) {
+        return {
+            'wsfunction': 'tool_dataprivacy_approve_data_request',
+            'wsparams': {'requestid': requestId}
+        };
+    }
+
+    /**
+     * Return the webservice data for the bulk approve request action.
+     *
+     * @param {Array} requestIds The array of request ID's.
+     * @return {Object}
+     */
+    function bulkApproveEventWsData(requestIds) {
+        return {
+            'wsfunction': 'tool_dataprivacy_bulk_approve_data_requests',
+            'wsparams': {'requestids': requestIds}
+        };
+    }
+
+    /**
+     * Return the webservice data for the deny request action.
+     *
+     * @param {Number} requestId The ID of the request.
+     * @return {Object}
+     */
+    function denyEventWsData(requestId) {
+        return {
+            'wsfunction': 'tool_dataprivacy_deny_data_request',
+            'wsparams': {'requestid': requestId}
+        };
+    }
+
+    /**
+     * Return the webservice data for the bulk deny request action.
+     *
+     * @param {Array} requestIds The array of request ID's.
+     * @return {Object}
+     */
+    function bulkDenyEventWsData(requestIds) {
+        return {
+            'wsfunction': 'tool_dataprivacy_bulk_deny_data_requests',
+            'wsparams': {'requestids': requestIds}
+        };
+    }
+
+    /**
+     * Return the webservice data for the complete request action.
+     *
+     * @param {Number} requestId The ID of the request.
+     * @return {Object}
+     */
+    function completeEventWsData(requestId) {
+        return {
+            'wsfunction': 'tool_dataprivacy_mark_complete',
+            'wsparams': {'requestid': requestId}
+        };
+    }
+
     /**
      * Show the confirmation dialogue.
      *
      * @param {String} action The action name.
-     * @param {Number} requestId The request ID.
+     * @param {Object} wsdata Object containing ws data.
      */
-    function showConfirmation(action, requestId) {
+    function showConfirmation(action, wsdata) {
         var keys = [];
-        var wsfunction = '';
-        var params = {
-            'requestid': requestId
-        };
+
         switch (action) {
             case DataPrivacyEvents.approve:
                 keys = [
@@ -177,7 +327,18 @@ function($, Ajax, Notification, Str, ModalFactory, ModalEvents, Templates, Modal
                         component: 'tool_dataprivacy'
                     }
                 ];
-                wsfunction = 'tool_dataprivacy_approve_data_request';
+                break;
+            case DataPrivacyEvents.bulkApprove:
+                keys = [
+                    {
+                        key: 'bulkapproverequests',
+                        component: 'tool_dataprivacy'
+                    },
+                    {
+                        key: 'confirmbulkapproval',
+                        component: 'tool_dataprivacy'
+                    }
+                ];
                 break;
             case DataPrivacyEvents.deny:
                 keys = [
@@ -190,7 +351,18 @@ function($, Ajax, Notification, Str, ModalFactory, ModalEvents, Templates, Modal
                         component: 'tool_dataprivacy'
                     }
                 ];
-                wsfunction = 'tool_dataprivacy_deny_data_request';
+                break;
+            case DataPrivacyEvents.bulkDeny:
+                keys = [
+                    {
+                        key: 'bulkdenyrequests',
+                        component: 'tool_dataprivacy'
+                    },
+                    {
+                        key: 'confirmbulkdenial',
+                        component: 'tool_dataprivacy'
+                    }
+                ];
                 break;
             case DataPrivacyEvents.complete:
                 keys = [
@@ -203,7 +375,6 @@ function($, Ajax, Notification, Str, ModalFactory, ModalEvents, Templates, Modal
                         component: 'tool_dataprivacy'
                     }
                 ];
-                wsfunction = 'tool_dataprivacy_mark_complete';
                 break;
         }
 
@@ -221,7 +392,7 @@ function($, Ajax, Notification, Str, ModalFactory, ModalEvents, Templates, Modal
 
             // Handle save event.
             modal.getRoot().on(ModalEvents.save, function() {
-                handleSave(wsfunction, params);
+                handleSave(wsdata.wsfunction, wsdata.wsparams);
             });
 
             // Handle hidden event.
index f323278..aa58917 100644 (file)
@@ -33,6 +33,7 @@ $title = get_string('editcategories', 'tool_dataprivacy');
 
 $output = $PAGE->get_renderer('tool_dataprivacy');
 echo $output->header();
+echo $output->heading($title);
 
 $categories = \tool_dataprivacy\api::get_categories();
 $renderable = new \tool_dataprivacy\output\categories($categories);
index 6ee9707..7e5f350 100644 (file)
@@ -24,7 +24,7 @@
 namespace tool_dataprivacy;
 
 use coding_exception;
-use context_course;
+use context_helper;
 use context_system;
 use core\invalid_persistent_exception;
 use core\message\message;
@@ -94,6 +94,12 @@ class api {
     /** Data delete request completed, account is removed. */
     const DATAREQUEST_STATUS_DELETED = 10;
 
+    /** Approve data request. */
+    const DATAREQUEST_ACTION_APPROVE = 1;
+
+    /** Reject data request. */
+    const DATAREQUEST_ACTION_REJECT = 2;
+
     /**
      * Determines whether the user can contact the site's Data Protection Officer via Moodle.
      *
@@ -105,18 +111,14 @@ class api {
     }
 
     /**
-     * Check's whether the current user has the capability to manage data requests.
+     * Checks whether the current user has the capability to manage data requests.
      *
      * @param int $userid The user ID.
      * @return bool
-     * @throws coding_exception
-     * @throws dml_exception
      */
     public static function can_manage_data_requests($userid) {
-        $context = context_system::instance();
-
-        // A user can manage data requests if he/she has the site DPO role and has the capability to manage data requests.
-        return self::is_site_dpo($userid) && has_capability('tool/dataprivacy:managedatarequests', $context, $userid);
+        // Privacy officers can manage data requests.
+        return self::is_site_dpo($userid);
     }
 
     /**
@@ -136,6 +138,31 @@ class api {
         require_capability('tool/dataprivacy:managedataregistry', $context);
     }
 
+    /**
+     * Fetches the list of configured privacy officer roles.
+     *
+     * Every time this function is called, it checks each role if they have the 'managedatarequests' capability and removes
+     * any role that doesn't have the required capability anymore.
+     *
+     * @return int[]
+     * @throws dml_exception
+     */
+    public static function get_assigned_privacy_officer_roles() {
+        $roleids = [];
+
+        // Get roles from config.
+        $configroleids = explode(',', str_replace(' ', '', get_config('tool_dataprivacy', 'dporoles')));
+        if (!empty($configroleids)) {
+            // Fetch roles that have the capability to manage data requests.
+            $capableroles = array_keys(get_roles_with_capability('tool/dataprivacy:managedatarequests'));
+
+            // Extract the configured roles that have the capability from the list of capable roles.
+            $roleids = array_intersect($capableroles, $configroleids);
+        }
+
+        return $roleids;
+    }
+
     /**
      * Fetches the role shortnames of Data Protection Officer roles.
      *
@@ -144,7 +171,7 @@ class api {
     public static function get_dpo_role_names() : array {
         global $DB;
 
-        $dporoleids = explode(',', str_replace(' ', '', get_config('tool_dataprivacy', 'dporoles')));
+        $dporoleids = self::get_assigned_privacy_officer_roles();
         $dponames = array();
 
         if (!empty($dporoleids)) {
@@ -156,20 +183,15 @@ class api {
     }
 
     /**
-     * Fetches the list of users with the Data Protection Officer role.
-     *
-     * @throws dml_exception
+     * Fetches the list of users with the Privacy Officer role.
      */
     public static function get_site_dpos() {
         // Get role(s) that can manage data requests.
-        $dporoles = explode(',', get_config('tool_dataprivacy', 'dporoles'));
+        $dporoles = self::get_assigned_privacy_officer_roles();
 
         $dpos = [];
         $context = context_system::instance();
         foreach ($dporoles as $roleid) {
-            if (empty($roleid)) {
-                continue;
-            }
             $allnames = get_all_user_name_fields(true, 'u');
             $fields = 'u.id, u.confirmed, u.username, '. $allnames . ', ' .
                       'u.maildisplay, u.mailformat, u.maildigest, u.email, u.emailstop, u.city, '.
@@ -189,15 +211,14 @@ class api {
     }
 
     /**
-     * Checks whether a given user is a site DPO.
+     * Checks whether a given user is a site Privacy Officer.
      *
      * @param int $userid The user ID.
      * @return bool
-     * @throws dml_exception
      */
     public static function is_site_dpo($userid) {
         $dpos = self::get_site_dpos();
-        return array_key_exists($userid, $dpos);
+        return array_key_exists($userid, $dpos) || is_siteadmin();
     }
 
     /**
@@ -619,6 +640,8 @@ class api {
             'requestedby' => $requestedby->fullname,
             'requesttype' => $typetext,
             'requestdate' => userdate($requestdata->timecreated),
+            'requestorigin' => $SITE->fullname,
+            'requestoriginurl' => new moodle_url('/'),
             'requestcomments' => $requestdata->messagehtml,
             'datarequestsurl' => $datarequestsurl
         ];
@@ -1141,4 +1164,91 @@ class api {
 
         return $approvedcollection;
     }
+
+    /**
+     * Updates the default category and purpose for a given context level (and optionally, a plugin).
+     *
+     * @param int $contextlevel The context level.
+     * @param int $categoryid The ID matching the category.
+     * @param int $purposeid The ID matching the purpose record.
+     * @param int $activity The name of the activity that we're making a defaults configuration for.
+     * @param bool $override Whether to override the purpose/categories of existing instances to these defaults.
+     * @return boolean True if set/unset config succeeds. Otherwise, it throws an exception.
+     */
+    public static function set_context_defaults($contextlevel, $categoryid, $purposeid, $activity = null, $override = false) {
+        global $DB;
+
+        self::check_can_manage_data_registry();
+
+        // Get the class name associated with this context level.
+        $classname = context_helper::get_class_for_level($contextlevel);
+        list($purposevar, $categoryvar) = data_registry::var_names_from_context($classname, $activity);
+
+        // Check the default category to be set.
+        if ($categoryid == context_instance::INHERIT) {
+            unset_config($categoryvar, 'tool_dataprivacy');
+
+        } else {
+            // Make sure the given category ID exists first.
+            $categorypersistent = new category($categoryid);
+            $categorypersistent->read();
+
+            // Then set the new default value.
+            set_config($categoryvar, $categoryid, 'tool_dataprivacy');
+        }
+
+        // Check the default purpose to be set.
+        if ($purposeid == context_instance::INHERIT) {
+            // If the defaults is set to inherit, just unset the config value.
+            unset_config($purposevar, 'tool_dataprivacy');
+
+        } else {
+            // Make sure the given purpose ID exists first.
+            $purposepersistent = new purpose($purposeid);
+            $purposepersistent->read();
+
+            // Then set the new default value.
+            set_config($purposevar, $purposeid, 'tool_dataprivacy');
+        }
+
+        // Unset instances that have been assigned with custom purpose and category, if override was specified.
+        if ($override) {
+            // We'd like to find context IDs that we want to unset.
+            $statements = ["SELECT c.id as contextid FROM {context} c"];
+            // Based on this context level.
+            $params = ['contextlevel' => $contextlevel];
+
+            if ($contextlevel == CONTEXT_MODULE) {
+                // If we're deleting module context instances, we need to make sure the instance ID is in the course modules table.
+                $statements[] = "JOIN {course_modules} cm ON cm.id = c.instanceid";
+                // And that the module is listed on the modules table.
+                $statements[] = "JOIN {modules} m ON m.id = cm.module";
+
+                if ($activity) {
+                    // If we're overriding for an activity module, make sure that the context instance matches that activity.
+                    $statements[] = "AND m.name = :modname";
+                    $params['modname'] = $activity;
+                }
+            }
+            // Make sure this context instance exists in the tool_dataprivacy_ctxinstance table.
+            $statements[] = "JOIN {tool_dataprivacy_ctxinstance} tdc ON tdc.contextid = c.id";
+            // And that the context level of this instance matches the given context level.
+            $statements[] = "WHERE c.contextlevel = :contextlevel";
+
+            // Build our SQL query by gluing the statements.
+            $sql = implode("\n", $statements);
+
+            // Get the context records matching our query.
+            $contextids = $DB->get_fieldset_sql($sql, $params);
+
+            // Delete the matching context instances.
+            foreach ($contextids as $contextid) {
+                if ($instance = context_instance::get_record_by_contextid($contextid, false)) {
+                    self::unset_context_instance($instance);
+                }
+            }
+        }
+
+        return true;
+    }
 }
index 1fac2f5..7b46b4d 100644 (file)
 namespace tool_dataprivacy;
 
 use coding_exception;
-use tool_dataprivacy\purpose;
-use tool_dataprivacy\category;
-use tool_dataprivacy\contextlevel;
-use tool_dataprivacy\context_instance;
+use core\persistent;
 
 defined('MOODLE_INTERNAL') || die();
 
@@ -57,13 +54,22 @@ class data_registry {
     /**
      * Returns purpose and category var names from a context class name
      *
-     * @param string $classname
+     * @param string $classname The context level's class.
+     * @param string $pluginname The name of the plugin associated with the context level.
      * @return string[]
      */
-    public static function var_names_from_context($classname) {
+    public static function var_names_from_context($classname, $pluginname = '') {
+        $pluginname = trim($pluginname);
+        if (!empty($pluginname)) {
+            $categoryvar = $classname . '_' . $pluginname . '_category';
+            $purposevar = $classname . '_' . $pluginname . '_purpose';
+        } else {
+            $categoryvar = $classname . '_category';
+            $purposevar = $classname . '_purpose';
+        }
         return [
-            $classname . '_purpose',
-            $classname . '_category',
+            $purposevar,
+            $categoryvar
         ];
     }
 
@@ -72,17 +78,31 @@ class data_registry {
      *
      * The caller code is responsible of checking that $contextlevel is an integer.
      *
-     * @param int $contextlevel
-     * @return int|false[]
+     * @param int $contextlevel The context level.
+     * @param string $pluginname The name of the plugin associated with the context level.
+     * @return int[]|false[]
      */
-    public static function get_defaults($contextlevel) {
+    public static function get_defaults($contextlevel, $pluginname = '') {
 
         $classname = \context_helper::get_class_for_level($contextlevel);
-        list($purposevar, $categoryvar) = self::var_names_from_context($classname);
+        list($purposevar, $categoryvar) = self::var_names_from_context($classname, $pluginname);
 
         $purposeid = get_config('tool_dataprivacy', $purposevar);
         $categoryid = get_config('tool_dataprivacy', $categoryvar);
 
+        if (!empty($pluginname)) {
+            list($purposevar, $categoryvar) = self::var_names_from_context($classname);
+            // If the plugin-level doesn't have a default purpose set, try the context level.
+            if ($purposeid === false) {
+                $purposeid = get_config('tool_dataprivacy', $purposevar);
+            }
+
+            // If the plugin-level doesn't have a default category set, try the context level.
+            if ($categoryid === false) {
+                $categoryid = get_config('tool_dataprivacy', $categoryvar);
+            }
+        }
+
         if (empty($purposeid)) {
             $purposeid = false;
         }
@@ -197,9 +217,15 @@ class data_registry {
                 // Use the context level value as we don't allow people to set specific instances values.
                 return self::get_effective_contextlevel_value($context->contextlevel, $element);
             } else {
+                // Check if we need to pass the plugin name of an activity.
+                $forplugin = '';
+                if ($context->contextlevel == CONTEXT_MODULE) {
+                    list($course, $cm) = get_course_and_cm_from_cmid($context->instanceid);
+                    $forplugin = $cm->modname;
+                }
                 // Use the default context level value.
                 list($purposeid, $categoryid) = self::get_effective_default_contextlevel_purpose_and_category(
-                    $context->contextlevel
+                    $context->contextlevel, false, false, $forplugin
                 );
                 return self::get_element_instance($element, $$fieldname);
             }
@@ -283,14 +309,15 @@ class data_registry {
      * Returns the effective default purpose and category for a context level.
      *
      * @param int $contextlevel
-     * @param int $forcedpurposevalue Use this value as if this was this context level purpose.
-     * @param int $forcedcategoryvalue Use this value as if this was this context level category.
+     * @param int|bool $forcedpurposevalue Use this value as if this was this context level purpose.
+     * @param int|bool $forcedcategoryvalue Use this value as if this was this context level category.
+     * @param string $activity The plugin name of the activity.
      * @return int[]
      */
     public static function get_effective_default_contextlevel_purpose_and_category($contextlevel, $forcedpurposevalue = false,
-                                                                                   $forcedcategoryvalue = false) {
+                                                                                   $forcedcategoryvalue = false, $activity = '') {
 
-        list($purposeid, $categoryid) = self::get_defaults($contextlevel);
+        list($purposeid, $categoryid) = self::get_defaults($contextlevel, $activity);
 
         // Honour forced values.
         if ($forcedpurposevalue) {
index e14e072..c3f9f65 100644 (file)
@@ -421,6 +421,85 @@ class external extends external_api {
         ]);
     }
 
+    /**
+     * Parameter description for bulk_approve_data_requests().
+     *
+     * @since Moodle 3.5
+     * @return external_function_parameters
+     */
+    public static function bulk_approve_data_requests_parameters() {
+        return new external_function_parameters([
+            'requestids' => new external_multiple_structure(
+                new external_value(PARAM_INT, 'The request ID', VALUE_REQUIRED)
+            )
+        ]);
+    }
+
+    /**
+     * Bulk approve bulk data request.
+     *
+     * @since Moodle 3.5
+     * @param array $requestids Array consisting the request ID's.
+     * @return array
+     * @throws coding_exception
+     * @throws dml_exception
+     * @throws invalid_parameter_exception
+     * @throws restricted_context_exception
+     * @throws moodle_exception
+     */
+    public static function bulk_approve_data_requests($requestids) {
+        $warnings = [];
+        $result = false;
+        $params = external_api::validate_parameters(self::bulk_approve_data_requests_parameters(), [
+            'requestids' => $requestids
+        ]);
+        $requestids = $params['requestids'];
+
+        // Validate context.
+        $context = context_system::instance();
+        self::validate_context($context);
+        require_capability('tool/dataprivacy:managedatarequests', $context);
+
+        foreach ($requestids as $requestid) {
+            // Ensure the request exists.
+            $requestexists = data_request::record_exists($requestid);
+
+            if ($requestexists) {
+                api::approve_data_request($requestid);
+            } else {
+                $warnings[] = [
+                    'item' => $requestid,
+                    'warningcode' => 'errorrequestnotfound',
+                    'message' => get_string('errorrequestnotfound', 'tool_dataprivacy')
+                ];
+            }
+        }
+
+        if (empty($warnings)) {
+            $result = true;
+            // Add notification in the session to be shown when the page is reloaded on the JS side.
+            notification::success(get_string('requestsapproved', 'tool_dataprivacy'));
+        }
+
+        return [
+            'result' => $result,
+            'warnings' => $warnings
+        ];
+    }
+
+    /**
+     * Parameter description for bulk_approve_data_requests().
+     *
+     * @since Moodle 3.5
+     * @return external_description
+     */
+    public static function bulk_approve_data_requests_returns() {
+        return new external_single_structure([
+            'result' => new external_value(PARAM_BOOL, 'The processing result'),
+            'warnings' => new external_warnings()
+        ]);
+    }
+
     /**
      * Parameter description for deny_data_request().
      *
@@ -493,6 +572,85 @@ class external extends external_api {
         ]);
     }
 
+    /**
+     * Parameter description for bulk_deny_data_requests().
+     *
+     * @since Moodle 3.5
+     * @return external_function_parameters
+     */
+    public static function bulk_deny_data_requests_parameters() {
+        return new external_function_parameters([
+            'requestids' => new external_multiple_structure(
+                new external_value(PARAM_INT, 'The request ID', VALUE_REQUIRED)
+            )
+        ]);
+    }
+
+    /**
+     * Bulk deny data requests.
+     *
+     * @since Moodle 3.5
+     * @param array $requestids Array consisting of request ID's.
+     * @return array
+     * @throws coding_exception
+     * @throws dml_exception
+     * @throws invalid_parameter_exception
+     * @throws restricted_context_exception
+     * @throws moodle_exception
+     */
+    public static function bulk_deny_data_requests($requestids) {
+        $warnings = [];
+        $result = false;
+        $params = external_api::validate_parameters(self::bulk_deny_data_requests_parameters(), [
+            'requestids' => $requestids
+        ]);
+        $requestids = $params['requestids'];
+
+        // Validate context.
+        $context = context_system::instance();
+        self::validate_context($context);
+        require_capability('tool/dataprivacy:managedatarequests', $context);
+
+        foreach ($requestids as $requestid) {
+            // Ensure the request exists.
+            $requestexists = data_request::record_exists($requestid);
+
+            if ($requestexists) {
+                api::deny_data_request($requestid);
+            } else {
+                $warnings[] = [
+                    'item' => $requestid,
+                    'warningcode' => 'errorrequestnotfound',
+                    'message' => get_string('errorrequestnotfound', 'tool_dataprivacy')
+                ];
+            }
+        }
+
+        if (empty($warnings)) {
+            $result = true;
+            // Add notification in the session to be shown when the page is reloaded on the JS side.
+            notification::success(get_string('requestsdenied', 'tool_dataprivacy'));
+        }
+
+        return [
+            'result' => $result,
+            'warnings' => $warnings
+        ];
+    }
+
+    /**
+     * Parameter description for bulk_deny_data_requests().
+     *
+     * @since Moodle 3.5
+     * @return external_description
+     */
+    public static function bulk_deny_data_requests_returns() {
+        return new external_single_structure([
+            'result' => new external_value(PARAM_BOOL, 'The processing result'),
+            'warnings' => new external_warnings()
+        ]);
+    }
+
     /**
      * Parameter description for get_data_request().
      *
@@ -1110,6 +1268,287 @@ class external extends external_api {
         ]);
     }
 
+    /**
+     * Parameters for set_context_defaults().
+     *
+     * @return external_function_parameters
+     */
+    public static function set_context_defaults_parameters() {
+        return new external_function_parameters([
+            'contextlevel' => new external_value(PARAM_INT, 'The context level', VALUE_REQUIRED),
+            'category' => new external_value(PARAM_INT, 'The default category for the given context level', VALUE_REQUIRED),
+            'purpose' => new external_value(PARAM_INT, 'The default purpose for the given context level', VALUE_REQUIRED),
+            'activity' => new external_value(PARAM_PLUGIN, 'The plugin name of the activity', VALUE_DEFAULT, null),
+            'override' => new external_value(PARAM_BOOL, 'Whether to override existing instances with the defaults', VALUE_DEFAULT,
+                false),
+        ]);
+    }
+
+    /**
+     * Updates the default category and purpose for a given context level (and optionally, a plugin).
+     *
+     * @param int $contextlevel The context level.
+     * @param int $category The ID matching the category.
+     * @param int $purpose The ID matching the purpose record.
+     * @param int $activity The name of the activity that we're making a defaults configuration for.
+     * @param bool $override Whether to override the purpose/categories of existing instances to these defaults.
+     * @return array
+     */
+    public static function set_context_defaults($contextlevel, $category, $purpose, $activity, $override) {
+        $warnings = [];
+
+        $params = external_api::validate_parameters(self::set_context_defaults_parameters(), [
+            'contextlevel' => $contextlevel,
+            'category' => $category,
+            'purpose' => $purpose,
+            'activity' => $activity,
+            'override' => $override,
+        ]);
+        $contextlevel = $params['contextlevel'];
+        $category = $params['category'];
+        $purpose = $params['purpose'];
+        $activity = $params['activity'];
+        $override = $params['override'];
+
+        // Validate context.
+        $context = context_system::instance();
+        self::validate_context($context);
+
+        // Set the context defaults.
+        $result = api::set_context_defaults($contextlevel, $category, $purpose, $activity, $override);
+
+        return [
+            'result' => $result,
+            'warnings' => $warnings
+        ];
+    }
+
+    /**
+     * Returns for set_context_defaults().
+     *
+     * @return external_single_structure
+     */
+    public static function set_context_defaults_returns() {
+        return new external_single_structure([
+            'result' => new external_value(PARAM_BOOL, 'Whether the context defaults were successfully set or not'),
+            'warnings' => new external_warnings()
+        ]);
+    }
+
+    /**
+     * Parameters for get_category_options().
+     *
+     * @return external_function_parameters
+     */
+    public static function get_category_options_parameters() {
+        return new external_function_parameters([
+            'includeinherit' => new external_value(PARAM_BOOL, 'Include option "Inherit"', VALUE_DEFAULT, true),
+            'includenotset' => new external_value(PARAM_BOOL, 'Include option "Not set"', VALUE_DEFAULT, false),
+        ]);
+    }
+
+    /**
+     * Fetches a list of data category options containing category IDs as keys and the category name for the value.
+     *
+     * @param bool $includeinherit Whether to include the "Inherit" option.
+     * @param bool $includenotset Whether to include the "Not set" option.
+     * @return array
+     */
+    public static function get_category_options($includeinherit, $includenotset) {
+        $warnings = [];
+
+        $params = self::validate_parameters(self::get_category_options_parameters(), [
+            'includeinherit' => $includeinherit,
+            'includenotset' => $includenotset
+        ]);
+        $includeinherit = $params['includeinherit'];
+        $includenotset = $params['includenotset'];
+
+        $context = context_system::instance();
+        self::validate_context($context);
+
+        $categories = api::get_categories();
+        $options = data_registry_page::category_options($categories, $includenotset, $includeinherit);
+        $categoryoptions = [];
+        foreach ($options as $id => $name) {
+            $categoryoptions[] = [
+                'id' => $id,
+                'name' => $name,
+            ];
+        }
+
+        return [
+            'options' => $categoryoptions,
+            'warnings' => $warnings
+        ];
+    }
+
+    /**
+     * Returns for get_category_options().
+     *
+     * @return external_single_structure
+     */
+    public static function get_category_options_returns() {
+        $optiondefinition = new external_single_structure(
+            [
+                'id' => new external_value(PARAM_INT, 'The category ID'),
+                'name' => new external_value(PARAM_TEXT, 'The category name'),
+            ]
+        );
+
+        return new external_single_structure([
+            'options' => new external_multiple_structure($optiondefinition),
+            'warnings' => new external_warnings()
+        ]);
+    }
+
+    /**
+     * Parameters for get_purpose_options().
+     *
+     * @return external_function_parameters
+     */
+    public static function get_purpose_options_parameters() {
+        return new external_function_parameters([
+            'includeinherit' => new external_value(PARAM_BOOL, 'Include option "Inherit"', VALUE_DEFAULT, true),
+            'includenotset' => new external_value(PARAM_BOOL, 'Include option "Not set"', VALUE_DEFAULT, false),
+        ]);
+    }
+
+    /**
+     * Fetches a list of data storage purposes containing purpose IDs as keys and the purpose name for the value.
+     *
+     * @param bool $includeinherit Whether to include the "Inherit" option.
+     * @param bool $includenotset Whether to include the "Not set" option.
+     * @return array
+     */
+    public static function get_purpose_options($includeinherit, $includenotset) {
+        $warnings = [];
+
+        $params = self::validate_parameters(self::get_category_options_parameters(), [
+            'includeinherit' => $includeinherit,
+            'includenotset' => $includenotset
+        ]);
+        $includeinherit = $params['includeinherit'];
+        $includenotset = $params['includenotset'];
+
+        $context = context_system::instance();
+        self::validate_context($context);
+
+        $purposes = api::get_purposes();
+        $options = data_registry_page::purpose_options($purposes, $includenotset, $includeinherit);
+        $purposeoptions = [];
+        foreach ($options as $id => $name) {
+            $purposeoptions[] = [
+                'id' => $id,
+                'name' => $name,
+            ];
+        }
+
+        return [
+            'options' => $purposeoptions,
+            'warnings' => $warnings
+        ];
+    }
+
+    /**
+     * Returns for get_purpose_options().
+     *
+     * @return external_single_structure
+     */
+    public static function get_purpose_options_returns() {
+        $optiondefinition = new external_single_structure(
+            [
+                'id' => new external_value(PARAM_INT, 'The purpose ID'),
+                'name' => new external_value(PARAM_TEXT, 'The purpose name'),
+            ]
+        );
+
+        return new external_single_structure([
+            'options' => new external_multiple_structure($optiondefinition),
+            'warnings' => new external_warnings()
+        ]);
+    }
+
+    /**
+     * Parameters for get_activity_options().
+     *
+     * @return external_function_parameters
+     */
+    public static function get_activity_options_parameters() {
+        return new external_function_parameters([
+            'nodefaults' => new external_value(PARAM_BOOL, 'Whether to fetch all activities or only those without defaults',
+                VALUE_DEFAULT, false),
+        ]);
+    }
+
+    /**
+     * Fetches a list of activity options for setting data registry defaults.
+     *
+     * @param boolean $nodefaults If false, it will fetch all of the activities. Otherwise, it will only fetch the activities
+     *                            that don't have defaults yet (e.g. when adding a new activity module defaults).
+     * @return array
+     */
+    public static function get_activity_options($nodefaults) {
+        $warnings = [];
+
+        $params = self::validate_parameters(self::get_activity_options_parameters(), [
+            'nodefaults' => $nodefaults,
+        ]);
+        $nodefaults = $params['nodefaults'];
+
+        $context = context_system::instance();
+        self::validate_context($context);
+
+        // Get activity module plugin info.
+        $pluginmanager = \core_plugin_manager::instance();
+        $modplugins = $pluginmanager->get_enabled_plugins('mod');
+        $modoptions = [];
+
+        // Get the module-level defaults. data_registry::get_defaults falls back to this when there are no activity defaults.
+        list($levelpurpose, $levelcategory) = data_registry::get_defaults(CONTEXT_MODULE);
+        foreach ($modplugins as $name) {
+            // Check if we have default purpose and category for this module if we want don't want to fetch everything.
+            if ($nodefaults) {
+                list($purpose, $category) = data_registry::get_defaults(CONTEXT_MODULE, $name);
+                // Compare this with the module-level defaults.
+                if ($purpose !== $levelpurpose || $category !== $levelcategory) {
+                    // If the defaults for this activity has been already set, there's no need to add this in the list of options.
+                    continue;
+                }
+            }
+
+            $displayname = $pluginmanager->plugin_name('mod_' . $name);
+            $modoptions[] = (object)[
+                'name' => $name,
+                'displayname' => $displayname
+            ];
+        }
+
+        return [
+            'options' => $modoptions,
+            'warnings' => $warnings
+        ];
+    }
+
+    /**
+     * Returns for get_category_options().
+     *
+     * @return external_single_structure
+     */
+    public static function get_activity_options_returns() {
+        $optionsdefinition = new external_single_structure(
+            [
+                'name' => new external_value(PARAM_TEXT, 'The plugin name of the activity'),
+                'displayname' => new external_value(PARAM_TEXT, 'The display name of the activity'),
+            ]
+        );
+
+        return new external_single_structure([
+            'options' => new external_multiple_structure($optionsdefinition),
+            'warnings' => new external_warnings()
+        ]);
+    }
+
     /**
      * Gets the structure of a tree node (link + child branches).
      *
index c5c350c..06d2469 100644 (file)
@@ -25,6 +25,8 @@ namespace tool_dataprivacy\external;
 defined('MOODLE_INTERNAL') || die();
 
 use core\external\persistent_exporter;
+use tool_dataprivacy\category;
+use tool_dataprivacy\context_instance;
 
 /**
  * Class for exporting field data.
@@ -53,4 +55,25 @@ class category_exporter extends persistent_exporter {
             'context' => 'context',
         );
     }
+
+    /**
+     * Utility function that fetches a category name from the given ID.
+     *
+     * @param int $categoryid The category ID. Could be INHERIT (false, -1), NOT_SET (0), or the actual ID.
+     * @return string The purpose name.
+     */
+    public static function get_name($categoryid) {
+        global $PAGE;
+        if ($categoryid === false || $categoryid == context_instance::INHERIT) {
+            return get_string('inherit', 'tool_dataprivacy');
+        } else if ($categoryid == context_instance::NOTSET) {
+            return get_string('notset', 'tool_dataprivacy');
+        } else {
+            $purpose = new category($categoryid);
+            $output = $PAGE->get_renderer('tool_dataprivacy');
+            $exporter = new self($purpose, ['context' => \context_system::instance()]);
+            $data = $exporter->export($output);
+            return $data->name;
+        }
+    }
 }
index f91e2f2..a6519c6 100644 (file)
@@ -29,6 +29,7 @@ use core\external\persistent_exporter;
 use DateInterval;
 use Exception;
 use renderer_base;
+use tool_dataprivacy\context_instance;
 use tool_dataprivacy\purpose;
 
 /**
@@ -143,4 +144,25 @@ class purpose_exporter extends persistent_exporter {
 
         return $values;
     }
+
+    /**
+     * Utility function that fetches a purpose name from the given ID.
+     *
+     * @param int $purposeid The purpose ID. Could be INHERIT (false, -1), NOT_SET (0), or the actual ID.
+     * @return string The purpose name.
+     */
+    public static function get_name($purposeid) {
+        global $PAGE;
+        if ($purposeid === false || $purposeid == context_instance::INHERIT) {
+            return get_string('inherit', 'tool_dataprivacy');
+        } else if ($purposeid == context_instance::NOTSET) {
+            return get_string('notset', 'tool_dataprivacy');
+        } else {
+            $purpose = new purpose($purposeid);
+            $output = $PAGE->get_renderer('tool_dataprivacy');
+            $exporter = new self($purpose, ['context' => \context_system::instance()]);
+            $data = $exporter->export($output);
+            return $data->name;
+        }
+    }
 }
diff --git a/admin/tool/dataprivacy/classes/form/defaults.php b/admin/tool/dataprivacy/classes/form/defaults.php
deleted file mode 100644 (file)
index 4e599c3..0000000
+++ /dev/null
@@ -1,81 +0,0 @@
-<?php
-// This file is part of Moodle - http://moodle.org/
-//
-// Moodle is free software: you can redistribute it and/or modify
-// it under the terms of the GNU General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// Moodle is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-// GNU General Public License for more details.
-//
-// You should have received a copy of the GNU General Public License
-// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
-
-/**
- * This file contains the defaults form.
- *
- * @package   tool_dataprivacy
- * @copyright 2018 David Monllao
- * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-
-namespace tool_dataprivacy\form;
-defined('MOODLE_INTERNAL') || die();
-
-use \tool_dataprivacy\output\data_registry_page;
-
-require_once($CFG->libdir . '/formslib.php');
-require_once($CFG->dirroot . '/' . $CFG->admin . '/tool/dataprivacy/lib.php');
-
-/**
- * Context levels defaults form.
- *
- * @package   tool_dataprivacy
- * @copyright 2018 David Monllao
- * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-class defaults extends \moodleform {
-
-    /**
-     * Define the form.
-     */
-    public function definition() {
-        global $OUTPUT;
-
-        $mform = $this->_form;
-        $mform->setDisableShortforms();
-
-        $notification = $OUTPUT->notification(get_string('defaultsinfo', 'tool_dataprivacy'),
-            \core\output\notification::NOTIFY_INFO);
-        $mform->addElement('html', $notification);
-
-        foreach ($this->_customdata['levels'] as $level => $classname) {
-
-            $mform->addElement('header', $classname . '-header',
-                get_string('contextlevelname' . $level, 'tool_dataprivacy'));
-
-            list($purposevar, $categoryvar) = \tool_dataprivacy\data_registry::var_names_from_context($classname);
-
-            $includeinherit = true;
-            if ($level == CONTEXT_SYSTEM) {
-                $includeinherit = false;
-            }
-
-            $categoryoptions = data_registry_page::category_options($this->_customdata['categories'], false, $includeinherit);
-            $purposeoptions = data_registry_page::category_options($this->_customdata['purposes'], false, $includeinherit);
-
-            $mform->addElement('select', $categoryvar, get_string('category', 'tool_dataprivacy'), $categoryoptions);
-            $mform->addHelpButton($categoryvar, 'categorydefault', 'tool_dataprivacy');
-            $mform->setType($categoryvar, PARAM_INT);
-
-            $mform->addElement('select', $purposevar, get_string('purpose', 'tool_dataprivacy'), $purposeoptions);
-            $mform->addHelpButton($purposevar, 'purposedefault', 'tool_dataprivacy');
-            $mform->setType($purposevar, PARAM_INT);
-        }
-
-        $this->add_action_buttons(true, get_string('savechanges'));
-    }
-}
index 36dd93a..2609ef1 100644 (file)
@@ -47,6 +47,9 @@ class helper {
     /** The request filters preference key. */
     const PREF_REQUEST_FILTERS = 'tool_dataprivacy_request-filters';
 
+    /** The number of data request records per page preference key. */
+    const PREF_REQUEST_PERPAGE = 'tool_dataprivacy_request-perpage';
+
     /**
      * Retrieves the human-readable text value of a data request type.
      *
index 8d5fd35..be0e996 100644 (file)
@@ -425,7 +425,7 @@ class data_registry_page implements renderable, templatable {
     /**
      * From a list of purpose persistents to a list of id => name purposes.
      *
-     * @param \tool_dataprivacy\purpose $purposes
+     * @param \tool_dataprivacy\purpose[] $purposes
      * @param bool $includenotset
      * @param bool $includeinherit
      * @return string[]
@@ -442,7 +442,7 @@ class data_registry_page implements renderable, templatable {
     /**
      * From a list of category persistents to a list of id => name categories.
      *
-     * @param \tool_dataprivacy\category $categories
+     * @param \tool_dataprivacy\category[] $categories
      * @param bool $includenotset
      * @param bool $includeinherit
      * @return string[]
index 7ea4bf8..402a22d 100644 (file)
@@ -86,7 +86,7 @@ class data_requests_page implements renderable, templatable {
         $data->filter = $filter->export_for_template($output);
 
         ob_start();
-        $this->table->out(helper::DEFAULT_PAGE_SIZE, true);
+        $this->table->out($this->table->get_requests_per_page(), true);
         $requests = ob_get_contents();
         ob_end_clean();
 
index 477e503..b6fa957 100644 (file)
@@ -62,6 +62,12 @@ class data_requests_table extends table_sql {
     /** @var \tool_dataprivacy\data_request[] Array of data request persistents. */
     protected $datarequests = [];
 
+    /** @var int The number of data request to be displayed per page. */
+    protected $perpage;
+
+    /** @var int[] The available options for the number of data request to be displayed per page. */
+    protected $perpageoptions = [25, 50, 100, 250];
+
     /**
      * data_requests_table constructor.
      *
@@ -79,7 +85,13 @@ class data_requests_table extends table_sql {
         $this->types = $types;
         $this->manage = $manage;
 
+        $checkboxattrs = [
+            'title' => get_string('selectall'),
+            'data-action' => 'selectall'
+        ];
+
         $columnheaders = [
+            'select' => html_writer::checkbox('selectall', 1, false, null, $checkboxattrs),
             'type' => get_string('requesttype', 'tool_dataprivacy'),
             'userid' => get_string('user', 'tool_dataprivacy'),
             'timecreated' => get_string('daterequested', 'tool_dataprivacy'),
@@ -91,7 +103,26 @@ class data_requests_table extends table_sql {
 
         $this->define_columns(array_keys($columnheaders));
         $this->define_headers(array_values($columnheaders));
-        $this->no_sorting('actions');
+        $this->no_sorting('select', 'actions');
+    }
+
+    /**
+     * The select column.
+     *
+     * @param stdClass $data The row data.
+     * @return string
+     */
+    public function col_select($data) {
+        if ($data->status == \tool_dataprivacy\api::DATAREQUEST_STATUS_AWAITING_APPROVAL) {
+            $stringdata = [
+                'username' => $data->foruser->fullname,
+                'requesttype' => \core_text::strtolower($data->typenameshort)
+            ];
+
+            return \html_writer::checkbox('requestids[]', $data->id, false, '',
+                    ['class' => 'selectrequests', 'title' => get_string('selectuserdatarequest',
+                    'tool_dataprivacy', $stringdata)]);
+        }
     }
 
     /**
@@ -290,4 +321,72 @@ class data_requests_table extends table_sql {
     protected function show_hide_link($column, $index) {
         return '';
     }
+
+    /**
+     * Override the table's wrap_html_finish method in order to render the bulk actions and
+     * records per page options.
+     */
+    public function wrap_html_finish() {
+        global $OUTPUT;
+
+        $data = new stdClass();
+        $data->options = [
+            [
+                'value' => 0,
+                'name' => ''
+            ],
+            [
+                'value' => \tool_dataprivacy\api::DATAREQUEST_ACTION_APPROVE,
+                'name' => get_string('approve', 'tool_dataprivacy')
+            ],
+            [
+                'value' => \tool_dataprivacy\api::DATAREQUEST_ACTION_REJECT,
+                'name' => get_string('deny', 'tool_dataprivacy')
+            ]
+        ];
+
+        $perpageoptions = array_combine($this->perpageoptions, $this->perpageoptions);
+        $perpageselect = new \single_select(new moodle_url(''), 'perpage',
+                $perpageoptions, get_user_preferences('tool_dataprivacy_request-perpage'), null, 'selectgroup');
+        $perpageselect->label = get_string('perpage', 'moodle');
+        $data->perpage = $OUTPUT->render($perpageselect);
+
+        echo $OUTPUT->render_from_template('tool_dataprivacy/data_requests_bulk_actions', $data);
+    }
+
+    /**
+     * Set the number of data request records to be displayed per page.
+     *
+     * @param int $perpage The number of data request records.
+     */
+    public function set_requests_per_page(int $perpage) {
+        $this->perpage = $perpage;
+    }
+
+    /**
+     * Get the number of data request records to be displayed per page.
+     *
+     * @return int The number of data request records.
+     */
+    public function get_requests_per_page() : int {
+        return $this->perpage;
+    }
+
+    /**
+     * Set the available options for the number of data request to be displayed per page.
+     *
+     * @param array $perpageoptions The available options for the number of data request to be displayed per page.
+     */
+    public function set_requests_per_page_options(array $perpageoptions) {
+        $this->$perpageoptions = $perpageoptions;
+    }
+
+    /**
+     * Get the available options for the number of data request to be displayed per page.
+     *
+     * @return array The available options for the number of data request to be displayed per page.
+     */
+    public function get_requests_per_page_options() : array {
+        return $this->perpageoptions;
+    }
 }
diff --git a/admin/tool/dataprivacy/classes/output/defaults_page.php b/admin/tool/dataprivacy/classes/output/defaults_page.php
new file mode 100644 (file)
index 0000000..166228f
--- /dev/null
@@ -0,0 +1,178 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Class containing data for the data registry defaults.
+ *
+ * @package    tool_dataprivacy
+ * @copyright  2018 Jun Pataleta
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace tool_dataprivacy\output;
+defined('MOODLE_INTERNAL') || die();
+
+use action_menu_link_primary;
+use coding_exception;
+use moodle_exception;
+use moodle_url;
+use renderable;
+use renderer_base;
+use stdClass;
+use templatable;
+use tool_dataprivacy\data_registry;
+use tool_dataprivacy\external\category_exporter;
+use tool_dataprivacy\external\purpose_exporter;
+
+/**
+ * Class containing data for the data registry defaults.
+ *
+ * @copyright  2018 Jun Pataleta
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class defaults_page implements renderable, templatable {
+
+    /** @var int $mode The display mode. */
+    protected $mode = null;
+
+    /** @var int $category The default category for the given mode. */
+    protected $category = null;
+
+    /** @var int $purpose The default purpose for the given mode. */
+    protected $purpose = null;
+
+    /** @var stdClass[] $otherdefaults Other defaults for the given mode. */
+    protected $otherdefaults = [];
+
+    /** @var bool $canedit Whether editing is allowed. */
+    protected $canedit = false;
+
+    /**
+     * Construct this renderable.
+     *
+     * @param int $mode The display mode.
+     * @param int $category The default category for the given mode.
+     * @param int $purpose The default purpose for the given mode.
+     * @param stdClass[] $otherdefaults Other defaults for the given mode.
+     * @param bool $canedit Whether editing is allowed.
+     */
+    public function __construct($mode, $category, $purpose, $otherdefaults = [], $canedit = false) {
+        $this->mode = $mode;
+        $this->category = $category;
+        $this->purpose = $purpose;
+        $this->otherdefaults = $otherdefaults;
+        $this->canedit = $canedit;
+    }
+
+    /**
+     * Export this data so it can be used as the context for a mustache template.
+     *
+     * @param renderer_base $output
+     * @return stdClass
+     * @throws coding_exception
+     * @throws moodle_exception
+     */
+    public function export_for_template(renderer_base $output) {
+        $data = new stdClass();
+
+        // Set tab URLs.
+        $coursecaturl = new moodle_url('/admin/tool/dataprivacy/defaults.php', ['mode' => CONTEXT_COURSECAT]);
+        $courseurl = new moodle_url('/admin/tool/dataprivacy/defaults.php', ['mode' => CONTEXT_COURSE]);
+        $moduleurl = new moodle_url('/admin/tool/dataprivacy/defaults.php', ['mode' => CONTEXT_MODULE]);
+        $blockurl = new moodle_url('/admin/tool/dataprivacy/defaults.php', ['mode' => CONTEXT_BLOCK]);
+        $data->coursecaturl = $coursecaturl;
+        $data->courseurl = $courseurl;
+        $data->moduleurl = $moduleurl;
+        $data->blockurl = $blockurl;
+
+        // Set display mode.
+        switch ($this->mode) {
+            case CONTEXT_COURSECAT:
+                $data->modecoursecat = true;
+                break;
+            case CONTEXT_COURSE:
+                $data->modecourse = true;
+                break;
+            case CONTEXT_MODULE:
+                $data->modemodule = true;
+                break;
+            case CONTEXT_BLOCK:
+                $data->modeblock = true;
+                break;
+            default:
+                $data->modecoursecat = true;
+                break;
+        }
+
+        // Set config variables.
+        $configname = \context_helper::get_class_for_level($this->mode);
+        list($purposevar, $categoryvar) = data_registry::var_names_from_context($configname);
+        $data->categoryvar = $categoryvar;
+        $data->purposevar = $purposevar;
+
+        // Set default category.
+        $data->categoryid = $this->category;
+        $data->category = category_exporter::get_name($this->category);
+
+        // Set default purpose.
+        $data->purposeid = $this->purpose;
+        $data->purpose = purpose_exporter::get_name($this->purpose);
+
+        // Set other defaults.
+        $otherdefaults = [];
+        $url = new moodle_url('#');
+        foreach ($this->otherdefaults as $pluginname => $values) {
+            $defaults = [
+                'name' => $values->name,
+                'category' => category_exporter::get_name($values->category),
+                'purpose' => purpose_exporter::get_name($values->purpose),
+            ];
+            if ($this->canedit) {
+                $actions = [];
+                // Edit link.
+                $editattrs = [
+                    'data-action' => 'edit-activity-defaults',
+                    'data-contextlevel' => $this->mode,
+                    'data-activityname' => $pluginname,
+                    'data-category' => $values->category,
+                    'data-purpose' => $values->purpose,
+                ];
+                $editlink = new action_menu_link_primary($url, new \pix_icon('t/edit', get_string('edit')),
+                    get_string('edit'), $editattrs);
+                $actions[] = $editlink->export_for_template($output);
+
+                // Delete link.
+                $deleteattrs = [
+                    'data-action' => 'delete-activity-defaults',
+                    'data-contextlevel' => $this->mode,
+                    'data-activityname' => $pluginname,
+                    'data-activitydisplayname' => $values->name,
+                ];
+                $deletelink = new action_menu_link_primary($url, new \pix_icon('t/delete', get_string('delete')),
+                    get_string('delete'), $deleteattrs);
+                $actions[] = $deletelink->export_for_template($output);
+
+                $defaults['actions'] = $actions;
+            }
+            $otherdefaults[] = (object)$defaults;
+        }
+        $data->otherdefaults = $otherdefaults;
+
+        $data->canedit = $this->canedit;
+        $data->contextlevel = $this->mode;
+
+        return $data;
+    }
+}
index 0a8cf73..d7ec6f7 100644 (file)
@@ -47,7 +47,7 @@ class page_helper {
      */
     public static function setup(moodle_url $url, $title, $attachtoparentnode = '',
                                  $requiredcapability = 'tool/dataprivacy:managedataregistry') {
-        global $PAGE;
+        global $PAGE, $SITE;
 
         $context = context_system::instance();
 
@@ -65,7 +65,7 @@ class page_helper {
         $PAGE->set_context($context);
         $PAGE->set_pagelayout('admin');
         $PAGE->set_title($title);
-        $PAGE->set_heading($title);
+        $PAGE->set_heading($SITE->fullname);
 
         // If necessary, override the settings navigation to add this page into the breadcrumb navigation.
         if ($attachtoparentnode) {
index 4c2d8c4..4ddd411 100644 (file)
@@ -76,6 +76,8 @@ class provider implements
 
         $collection->add_user_preference(tool_helper::PREF_REQUEST_FILTERS,
             'privacy:metadata:preference:tool_dataprivacy_request-filters');
+        $collection->add_user_preference(tool_helper::PREF_REQUEST_PERPAGE,
+            'privacy:metadata:preference:tool_dataprivacy_request-perpage');
 
         return $collection;
     }
@@ -200,5 +202,11 @@ class provider implements
             $descriptionstext = implode(', ', $descriptions);
             writer::export_user_preference('tool_dataprivacy', tool_helper::PREF_REQUEST_FILTERS, $values, $descriptionstext);
         }
+
+        $prefperpage = get_user_preferences(tool_helper::PREF_REQUEST_PERPAGE, null, $userid);
+        if ($prefperpage !== null) {
+            writer::export_user_preference('tool_dataprivacy', tool_helper::PREF_REQUEST_PERPAGE, $prefperpage,
+                get_string('privacy:metadata:preference:tool_dataprivacy_request-perpage', 'tool_dataprivacy'));
+        }
     }
 }
index 83a5c90..2a2fb81 100644 (file)
@@ -78,8 +78,8 @@ if ($data = $mform->get_data()) {
     redirect($returnurl, $redirectmessage);
 }
 
-$title = get_string('contactdataprotectionofficer', 'tool_dataprivacy');
-$PAGE->set_heading($title);
+$title = get_string('createnewdatarequest', 'tool_dataprivacy');
+$PAGE->set_heading($SITE->fullname);
 $PAGE->set_title($title);
 echo $OUTPUT->header();
 echo $OUTPUT->heading($title);
index c92c676..df088ca 100644 (file)
@@ -36,6 +36,7 @@ $title = get_string('datadeletion', 'tool_dataprivacy');
 \tool_dataprivacy\page_helper::setup($url, $title);
 
 echo $OUTPUT->header();
+echo $OUTPUT->heading($title);
 
 if (\tool_dataprivacy\api::is_site_dpo($USER->id)) {
     $table = new \tool_dataprivacy\output\expired_contexts_table($filter);
index 50c0ab9..e11c47c 100644 (file)
@@ -37,6 +37,7 @@ $title = get_string('dataregistry', 'tool_dataprivacy');
 
 $output = $PAGE->get_renderer('tool_dataprivacy');
 echo $output->header();
+echo $OUTPUT->heading($title);
 
 if (\tool_dataprivacy\api::is_site_dpo($USER->id)) {
     $dataregistry = new tool_dataprivacy\output\data_registry_page($contextlevel, $contextid);
index 6a8140d..8b2b16f 100644 (file)
@@ -27,6 +27,8 @@ require_once('lib.php');
 
 require_login(null, false);
 
+$perpage = optional_param('perpage', 0, PARAM_INT);
+
 $url = new moodle_url('/admin/tool/dataprivacy/datarequests.php');
 
 $title = get_string('datarequests', 'tool_dataprivacy');
@@ -66,6 +68,13 @@ if (\tool_dataprivacy\api::is_site_dpo($USER->id)) {
     }
 
     $table = new \tool_dataprivacy\output\data_requests_table(0, $statuses, $types, true);
+    if (!empty($perpage)) {
+        set_user_preference(\tool_dataprivacy\local\helper::PREF_REQUEST_PERPAGE, $perpage);
+    } else {
+        $prefperpage = get_user_preferences(\tool_dataprivacy\local\helper::PREF_REQUEST_PERPAGE);
+        $perpage = ($prefperpage) ? $prefperpage : $table->get_requests_per_page_options()[0];
+    }
+    $table->set_requests_per_page($perpage);
     $table->baseurl = $url;
 
     $requestlist = new tool_dataprivacy\output\data_requests_page($table, $filtersapplied);
index 9c71e8c..dac6ba4 100644 (file)
@@ -73,6 +73,16 @@ $functions = [
         'ajax'          => true,
         'loginrequired' => true,
     ],
+    'tool_dataprivacy_bulk_approve_data_requests' => [
+        'classname'     => 'tool_dataprivacy\external',
+        'methodname'    => 'bulk_approve_data_requests',
+        'classpath'     => '',
+        'description'   => 'Bulk approve data requests',
+        'type'          => 'write',
+        'capabilities'  => 'tool/dataprivacy:managedatarequests',
+        'ajax'          => true,
+        'loginrequired' => true,
+    ],
     'tool_dataprivacy_deny_data_request' => [
         'classname'     => 'tool_dataprivacy\external',
         'methodname'    => 'deny_data_request',
@@ -83,6 +93,16 @@ $functions = [
         'ajax'          => true,
         'loginrequired' => true,
     ],
+    'tool_dataprivacy_bulk_deny_data_requests' => [
+        'classname'     => 'tool_dataprivacy\external',
+        'methodname'    => 'bulk_deny_data_requests',
+        'classpath'     => '',
+        'description'   => 'Bulk deny data requests',
+        'type'          => 'write',
+        'capabilities'  => 'tool/dataprivacy:managedatarequests',
+        'ajax'          => true,
+        'loginrequired' => true,
+    ],
     'tool_dataprivacy_get_users' => [
         'classname'     => 'tool_dataprivacy\external',
         'methodname'    => 'get_users',
@@ -173,4 +193,44 @@ $functions = [
         'ajax'          => true,
         'loginrequired' => true,
     ],
+    'tool_dataprivacy_set_context_defaults' => [
+        'classname'     => 'tool_dataprivacy\external',
+        'methodname'    => 'set_context_defaults',
+        'classpath'     => '',
+        'description'   => 'Updates the default category and purpose for a given context level (and optionally, a plugin)',
+        'type'          => 'write',
+        'capabilities'  => 'tool/dataprivacy:managedataregistry',
+        'ajax'          => true,
+        'loginrequired' => true,
+    ],
+    'tool_dataprivacy_get_category_options' => [
+        'classname'     => 'tool_dataprivacy\external',
+        'methodname'    => 'get_category_options',
+        'classpath'     => '',
+        'description'   => 'Fetches a list of data category options',
+        'type'          => 'read',
+        'capabilities'  => 'tool/dataprivacy:managedataregistry',
+        'ajax'          => true,
+        'loginrequired' => true,
+    ],
+    'tool_dataprivacy_get_purpose_options' => [
+        'classname'     => 'tool_dataprivacy\external',
+        'methodname'    => 'get_purpose_options',
+        'classpath'     => '',
+        'description'   => 'Fetches a list of data storage purpose options',
+        'type'          => 'read',
+        'capabilities'  => 'tool/dataprivacy:managedataregistry',
+        'ajax'          => true,
+        'loginrequired' => true,
+    ],
+    'tool_dataprivacy_get_activity_options' => [
+        'classname'     => 'tool_dataprivacy\external',
+        'methodname'    => 'get_activity_options',
+        'classpath'     => '',
+        'description'   => 'Fetches a list of activity options',
+        'type'          => 'read',
+        'capabilities'  => 'tool/dataprivacy:managedataregistry',
+        'ajax'          => true,
+        'loginrequired' => true,
+    ],
 ];
index d936ba2..e920585 100644 (file)
@@ -32,47 +32,40 @@ $title = get_string('setdefaults', 'tool_dataprivacy');
 
 \tool_dataprivacy\page_helper::setup($url, $title, 'dataregistry');
 
-$levels = \context_helper::get_all_levels();
-// They are set through the context level site and user.
-unset($levels[CONTEXT_SYSTEM]);
-unset($levels[CONTEXT_USER]);
+$mode = optional_param('mode', CONTEXT_COURSECAT, PARAM_INT);
+$classname = context_helper::get_class_for_level($mode);
+list($purposevar, $categoryvar) = \tool_dataprivacy\data_registry::var_names_from_context($classname);
+$purpose = get_config('tool_dataprivacy', $purposevar);
+$category = get_config('tool_dataprivacy', $categoryvar);
 
-$customdata = [
-    'levels' => $levels,
-    'purposes' => \tool_dataprivacy\api::get_purposes(),
-    'categories' => \tool_dataprivacy\api::get_categories(),
-];
-$form = new \tool_dataprivacy\form\defaults($PAGE->url->out(false), $customdata);
+$otherdefaults = [];
+if ($mode == CONTEXT_MODULE) {
+    // Get activity module plugin info.
+    $pluginmanager = core_plugin_manager::instance();
+    $modplugins = $pluginmanager->get_enabled_plugins('mod');
 
-$toform = new stdClass();
-foreach ($levels as $level => $classname) {
-    list($purposevar, $categoryvar) = \tool_dataprivacy\data_registry::var_names_from_context($classname);
-    $toform->{$purposevar} = get_config('tool_dataprivacy', $purposevar);
-    $toform->{$categoryvar} = get_config('tool_dataprivacy', $categoryvar);
-}
-$form->set_data($toform);
-
-$returnurl = new \moodle_url('/admin/tool/dataprivacy/dataregistry.php');
-if ($form->is_cancelled()) {
-    redirect($returnurl);
-} else if ($data = $form->get_data()) {
-
-    foreach ($levels as $level => $classname) {
-
-        list($purposevar, $categoryvar) = \tool_dataprivacy\data_registry::var_names_from_context($classname);
-
-        if (isset($data->{$purposevar})) {
-            set_config($purposevar, $data->{$purposevar}, 'tool_dataprivacy');
-        }
-        if (isset($data->{$categoryvar})) {
-            set_config($categoryvar, $data->{$categoryvar}, 'tool_dataprivacy');
+    foreach ($modplugins as $name) {
+        list($purposevar, $categoryvar) = \tool_dataprivacy\data_registry::var_names_from_context($classname, $name);
+        $plugincategory = get_config('tool_dataprivacy', $categoryvar);
+        $pluginpurpose = get_config('tool_dataprivacy', $purposevar);
+        if ($plugincategory === false && $pluginpurpose === false) {
+            // If no purpose and category has been set for this plugin, then there's no need to show this on the list.
+            continue;
         }
+
+        $displayname = $pluginmanager->plugin_name('mod_' . $name);
+        $otherdefaults[$name] = (object)[
+            'name' => $displayname,
+            'category' => $plugincategory,
+            'purpose' => $pluginpurpose,
+        ];
     }
-    redirect($returnurl, get_string('defaultssaved', 'tool_dataprivacy'),
-        0, \core\output\notification::NOTIFY_SUCCESS);
 }
 
+$defaultspage = new \tool_dataprivacy\output\defaults_page($mode, $category, $purpose, $otherdefaults, true);
+
 $output = $PAGE->get_renderer('tool_dataprivacy');
 echo $output->header();
-$form->display();
+echo $output->heading($title);
+echo $output->render_from_template('tool_dataprivacy/defaults_page', $defaultspage->export_for_template($output));
 echo $output->footer();
index eb2c97f..7b8aa51 100644 (file)
@@ -28,9 +28,12 @@ $string['pluginname'] = 'Data privacy';
 $string['pluginname_help'] = 'Data privacy plugin';
 $string['activitiesandresources'] = 'Activities and resources';
 $string['addcategory'] = 'Add category';
+$string['addnewdefaults'] = 'Add a new module default';
 $string['addpurpose'] = 'Add purpose';
 $string['approve'] = 'Approve';
 $string['approverequest'] = 'Approve request';
+$string['bulkapproverequests'] = 'Approve requests';
+$string['bulkdenyrequests'] = 'Deny requests';
 $string['cachedef_purpose'] = 'Data purposes';
 $string['cachedef_contextlevel'] = 'Context levels purpose and category';
 $string['cancelrequest'] = 'Cancel request';
@@ -46,9 +49,11 @@ $string['categoryupdated'] = 'Category updated';
 $string['close'] = 'Close';
 $string['compliant'] = 'Compliant';
 $string['confirmapproval'] = 'Do you really want to approve this data request?';
+$string['confirmbulkapproval'] = 'Do you really want to bulk approve the selected data requests?';
 $string['confirmcompletion'] = 'Do you really want to mark this user enquiry as complete?';
 $string['confirmcontextdeletion'] = 'Do you really want to confirm the deletion of the selected contexts? This will also delete all of the user data for their respective sub-contexts.';
 $string['confirmdenial'] = 'Do you really want deny this data request?';
+$string['confirmbulkdenial'] = 'Do you really want to bulk deny the selected data requests?';
 $string['contactdataprotectionofficer'] = 'Contact the privacy officer';
 $string['contactdataprotectionofficer_desc'] = 'If enabled, users will be able to contact the privacy officer and make a data request via a link on their profile page.';
 $string['contextlevelname10'] = 'Site';
@@ -60,6 +65,7 @@ $string['contextlevelname80'] = 'Blocks';
 $string['contextpurposecategorysaved'] = 'Purpose and category saved.';
 $string['contactdpoviaprivacypolicy'] = 'Please contact the privacy officer as described in the privacy policy.';
 $string['createcategory'] = 'Create data category';
+$string['createnewdatarequest'] = 'Create a new data request';
 $string['createpurpose'] = 'Create data purpose';
 $string['datadeletion'] = 'Data deletion';
 $string['datadeletionpagehelp'] = 'Data for which the retention period has expired are listed here. Please review and confirm data deletion, which will then be executed by the "Delete expired contexts" scheduled task.';
@@ -76,9 +82,12 @@ $string['datarequests'] = 'Data requests';
 $string['datecomment'] = '[{$a->date}]: ' . PHP_EOL . ' {$a->comment}';
 $string['daterequested'] = 'Date requested';
 $string['daterequesteddetail'] = 'Date requested:';
-$string['defaultsinfo'] = 'Default categories and purposes are applied to all newly created instances.';
+$string['defaultsinfo'] = 'Default categories and purposes are applied to all new and existing instances where a value is not set.';
+$string['defaultswarninginfo'] = 'Warning: Changing these defaults may affect the retention period of existing instances.';
 $string['deletecategory'] = 'Delete category';
 $string['deletecategorytext'] = 'Are you sure you want to delete the category \'{$a}\'?';
+$string['deletedefaults'] = 'Delete defaults: {$a}';
+$string['deletedefaultsconfirmation'] = 'Are you sure you want to delete the default category and purpose for {$a} modules?';
 $string['deleteexpiredcontextstask'] = 'Delete expired contexts';
 $string['deleteexpireddatarequeststask'] = 'Delete files from completed data requests that have expired';
 $string['deletepurpose'] = 'Delete purpose';
@@ -95,6 +104,8 @@ $string['dporolemapping_desc'] = 'The privacy officer can manage data requests.
 $string['editcategories'] = 'Edit categories';
 $string['editcategory'] = 'Edit category';
 $string['editcategories'] = 'Edit categories';
+$string['editdefaults'] = 'Edit defaults: {$a}';
+$string['editmoduledefaults'] = 'Edit module defaults';
 $string['editpurpose'] = 'Edit purpose';
 $string['editpurposes'] = 'Edit purposes';
 $string['effectiveretentionperiodcourse'] = '{$a} (after the course end date)';
@@ -185,11 +196,13 @@ $string['nopurposes'] = 'There are no purposes yet';
 $string['nosubjectaccessrequests'] = 'There are no data requests that you need to act on';
 $string['nosystemdefaults'] = 'Site purpose and category have not yet been defined.';
 $string['notset'] = 'Not set (use the default value)';
+$string['overrideinstances'] = 'Reset instances with custom values';
 $string['pluginregistry'] = 'Plugin privacy registry';
 $string['pluginregistrytitle'] = 'Plugin privacy compliance registry';
 $string['privacy'] = 'Privacy';
 $string['privacyofficeronly'] = 'Only users who are assigned a privacy officer role ({$a}) have access to this content';
 $string['privacy:metadata:preference:tool_dataprivacy_request-filters'] = 'The filters currently applied to the data requests page.';
+$string['privacy:metadata:preference:tool_dataprivacy_request-perpage'] = 'The number of data requests the user prefers to see on one page';
 $string['privacy:metadata:request'] = 'Information from personal data requests (subject access and deletion requests) made for this site.';
 $string['privacy:metadata:request:comments'] = 'Any user comments accompanying the request.';
 $string['privacy:metadata:request:userid'] = 'The ID of the user to whom the request belongs';
@@ -219,6 +232,9 @@ $string['requestdenied'] = 'The request has been denied';
 $string['requestemailintro'] = 'You have received a data request:';
 $string['requestfor'] = 'Requesting for';
 $string['requestmarkedcomplete'] = 'The request has been marked as complete';
+$string['requestorigin'] = 'Request origin';
+$string['requestsapproved'] = 'The requests have been approved';
+$string['requestsdenied'] = 'The requests have been denied';
 $string['requeststatus'] = 'Status';
 $string['requestsubmitted'] = 'Your request has been submitted to the privacy officer';
 $string['requesttype'] = 'Type';
@@ -239,6 +255,9 @@ $string['retentionperiod'] = 'Retention period';
 $string['retentionperiod_help'] = 'The retention period specifies the length of time that data should be kept for. When the retention period has expired, the data is flagged and listed for deletion, awaiting admin confirmation.';
 $string['retentionperiodnotdefined'] = 'No retention period was defined';
 $string['retentionperiodzero'] = 'No retention period';
+$string['selectbulkaction'] = 'Please select a bulk action.';
+$string['selectdatarequests'] = 'Please select data requests.';
+$string['selectuserdatarequest'] = 'Select {$a->username}\'s {$a->requesttype} data request.';
 $string['send'] = 'Send';
 $string['sensitivedatareasons'] = 'Sensitive personal data processing reasons';
 $string['sensitivedatareasons_help'] = 'Select one or more applicable reasons that exempts the prohibition of processing sensitive personal data tied to this purpose. For more information, please see  <a href="https://gdpr-info.eu/art-9-gdpr/" target="_blank">GDPR Art. 9.2</a>';
index 5fad922..fb1b0c5 100644 (file)
@@ -33,6 +33,7 @@ $title = get_string('editpurposes', 'tool_dataprivacy');
 
 $output = $PAGE->get_renderer('tool_dataprivacy');
 echo $output->header();
+echo $output->heading($title);
 
 $purposes = \tool_dataprivacy\api::get_purposes();
 $renderable = new \tool_dataprivacy\output\purposes($purposes);
index e6ddf93..7a7015a 100644 (file)
@@ -28,3 +28,7 @@ dd a.contactdpo {
 [data-region="data-requests-table"] .moodle-actionmenu {
     min-width: 150px;
 }
+
+.context-level-view {
+    margin: 1em;
+}
\ No newline at end of file
index ef31041..523a56b 100644 (file)
@@ -51,7 +51,6 @@
 {{/navigation}}
 
 <div data-region="categories" class="m-t-3 m-b-1">
-    <h3>{{#str}}categories, tool_dataprivacy{{/str}}</h3>
     <div class="m-y-1">
         <button class="btn btn-secondary" data-add-element="category" title="{{#str}}addcategory, tool_dataprivacy{{/str}}">
             {{#pix}}t/add, moodle, {{#str}}addcategory, tool_dataprivacy{{/str}}{{/pix}}
diff --git a/admin/tool/dataprivacy/templates/category_purpose_form.mustache b/admin/tool/dataprivacy/templates/category_purpose_form.mustache
new file mode 100644 (file)
index 0000000..c5d0ed4
--- /dev/null
@@ -0,0 +1,99 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template tool_dataprivacy/category_purpose_form
+
+    Manage data registry defaults.
+
+    Classes required for JS:
+
+    Data attributes required for JS:
+
+    Context variables required for this template:
+    * actionurl String The action URL.
+    * contextlevel Number - The context level.
+    * modemodule Boolean - Whether to display the activity select element.
+    * activityoptions Array - An array of objects for the activity select element.
+    * categoryoptions Array - An array of objects for the category select element.
+    * purposeoptions Array - An array of objects for the purpose select element.
+
+    Example context (json):
+    {
+        "actionurl": "#",
+        "contextlevel": 70,
+        "newactivitydefaults": true,
+        "modemodule": true,
+        "activityoptions": [
+            { "name": "assign", "displayname": "Assignment" },
+            { "name": "forum", "displayname": "Forum", "selected": true },
+            { "name": "lesson", "displayname": "Lesson" },
+            { "name": "quiz", "displayname": "Quiz" }
+        ],
+        "categoryoptions": [
+            { "id": 1, "name": "Category 1" },
+            { "id": 2, "name": "Category 2", "selected": true },
+            { "id": 3, "name": "Category 3" }
+        ],
+        "purposeoptions": [
+            { "id": 1, "name": "Purpose 1" },
+            { "id": 2, "name": "Purpose 2" },
+            { "id": 3, "name": "Purpose 3", "selected": true }
+        ]
+    }
+}}
+
+<div class="alert alert-warning" role="alert">
+    {{#str}}defaultswarninginfo, tool_dataprivacy{{/str}}
+</div>
+<form method="post" action="{{actionurl}}" id="category_purpose_form">
+    <input type="hidden" value="{{contextlevel}}" id="contextlevel" />
+    {{#modemodule}}
+    <div class="form-group">
+        <label for="activity">{{#str}}activitymodule{{/str}}</label>
+        {{^newactivitydefaults}}
+            <input type="hidden" id="activity" value="{{#activityoptions}}{{#selected}}{{name}}{{/selected}}{{/activityoptions}}" />
+        {{/newactivitydefaults}}
+        <select class="form-control" {{#newactivitydefaults}}id="activity" {{/newactivitydefaults}}{{^newactivitydefaults}}disabled{{/newactivitydefaults}}>
+            {{#activityoptions}}
+                <option value="{{name}}" {{#selected}}selected{{/selected}}>{{displayname}}</option>
+            {{/activityoptions}}
+        </select>
+    </div>
+    {{/modemodule}}
+    <div class="form-group">
+        <label for="category">{{#str}}category, tool_dataprivacy{{/str}}</label>
+        <select class="form-control" id="category">
+        {{#categoryoptions}}
+            <option value="{{id}}" {{#selected}}selected{{/selected}}>{{name}}</option>
+        {{/categoryoptions}}
+        </select>
+    </div>
+    <div class="form-group">
+        <label for="purpose">{{#str}}purpose, tool_dataprivacy{{/str}}</label>
+        <select class="form-control" id="purpose">
+        {{#purposeoptions}}
+            <option value="{{id}}" {{#selected}}selected{{/selected}}>{{name}}</option>
+        {{/purposeoptions}}
+        </select>
+    </div>
+    <div class="form-check">
+        <input class="form-check-input" type="checkbox" value="1" id="override">
+        <label class="form-check-label" for="override">
+            {{#str}}overrideinstances, tool_dataprivacy{{/str}}
+        </label>
+    </div>
+</form>
index 1c993b2..3cfda8b 100644 (file)
@@ -31,6 +31,8 @@
     * string requestedby The one making the request.
     * string requesttype The request type.
     * string requestdate The date the request was made.
+    * string requestorigin The name of the site the request originates from.
+    * string requestoriginurl The homepage of the site the request originates from.
     * string requestcomments Additional details regarding the request.
     * bool forself Whether the request has been made on behalf of another user or not.
     * string datarequestsurl The URL to the data requests page.
@@ -42,6 +44,8 @@
         "requestedby": "Angus Zhang",
         "requesttype": "Export user data",
         "requestdate": "31 January 2018",
+        "requestorigin": "My Amazing Site",
+        "requestoriginurl": "https://www.bestmoodlesiteever.com",
         "requestcomments": "Dear admin,<br/> I would like to request a copy of my son's user data. Thanks!",
         "forself": true,
         "datarequestsurl": "#"
                     </td>
                 </tr>
             {{/forself}}
+            <tr>
+                <th scope="row">
+                    {{#str}}requestorigin, tool_dataprivacy{{/str}}
+                </th>
+                <td>
+                    <a href="{{requestoriginurl}}">{{{requestorigin}}}</a>
+                </td>
+            </tr>
             <tr>
                 <th scope="row">
                     {{#str}}requestcomments, tool_dataprivacy{{/str}}
diff --git a/admin/tool/dataprivacy/templates/data_requests_bulk_actions.mustache b/admin/tool/dataprivacy/templates/data_requests_bulk_actions.mustache
new file mode 100644 (file)
index 0000000..2ec0473
--- /dev/null
@@ -0,0 +1,59 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template tool_dataprivacy/data_requests_bulk_actions
+
+    Moodle template for the bulk action select element in the data requests page.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * none
+
+    Context variables required for this template:
+    * options - Array of options for the select with value and name.
+    * perpage - HTML content of the records per page select element.
+
+    Example context (json):
+    {
+        "options": [
+            {
+                "value": 1,
+                "name": "Approve"
+            },
+            {
+                "value": 2,
+                "name": "Deny"
+            }
+        ],
+        "perpage" : "<div class='singleselect'></div>"
+    }
+}}
+<div class="m-t-1 d-inline-block w-100">
+    <div class="pull-left">
+        <select id="bulk-action" class="select custom-select">
+        {{#options}}
+            <option value="{{ value }}">{{ name }}</option>
+        {{/options}}
+        </select>
+        <button class="btn btn-primary" id="confirm-bulk-action">{{# str}} confirm {{/ str}}</button>
+    </div>
+    <div class="pull-right">
+        {{{ perpage }}}
+    </div>
+</div>
diff --git a/admin/tool/dataprivacy/templates/defaults_display.mustache b/admin/tool/dataprivacy/templates/defaults_display.mustache
new file mode 100644 (file)
index 0000000..39a29aa
--- /dev/null
@@ -0,0 +1,74 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template tool_dataprivacy/defaults_display
+
+    Renders the current default category and purpose.
+
+    Classes required for JS:
+
+    Data attributes required for JS:
+
+    Context variables required for this template:
+    * contextlevel Number - The context level.
+    * categoryid Number - The category ID.
+    * purposeid Number - The purpose ID.
+    * category String - The category name.
+    * purpose String - The purpose name.
+    * canedit Boolean - Whether this is being rendered for editing purposes.
+
+    Example context (json):
+    {
+        "category": "Awesome default category",
+        "categoryid": 1,
+        "purpose": "Awesome default purpose",
+        "purposeid": 2,
+        "canedit": true,
+        "contextlevel": 70
+    }
+}}
+<div class="row-fluid rtl-compatible m-t-1 m-b-1">
+    <div class="col-md-9 span9">
+        <div class="row-fluid rtl-compatible m-t-1 m-b-1">
+            <div class="col-md-3 span3">
+                <strong>{{#str}}category, tool_dataprivacy{{/str}}</strong>
+            </div>
+            <div class="col-md-9 span9">
+                {{category}}
+            </div>
+        </div>
+        <div class="row-fluid rtl-compatible m-t-1 m-b-1">
+            <div class="col-md-3 span3">
+                <strong>{{#str}}purpose, tool_dataprivacy{{/str}}</strong>
+            </div>
+            <div class="col-md-9 span9">
+                {{purpose}}
+            </div>
+        </div>
+    </div>
+    <div class="col-md-3 span3 align-self-center">
+    {{#canedit}}
+        <button class="btn btn-default" {{!
+            }}data-action="edit-level-defaults" {{!
+            }}data-contextlevel="{{contextlevel}}" {{!
+            }}data-category="{{categoryid}}" {{!
+            }}data-purpose="{{purposeid}}">
+            {{#str}}edit{{/str}}
+        </button>
+    {{/canedit}}
+    </div>
+</div>
diff --git a/admin/tool/dataprivacy/templates/defaults_page.mustache b/admin/tool/dataprivacy/templates/defaults_page.mustache
new file mode 100644 (file)
index 0000000..bb531da
--- /dev/null
@@ -0,0 +1,147 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template tool_dataprivacy/defaults_page
+
+    Manage data registry defaults.
+
+    Classes required for JS:
+
+    Data attributes required for JS:
+
+    Context variables required for this template:
+    * contextlevel Number - The context level.
+    * modecoursecat Boolean - Whether we're displaying defaults for course categories.
+    * modecourse Boolean - Whether we're displaying defaults for courses.
+    * modemodule Boolean - Whether we're displaying defaults for activity modules.
+    * modeblock Boolean - Whether we're displaying defaults for blocks.
+    * coursecaturl String - The URL for the course category defaults tab.
+    * courseurl String - The URL for the course defaults tab.
+    * moduleurl String - The URL for the activity module defaults tab.
+    * blockurl String - The URL for the block defaults tab.
+    * purposeid Number - The purpose ID for this context level.
+    * canedit Boolean - Whether this is being rendered for editing purposes.
+    * categoryid Number - The ID of the default category for this context level.
+    * purposeid Number - The ID of the default purpose for this context level.
+    * category String - The category name.
+    * purpose String - The purpose name.
+    * otherdefaults Array - An array containing the defaults for the activity modules.
+
+    Example context (json):
+    {
+        "contextlevel": 70,
+        "modecoursecat": false,
+        "modecourse": false,
+        "modemodule": true,
+        "modeblock": false,
+        "coursecaturl": "#",
+        "courseurl": "#",
+        "moduleurl": "#",
+        "blockurl": "#",
+        "category": "Awesome default category",
+        "purpose": "Awesome default purpose",
+        "canedit": true,
+        "otherdefaults": [
+            {
+                "name": "Assignment",
+                "category": "Category for activity modules",
+                "purpose": "Assessments"
+            },
+            {
+                "name": "Forum",
+                "category": "Category for activity modules",
+                "purpose": "Social interactions"
+            }
+        ]
+    }
+}}
+<div class="card">
+    <div class="card-header">
+        <ul class="nav nav-tabs card-header-tabs">
+            <li class="nav-item">
+                <a class="nav-link {{#modecoursecat}}active{{/modecoursecat}}" href="{{coursecaturl}}">{{#str}}categories{{/str}}</a>
+            </li>
+            <li class="nav-item">
+                <a class="nav-link {{#modecourse}}active{{/modecourse}}" href="{{courseurl}}">{{#str}}courses{{/str}}</a>
+            </li>
+            <li class="nav-item">
+                <a class="nav-link {{#modemodule}}active{{/modemodule}}" href="{{moduleurl}}">{{#str}}activitymodules{{/str}}</a>
+            </li>
+            <li class="nav-item">
+                <a class="nav-link {{#modeblock}}active{{/modeblock}}" href="{{blockurl}}">{{#str}}blocks{{/str}}</a>
+            </li>
+        </ul>
+    </div>
+    <div class="card-body context-level-view">
+        <div class="alert alert-primary" role="alert">
+            {{#str}}defaultsinfo, tool_dataprivacy{{/str}}
+        </div>
+        <h4 class="card-title" id="defaults-header">
+            {{#modecoursecat}}{{#str}}categories{{/str}}{{/modecoursecat}}
+            {{#modecourse}}{{#str}}courses{{/str}}{{/modecourse}}
+            {{#modemodule}}{{#str}}activitymodules{{/str}}{{/modemodule}}
+            {{#modeblock}}{{#str}}blocks{{/str}}{{/modeblock}}
+        </h4>
+        <div>
+            {{> tool_dataprivacy/defaults_display}}
+            {{#canedit}}
+                {{#modemodule}}
+                <button class="btn btn-primary" data-action="new-activity-defaults" data-contextlevel="{{contextlevel}}">
+                    {{#str}}addnewdefaults, tool_dataprivacy{{/str}}
+                </button>
+                {{/modemodule}}
+            {{/canedit}}
+            {{#modemodule}}
+            <table class="m-t-1 table table-striped">
+                <thead>
+                    <tr>
+                        <th>&nbsp;</th>
+                        <th scope="col">{{#str}}category, tool_dataprivacy{{/str}}</th>
+                        <th scope="col">{{#str}}purpose, tool_dataprivacy{{/str}}</th>
+                        {{#canedit}}
+                            <th scope="col">{{#str}}actions{{/str}}</th>
+                        {{/canedit}}
+                    </tr>
+                </thead>
+                <tbody>
+                    {{#otherdefaults}}
+                    <tr>
+                        <th scope="row">{{name}}</th>
+                        <td>{{category}}</td>
+                        <td>{{purpose}}</td>
+                        {{#canedit}}
+                            <td>
+                                {{#actions}}
+                                    {{> core/action_menu_link}}
+                                {{/actions}}
+                            </td>
+                        {{/canedit}}
+                    </tr>
+                    {{/otherdefaults}}
+                </tbody>
+            </table>
+            {{/modemodule}}
+        </div>
+    </div>
+</div>
+
+{{#js}}
+// Initialise the JS.
+require(['tool_dataprivacy/defaultsactions'], function(ActionsMod) {
+    ActionsMod.init();
+});
+{{/js}}
diff --git a/admin/tool/dataprivacy/templates/delete_activity_defaults.mustache b/admin/tool/dataprivacy/templates/delete_activity_defaults.mustache
new file mode 100644 (file)
index 0000000..c0220d3
--- /dev/null
@@ -0,0 +1,41 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template tool_dataprivacy/delete_activity_defaults
+
+    Renders the confirmation.
+
+    Classes required for JS:
+
+    Data attributes required for JS:
+    * none
+
+    Context variables required for this template:
+    * activityname String - The activity name.
+
+    Example context (json):
+    {
+        "activityname": "Assignment"
+    }
+}}
+
+<div class="alert alert-warning" role="alert">
+    {{#str}}defaultswarninginfo, tool_dataprivacy{{/str}}
+</div>
+<div>
+    {{#str}}deletedefaultsconfirmation, tool_dataprivacy, {{activityname}}{{/str}}
+</div>
index 4461eab..fe25440 100644 (file)
@@ -58,7 +58,6 @@
 {{/navigation}}
 
 <div data-region="purposes" class="m-t-3 m-b-1">
-    <h3>{{#str}}purposes, tool_dataprivacy{{/str}}</h3>
     <div class="m-y-1">
         <button class="btn btn-secondary" data-add-element="purpose" title="{{#str}}addpurpose, tool_dataprivacy{{/str}}">
             {{#pix}}t/add, moodle, {{#str}}addpurpose, tool_dataprivacy{{/str}}{{/pix}}
index 9feebcf..820611c 100644 (file)
@@ -124,6 +124,47 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
         $this->assertEquals($u1->id, $dpo->id);
     }
 
+    /**
+     * Test for \tool_dataprivacy\api::get_assigned_privacy_officer_roles().
+     */
+    public function test_get_assigned_privacy_officer_roles() {
+        global $DB;
+
+        // Erroneously set the manager roles as the PO, even if it doesn't have the managedatarequests capability yet.
+        $managerroleid = $DB->get_field('role', 'id', array('shortname' => 'manager'));
+        set_config('dporoles', $managerroleid, 'tool_dataprivacy');
+        // Get the assigned PO roles when nothing has been set yet.
+        $roleids = api::get_assigned_privacy_officer_roles();
+        // Confirm that the returned list is empty.
+        $this->assertEmpty($roleids);
+
+        $context = context_system::instance();
+
+        // Give the manager role with the capability to manage data requests.
+        assign_capability('tool/dataprivacy:managedatarequests', CAP_ALLOW, $managerroleid, $context->id, true);
+
+        // Give the editing teacher role with the capability to manage data requests.
+        $editingteacherroleid = $DB->get_field('role', 'id', array('shortname' => 'editingteacher'));
+        assign_capability('tool/dataprivacy:managedatarequests', CAP_ALLOW, $editingteacherroleid, $context->id, true);
+
+        // Get the non-editing teacher role ID.
+        $teacherroleid = $DB->get_field('role', 'id', array('shortname' => 'teacher'));
+
+        // Erroneously map the manager and the non-editing teacher roles to the PO role.
+        $badconfig = $managerroleid . ',' . $teacherroleid;
+        set_config('dporoles', $badconfig, 'tool_dataprivacy');
+
+        // Get the assigned PO roles.
+        $roleids = api::get_assigned_privacy_officer_roles();
+
+        // There should only be one PO role.
+        $this->assertCount(1, $roleids);
+        // Confirm it contains the manager role.
+        $this->assertContains($managerroleid, $roleids);
+        // And it does not contain the editing teacher role.
+        $this->assertNotContains($editingteacherroleid, $roleids);
+    }
+
     /**
      * Test for api::approve_data_request().
      */
@@ -1321,6 +1362,164 @@ class tool_dataprivacy_api_testcase extends advanced_testcase {
         $this->assertEquals($data->contexts->used, $contextids, '', 0.0, 10, true);
     }
 
+    /**
+     * Data provider for \tool_dataprivacy_api_testcase::test_set_context_defaults
+     */
+    public function set_context_defaults_provider() {
+        $contextlevels = [
+            [CONTEXT_COURSECAT],
+            [CONTEXT_COURSE],
+            [CONTEXT_MODULE],
+            [CONTEXT_BLOCK],
+        ];
+        $paramsets = [
+            [true, true, false, false], // Inherit category and purpose, Not for activity, Don't override.
+            [true, false, false, false], // Inherit category but not purpose, Not for activity, Don't override.
+            [false, true, false, false], // Inherit purpose but not category, Not for activity, Don't override.
+            [false, false, false, false], // Don't inherit both category and purpose, Not for activity, Don't override.
+            [false, false, false, true], // Don't inherit both category and purpose, Not for activity, Override instances.
+        ];
+        $data = [];
+        foreach ($contextlevels as $level) {
+            foreach ($paramsets as $set) {
+                $data[] = array_merge($level, $set);
+            }
+            if ($level == CONTEXT_MODULE) {
+                // Add a combination where defaults for activity is being set.
+                $data[] = [CONTEXT_MODULE, false, false, true, false];
+                $data[] = [CONTEXT_MODULE, false, false, true, true];
+            }
+        }
+        return $data;
+    }
+
+    /**
+     * Test for \tool_dataprivacy\api::set_context_defaults()
+     *
+     * @dataProvider set_context_defaults_provider
+     * @param int $contextlevel The context level
+     * @param bool $inheritcategory Whether to set category value as INHERIT.
+     * @param bool $inheritpurpose Whether to set purpose value as INHERIT.
+     * @param bool $foractivity Whether to set defaults for an activity.
+     * @param bool $override Whether to override instances.
+     */
+    public function test_set_context_defaults($contextlevel, $inheritcategory, $inheritpurpose, $foractivity, $override) {
+        $this->setAdminUser();
+
+        $generator = $this->getDataGenerator();
+
+        // Generate course cat, course, block, assignment, forum instances.
+        $coursecat = $generator->create_category();
+        $course = $generator->create_course(['category' => $coursecat->id]);
+        $block = $generator->create_block('online_users');
+        $assign = $generator->create_module('assign', ['course' => $course->id]);
+        $forum = $generator->create_module('forum', ['course' => $course->id]);
+
+        $coursecatcontext = context_coursecat::instance($coursecat->id);
+        $coursecontext = context_course::instance($course->id);
+        $blockcontext = context_block::instance($block->id);
+
+        list($course, $assigncm) = get_course_and_cm_from_instance($assign->id, 'assign');
+        list($course, $forumcm) = get_course_and_cm_from_instance($forum->id, 'forum');
+        $assigncontext = context_module::instance($assigncm->id);
+        $forumcontext = context_module::instance($forumcm->id);
+
+        // Generate purposes and categories.
+        $category1 = api::create_category((object)['name' => 'Test category 1']);
+        $category2 = api::create_category((object)['name' => 'Test category 2']);
+        $purpose1 = api::create_purpose((object)[
+            'name' => 'Test purpose 1', 'retentionperiod' => 'PT1M', 'lawfulbases' => 'gdpr_art_6_1_a'
+        ]);
+        $purpose2 = api::create_purpose((object)[
+            'name' => 'Test purpose 2', 'retentionperiod' => 'PT1M', 'lawfulbases' => 'gdpr_art_6_1_a'
+        ]);
+
+        // Assign purposes and categories to contexts.
+        $coursecatctxinstance = api::set_context_instance((object) [
+            'contextid' => $coursecatcontext->id,
+            'purposeid' => $purpose1->get('id'),
+            'categoryid' => $category1->get('id'),
+        ]);
+        $coursectxinstance = api::set_context_instance((object) [
+            'contextid' => $coursecontext->id,
+            'purposeid' => $purpose1->get('id'),
+            'categoryid' => $category1->get('id'),
+        ]);
+        $blockctxinstance = api::set_context_instance((object) [
+            'contextid' => $blockcontext->id,
+            'purposeid' => $purpose1->get('id'),
+            'categoryid' => $category1->get('id'),
+        ]);
+        $assignctxinstance = api::set_context_instance((object) [
+            'contextid' => $assigncontext->id,
+            'purposeid' => $purpose1->get('id'),
+            'categoryid' => $category1->get('id'),
+        ]);
+        $forumctxinstance = api::set_context_instance((object) [
+            'contextid' => $forumcontext->id,
+            'purposeid' => $purpose1->get('id'),
+            'categoryid' => $category1->get('id'),
+        ]);
+
+        $categoryid = $inheritcategory ? context_instance::INHERIT : $category2->get('id');
+        $purposeid = $inheritpurpose ? context_instance::INHERIT : $purpose2->get('id');
+        $activity = '';
+        if ($contextlevel == CONTEXT_MODULE && $foractivity) {
+            $activity = 'assign';
+        }
+        $result = api::set_context_defaults($contextlevel, $categoryid, $purposeid, $activity, $override);
+        $this->assertTrue($result);
+
+        $targetctxinstance = false;
+        switch ($contextlevel) {
+            case CONTEXT_COURSECAT:
+                $targetctxinstance = $coursecatctxinstance;
+                break;
+            case CONTEXT_COURSE:
+                $targetctxinstance = $coursectxinstance;
+                break;
+            case CONTEXT_MODULE:
+                $targetctxinstance = $assignctxinstance;
+                break;
+            case CONTEXT_BLOCK:
+                $targetctxinstance = $blockctxinstance;
+                break;
+        }
+        $this->assertNotFalse($targetctxinstance);
+
+        // Check the context instances.
+        $instanceexists = context_instance::record_exists($targetctxinstance->get('id'));
+        if ($override) {
+            // If overridden, context instances on this context level would have been deleted.
+            $this->assertFalse($instanceexists);
+
+            // Check forum context instance.
+            $forumctxexists = context_instance::record_exists($forumctxinstance->get('id'));
+            if ($contextlevel != CONTEXT_MODULE || $foractivity) {
+                // The forum context instance won't be affected in this test if:
+                // - The overridden defaults are not for context modules.
+                // - Only the defaults for assign have been set.
+                $this->assertTrue($forumctxexists);
+            } else {
+                // If we're overriding for the whole course module context level,
+                // then this forum context instance will be deleted as well.
+                $this->assertFalse($forumctxexists);
+            }
+        } else {
+            // Otherwise, the context instance record remains.
+            $this->assertTrue($instanceexists);
+        }
+
+        // Check defaults.
+        list($defaultpurpose, $defaultcategory) = data_registry::get_defaults($contextlevel, $activity);
+        if (!$inheritpurpose) {
+            $this->assertEquals($purposeid, $defaultpurpose);
+        }
+        if (!$inheritcategory) {
+            $this->assertEquals($categoryid, $defaultcategory);
+        }
+    }
+
     /**
      * Perform setup for the test_add_request_contexts_with_status_xxxxx tests.
      *
diff --git a/admin/tool/dataprivacy/tests/behat/behat_tool_dataprivacy.php b/admin/tool/dataprivacy/tests/behat/behat_tool_dataprivacy.php
new file mode 100644 (file)
index 0000000..857bb33
--- /dev/null
@@ -0,0 +1,284 @@
+<?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/>.
+
+/**
+ * Step definitions to generate database fixtures for the data privacy tool.
+ *
+ * @package    tool_dataprivacy
+ * @category   test
+ * @copyright  2018 Jun Pataleta
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require_once(__DIR__ . '/../../../../../lib/behat/behat_base.php');
+
+use Behat\Gherkin\Node\TableNode as TableNode;
+use Behat\Behat\Tester\Exception\PendingException as PendingException;
+use tool_dataprivacy\api;
+
+/**
+ * Step definitions to generate database fixtures for the data privacy tool.
+ *
+ * @package    tool_dataprivacy
+ * @category   test
+ * @copyright  2018 Jun Pataleta
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class behat_tool_dataprivacy extends behat_base {
+
+    /**
+     * Each element specifies:
+     * - The data generator suffix used.
+     * - The required fields.
+     * - The mapping between other elements references and database field names.
+     * @var array
+     */
+    protected static $elements = array(
+        'categories' => array(
+            'datagenerator' => 'category',
+            'required' => array()
+        ),
+        'purposes' => array(
+            'datagenerator' => 'purpose',
+            'required' => array()
+        ),
+    );
+
+    /**
+     * Creates the specified element. More info about available elements in http://docs.moodle.org/dev/Acceptance_testing#Fixtures.
+     *
+     * @Given /^the following data privacy "(?P<element_string>(?:[^"]|\\")*)" exist:$/
+     *
+     * @param string    $elementname The name of the entity to add
+     * @param TableNode $data
+     */
+    public function the_following_data_categories_exist($elementname, TableNode $data) {
+
+        // Now that we need them require the data generators.
+        require_once(__DIR__.'/../../../../../lib/phpunit/classes/util.php');
+
+        if (empty(self::$elements[$elementname])) {
+            throw new PendingException($elementname . ' data generator is not implemented');
+        }
+
+        $datagenerator = testing_util::get_data_generator();
+        $dataprivacygenerator = $datagenerator->get_plugin_generator('tool_dataprivacy');
+
+        $elementdatagenerator = self::$elements[$elementname]['datagenerator'];
+        $requiredfields = self::$elements[$elementname]['required'];
+        if (!empty(self::$elements[$elementname]['switchids'])) {
+            $switchids = self::$elements[$elementname]['switchids'];
+        }
+
+        foreach ($data->getHash() as $elementdata) {
+
+            // Check if all the required fields are there.
+            foreach ($requiredfields as $requiredfield) {
+                if (!isset($elementdata[$requiredfield])) {
+                    throw new Exception($elementname . ' requires the field ' . $requiredfield . ' to be specified');
+                }
+            }
+
+            // Switch from human-friendly references to ids.
+            if (isset($switchids)) {
+                foreach ($switchids as $element => $field) {
+                    $methodname = 'get_' . $element . '_id';
+
+                    // Not all the switch fields are required, default vars will be assigned by data generators.
+                    if (isset($elementdata[$element])) {
+                        // Temp $id var to avoid problems when $element == $field.
+                        $id = $this->{$methodname}($elementdata[$element]);
+                        unset($elementdata[$element]);
+                        $elementdata[$field] = $id;
+                    }
+                }
+            }
+
+            // Preprocess the entities that requires a special treatment.
+            if (method_exists($this, 'preprocess_' . $elementdatagenerator)) {
+                $elementdata = $this->{'preprocess_' . $elementdatagenerator}($elementdata);
+            }
+
+            // Creates element.
+            $methodname = 'create_' . $elementdatagenerator;
+            if (method_exists($dataprivacygenerator, $methodname)) {
+                // Using data generators directly.
+                $dataprivacygenerator->{$methodname}($elementdata);
+
+            } else if (method_exists($this, 'process_' . $elementdatagenerator)) {
+                // Using an alternative to the direct data generator call.
+                $this->{'process_' . $elementdatagenerator}($elementdata);
+            } else {
+                throw new PendingException($elementname . ' data generator is not implemented');
+            }
+        }
+    }
+
+    /**
+     * Sets the data category and data storage purpose for the site.
+     *
+     * @Given /^I set the site category and purpose to "(?P<category_string>(?:[^"]|\\")*)" and "(?P<purpose_string>(?:[^"]|\\")*)"$/
+     *
+     * @param string $category The ID of the category to be set for the instance.
+     * @param string $purpose The ID of the purpose to be set for the instance.
+     */
+    public function i_set_the_site_category_and_purpose($category, $purpose) {
+        $category = \tool_dataprivacy\category::get_record(['name' => $category]);
+        $purpose = \tool_dataprivacy\purpose::get_record(['name' => $purpose]);
+        $data = (object)[
+            'contextlevel' => CONTEXT_SYSTEM,
+            'categoryid' => $category->get('id'),
+            'purposeid' => $purpose->get('id'),
+        ];
+        api::set_contextlevel($data);
+    }
+
+    /**
+     * Sets the data category and data storage purpose for a course category instance.
+     *
+     * @Given /^I set the category and purpose for the course category "(?P<categoryname_string>(?:[^"]|\\")*)" to "(?P<category_string>(?:[^"]|\\")*)" and "(?P<purpose_string>(?:[^"]|\\")*)"$/
+     *
+     * @param string $name The instance name. It should match the name or the idnumber.
+     * @param string $category The ID of the category to be set for the instance.
+     * @param string $purpose The ID of the purpose to be set for the instance.
+     */
+    public function i_set_the_category_and_purpose_for_course_category($name, $category, $purpose) {
+        global $DB;
+
+        $params = [
+            'name' => $name,
+            'idnumber' => $name,
+        ];
+        $select = 'name = :name OR idnumber = :idnumber';
+        $coursecatid = $DB->get_field_select('course_categories', 'id', $select, $params, MUST_EXIST);
+        $context = context_coursecat::instance($coursecatid);
+
+        $this->set_category_and_purpose($context->id, $category, $purpose);
+    }
+
+    /**
+     * Sets the data category and data storage purpose for a course instance.
+     *
+     * @Given /^I set the category and purpose for the course "(?P<coursename_string>(?:[^"]|\\")*)" to "(?P<category_string>(?:[^"]|\\")*)" and "(?P<purpose_string>(?:[^"]|\\")*)"$/
+     *
+     * @param string $name The instance name. It should match the fullname or the shortname, or the idnumber.
+     * @param string $category The ID of the category to be set for the instance.
+     * @param string $purpose The ID of the purpose to be set for the instance.
+     */
+    public function i_set_the_category_and_purpose_for_course($name, $category, $purpose) {
+        global $DB;
+
+        $params = [
+            'shortname' => $name,
+            'fullname' => $name,
+            'idnumber' => $name,
+        ];
+        $select = 'shortname = :shortname OR fullname = :fullname OR idnumber = :idnumber';
+        $courseid = $DB->get_field_select('course', 'id', $select, $params, MUST_EXIST);
+        $context = context_course::instance($courseid);
+
+        $this->set_category_and_purpose($context->id, $category, $purpose);
+    }
+
+    /**
+     * Sets the data category and data storage purpose for a course instance.
+     *
+     * @Given /^I set the category and purpose for the "(?P<activityname_string>(?:[^"]|\\")*)" "(?P<activitytype_string>(?:[^"]|\\")*)" in course "(?P<coursename_string>(?:[^"]|\\")*)" to "(?P<category_string>(?:[^"]|\\")*)" and "(?P<purpose_string>(?:[^"]|\\")*)"$/
+     *
+     * @param string $name The instance name. It should match the name of the activity.
+     * @param string $type The activity type. E.g. assign, quiz, forum, etc.
+     * @param string $coursename The course name. It should match the fullname or the shortname, or the idnumber.
+     * @param string $category The ID of the category to be set for the instance.
+     * @param string $purpose The ID of the purpose to be set for the instance.
+     */
+    public function i_set_the_category_and_purpose_for_activity($name, $type, $coursename, $category, $purpose) {
+        global $DB;
+
+        $params = [
+            'shortname' => $coursename,
+            'fullname' => $coursename,
+            'idnumber' => $coursename,
+        ];
+        $select = 'shortname = :shortname OR fullname = :fullname OR idnumber = :idnumber';
+        $courseid = $DB->get_field_select('course', 'id', $select, $params, MUST_EXIST);
+
+        $cmid = null;
+        $cms = get_coursemodules_in_course($type, $courseid);
+        foreach ($cms as $cm) {
+            if ($cm->name === $name || $cm->idnumber === $name) {
+                $cmid = $cm->id;
+                break;
+            }
+        }
+        if ($cmid === null) {
+            throw new coding_exception("Activity module '{$name}' of type '{$type}' not found!");
+        }
+        $context = context_module::instance($cmid);
+
+        $this->set_category_and_purpose($context->id, $category, $purpose);
+    }
+
+    /**
+     * Sets the data category and data storage purpose for a course instance.
+     *
+     * @Given /^I set the category and purpose for the "(?P<blockname_string>(?:[^"]|\\")*)" block in the "(?P<coursename_string>(?:[^"]|\\")*)" course to "(?P<category_string>(?:[^"]|\\")*)" and "(?P<purpose_string>(?:[^"]|\\")*)"$/
+     *
+     * @param string $name The instance name. It should match the name of the block. (e.g. online_users)
+     * @param string $coursename The course name. It should match the fullname or the shortname, or the idnumber.
+     * @param string $category The ID of the category to be set for the instance.
+     * @param string $purpose The ID of the purpose to be set for the instance.
+     */
+    public function i_set_the_category_and_purpose_for_block($name, $coursename, $category, $purpose) {
+        global $DB;
+
+        $params = [
+            'shortname' => $coursename,
+            'fullname' => $coursename,
+            'idnumber' => $coursename,
+        ];
+        $select = 'shortname = :shortname OR fullname = :fullname OR idnumber = :idnumber';
+        $courseid = $DB->get_field_select('course', 'id', $select, $params, MUST_EXIST);
+
+        // Fetch the course context.
+        $coursecontext = context_course::instance($courseid);
+
+        // Fetch the block record and context.
+        $blockid = $DB->get_field('block_instances', 'id', ['blockname' => $name, 'parentcontextid' => $coursecontext->id]);
+        $context = context_block::instance($blockid);
+
+        // Set the category and purpose.
+        $this->set_category_and_purpose($context->id, $category, $purpose);
+    }
+
+    /**
+     * Sets the category and purpose for a context instance.
+     *
+     * @param int $contextid The context ID.
+     * @param int $categoryname The category name.
+     * @param int $purposename The purpose name.
+     * @throws coding_exception
+     */
+    protected function set_category_and_purpose($contextid, $categoryname, $purposename) {
+        $category = \tool_dataprivacy\category::get_record(['name' => $categoryname]);
+        $purpose = \tool_dataprivacy\purpose::get_record(['name' => $purposename]);
+
+        api::set_context_instance((object) [
+            'contextid' => $contextid,
+            'purposeid' => $purpose->get('id'),
+            'categoryid' => $category->get('id'),
+        ]);
+    }
+}
index bdc43e7..9eb2a8a 100644 (file)
@@ -56,3 +56,93 @@ Feature: Manage data requests
     And I click on "Actions" "link" in the "Jane Doe" "table_row"
     And I should see "View the request"
     But I should not see "Mark as complete"
+
+  @javascript
+  Scenario: Bulk accepting requests
+    Given I log in as "student1"
+    And I follow "Profile" in the user menu
+    And I should see "Data requests"
+    And I click on "Data requests" "link"
+    And I should see "New request"
+    And I click on "New request" "link"
+    And I should see "Type"
+    And I should see "Comments"
+    And I set the field "Type" to "Export all of my personal data"
+    And I set the field "Comments" to "Comment1"
+    And I press "Save changes"
+    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 should see "Data requests"
+    And I click on "Data requests" "link"
+    And I should see "New request"
+    And I click on "New request" "link"
+    And I should see "Type"
+    And I should see "Comments"
+    And I set the field "Type" to "Export all of my personal data"
+    And I set the field "Comments" to "Comment2"
+    And I press "Save changes"
+    And I should see "Your request has been submitted to the privacy officer"
+    And I log out
+    And I trigger cron
+    And I log in as "admin"
+    And I navigate to "Users > Privacy and policies > Data requests" in site administration
+    And I should see "Comment1" in the "John Doe" "table_row"
+    And I should see "Awaiting approval" in the "John Doe" "table_row"
+    And I should see "Comment2" in the "Jane Doe" "table_row"
+    And I should see "Awaiting approval" in the "Jane Doe" "table_row"
+    And I click on ".selectrequests" "css_element" in the "John Doe" "table_row"
+    And I click on ".selectrequests" "css_element" in the "Jane Doe" "table_row"
+    And I set the field with xpath "//select[@id='bulk-action']" to "Approve"
+    And I press "Confirm"
+    And I should see "Approve requests"
+    And I should see "Do you really want to bulk approve the selected data requests?"
+    When I press "Approve requests"
+    Then I should see "Approved" in the "John Doe" "table_row"
+    And I should see "Approved" in the "Jane Doe" "table_row"
+
+  @javascript
+  Scenario: Bulk denying requests
+    Given I log in as "student1"
+    And I follow "Profile" in the user menu
+    And I should see "Data requests"
+    And I click on "Data requests" "link"
+    And I should see "New request"
+    And I click on "New request" "link"
+    And I should see "Type"
+    And I should see "Comments"
+    And I set the field "Type" to "Export all of my personal data"
+    And I set the field "Comments" to "Comment1"
+    And I press "Save changes"
+    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 should see "Data requests"
+    And I click on "Data requests" "link"
+    And I should see "New request"
+    And I click on "New request" "link"
+    And I should see "Type"
+    And I should see "Comments"
+    And I set the field "Type" to "Export all of my personal data"
+    And I set the field "Comments" to "Comment2"
+    And I press "Save changes"
+    And I should see "Your request has been submitted to the privacy officer"
+    And I log out
+    And I trigger cron
+    And I log in as "admin"
+    And I navigate to "Users > Privacy and policies > Data requests" in site administration
+    And I should see "Comment1" in the "John Doe" "table_row"
+    And I should see "Awaiting approval" in the "John Doe" "table_row"
+    And I should see "Comment2" in the "Jane Doe" "table_row"
+    And I should see "Awaiting approval" in the "Jane Doe" "table_row"
+    And I click on ".selectrequests" "css_element" in the "John Doe" "table_row"
+    And I click on ".selectrequests" "css_element" in the "Jane Doe" "table_row"
+    And I set the field with xpath "//select[@id='bulk-action']" to "Deny"
+    And I press "Confirm"
+    And I should see "Deny requests"
+    And I should see "Do you really want to bulk deny the selected data requests?"
+    When I press "Deny requests"
+    Then I should see "Rejected" in the "John Doe" "table_row"
+    And I should see "Rejected" in the "Jane Doe" "table_row"
diff --git a/admin/tool/dataprivacy/tests/behat/manage_defaults.feature b/admin/tool/dataprivacy/tests/behat/manage_defaults.feature
new file mode 100644 (file)
index 0000000..a6287d5
--- /dev/null
@@ -0,0 +1,298 @@
+@tool @tool_dataprivacy @javascript
+Feature: Manage data registry defaults
+  As the privacy officer
+  In order to manage the data registry
+  I need to be able to manage the default data categories and data storage purposes for various context levels.
+
+  Background:
+    Given I log in as "admin"
+    And the following "categories" exist:
+      | name                   | idnumber | category |
+      | Science and technology | scitech  |          |
+      | Physics                | st-phys  | scitech  |
+    And the following "courses" exist:
+      | fullname                  | shortname   | category |
+      | Fundamentals of physics 1 | Physics 101 | st-phys  |
+    And the following "activities" exist:
+      | activity | name         | idnumber | course      |
+      | assign   | Assignment 1 | assign1  | Physics 101 |
+      | forum    | Forum 1      | forum1   | Physics 101 |
+    And the following "blocks" exist:
+      | blockname    | contextlevel | reference   | pagetypepattern | defaultregion |
+      | online_users | Course       | Physics 101 | course-view-*   | site-post     |
+    And the following data privacy "categories" exist:
+      | name          |
+      | Site category |
+      | Category 1    |
+      | Category 2    |
+    And the following data privacy "purposes" exist:
+      | name         | retentionperiod |
+      | Site purpose | P10Y           |
+      | Purpose 1    | P3Y            |
+      | Purpose 2    | P5Y            |
+    And I set the site category and purpose to "Site category" and "Site purpose"
+
+  Scenario: Set course category data registry defaults
+    Given I set the category and purpose for the course category "scitech" to "Category 2" and "Purpose 2"
+    And I navigate to "Users > Privacy and policies > Data registry" in site administration
+    And I click on "Set defaults" "link"
+    And I should see "Inherit"
+    And I should not see "Add a new module default"
+    And I press "Edit"
+    And I set the field "Category" to "Category 1"
+    And I set the field "Purpose" to "Purpose 1"
+    When I press "Save changes"
+    Then I should see "Category 1"
+    And I should see "Purpose 1"
+    And I navigate to "Users > Privacy and policies > Data registry" in site administration
+    And I click on "Science and technology" "link"
+    And I wait until the page is ready
+    And the field "categoryid" matches value "Category 2"
+    And the field "purposeid" matches value "Purpose 2"
+    And I should see "5 years"
+
+  Scenario: Set course category data registry defaults with override
+    Given I set the category and purpose for the course category "scitech" to "Category 2" and "Purpose 2"
+    And I navigate to "Users > Privacy and policies > Data registry" in site administration
+    And I click on "Set defaults" "link"
+    And I should see "Inherit"
+    And I should not see "Add a new module default"
+    And I press "Edit"
+    And I set the field "Category" to "Category 1"
+    And I set the field "Purpose" to "Purpose 1"
+    And I click on "Reset instances with custom values" "checkbox"
+    When I press "Save changes"
+    Then I should see "Category 1"
+    And I should see "Purpose 1"
+    And I navigate to "Users > Privacy and policies > Data registry" in site administration
+    And I click on "Science and technology" "link"
+    And I wait until the page is ready
+    And the field "categoryid" matches value "Not set (use the default value)"
+    And the field "purposeid" matches value "Not set (use the default value)"
+    And I should see "3 years"
+
+  Scenario: Set course data registry defaults
+    Given I set the category and purpose for the course "Physics 101" to "Category 2" and "Purpose 2"
+    And I navigate to "Users > Privacy and policies > Data registry" in site administration
+    And I click on "Set defaults" "link"
+    And I click on "Courses" "link"
+    And I should see "Inherit"
+    And I should not see "Add a new module default"
+    And I press "Edit"
+    And I set the field "Category" to "Category 1"
+    And I set the field "Purpose" to "Purpose 1"
+    When I press "Save changes"
+    Then I should see "Category 1"
+    And I should see "Purpose 1"
+    And I navigate to "Users > Privacy and policies > Data registry" in site administration
+    And I click on "Science and technology" "link"
+    And I wait until the page is ready
+    And I click on "Courses" "link"
+    And I wait until the page is ready
+    And I click on "Physics 101" "link"
+    And I wait until the page is ready
+    And the field "categoryid" matches value "Category 2"
+    And the field "purposeid" matches value "Purpose 2"
+    And I should see "5 years (after the course end date)"
+
+  Scenario: Set course data registry defaults with override
+    Given I set the category and purpose for the course "Physics 101" to "Category 2" and "Purpose 2"
+    And I navigate to "Users > Privacy and policies > Data registry" in site administration
+    And I click on "Set defaults" "link"
+    And I click on "Courses" "link"
+    And I should see "Inherit"
+    And I should not see "Add a new module default"
+    And I press "Edit"
+    And I set the field "Category" to "Category 1"
+    And I set the field "Purpose" to "Purpose 1"
+    And I click on "Reset instances with custom values" "checkbox"
+    When I press "Save changes"
+    Then I should see "Category 1"
+    And I should see "Purpose 1"
+    And I navigate to "Users > Privacy and policies > Data registry" in site administration
+    And I click on "Science and technology" "link"
+    And I wait until the page is ready
+    And I click on "Courses" "link"
+    And I wait until the page is ready
+    And I click on "Physics 101" "link"
+    And I wait until the page is ready
+    And the field "categoryid" matches value "Not set (use the default value)"
+    And the field "purposeid" matches value "Not set (use the default value)"
+    And I should see "3 years (after the course end date)"
+
+  Scenario: Set module level data registry defaults
+    Given I set the category and purpose for the "assign1" "assign" in course "Physics 101" to "Category 2" and "Purpose 2"
+    And I navigate to "Users > Privacy and policies > Data registry" in site administration
+    And I click on "Set defaults" "link"
+    And I click on "Activity modules" "link"
+    And I should see "Inherit"
+    And I should see "Add a new module default"
+    And I press "Edit"
+    And I set the field "Category" to "Category 1"
+    And I set the field "Purpose" to "Purpose 1"
+    When I press "Save changes"
+    Then I should see "Category 1"
+    And I should see "Purpose 1"
+    And I navigate to "Users > Privacy and policies > Data registry" in site administration
+    And I click on "Science and technology" "link"
+    And I wait until the page is ready
+    And I click on "Courses" "link"
+    And I wait until the page is ready
+    And I click on "Physics 101" "link"
+    And I wait until the page is ready
+    And I click on "Activities and resources" "link"
+    And I wait until the page is ready
+    And I click on "Assignment 1 (Assignment)" "link"
+    And I wait until the page is ready
+    And the field "categoryid" matches value "Category 2"
+    And the field "purposeid" matches value "Purpose 2"
+    And I should see "5 years (after the course end date)"
+
+  Scenario: Set module level data registry defaults with override
+    Given I set the category and purpose for the "assign1" "assign" in course "Physics 101" to "Category 2" and "Purpose 2"
+    And I navigate to "Users > Privacy and policies > Data registry" in site administration
+    And I click on "Set defaults" "link"
+    And I click on "Activity modules" "link"
+    And I should see "Inherit"
+    And I should see "Add a new module default"
+    And I press "Edit"
+    And I set the field "Category" to "Category 1"
+    And I set the field "Purpose" to "Purpose 1"
+    And I click on "Reset instances with custom values" "checkbox"
+    When I press "Save changes"
+    Then I should see "Category 1"
+    And I should see "Purpose 1"
+    And I navigate to "Users > Privacy and policies > Data registry" in site administration
+    And I click on "Science and technology" "link"
+    And I wait until the page is ready
+    And I click on "Courses" "link"
+    And I wait until the page is ready
+    And I click on "Physics 101" "link"
+    And I wait until the page is ready
+    And I click on "Activities and resources" "link"
+    And I wait until the page is ready
+    And I click on "Assignment 1 (Assignment)" "link"
+    And I wait until the page is ready
+    And the field "categoryid" matches value "Not set (use the default value)"
+    And the field "purposeid" matches value "Not set (use the default value)"
+    And I click on "Forum 1 (Forum)" "link"
+    And I wait until the page is ready
+    And the field "categoryid" matches value "Not set (use the default value)"
+    And the field "purposeid" matches value "Not set (use the default value)"
+    And I should see "3 years (after the course end date)"
+
+  Scenario: Set data registry defaults for an activity module
+    Given I set the category and purpose for the "assign1" "assign" in course "Physics 101" to "Category 2" and "Purpose 2"
+    And I navigate to "Users > Privacy and policies > Data registry" in site administration
+    And I click on "Set defaults" "link"
+    And I click on "Activity modules" "link"
+    And I should see "Inherit"
+    And I should see "Add a new module default"
+    And I press "Add a new module default"
+    And I set the field "Activity module" to "Assignment"
+    And I set the field "Category" to "Category 1"
+    And I set the field "Purpose" to "Purpose 1"
+    When I press "Save changes"
+    Then I should see "Category 1" in the "Assignment" "table_row"
+    And I should see "Purpose 1" in the "Assignment" "table_row"
+    And I navigate to "Users > Privacy and policies > Data registry" in site administration
+    And I click on "Science and technology" "link"
+    And I wait until the page is ready
+    And I click on "Courses" "link"
+    And I wait until the page is ready
+    And I click on "Physics 101" "link"
+    And I wait until the page is ready
+    And I click on "Activities and resources" "link"
+    And I wait until the page is ready
+    And I click on "Assignment 1 (Assignment)" "link"
+    And I wait until the page is ready
+    And the field "categoryid" matches value "Category 2"
+    And the field "purposeid" matches value "Purpose 2"
+    And I should see "5 years (after the course end date)"
+
+  Scenario: Set data registry defaults for an activity module with override
+    Given I set the category and purpose for the "assign1" "assign" in course "Physics 101" to "Category 2" and "Purpose 2"
+    And I navigate to "Users > Privacy and policies > Data registry" in site administration
+    And I click on "Set defaults" "link"
+    And I click on "Activity modules" "link"
+    And I should see "Inherit"
+    And I should see "Add a new module default"
+    And I press "Add a new module default"
+    And I set the field "Activity module" to "Assignment"
+    And I set the field "Category" to "Category 1"
+    And I set the field "Purpose" to "Purpose 1"
+    And I click on "Reset instances with custom values" "checkbox"
+    When I press "Save changes"
+    Then I should see "Category 1" in the "Assignment" "table_row"
+    And I should see "Purpose 1" in the "Assignment" "table_row"
+    And I navigate to "Users > Privacy and policies > Data registry" in site administration
+    And I click on "Science and technology" "link"
+    And I wait until the page is ready
+    And I click on "Courses" "link"
+    And I wait until the page is ready
+    And I click on "Physics 101" "link"
+    And I wait until the page is ready
+    And I click on "Activities and resources" "link"
+    And I wait until the page is ready
+    And I click on "Assignment 1 (Assignment)" "link"
+    And I wait until the page is ready
+    And the field "categoryid" matches value "Not set (use the default value)"
+    And the field "purposeid" matches value "Not set (use the default value)"
+    And I should see "3 years (after the course end date)"
+
+  Scenario: Set block category data registry defaults
+    Given I set the category and purpose for the "online_users" block in the "Physics 101" course to "Category 2" and "Purpose 2"
+    And I navigate to "Users > Privacy and policies > Data registry" in site administration
+    And I click on "Set defaults" "link"
+    And I click on "Blocks" "link"
+    And I should see "Inherit"
+    And I should not see "Add a new module default"
+    And I press "Edit"
+    And I set the field "Category" to "Category 1"
+    And I set the field "Purpose" to "Purpose 1"
+    When I press "Save changes"
+    Then I should see "Category 1"
+    And I should see "Purpose 1"
+    And I navigate to "Users > Privacy and policies > Data registry" in site administration
+    And I click on "Science and technology" "link"
+    And I wait until the page is ready
+    And I click on "Courses" "link"
+    And I wait until the page is ready
+    And I click on "Physics 101" "link"
+    And I wait until the page is ready
+    And I click on "Blocks" "link"
+    And I wait until the page is ready
+    And I click on "Online users" "link"
+    And I wait until the page is ready
+    And the field "categoryid" matches value "Category 2"
+    And the field "purposeid" matches value "Purpose 2"
+    And I should see "5 years (after the course end date)"
+
+  Scenario: Set course category data registry defaults with override
+    Given I set the category and purpose for the "online_users" block in the "Physics 101" course to "Category 2" and "Purpose 2"
+    And I navigate to "Users > Privacy and policies > Data registry" in site administration
+    And I click on "Set defaults" "link"
+    And I click on "Blocks" "link"
+    And I should see "Inherit"
+    And I should not see "Add a new module default"
+    And I press "Edit"
+    And I set the field "Category" to "Category 1"
+    And I set the field "Purpose" to "Purpose 1"
+    And I click on "Reset instances with custom values" "checkbox"
+    When I press "Save changes"
+    Then I should see "Category 1"
+    And I should see "Purpose 1"
+    And I navigate to "Users > Privacy and policies > Data registry" in site administration
+    And I click on "Science and technology" "link"
+    And I wait until the page is ready
+    And I click on "Courses" "link"
+    And I wait until the page is ready
+    And I click on "Physics 101" "link"
+    And I wait until the page is ready
+    And I click on "Blocks" "link"
+    And I wait until the page is ready
+    And I click on "Online users" "link"
+    And I wait until the page is ready
+    And the field "categoryid" matches value "Not set (use the default value)"
+    And the field "purposeid" matches value "Not set (use the default value)"
+    And I should see "3 years (after the course end date)"
index 71d0111..b768786 100644 (file)
@@ -28,6 +28,7 @@ global $CFG;
 require_once($CFG->dirroot . '/webservice/tests/helpers.php');
 
 use tool_dataprivacy\api;
+use tool_dataprivacy\context_instance;
 use tool_dataprivacy\external;
 
 /**
@@ -282,4 +283,383 @@ class tool_dataprivacy_external_testcase extends externallib_advanced_testcase {
         $this->expectException(dml_missing_record_exception::class);
         external::get_data_request($this->requestid + 1);
     }
+
+    /**
+     * Test for \tool_dataprivacy\external::set_context_defaults()
+     * when called by a user that doesn't have the manage registry capability.
+     */
+    public function test_set_context_defaults_no_capability() {
+        $generator = $this->getDataGenerator();
+        $user = $generator->create_user();
+        $this->setUser($user);
+        $this->expectException(required_capability_exception::class);
+        external::set_context_defaults(CONTEXT_COURSECAT, context_instance::INHERIT, context_instance::INHERIT, '', false);
+    }
+
+    /**
+     * Test for \tool_dataprivacy\external::set_context_defaults().
+     *
+     * We're just checking the module context level here to test the WS function.
+     * More testing is done in \tool_dataprivacy_api_testcase::test_set_context_defaults().
+     *
+     * @dataProvider get_options_provider
+     * @param bool $modulelevel Whether defaults are to be applied on the module context level or for an activity only.
+     * @param bool $override Whether to override instances.
+     */
+    public function test_set_context_defaults($modulelevel, $override) {
+        $this->setAdminUser();
+        $generator = $this->getDataGenerator();
+
+        // Generate course cat, course, block, assignment, forum instances.
+        $coursecat = $generator->create_category();
+        $course = $generator->create_course(['category' => $coursecat->id]);
+        $assign = $generator->create_module('assign', ['course' => $course->id]);
+        list($course, $assigncm) = get_course_and_cm_from_instance($assign->id, 'assign');
+        $assigncontext = context_module::instance($assigncm->id);
+
+        // Generate purpose and category.
+        $category1 = api::create_category((object)['name' => 'Test category 1']);
+        $category2 = api::create_category((object)['name' => 'Test category 2']);
+        $purpose1 = api::create_purpose((object)[
+            'name' => 'Test purpose 1', 'retentionperiod' => 'PT1M', 'lawfulbases' => 'gdpr_art_6_1_a'
+        ]);
+        $purpose2 = api::create_purpose((object)[
+            'name' => 'Test purpose 2', 'retentionperiod' => 'PT1M', 'lawfulbases' => 'gdpr_art_6_1_a'
+        ]);
+
+        // Set a custom purpose and ID for this assignment instance.
+        $assignctxinstance = api::set_context_instance((object) [
+            'contextid' => $assigncontext->id,
+            'purposeid' => $purpose1->get('id'),
+            'categoryid' => $category1->get('id'),
+        ]);
+
+        $modulename = $modulelevel ? 'assign' : '';
+        $categoryid = $category2->get('id');
+        $purposeid = $purpose2->get('id');
+        $result = external::set_context_defaults(CONTEXT_MODULE, $categoryid, $purposeid, $modulename, $override);
+
+        // Extract the result.
+        $return = external_api::clean_returnvalue(external::set_context_defaults_returns(), $result);
+        $this->assertTrue($return['result']);
+
+        // Check the assignment context instance.
+        $instanceexists = context_instance::record_exists($assignctxinstance->get('id'));
+        if ($override) {
+            // The custom assign instance should have been deleted.
+            $this->assertFalse($instanceexists);
+        } else {
+            // The custom assign instance should still exist.
+            $this->assertTrue($instanceexists);
+        }
+
+        // Check the saved defaults.
+        list($savedpurpose, $savedcategory) = \tool_dataprivacy\data_registry::get_defaults(CONTEXT_MODULE, $modulename);
+        $this->assertEquals($categoryid, $savedcategory);
+        $this->assertEquals($purposeid, $savedpurpose);
+    }
+
+    /**
+     * Test for \tool_dataprivacy\external::get_category_options()
+     * when called by a user that doesn't have the manage registry capability.
+     */
+    public function test_get_category_options_no_capability() {
+        $generator = $this->getDataGenerator();
+        $user = $generator->create_user();
+        $this->setUser($user);
+        $this->expectException(required_capability_exception::class);
+        external::get_category_options(true, true);
+    }
+
+    /**
+     * Data provider for \tool_dataprivacy_external_testcase::test_XX_options().
+     */
+    public function get_options_provider() {
+        return [
+            [false, false],
+            [false, true],
+            [true, false],
+            [true, true],
+        ];
+    }
+
+    /**
+     * Test for \tool_dataprivacy\external::get_category_options().
+     *
+     * @dataProvider get_options_provider
+     * @param bool $includeinherit Whether "Inherit" would be included to the options.
+     * @param bool $includenotset Whether "Not set" would be included to the options.
+     */
+    public function test_get_category_options($includeinherit, $includenotset) {
+        $this->setAdminUser();
+
+        // Prepare our expected options.
+        $expectedoptions = [];
+        if ($includeinherit) {
+            $expectedoptions[] = [
+                'id' => context_instance::INHERIT,
+                'name' => get_string('inherit', 'tool_dataprivacy'),
+            ];
+        }
+
+        if ($includenotset) {
+            $expectedoptions[] = [
+                'id' => context_instance::NOTSET,
+                'name' => get_string('notset', 'tool_dataprivacy'),
+            ];
+        }
+
+        for ($i = 1; $i <= 3; $i++) {
+            $category = api::create_category((object)['name' => 'Category ' . $i]);
+            $expectedoptions[] = [
+                'id' => $category->get('id'),
+                'name' => $category->get('name'),
+            ];
+        }
+
+        // Call the WS function.
+        $result = external::get_category_options($includeinherit, $includenotset);
+
+        // Extract the options.
+        $return = (object) external_api::clean_returnvalue(external::get_category_options_returns(), $result);
+        $options = $return->options;
+
+        // Make sure everything checks out.
+        $this->assertCount(count($expectedoptions), $options);
+        foreach ($options as $option) {
+            $this->assertContains($option, $expectedoptions);
+        }
+    }
+
+    /**
+     * Test for \tool_dataprivacy\external::get_purpose_options()
+     * when called by a user that doesn't have the manage registry capability.
+     */
+    public function test_get_purpose_options_no_capability() {
+        $generator = $this->getDataGenerator();
+        $user = $generator->create_user();
+        $this->setUser($user);
+        $this->expectException(required_capability_exception::class);
+        external::get_category_options(true, true);
+    }
+
+    /**
+     * Test for \tool_dataprivacy\external::get_purpose_options().
+     *
+     * @dataProvider get_options_provider
+     * @param bool $includeinherit Whether "Inherit" would be included to the options.
+     * @param bool $includenotset Whether "Not set" would be included to the options.
+     */
+    public function test_get_purpose_options($includeinherit, $includenotset) {
+        $this->setAdminUser();
+
+        // Prepare our expected options.
+        $expectedoptions = [];
+        if ($includeinherit) {
+            $expectedoptions[] = [
+                'id' => context_instance::INHERIT,
+                'name' => get_string('inherit', 'tool_dataprivacy'),
+            ];
+        }
+
+        if ($includenotset) {
+            $expectedoptions[] = [
+                'id' => context_instance::NOTSET,
+                'name' => get_string('notset', 'tool_dataprivacy'),
+            ];
+        }
+
+        for ($i = 1; $i <= 3; $i++) {
+            $purpose = api::create_purpose((object)[
+                'name' => 'Purpose ' . $i, 'retentionperiod' => 'PT1M', 'lawfulbases' => 'gdpr_art_6_1_a'
+            ]);
+            $expectedoptions[] = [
+                'id' => $purpose->get('id'),
+                'name' => $purpose->get('name'),
+            ];
+        }
+
+        // Call the WS function.
+        $result = external::get_purpose_options($includeinherit, $includenotset);
+
+        // Extract the options.
+        $return = (object) external_api::clean_returnvalue(external::get_purpose_options_returns(), $result);
+        $options = $return->options;
+
+        // Make sure everything checks out.
+        $this->assertCount(count($expectedoptions), $options);
+        foreach ($options as $option) {
+            $this->assertContains($option, $expectedoptions);
+        }
+    }
+
+    /**
+     * Data provider for \tool_dataprivacy_external_testcase::get_activity_options().
+     */
+    public function get_activity_options_provider() {
+        return [
+            [false, false, true],
+            [false, true, true],
+            [true, false, true],
+            [true, true, true],
+            [false, false, false],
+            [false, true, false],
+            [true, false, false],
+            [true, true, false],
+        ];
+    }
+
+    /**
+     * Test for \tool_dataprivacy\external::get_activity_options().
+     *
+     * @dataProvider get_activity_options_provider
+     * @param bool $inheritcategory Whether the category would be set to "Inherit".
+     * @param bool $inheritpurpose Whether the purpose would be set to "Inherit".
+     * @param bool $nodefaults Whether to fetch only activities that don't have defaults.
+     */
+    public function test_get_activity_options($inheritcategory, $inheritpurpose, $nodefaults) {
+        $this->setAdminUser();
+
+        $category = api::create_category((object)['name' => 'Test category']);
+        $purpose = api::create_purpose((object)[
+            'name' => 'Test purpose ', 'retentionperiod' => 'PT1M', 'lawfulbases' => 'gdpr_art_6_1_a'
+        ]);
+        $categoryid = $category->get('id');
+        $purposeid = $purpose->get('id');
+
+        if ($inheritcategory) {
+            $categoryid = context_instance::INHERIT;
+        }
+        if ($inheritpurpose) {
+            $purposeid = context_instance::INHERIT;
+        }
+
+        // Set the context default for the assignment module.
+        api::set_context_defaults(CONTEXT_MODULE, $categoryid, $purposeid, 'assign');
+
+        // Call the WS function.
+        $result = external::get_activity_options($nodefaults);
+
+        // Extract the options.
+        $return = (object) external_api::clean_returnvalue(external::get_activity_options_returns(), $result);
+        $options = $return->options;
+
+        // Make sure the options list is not empty.
+        $this->assertNotEmpty($options);
+
+        $pluginwithdefaults = [
+            'name' => 'assign',
+            'displayname' => get_string('pluginname', 'assign')
+        ];
+
+        // If we don't want plugins with defaults to be listed or if both of the category and purpose are set to inherit,
+        // the assign module should be listed.
+        if (!$nodefaults || ($inheritcategory && $inheritpurpose)) {
+            $this->assertContains($pluginwithdefaults, $options);
+        } else {
+            $this->assertNotContains($pluginwithdefaults, $options);
+        }
+    }
+
+    /**
+     * Test for external::bulk_approve_data_requests().
+     */
+    public function test_bulk_approve_data_requests() {
+        $generator = new testing_data_generator();
+        $requester1 = $generator->create_user();
+        $comment1 = 'sample comment';
+        // Login as requester2.
+        $this->setUser($requester1->id);
+        // Create delete data request.
+        $datarequest1 = api::create_data_request($requester1->id, api::DATAREQUEST_TYPE_DELETE, $comment1);
+
+        $requestid1 = $datarequest1->get('id');
+        $requestid2 = $this->requestid;
+
+        $this->setAdminUser();
+        api::update_request_status($requestid1, api::DATAREQUEST_STATUS_AWAITING_APPROVAL);
+        api::update_request_status($requestid2, api::DATAREQUEST_STATUS_AWAITING_APPROVAL);
+        $result = external::bulk_approve_data_requests([$requestid1, $requestid2]);
+        $return = (object) external_api::clean_returnvalue(external::bulk_approve_data_requests_returns(), $result);
+        $this->assertTrue($return->result);
+        $this->assertEmpty($return->warnings);
+    }
+
+    /**
+     * Test for external::bulk_approve_data_requests() for a non-existent request ID.
+     */
+    public function test_bulk_approve_data_requests_non_existent() {
+        $generator = new testing_data_generator();
+        $requester1 = $generator->create_user();
+        $comment1 = 'sample comment';
+        // Login as requester2.
+        $this->setUser($requester1->id);
+        // Create delete data request.
+        $datarequest1 = api::create_data_request($requester1->id, api::DATAREQUEST_TYPE_DELETE, $comment1);
+
+        $requestid1 = $datarequest1->get('id');
+        $requestid2 = $this->requestid;
+
+        $this->setAdminUser();
+        api::update_request_status($requestid1, api::DATAREQUEST_STATUS_AWAITING_APPROVAL);
+        api::update_request_status($requestid2, api::DATAREQUEST_STATUS_AWAITING_APPROVAL);
+        $result = external::bulk_approve_data_requests([$requestid1 + 1, $requestid2]);
+        $return = (object) external_api::clean_returnvalue(external::bulk_approve_data_requests_returns(), $result);
+        $this->assertFalse($return->result);
+        $this->assertCount(1, $return->warnings);
+        $warning = reset($return->warnings);
+        $this->assertEquals('errorrequestnotfound', $warning['warningcode']);
+        $this->assertEquals($requestid1 + 1, $warning['item']);
+    }
+
+    /**
+     * Test for external::bulk_deny_data_requests().
+     */
+    public function test_bulk_deny_data_requests() {
+        $generator = new testing_data_generator();
+        $requester1 = $generator->create_user();
+        $comment1 = 'sample comment';
+        // Login as requester2.
+        $this->setUser($requester1->id);
+        // Create delete data request.
+        $datarequest1 = api::create_data_request($requester1->id, api::DATAREQUEST_TYPE_DELETE, $comment1);
+
+        $requestid1 = $datarequest1->get('id');
+        $requestid2 = $this->requestid;
+
+        $this->setAdminUser();
+        api::update_request_status($requestid1, api::DATAREQUEST_STATUS_AWAITING_APPROVAL);
+        api::update_request_status($requestid2, api::DATAREQUEST_STATUS_AWAITING_APPROVAL);
+        $result = external::bulk_deny_data_requests([$requestid1, $requestid2]);
+        $return = (object) external_api::clean_returnvalue(external::bulk_approve_data_requests_returns(), $result);
+        $this->assertTrue($return->result);
+        $this->assertEmpty($return->warnings);
+    }
+
+    /**
+     * Test for external::bulk_deny_data_requests() for a non-existent request ID.
+     */
+    public function test_bulk_deny_data_requests_non_existent() {
+        $generator = new testing_data_generator();
+        $requester1 = $generator->create_user();
+        $comment1 = 'sample comment';
+        // Login as requester2.
+        $this->setUser($requester1->id);
+        // Create delete data request.
+        $datarequest1 = api::create_data_request($requester1->id, api::DATAREQUEST_TYPE_DELETE, $comment1);
+
+        $requestid1 = $datarequest1->get('id');
+        $requestid2 = $this->requestid;
+
+        $this->setAdminUser();
+        api::update_request_status($requestid1, api::DATAREQUEST_STATUS_AWAITING_APPROVAL);
+        api::update_request_status($requestid2, api::DATAREQUEST_STATUS_AWAITING_APPROVAL);
+        $result = external::bulk_deny_data_requests([$requestid1 + 1, $requestid2]);
+        $return = (object) external_api::clean_returnvalue(external::bulk_approve_data_requests_returns(), $result);
+        $this->assertFalse($return->result);
+        $this->assertCount(1, $return->warnings);
+        $warning = reset($return->warnings);
+        $this->assertEquals('errorrequestnotfound', $warning['warningcode']);
+        $this->assertEquals($requestid1 + 1, $warning['item']);
+    }
 }
diff --git a/admin/tool/dataprivacy/tests/generator/lib.php b/admin/tool/dataprivacy/tests/generator/lib.php
new file mode 100644 (file)
index 0000000..744e34a
--- /dev/null
@@ -0,0 +1,115 @@
+<?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/>.
+
+/**
+ * Data privacy tool data generator.
+ *
+ * @package    tool_dataprivacy
+ * @category   test
+ * @copyright  2018 Jun Pataleta
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+use tool_dataprivacy\api;
+use tool_dataprivacy\category;
+use tool_dataprivacy\purpose;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Data privacy tool data generator class.
+ *
+ * @package    tool_dataprivacy
+ * @category   test
+ * @copyright  2018 Jun Pataleta
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class tool_dataprivacy_generator extends component_generator_base {
+
+    /** @var int Number of created categories. */
+    protected $categorycount = 0;
+
+    /** @var int Number of created purposes. */
+    protected $purposecount = 0;
+
+    /**
+     * Reset process.
+     *
+     * Do not call directly.
+     *
+     * @return void
+     */
+    public function reset() {
+        $this->categorycount = 0;
+        $this->purposecount = 0;
+    }
+
+    /**
+     * Create a new category.
+     *
+     * @param array|stdClass $record
+     * @return category
+     */
+    public function create_category($record = null) {
+        $this->categorycount++;
+        $i = $this->categorycount;
+        $record = (object)$record;
+
+        if (!isset($record->name)) {
+            $record->name = "Test purpose $i";
+        }
+
+        if (!isset($record->description)) {
+            $record->description = "{$record->name} description";
+        }
+
+        $category = api::create_category($record);
+
+        return $category;
+    }
+
+    /**
+     * Create a new purpose.
+     *
+     * @param array|stdClass $record
+     * @return purpose
+     */
+    public function create_purpose($record = null) {
+        $this->purposecount++;
+        $i = $this->purposecount;
+        $record = (object)$record;
+
+        if (!isset($record->name)) {
+            $record->name = "Test purpose $i";
+        }
+
+        if (!isset($record->description)) {
+            $record->description = "{$record->name} $i description";
+        }
+
+        if (!isset($record->retentionperiod)) {
+            $record->retentionperiod = 'PT1M';
+        }
+
+        if (!isset($record->lawfulbases)) {
+            $record->lawfulbases = 'gdpr_art_6_1_a';
+        }
+
+        $purpose = api::create_purpose($record);
+
+        return $purpose;
+    }
+}
index ca666e3..a65e257 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die;
 
-$plugin->version   = 2018082100;
+$plugin->version   = 2018091100;
 $plugin->requires  = 2018050800;        // Moodle 3.5dev (Build 2018031600) and upwards.
 $plugin->component = 'tool_dataprivacy';
index da1432a..020e5bf 100644 (file)
Binary files a/admin/tool/policy/amd/build/acceptmodal.min.js and b/admin/tool/policy/amd/build/acceptmodal.min.js differ
index a642ac1..b923629 100644 (file)
@@ -82,6 +82,21 @@ define(['jquery', 'core/str', 'core/modal_factory', 'core/modal_events', 'core/n
             }
         ];
 
+        /**
+         * @var {object} currentTrigger The triggered HTML jQuery object
+         * @private
+         */
+        AcceptOnBehalf.prototype.currentTrigger = null;
+
+        /**
+         * @var {object} triggers The trigger selectors
+         * @private
+         */
+        AcceptOnBehalf.prototype.triggers = {
+            SINGLE: 'a[data-action=acceptmodal]',
+            BULK: 'input[data-action=acceptmodal]'
+        };
+
         /**
          * Initialise the class.
          *
@@ -89,21 +104,22 @@ define(['jquery', 'core/str', 'core/modal_factory', 'core/modal_events', 'core/n
          */
         AcceptOnBehalf.prototype.init = function() {
             // Initialise for links accepting policies for individual users.
-            var triggers = $('a[data-action=acceptmodal]');
-            triggers.on('click', function(e) {
+            $(this.triggers.SINGLE).on('click', function(e) {
                 e.preventDefault();
+                this.currentTrigger = $(e.currentTarget);
                 var href = $(e.currentTarget).attr('href'),
                     formData = href.slice(href.indexOf('?') + 1);
                 this.showFormModal(formData);
             }.bind(this));
 
             // Initialise for multiple users acceptance form.
-            triggers = $('form[data-action=acceptmodal]');
-            triggers.on('submit', function(e) {
+            $(this.triggers.BULK).on('click', function(e) {
                 e.preventDefault();
-                if ($(e.currentTarget).find('input[type=checkbox][name="userids[]"]:checked').length) {
-                    var formData = $(e.currentTarget).serialize();
-                    this.showFormModal(formData, triggers);
+                this.currentTrigger = $(e.currentTarget);
+                var form = $(e.currentTarget).closest('form');
+                if (form.find('input[type=checkbox][name="userids[]"]:checked').length) {
+                    var formData = form.serialize();
+                    this.showFormModal(formData);
                 } else {
                     Str.get_strings(this.stringKeys).done(function(strings) {
                         Notification.alert('', strings[2], strings[3]);
@@ -116,9 +132,8 @@ define(['jquery', 'core/str', 'core/modal_factory', 'core/modal_events', 'core/n
          * Show modal with a form
          *
          * @param {String} formData
-         * @param {object} triggerElement The trigger HTML jQuery object
          */
-        AcceptOnBehalf.prototype.showFormModal = function(formData, triggerElement) {
+        AcceptOnBehalf.prototype.showFormModal = function(formData) {
             var action;
             var params = formData.split('&');
             for (var i = 0; i < params.length; i++) {
@@ -143,7 +158,7 @@ define(['jquery', 'core/str', 'core/modal_factory', 'core/modal_events', 'core/n
                     type: ModalFactory.types.SAVE_CANCEL,
                     title: title,
                     body: ''
-                }, triggerElement).done(function(modal) {
+                }).done(function(modal) {
                     this.modal = modal;
                     this.setupFormModal(formData, saveText);
                 }.bind(this));
@@ -250,6 +265,7 @@ define(['jquery', 'core/str', 'core/modal_factory', 'core/modal_events', 'core/n
                 M.core_formchangechecker.reset_form_dirty_state();
             });
             this.modal.destroy();
+            this.currentTrigger.focus();
         };
 
         return /** @alias module:tool_policy/acceptmodal */ {
index a723519..a0efdcc 100644 (file)
@@ -415,7 +415,7 @@ class acceptances_table extends \table_sql {
      */
     public function wrap_html_start() {
         echo \html_writer::start_tag('form',
-            ['action' => new \moodle_url('/admin/tool/policy/accept.php'), 'data-action' => 'acceptmodal']);
+            ['action' => new \moodle_url('/admin/tool/policy/accept.php')]);
         echo \html_writer::empty_tag('input', ['type' => 'hidden', 'name' => 'sesskey', 'value' => sesskey()]);
         echo \html_writer::empty_tag('input', ['type' => 'hidden', 'name' => 'returnurl',
             'value' => $this->get_return_url()]);
@@ -433,8 +433,8 @@ class acceptances_table extends \table_sql {
     public function wrap_html_finish() {
         global $PAGE;
         if ($this->canagreeany) {
-            echo \html_writer::empty_tag('input', ['type' => 'submit',
-                'value' => get_string('consentbulk', 'tool_policy'), 'class' => 'btn btn-primary']);
+            echo \html_writer::empty_tag('input', ['type' => 'submit', 'data-action' => 'acceptmodal',
+                'value' => get_string('consentbulk', 'tool_policy'), 'class' => 'btn btn-primary m-t-1']);
             $PAGE->requires->js_call_amd('tool_policy/acceptmodal', 'getInstance', [\context_system::instance()->id]);
         }
         echo "</form>\n";
index 3d436d4..23198da 100644 (file)
@@ -260,3 +260,37 @@ Feature: Viewing acceptances reports and accepting on behalf of other users
     And "Admin User" "link" should exist in the "User One" "table_row"
     And "Consent received from a parent" "text" should exist in the "User One" "table_row"
     And "Consent not given" "icon" should exist in the "User Two" "table_row"
+
+  @javascript
+  Scenario: Bulk agree on behalf of another users as a manager, multiple policies, javascript on
+    Given I log in as "admin"
+    And I navigate to "Users > Privacy and policies > Manage policies" in site administration
+    And I open the action menu in "This privacy policy" "table_row"
+    And I click on "Set status to \"Active\"" "link" in the "This privacy policy" "table_row"
+    And I press "Activate"
+    And I set the following system permissions of "Manager" role:
+      | capability | permission |
+      | tool/policy:acceptbehalf | Allow |
+    And I log out
+    When I log in as "manager"
+    And I press "Next"
+    And I press "Next"
+    And I set the field "I agree to the This site policy" to "1"
+    And I set the field "I agree to the This privacy policy" to "1"
+    And I press "Next"
+    And I navigate to "Users > Privacy and policies > User agreements" in site administration
+    And I click on "Select" "checkbox" in the "User One" "table_row"
+    And I press "Consent"
+    And I should see "Give consent on behalf of user(s)"
+    And I should see "One"
+    And I press "Cancel"
+    And I should not see "Give consent on behalf of user(s)"
+    And I click on "Select" "checkbox" in the "User Two" "table_row"
+    And I press "Consent"
+    And I should see "Give consent on behalf of user(s)"
+    And I should see "User One, User Two"
+    When I press "Give consent"
+    Then "Consent given on behalf of user" "icon" should exist in the "User One" "table_row"
+    And I should see "2 of 2" in the "User One" "table_row"
+    And "Consent given on behalf of user" "icon" should exist in the "User Two" "table_row"
+    And I should see "2 of 2" in the "User Two" "table_row"
index 3793d9b..b90490a 100644 (file)
@@ -2211,7 +2211,7 @@ class backup_questions_structure_step extends backup_structure_step {
         $qcategory = new backup_nested_element('question_category', array('id'), array(
             'name', 'contextid', 'contextlevel', 'contextinstanceid',
             'info', 'infoformat', 'stamp', 'parent',
-            'sortorder'));
+            'sortorder', 'idnumber'));
 
         $questions = new backup_nested_element('questions');
 
@@ -2219,7 +2219,7 @@ class backup_questions_structure_step extends backup_structure_step {
             'parent', 'name', 'questiontext', 'questiontextformat',
             'generalfeedback', 'generalfeedbackformat', 'defaultmark', 'penalty',
             'qtype', 'length', 'stamp', 'version',
-            'hidden', 'timecreated', 'timemodified', 'createdby', 'modifiedby'));
+            'hidden', 'timecreated', 'timemodified', 'createdby', 'modifiedby', 'idnumber'));
 
         // attach qtype plugin structure to $question element, only one allowed
         $this->add_plugin_structure('qtype', $question, false);
index 8443174..250aeb2 100644 (file)
@@ -4434,6 +4434,12 @@ class restore_create_categories_and_questions extends restore_structure_step {
                 $data->stamp = make_unique_id_code();
             }
 
+            // The idnumber if it exists also needs to be unique within a context or reset it to null.
+            if (!empty($data->idnumber) && $DB->record_exists('question_categories',
+                    ['idnumber' => $data->idnumber, 'contextid' => $data->contextid])) {
+                unset($data->idnumber);
+            }
+
             // Let's create the question_category and save mapping.
             $newitemid = $DB->insert_record('question_categories', $data);
             $this->set_mapping('question_category', $oldid, $newitemid);
@@ -4479,6 +4485,13 @@ class restore_create_categories_and_questions extends restore_structure_step {
 
         // With newitemid = 0, let's create the question
         if (!$questionmapping->newitemid) {
+
+            // The idnumber if it exists also needs to be unique within a category or reset it to null.
+            if (!empty($data->idnumber) && $DB->record_exists('question',
+                    ['idnumber' => $data->idnumber, 'category' => $data->category])) {
+                unset($data->idnumber);
+            }
+
             $newitemid = $DB->insert_record('question', $data);
             $this->set_mapping('question', $oldid, $newitemid);
             // Also annotate them as question_created, we need
index 1207eb7..40a65dc 100644 (file)
@@ -20,12 +20,13 @@ Feature: Duplicate activities
       | Name | Test database name |
       | Description | Test database description |
     And I duplicate "Test database name" activity
+    And I should see "Test database name (copy)"
     And I wait until section "1" is available
     And I click on "Edit settings" "link" in the "Test database name" activity
     And I set the following fields to these values:
       | Name | Original database name |
     And I press "Save and return to course"
-    And I click on "Edit settings" "link" in the "Test database name" activity
+    And I click on "Edit settings" "link" in the "Test database name (copy)" activity
     And I set the following fields to these values:
       | Name | Duplicated database name |
       | Description | Duplicated database description |
index e988304..4bb603a 100644 (file)
@@ -131,7 +131,7 @@ class item implements \renderable, \templatable {
         }
 
         // Allow the renderer to format the title and description.
-        $data['title']          = $output->format_title($title);
+        $data['title']          = strip_tags($output->format_title($title));
         $data['description']    = $this->showdescription ? $output->format_description($this->description) : null;
 
         return $data;
index 5ebfdce..51ee3e4 100644 (file)
@@ -92,6 +92,10 @@ class event_exporter extends event_exporter_base {
         }
         $values['url'] = $url->out(false);
 
+        // Override default formatted time to make sure the date portion of the time is always rendered.
+        $legacyevent = container::get_event_mapper()->from_event_to_legacy_event($event);
+        $values['formattedtime'] = calendar_format_event_time($legacyevent, time(), null, false);
+
         if ($event instanceof action_event_interface) {
             $actionrelated = [
                 'context' => $context,
index 576ffbe..be43e5f 100644 (file)
@@ -42,7 +42,7 @@
     <div class="container-fluid">
         <div class="row">
             <div class="col-xs-1">{{#pix}} i/calendareventtime, core, {{#str}} when, core_calendar {{/str}} {{/pix}}</div>
-            <div class="col-xs-11">{{#userdate}} {{timestart}}, {{#str}} strftimerecentfull {{/str}} {{/userdate}}</div>
+            <div class="col-xs-11">{{{formattedtime}}}</div>
         </div>
         <div class="row m-t-1">
             <div class="col-xs-1">{{#pix}} i/calendar, core, {{#str}} eventtype, core_calendar {{/str}} {{/pix}}</div>
index 0b9c4e8..291afd9 100644 (file)
@@ -575,6 +575,14 @@ $CFG->admin = 'admin';
 //
 //      $CFG->upgradekey = 'put_some_password-like_value_here';
 //
+// Document conversion limit
+//
+// How many times the background task should attempt to convert a given attempt
+// before removing it from the queue. Currently this limit is only used by the
+// mod_assign conversion task.
+//
+//      $CFG->conversionattemptlimit = 3;
+//
 //=========================================================================
 // 7. SETTINGS FOR DEVELOPMENT SERVERS - not intended for production use!!!
 //=========================================================================
index a0345e6..bf4bfd6 100644 (file)
@@ -445,9 +445,6 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
         $path = $parent->path . '/' . $newcategory->id;
         $DB->set_field('course_categories', 'path', $path, array('id' => $newcategory->id));
 
-        // We should mark the context as dirty.
-        context_coursecat::instance($newcategory->id)->mark_dirty();
-
         fix_course_sortorder();
 
         // If this is data from form results, save embedded files and update description.
index 727a1f7..81626d8 100644 (file)
@@ -103,8 +103,13 @@ if ($id) {
     $PAGE->set_context($catcontext);
 
 } else {
+    // Creating new course in default category.
+    $course = null;
     require_login();
-    print_error('needcoursecategroyid');
+    $category = core_course_category::get_default();
+    $catcontext = context_coursecat::instance($category->id);
+    require_capability('moodle/course:create', $catcontext);
+    $PAGE->set_context($catcontext);
 }
 
 // Prepare course and the editor.
index aba5bdc..2db5dff 100644 (file)
@@ -2450,9 +2450,6 @@ function create_course($data, $editoroptions = NULL) {
     // purge appropriate caches in case fix_course_sortorder() did not change anything
     cache_helper::purge_by_event('changesincourse');
 
-    // new context created - better mark it as dirty
-    $context->mark_dirty();
-
     // Trigger a course created event.
     $event = \core\event\course_created::create(array(
         'objectid' => $course->id,
@@ -3453,11 +3450,18 @@ function duplicate_module($course, $cm) {
     // right below the original one. otherwise it will stay at the
     // end of the section.
     if ($newcmid) {
+        // Proceed with activity renaming before everything else. We don't use APIs here to avoid
+        // triggering a lot of create/update duplicated events.
+        $newcm = get_coursemodule_from_id($cm->modname, $newcmid, $cm->course);
+        // Add ' (copy)' to duplicates. Note we don't cleanup or validate lengths here. It comes
+        // from original name that was valid, so the copy should be too.
+        $newname = get_string('duplicatedmodule', 'moodle', $newcm->name);
+        $DB->set_field($cm->modname, 'name', $newname, ['id' => $newcm->instance]);
+
         $section = $DB->get_record('course_sections', array('id' => $cm->section, 'course' => $cm->course));
         $modarray = explode(",", trim($section->sequence));
         $cmindex = array_search($cm->id, $modarray);
         if ($cmindex !== false && $cmindex < count($modarray) - 1) {
-            $newcm = get_coursemodule_from_id($cm->modname, $newcmid, $cm->course);
             moveto_module($newcm, $section, $modarray[$cmindex + 1]);
         }
 
index ce18504..0174dab 100644 (file)
@@ -985,7 +985,6 @@ class core_course_courselib_testcase extends advanced_testcase {
         $modulecontext = context_module::instance($assign1->cmid);
         assign_capability('moodle/course:manageactivities', CAP_PROHIBIT, $roleids['editingteacher'],
             $modulecontext);
-        $modulecontext->mark_dirty();
         $this->assertFalse(course_can_delete_section($courseweeks, 1));
         $this->assertTrue(course_can_delete_section($courseweeks, 2));
 
@@ -2853,6 +2852,10 @@ class core_course_courselib_testcase extends advanced_testcase {
                 // Ignore obviously different properties.
                 continue;
             }
+            if ($prop == 'name') {
+                // We expect ' (copy)' to be added to the original name since MDL-59227.
+                $value = get_string('duplicatedmodule', 'moodle', $value);
+            }
             $this->assertEquals($value, $newcm->$prop);
         }
     }
@@ -3127,7 +3130,6 @@ class core_course_courselib_testcase extends advanced_testcase {
         $CFG->enableblogs = 0;
         // Disable view participants capability.
         assign_capability('moodle/course:viewparticipants', CAP_PROHIBIT, $roleid, $context);
-        $context->mark_dirty();
 
         $navoptions = course_get_user_navigation_options($context);
         $this->assertFalse($navoptions->blogs);
@@ -3423,6 +3425,39 @@ class core_course_courselib_testcase extends advanced_testcase {
         ];
     }
 
+    /**
+     * Test reset_course_userdata() with reset_roles_overrides enabled.
+     */
+    public function test_course_roles_reset() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+
+        $generator = $this->getDataGenerator();
+
+        // Create test course and user, enrol one in the other.
+        $course = $generator->create_course();
+        $user = $generator->create_user();
+        $roleid = $DB->get_field('role', 'id', array('shortname' => 'student'), MUST_EXIST);
+        $generator->enrol_user($user->id, $course->id, $roleid);
+
+        // Override course so it does NOT allow students 'mod/forum:viewdiscussion'.
+        $coursecontext = context_course::instance($course->id);
+        assign_capability('mod/forum:viewdiscussion', CAP_PREVENT, $roleid, $coursecontext->id);
+
+        // Check expected capabilities so far.
+        $this->assertFalse(has_capability('mod/forum:viewdiscussion', $coursecontext, $user));
+
+        // Oops, preventing student from viewing forums was a mistake, let's reset the course.
+        $resetdata = new stdClass();
+        $resetdata->id = $course->id;
+        $resetdata->reset_roles_overrides = true;
+        reset_course_userdata($resetdata);
+
+        // Check new expected capabilities - override at the course level should be reset.
+        $this->assertTrue(has_capability('mod/forum:viewdiscussion', $coursecontext, $user));
+    }
+
     public function test_course_check_module_updates_since() {
         global $CFG, $DB, $USER;
         require_once($CFG->dirroot . '/mod/glossary/lib.php');
index 3d9ab46..7e264fc 100644 (file)
@@ -42,33 +42,15 @@ class enrol_category_plugin_testcase extends advanced_testcase {
     }
 
     protected function enable_role_sync($roleid) {
-        global $DB;
-
         $syscontext = context_system::instance();
 
-        if ($rc = $DB->record_exists('role_capabilities', array('capability'=>'enrol/category:synchronised', 'roleid'=>$roleid, 'contextid'=>$syscontext->id))) {
-            if ($rc->permission != CAP_ALLOW) {
-                $rc->permission = CAP_ALLOW;
-                $DB->update_record('role_capabilities', $rc);
-            }
-        } else {
-            $rc = new stdClass();
-            $rc->capability = 'enrol/category:synchronised';
-            $rc->roleid = $roleid;
-            $rc->contextid = $syscontext->id;
-            $rc->permission = CAP_ALLOW;
-            $rc->timemodified = time();
-            $rc->modifierid = 0;
-            $DB->insert_record('role_capabilities', $rc);
-        }
+        assign_capability('enrol/category:synchronised', CAP_ALLOW, $roleid, $syscontext, true);
     }
 
     protected function disable_role_sync($roleid) {
-        global $DB;
-
         $syscontext = context_system::instance();
 
-        $DB->delete_records('role_capabilities', array('capability'=>'enrol/category:synchronised', 'roleid'=>$roleid, 'contextid'=>$syscontext->id));
+        unassign_capability('enrol/category:synchronised', $roleid, $syscontext);
     }
 
     /**
index 1678462..0cabb96 100644 (file)
@@ -751,4 +751,45 @@ class core_enrollib_testcase extends advanced_testcase {
         $this->assertCount(2, enrol_get_course_users($course1->id, false));
         $this->assertCount(1, enrol_get_course_users($course1->id, true));
     }
+
+    /**
+     * Test count of enrolled users
+     *
+     * @return void
+     */
+    public function test_count_enrolled_users() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+
+        $course = $this->getDataGenerator()->create_course();
+        $context = \context_course::instance($course->id);
+
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+
+        $studentrole = $DB->get_record('role', ['shortname' => 'student']);
+
+        // Add each user to the manual enrolment instance.
+        $manual = enrol_get_plugin('manual');
+
+        $manualinstance = $DB->get_record('enrol', ['courseid' => $course->id, 'enrol' => 'manual'], '*', MUST_EXIST);
+
+        $manual->enrol_user($manualinstance, $user1->id, $studentrole->id);
+        $manual->enrol_user($manualinstance, $user2->id, $studentrole->id);
+
+        $this->assertEquals(2, count_enrolled_users($context));
+
+        // Create a self enrolment instance, enrol first user only.
+        $self = enrol_get_plugin('self');
+
+        $selfid = $self->add_instance($course,
+            ['status' => ENROL_INSTANCE_ENABLED, 'name' => 'Self', 'customint6' => 1, 'roleid' => $studentrole->id]);
+        $selfinstance = $DB->get_record('enrol', ['id' => $selfid], '*', MUST_EXIST);
+
+        $self->enrol_user($selfinstance, $user1->id, $studentrole->id);
+
+        // There are still only two distinct users.
+        $this->assertEquals(2, count_enrolled_users($context));
+    }
 }
index 5c4ba44..1f25918 100644 (file)
@@ -33,12 +33,8 @@ defined('MOODLE_INTERNAL') || die();
  * NOTE: multilang glossary entries are not compatible with this filter.
  */
 class filter_glossary extends moodle_text_filter {
-    /** @var int $cachecourseid cache invalidation flag in case content from multiple courses displayed. */
-    protected $cachecourseid = null;
-    /** @var int $cacheuserid cache invalidation flag in case user is switched. */
-    protected $cacheuserid = null;
-    /** @var array $cacheconceptlist page level filter cache, this should be always faster than MUC */
-    protected $cacheconceptlist = null;
+    /** @var null|cache_store cache used to store the terms for this course. */
+    protected $cache = null;
 
     public function setup($page, $context) {
         if ($page->requires->should_create_one_time_item_now('filter_glossary_autolinker')) {
@@ -50,8 +46,16 @@ class filter_glossary extends moodle_text_filter {
         }
     }
 
-    public function filter($text, array $options = array()) {
-        global $CFG, $USER, $GLOSSARY_EXCLUDEENTRY;
+    /**
+     * Get all the concepts for this context.
+     * @return filterobject[] the concepts, and filterobjects.
+     */
+    protected function get_all_concepts() {
+        global $USER;
+
+        if ($this->cache === null) {
+            $this->cache = cache::make_from_params(cache_store::MODE_REQUEST, 'filter', 'glossary');
+        }
 
         // Try to get current course.
         $coursectx = $this->context->get_course_context(false);
@@ -62,96 +66,131 @@ class filter_glossary extends moodle_text_filter {
             $courseid = $coursectx->instanceid;
         }
 
-        if ($this->cachecourseid != $courseid or $this->cacheuserid != $USER->id) {
+        $cached = $this->cache->get('concepts');
+        if ($cached !== false && ($cached->cachecourseid != $courseid || $cached->cacheuserid != $USER->id)) {
             // Invalidate the page cache.
-            $this->cacheconceptlist = null;
+            $cached = false;
         }
 
-        if (is_array($this->cacheconceptlist) and empty($GLOSSARY_EXCLUDEENTRY)) {
-            if (empty($this->cacheconceptlist)) {
-                return $text;
-            }
-            return filter_phrases($text, $this->cacheconceptlist);
+        if ($cached !== false && is_array($cached->cacheconceptlist)) {
+            return $cached->cacheconceptlist;
         }
 
         list($glossaries, $allconcepts) = \mod_glossary\local\concept_cache::get_concepts($courseid);
 
         if (!$allconcepts) {
-            $this->cacheuserid = $USER->id;
-            $this->cachecourseid = $courseid;
-            $this->cacheconcepts = array();
-            return $text;
+            $tocache = new stdClass();
+            $tocache->cacheuserid = $USER->id;
+            $tocache->cachecourseid = $courseid;
+            $tocache->cacheconceptlist = [];
+            $this->cache->set('concepts', $tocache);
+            return [];
         }
 
-        $strcategory = get_string('category', 'glossary');
-
         $conceptlist = array();
-        $excluded = false;
 
         foreach ($allconcepts as $concepts) {
             foreach ($concepts as $concept) {
-                if (!empty($GLOSSARY_EXCLUDEENTRY) and $concept->id == $GLOSSARY_EXCLUDEENTRY) {
-                    $excluded = true;
-                    continue;
-                }
-                if ($concept->category) { // Link to a category.
-                    // TODO: Fix this string usage.
-                    $title = $glossaries[$concept->glossaryid] . ': ' . $strcategory . ' ' . $concept->concept;
-                    $link = new moodle_url('/mod/glossary/view.php', array('g' => $concept->glossaryid, 'mode' => 'cat', 'hook' => $concept->id));
-                    $attributes = array(
-                        'href'  => $link,
-                        'title' => $title,
-                        'class' => 'glossary autolink category glossaryid' . $concept->glossaryid);
-
-                } else { // Link to entry or alias
-                    $title = $glossaries[$concept->glossaryid] . ': ' . $concept->concept;
-                    // Hardcoding dictionary format in the URL rather than defaulting
-                    // to the current glossary format which may not work in a popup.
-                    // for example "entry list" means the popup would only contain
-                    // a link that opens another popup.
-                    $link = new moodle_url('/mod/glossary/showentry.php', array('eid' => $concept->id, 'displayformat' => 'dictionary'));
-                    $attributes = array(
-                        'href'  => $link,
-                        'title' => str_replace('&amp;', '&', $title), // Undo the s() mangling.
-                        'class' => 'glossary autolink concept glossaryid' . $concept->glossaryid);
-                }
-                // This flag is optionally set by resource_pluginfile()
-                // if processing an embedded file use target to prevent getting nested Moodles.
-                if (!empty($CFG->embeddedsoforcelinktarget)) {
-                    $attributes['target'] = '_top';
-                }
-                $href_tag_begin = html_writer::start_tag('a', $attributes);
-
-                $conceptlist[] = new filterobject($concept->concept, $href_tag_begin, '</a>',
-                    $concept->casesensitive, $concept->fullmatch);
+                $conceptlist[] = new filterobject($concept->concept, null, null,
+                        $concept->casesensitive, $concept->fullmatch, null,
+                        [$this, 'filterobject_prepare_replacement_callback'], [$concept, $glossaries]);
             }
         }
 
-        usort($conceptlist, 'filter_glossary::sort_entries_by_length');
+        // We sort longest first, so that when we replace the terms,
+        // the longest ones are replaced first. This does the right thing
+        // when you have two terms like 'Moodle' and 'Moodle 3.5'. You want the longest match.
+        usort($conceptlist, [$this, 'sort_entries_by_length']);
+
+        $conceptlist = filter_prepare_phrases_for_filtering($conceptlist);
+
+        $tocache = new stdClass();
+        $tocache->cacheuserid = $USER->id;
+        $tocache->cachecourseid = $courseid;
+        $tocache->cacheconceptlist = $conceptlist;
+        $this->cache->set('concepts', $tocache);
+
+        return $conceptlist;
+    }
+
+    /**
+     * Callback used by filterobject / filter_phrases.
+     *
+     * @param object $concept the concept that is being replaced (from get_all_concepts).
+     * @param array $glossaries the list of glossary titles (from get_all_concepts).
+     * @return array [$hreftagbegin, $hreftagend, $replacementphrase] for filterobject.
+     */
+    public function filterobject_prepare_replacement_callback($concept, $glossaries) {
+        global $CFG;
+
+        if ($concept->category) { // Link to a category.
+            $title = get_string('glossarycategory', 'filter_glossary',
+                    ['glossary' => $glossaries[$concept->glossaryid], 'category' => $concept->concept]);
+            $link = new moodle_url('/mod/glossary/view.php',
+                    ['g' => $concept->glossaryid, 'mode' => 'cat', 'hook' => $concept->id]);
+            $attributes = array(
+                    'href'  => $link,
+                    'title' => $title,
+                    'class' => 'glossary autolink category glossaryid' . $concept->glossaryid);
+
+        } else { // Link to entry or alias.
+            $title = get_string('glossaryconcept', 'filter_glossary',
+                    ['glossary' => $glossaries[$concept->glossaryid], 'concept' => $concept->concept]);
+            // Hardcoding dictionary format in the URL rather than defaulting
+            // to the current glossary format which may not work in a popup.
+            // for example "entry list" means the popup would only contain
+            // a link that opens another popup.
+            $link = new moodle_url('/mod/glossary/showentry.php',
+                    ['eid' => $concept->id, 'displayformat' => 'dictionary']);
+            $attributes = array(
+                    'href'  => $link,
+                    'title' => str_replace('&amp;', '&', $title), // Undo the s() mangling.
+                    'class' => 'glossary autolink concept glossaryid' . $concept->glossaryid);
+        }
 
-        if (!$excluded) {
-            // Do not cache the excluded list here, it is used once per page only.
-            $this->cacheuserid = $USER->id;
-            $this->cachecourseid = $courseid;
-            $this->cacheconceptlist = $conceptlist;
+        // This flag is optionally set by resource_pluginfile()
+        // if processing an embedded file use target to prevent getting nested Moodles.
+        if (!empty($CFG->embeddedsoforcelinktarget)) {
+            $attributes['target'] = '_top';
         }
 
+        return [html_writer::start_tag('a', $attributes), '</a>', null];
+    }
+
+    public function filter($text, array $options = array()) {
+        global $GLOSSARY_EXCLUDEENTRY;
+
+        $conceptlist = $this->get_all_concepts();
+
         if (empty($conceptlist)) {
             return $text;
         }
-        return filter_phrases($text, $conceptlist);   // Actually search for concepts!
-    }
 
-    private static function sort_entries_by_length($entry0, $entry1) {
-        $len0 = strlen($entry0->phrase);
-        $len1 = strlen($entry1->phrase);
+        if (!empty($GLOSSARY_EXCLUDEENTRY)) {
+            foreach ($conceptlist as $key => $filterobj) {
+                // The original concept object was stored here in when $filterobj was constructed in
+                // get_all_concepts(). Get it back out now so we can check to see if it is excluded.
+                $concept = $filterobj->replacementcallbackdata[0];
+                if (!$concept->category && $concept->id == $GLOSSARY_EXCLUDEENTRY) {
+                    unset($conceptlist[$key]);
+                }
+            }
+        }
 
-        if ($len0 < $len1) {
-            return 1;
-        } else if ($len0 > $len1) {
-            return -1;
-        } else {
-            return 0;
+        if (empty($conceptlist)) {
+            return $text;
         }
+
+        return filter_phrases($text, $conceptlist, null, null, false, true);
+    }
+
+    /**
+     * usort helper used in get_all_concepts above.
+     * @param filterobject $filterobject0 first item to compare.
+     * @param filterobject $filterobject1 second item to compare.
+     * @return int -1, 0 or 1.
+     */
+    private function sort_entries_by_length($filterobject0, $filterobject1) {
+        return strlen($filterobject1->phrase) <=> strlen($filterobject0->phrase);
     }
 }
index 0f14c51..d7917af 100644 (file)
@@ -25,5 +25,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
+$string['glossarycategory'] = '{$a->glossary}: Category {$a->category}';
+$string['glossaryconcept'] = '{$a->glossary}: {$a->concept}';
 $string['filtername'] = 'Glossary auto-linking';
 $string['privacy:metadata'] = 'The Glossary auto-linking plugin does not store any personal data.';
index ccf54ed..f22822c 100644 (file)
@@ -33,6 +33,119 @@ require_once($CFG->dirroot . '/filter/glossary/filter.php'); // Include the code
  */
 class filter_glossary_filter_testcase extends advanced_testcase {
 
+    public function test_link_to_entry_with_alias() {
+        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.
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_glossary');
+        $normal = $generator->create_content($glossary, array('concept' => 'entry name'),
+                array('first alias', 'second alias'));
+
+        // Format text with all three entries in HTML.
+        $html = '<p>First we have entry name, then we have it twp aliases first alias and second alias.</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($normal->id, $matches[1][0]);
+        $this->assertEquals($normal->id, $matches[1][1]);
+        $this->assertEquals($normal->id, $matches[1][2]);
+
+        // Check text of title attribute.
+        $this->assertEquals($glossary->name . ': entry name',   $matches[2][0]);
+        $this->assertEquals($glossary->name . ': first alias',  $matches[2][1]);
+        $this->assertEquals($glossary->name . ': second alias', $matches[2][2]);
+    }
+
+    public function test_longest_link_used() {
+        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.
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_glossary');
+        $shorter = $generator->create_content($glossary, array('concept' => 'Tim'));
+        $longer = $generator->create_content($glossary, array('concept' => 'Time'));
+
+        // Format text with all three entries in HTML.
+        $html = '<p>Time will tell</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 1 glossary link to Time, not Tim.
+        $this->assertEquals(1, count($matches[1]));
+        $this->assertEquals($longer->id, $matches[1][0]);
+
+        // Check text of title attribute.
+        $this->assertEquals($glossary->name . ': Time',   $matches[2][0]);
+    }
+
+    public function test_link_to_category() {
+        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');
+        $category = $generator->create_category($glossary, array('name' => 'My category', 'usedynalink' => 1));
+
+        // Format text with all three entries in HTML.
+        $html = '<p>This is My category you know.</p>';
+        $filtered = format_text($html, FORMAT_HTML, array('context' => $context));
+
+        // Find all the glossary links in the result.
+        $matches = array();
+        preg_match_all('~hook=([0-9]+).*?title="(.*?)"~', $filtered, $matches);
+
+        // There should be 1 glossary link.
+        $this->assertEquals(1, count($matches[1]));
+        $this->assertEquals($category->id, $matches[1][0]);
+        $this->assertEquals($glossary->name . ': Category My category', $matches[2][0]);
+    }
+
     /**
      * Test ampersands.
      */
@@ -59,9 +172,6 @@ class filter_glossary_filter_testcase extends advanced_testcase {
         $amp1 = $generator->create_content($glossary, array('concept' => 'A&B'));
         $amp2 = $generator->create_content($glossary, array('concept' => 'C&amp;D'));
 
-        filter_manager::reset_caches();
-        \mod_glossary\local\concept_cache::reset_caches();
-
         // Format text with all three entries in HTML.
         $html = '<p>A&amp;B C&amp;D normal</p>';
         $filtered = format_text($html, FORMAT_HTML, array('context' => $context));
@@ -81,4 +191,81 @@ class filter_glossary_filter_testcase extends advanced_testcase {
         $this->assertEquals($glossary->name . ': C&amp;D', $matches[2][1]);
         $this->assertEquals($glossary->name . ': normal', $matches[2][2]);
     }
+
+    public function test_exclude_excludes_link_to_entry_with_alias() {
+        global $CFG, $GLOSSARY_EXCLUDEENTRY;
+
+        $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.
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_glossary');
+        $tobeexcluded = $generator->create_content($glossary, array('concept' => 'entry name'),
+                array('first alias', 'second alias'));
+        $normal = $generator->create_content($glossary, array('concept' => 'other entry'));
+
+        // Format text with all three entries in HTML.
+        $html = '<p>First we have entry name, then we have it twp aliases first alias and second alias. ' .
+                'In this case, those should not be linked, but this other entry should be.</p>';
+        $GLOSSARY_EXCLUDEENTRY = $tobeexcluded->id;
+        $filtered = format_text($html, FORMAT_HTML, array('context' => $context));
+        $GLOSSARY_EXCLUDEENTRY = null;
+
+        // Find all the glossary links in the result.
+        $matches = array();
+        preg_match_all('~eid=([0-9]+).*?title="(.*?)"~', $filtered, $matches);
+
+        // There should be 1 glossary links.
+        $this->assertEquals(1, count($matches[1]));
+        $this->assertEquals($normal->id, $matches[1][0]);
+        $this->assertEquals($glossary->name . ': other entry', $matches[2][0]);
+    }
+
+    public function test_exclude_does_not_exclude_categories() {
+        global $CFG, $GLOSSARY_EXCLUDEENTRY;
+        $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');
+        $category = $generator->create_category($glossary, array('name' => 'My category', 'usedynalink' => 1));
+
+        // Format text with all three entries in HTML.
+        $html = '<p>This is My category you know.</p>';
+        $GLOSSARY_EXCLUDEENTRY = $category->id;
+        $filtered = format_text($html, FORMAT_HTML, array('context' => $context));
+        $GLOSSARY_EXCLUDEENTRY = null;
+
+        // Find all the glossary links in the result.
+        $matches = array();
+        preg_match_all('~hook=([0-9]+).*?title="(.*?)"~', $filtered, $matches);
+
+        // There should be 1 glossary link.
+        $this->assertEquals(1, count($matches[1]));
+        $this->assertEquals($category->id, $matches[1][0]);
+        $this->assertEquals($glossary->name . ': Category My category', $matches[2][0]);
+    }
 }
index ddfbd82..3ccfc6e 100644 (file)
@@ -1,6 +1,20 @@
 This file describes API changes in core filter API and plugins,
 information provided here is intended especially for developers.
 
+=== 3.6 ===
+
+* Although there is no API change that require you to update your filter,
+  if you use the filter_phrases() helper method, you may wish to take
+  advantage of the changes that were made in MDL-47962 to improve performance.
+
+  Now, instead of having to compute the replacement HTML for each phrase before
+  you construct the filterobject for it. You can instead pass a callback to
+  the filterobject constructor which is only called if the phrase is used.
+
+  To understand how to use this, see the comment on filterobject::__construct and
+  look at the filter_glossary changes as an example:
+  https://github.com/moodle/moodle/commit/5a8c44d000ecc5669db26aefebe447f688e8f2ce
+
 === 3.0 ===
 
 * New argument $skipfilters to filter_manager::filter_text to allow applying
index 75f52c3..152fc3b 100644 (file)
@@ -287,7 +287,6 @@ class core_grade_report_graderlib_testcase extends advanced_testcase {
         $context = context_course::instance($course->id);
         $managerroleid = $DB->get_field('role', 'id', array('shortname' => 'manager'));
         assign_capability('moodle/grade:viewhidden', CAP_PROHIBIT, $managerroleid, $context->id, true);
-        $context->mark_dirty();
         $this->assertFalse(has_capability('moodle/grade:viewhidden', $context));
 
         // Recreate the report. Confirm it returns successfully still.
index 806646f..fe3ec49 100644 (file)
@@ -92,7 +92,8 @@ $currentmembersoptions = '';
 $currentmemberscount = 0;
 if ($currentmembers) {
     foreach($currentmembers as $group) {
-        $currentmembersoptions .= '<option value="'.$group->id.'.">'.format_string($group->name).'</option>';
+        $currentmembersoptions .= '<option value="' . $group->id . '." title="' . format_string($group->name) . '">' .
+                format_string($group->name) . '</option>';
         $currentmemberscount ++;
     }
 
@@ -112,7 +113,8 @@ $potentialmembersoptions = '';
 $potentialmemberscount = 0;
 if ($potentialmembers) {
     foreach($potentialmembers as $group) {
-        $potentialmembersoptions .= '<option value="'.$group->id.'.">'.format_string($group->name).'</option>';
+        $potentialmembersoptions .= '<option value="' . $group->id . '." title="' . format_string($group->name) . '">' .
+                format_string($group->name) . '</option>';
         $potentialmemberscount ++;
     }
 } else {
index 740b253..d2e6119 100644 (file)
@@ -192,7 +192,7 @@ Moodle community|https://moodle.org
 -Moodle development|https://moodle.org/development
 Moodle.com|http://moodle.com/
 </pre>';
-$string['configcustomusermenuitems'] = 'You can configure the contents of the user menu (with the exception of the log out link, which is automatically added). Each line is separated by pipe characters and consists of 1) a string in "langstringname, componentname" form or as plain text, 2) a URL, and 3) an icon either as a pix icon (in the folder pix/t, or prefix the icon name with ../ if icon is in another pix folder) or as a URL. Dividers can be used by adding a line of one or more # characters where desired.';
+$string['configcustomusermenuitems'] = 'You can configure the contents of the user menu (with the exception of the log out link, which is automatically added). Each line is separated by pipe characters and consists of 1) a string in "langstringname, componentname" form or as plain text, 2) a URL, and 3) an icon either as a pix icon (in the folder pix with the structure [subfoldername]/[iconname], e.g. i/publish) or as a URL. Dividers can be used by adding a line of one or more # characters where desired.';
 $string['configdbsessions'] = 'If enabled, this setting will use the database to store information about current sessions. Note that changing this setting now will log out all current users (including you). If you are using MySQL please make sure that \'max_allowed_packet\' in my.cnf (or my.ini) is at least 4M. Other session drivers can be configured directly in config.php, see config-dist.php for more information. This option disappears if you specify session driver in config.php file.';
 $string['configdebug'] = 'If you turn this on, then PHP\'s error_reporting will be increased so that more warnings are printed.  This is only useful for developers.';
 $string['configdebugdisplay'] = 'Set to on, the error reporting will go to the HTML page. This is practical, but breaks XHTML, JS, cookies and HTTP headers in general. Set to off, it will send the output to your server logs, allowing better debugging. The PHP setting error_log controls which log this goes to.';
@@ -272,10 +272,10 @@ $string['configmaxbytes'] = 'This specifies a maximum size for files uploaded to
 $string['configmaxconsecutiveidentchars'] = 'Passwords must not have more than this number of consecutive identical characters. Use 0 to disable this check.';
 $string['configmaxeditingtime'] = 'This specifies the amount of time people have to re-edit forum postings, glossary comments etc.  Usually 30 minutes is a good value.';
 $string['configmaxevents'] = 'Events to Lookahead';
-$string['configmessaging'] = 'Should the messaging system between site users be enabled?';
+$string['configmessaging'] = 'If enabled, users can send messages to other users on the site.';
 $string['configmessagingallowemailoverride'] = 'Allow users to have email message notifications sent to an email address other than the email address in their profile';
-$string['configmessaginghidereadnotifications'] = 'Hide read notifications of events like forum posts when viewing messaging history';
 $string['configmessagingdeletereadnotificationsdelay'] = 'Read notifications can be deleted to save space. How long after a notification is read can it be deleted?';
+$string['configmessagingallusers'] = 'If enabled, users can choose to allow anyone on the site to send them a message. Otherwise, users can choose to allow only their contacts or others in their courses to send them messages.';
 $string['configminpassworddigits'] = 'Passwords must have at least these many digits.';
 $string['configminpasswordlength'] = 'Passwords must be at least these many characters long.';
 $string['configminpasswordlower'] = 'Passwords must have at least these many lower case letters.';
@@ -747,7 +747,7 @@ $string['mediapluginwmv'] = 'Enable .wmv filter';
 $string['mediapluginyoutube'] = 'Enable YouTube links filter';
 $string['messaging'] = 'Enable messaging system';
 $string['messagingallowemailoverride'] = 'Notification email override';
-$string['messaginghidereadnotifications'] = 'Hide read notifications';
+$string['messagingallusers'] = 'Allow messages from anyone on the site';
 $string['messagingdeletereadnotificationsdelay'] = 'Delete read notifications';
 $string['minpassworddigits'] = 'Digits';
 $string['minpasswordlength'] = 'Password length';
index a854d19..1680fc1 100644 (file)
@@ -537,6 +537,7 @@ $string['downloadtext'] = 'Download in text format';
 $string['doyouagree'] = 'Have you read these conditions and understood them?';
 $string['droptoupload'] = 'Drop files here to upload';
 $string['duplicate'] = 'Duplicate';
+$string['duplicatedmodule'] = '{$a} (copy)';
 $string['edhelpaspellpath'] = 'To use spell-checking within the editor, you MUST have <strong>aspell 0.50</strong> or later installed on your server, and you must specify the correct path to access the aspell binary.  On Unix/Linux systems, this path is usually <strong>/usr/bin/aspell</strong>, but it might be something else.';
 $string['edhelpbgcolor'] = 'Define the edit area\'s background color.<br />Valid values are, for example: #FFFFFF or white';
 $string['edhelpcleanword'] = 'This setting enables or disables Word-specific format filtering.';
index fb8fe82..b853063 100644 (file)
@@ -170,6 +170,8 @@ $string['getcontextfromfile'] = 'Get context from file';
 $string['changepublishstatuscat'] = '<a href="{$a->caturl}">Category "{$a->name}"</a> in course "{$a->coursename}" will have it\'s sharing status changed from <strong>{$a->changefrom} to {$a->changeto}</strong>.';
 $string['chooseqtypetoadd'] = 'Choose a question type to add';
 $string['editquestions'] = 'Edit questions';
+$string['idnumber'] = 'ID number';
+$string['idnumber_help'] = 'If used, the ID number must be unique within each question category. It provides another way of identifying a question which is sometimes useful, but can usually be left blank.';
 $string['ignorebroken'] = 'Ignore broken links';
 $string['impossiblechar'] = 'Impossible character {$a} detected as parenthesis character';
 $string['import'] = 'Import';
index 8211ffb..2ca9f4d 100644 (file)
@@ -62,6 +62,8 @@
  * <b>Name conventions</b>
  *
  * "ctx" means context
+ * "ra" means role assignment
+ * "rdef" means role definition
  *
  * <b>accessdata</b>
  *
@@ -72,9 +74,7 @@
  * against userid in $ACCESSLIB_PRIVATE->accessdatabyuser).
  *
  * $accessdata is a multidimensional array, holding
- * role assignments (RAs), role-capabilities-perm sets
- * (role defs) and a list of courses we have loaded
- * data for.
+ * role assignments (RAs), role switches and initialization time.
  *
  * Things are keyed on "contextpaths" (the path field of
  * the context table) for fast walking up/down the tree.
@@ -182,6 +182,7 @@ global $ACCESSLIB_PRIVATE;
 $ACCESSLIB_PRIVATE = new stdClass();
 $ACCESSLIB_PRIVATE->cacheroledefs    = array(); // Holds site-wide role definitions.
 $ACCESSLIB_PRIVATE->dirtycontexts    = null;    // Dirty contexts cache, loaded from DB once per page
+$ACCESSLIB_PRIVATE->dirtyusers       = null;    // Dirty users cache, loaded from DB once per $USER->id
 $ACCESSLIB_PRIVATE->accessdatabyuser = array(); // Holds the cache of $accessdata structure for users (including $USER)
 
 /**
@@ -201,6 +202,7 @@ function accesslib_clear_all_caches_for_unit_testing() {
     }
 
     accesslib_clear_all_caches(true);
+    accesslib_reset_role_cache();
 
     unset($USER->access);
 }
@@ -218,17 +220,34 @@ function accesslib_clear_all_caches($resetcontexts) {
     global $ACCESSLIB_PRIVATE;
 
     $ACCESSLIB_PRIVATE->dirtycontexts    = null;
+    $ACCESSLIB_PRIVATE->dirtyusers       = null;
     $ACCESSLIB_PRIVATE->accessdatabyuser = array();
-    $ACCESSLIB_PRIVATE->cacheroledefs    = array();
-
-    $cache = cache::make('core', 'roledefs');
-    $cache->purge();
 
     if ($resetcontexts) {
         context_helper::reset_caches();
     }
 }
 
+/**
+ * Full reset of accesslib's private role cache. ONLY TO BE USED FROM THIS LIBRARY FILE!
+ *
+ * This reset does not touch global $USER.
+ *
+ * Note: Only use this when the roles that need a refresh are unknown.
+ *
+ * @see accesslib_clear_role_cache()
+ *
+ * @access private
+ * @return void
+ */
+function accesslib_reset_role_cache() {
+    global $ACCESSLIB_PRIVATE;
+
+    $ACCESSLIB_PRIVATE->cacheroledefs = array();
+    $cache = cache::make('core', 'roledefs');
+    $cache->purge();
+}
+
 /**
  * Clears accesslib's private cache of a specific role or roles. ONLY BE USED FROM THIS LIBRARY FILE!
  *
@@ -990,7 +1009,10 @@ function reload_all_capabilities() {
 
     accesslib_clear_all_caches(true);
     unset($USER->access);
-    $ACCESSLIB_PRIVATE->dirtycontexts = array(); // prevent dirty flags refetching on this page
+
+    // Prevent dirty flags refetching on this page.
+    $ACCESSLIB_PRIVATE->dirtycontexts = array();
+    $ACCESSLIB_PRIVATE->dirtyusers    = array($USER->id => false);
 
     load_all_capabilities();
 
@@ -1280,8 +1302,6 @@ function delete_role($roleid) {
 /**
  * Function to write context specific overrides, or default capabilities.
  *
- * NOTE: use $context->mark_dirty() after this
- *
  * @param string $capability string name
  * @param int $permission CAP_ constants
  * @param int $roleid role id
@@ -1335,8 +1355,6 @@ function assign_capability($capability, $permission, $roleid, $contextid, $overw
 /**
  * Unassign a capability from a role.
  *
- * NOTE: use $context->mark_dirty() after this
- *
  * @param string $capability the name of the capability
  * @param int $roleid the role id
  * @param int|context $contextid null means all contexts
@@ -1493,13 +1511,8 @@ function role_assign($roleid, $userid, $contextid, $component = '', $itemid = 0,
 
     $ra->id = $DB->insert_record('role_assignments', $ra);
 
-    // mark context as dirty - again expensive, but needed
-    $context->mark_dirty();
-
-    if (!empty($USER->id) && $USER->id == $userid) {
-        // If the user is the current user, then do full reload of capabilities too.
-        reload_all_capabilities();
-    }
+    // Role assignments have changed, so mark user as dirty.
+    mark_user_dirty($userid);
 
     core_course_category::role_assignment_changed($roleid, $context);
 
@@ -1592,12 +1605,9 @@ function role_unassign_all(array $params, $subcontexts = false, $includemanual =
     foreach($ras as $ra) {
         $DB->delete_records('role_assignments', array('id'=>$ra->id));
         if ($context = context::instance_by_id($ra->contextid, IGNORE_MISSING)) {
-            // this is a bit expensive but necessary
-            $context->mark_dirty();
-            // If the user is the current user, then do full reload of capabilities too.
-            if (!empty($USER->id) && $USER->id == $ra->userid) {
-                reload_all_capabilities();
-            }
+            // Role assignments have changed, so mark user as dirty.
+            mark_user_dirty($ra->userid);
+
             $event = \core\event\role_unassigned::create(array(
                 'context' => $context,
                 'objectid' => $ra->roleid,
@@ -1631,12 +1641,9 @@ function role_unassign_all(array $params, $subcontexts = false, $includemanual =
                 $ras = $DB->get_records('role_assignments', $mparams);
                 foreach($ras as $ra) {
                     $DB->delete_records('role_assignments', array('id'=>$ra->id));
-                    // this is a bit expensive but necessary
-                    $context->mark_dirty();
-                    // If the user is the current user, then do full reload of capabilities too.
-                    if (!empty($USER->id) && $USER->id == $ra->userid) {
-                        reload_all_capabilities();
-                    }
+                    // Role assignments have changed, so mark user as dirty.
+                    mark_user_dirty($ra->userid);
+
                     $event = \core\event\role_unassigned::create(
                         array('context'=>$context, 'objectid'=>$ra->roleid, 'relateduserid'=>$ra->userid,
                             'other'=>array('id'=>$ra->id, 'component'=>$ra->component, 'itemid'=>$ra->itemid)));
@@ -1655,6 +1662,30 @@ function role_unassign_all(array $params, $subcontexts = false, $includemanual =
     }
 }
 
+/**
+ * Mark a user as dirty (with timestamp) so as to force reloading of the user session.
+ *
+ * @param int $userid
+ * @return void
+ */
+function mark_user_dirty($userid) {
+    global $CFG, $ACCESSLIB_PRIVATE;
+
+    if (during_initial_install()) {
+        return;
+    }
+
+    // Throw exception if invalid userid is provided.
+    if (empty($userid)) {
+        throw new coding_exception('Invalid user parameter supplied for mark_user_dirty() function!');
+    }
+
+    // Set dirty flag in database, set dirty field locally, and clear local accessdata cache.
+    set_cache_flag('accesslib/dirtyusers', $userid, 1, time() + $CFG->sessiontimeout);
+    $ACCESSLIB_PRIVATE->dirtyusers[$userid] = 1;
+    unset($ACCESSLIB_PRIVATE->accessdatabyuser[$userid]);
+}
+
 /**
  * Determines if a user is currently logged in
  *
@@ -2097,9 +2128,6 @@ function reset_role_capabilities($roleid) {
 
     // Reset any cache of this role, including MUC.
     accesslib_clear_role_cache($roleid);
-
-    // Mark the system context dirty.
-    context_system::instance()->mark_dirty();
 }
 
 /**
@@ -2217,7 +2245,7 @@ function update_capabilities($component = 'moodle') {
     capabilities_cleanup($component, $filecaps);
 
     // reset static caches
-    accesslib_clear_all_caches(false);
+    accesslib_reset_role_cache();
 
     // Flush the cached again, as we have changed DB.
     cache::make('core', 'capabilities')->delete('core_capabilities');
@@ -2381,8 +2409,6 @@ function is_inside_frontpage(context $context) {
  * @return stdClass or null if capability not found
  */
 function get_capability_info($capabilityname) {
-    global $ACCESSLIB_PRIVATE, $DB; // one request per page only
-
     $caps = get_all_capabilities();
 
     if (!isset($caps[$capabilityname])) {
@@ -4592,7 +4618,6 @@ function role_change_permission($roleid, $context, $capname, $permission) {
 
     if ($permission == CAP_INHERIT) {
         unassign_capability($capname, $roleid, $context->id);
-        $context->mark_dirty();
         return;
     }
 
@@ -4625,7 +4650,6 @@ function role_change_permission($roleid, $context, $capname, $permission) {
                 // permission already set in parent context or parent - just unset in this context
                 // we do this because we want as few overrides as possible for performance reasons
                 unassign_capability($capname, $roleid, $context->id);
-                $context->mark_dirty();
                 return;
             }
         }
@@ -4639,9 +4663,6 @@ function role_change_permission($roleid, $context, $capname, $permission) {
 
     // assign the needed capability
     assign_capability($capname, $permission, $roleid, $context->id, true);
-
-    // force cap reloading
-    $context->mark_dirty();
 }
 
 
@@ -5066,8 +5087,6 @@ abstract class context extends stdClass implements IteratorAggregate {
 
         $trans = $DB->start_delegated_transaction();
 
-        $this->mark_dirty();
-
         $setdepth = '';
         if (($newparent->depth +1) != $this->_depth) {
             $diff = $newparent->depth - $this->_depth + 1;
@@ -5155,15 +5174,23 @@ abstract class context extends stdClass implements IteratorAggregate {
         require_once($CFG->dirroot.'/grade/grading/lib.php');
         grading_manager::delete_all_for_context($this->_id);
 
-        $ids = $DB->get_fieldset_select('role_capabilities', 'DISTINCT roleid', 'contextid = ?', array($this->_id));
-
         // now delete stuff from role related tables, role_unassign_all
         // and unenrol should be called earlier to do proper cleanup
         $DB->delete_records('role_assignments', array('contextid'=>$this->_id));
-        $DB->delete_records('role_capabilities', array('contextid'=>$this->_id));
         $DB->delete_records('role_names', array('contextid'=>$this->_id));
+        $this->delete_capabilities();
+    }
+
+    /**
+     * Unassign all capabilities from a context.
+     */
+    public function delete_capabilities() {
+        global $DB;
 
+        $ids = $DB->get_fieldset_select('role_capabilities', 'DISTINCT roleid', 'contextid = ?', array($this->_id));
         if ($ids) {
+            $DB->delete_records('role_capabilities', array('contextid' => $this->_id));
+
             // Reset any cache of these roles, including MUC.
             accesslib_clear_role_cache($ids);
         }
@@ -5189,11 +5216,6 @@ abstract class context extends stdClass implements IteratorAggregate {
         $DB->delete_records('context', array('id'=>$this->_id));
         // purge static context cache if entry present
         context::cache_remove($this);
-
-        // do not mark dirty contexts if parents unknown
-        if (!is_null($this->_path) and $this->_depth > 0) {
-            $this->mark_dirty();
-        }
     }
 
     // ====== context level related methods ======
@@ -5297,7 +5319,7 @@ abstract class context extends stdClass implements IteratorAggregate {
      * Returns parent contexts of this context in reversed order, i.e. parent first,
      * then grand parent, etc.
      *
-     * @param bool $includeself tre means include self too
+     * @param bool $includeself true means include self too
      * @return array of context instances
      */
     public function get_parent_contexts($includeself = false) {
@@ -5315,10 +5337,10 @@ abstract class context extends stdClass implements IteratorAggregate {
     }
 
     /**
-     * Returns parent contexts of this context in reversed order, i.e. parent first,
+     * Returns parent context ids of this context in reversed order, i.e. parent first,
      * then grand parent, etc.
      *
-     * @param bool $includeself tre means include self too
+     * @param bool $includeself true means include self too
      * @return array of context ids
      */
     public function get_parent_context_ids($includeself = false) {
@@ -5335,6 +5357,35 @@ abstract class context extends stdClass implements IteratorAggregate {
         return array_reverse($parentcontexts);
     }
 
+    /**
+     * Returns parent context paths of this context.
+     *
+     * @param bool $includeself true means include self too
+     * @return array of context paths
+     */
+    public function get_parent_context_paths($includeself = false) {
+        if (empty($this->_path)) {
+            return array();
+        }
+
+        $contextids = explode('/', $this->_path);
+
+        $path = '';
+        $paths = array();
+        foreach ($contextids as $contextid) {
+            if ($contextid) {
+                $path .= '/' . $contextid;
+                $paths[$contextid] = $path;
+            }
+        }
+
+        if (!$includeself) {
+            unset($paths[$this->_id]);
+        }
+
+        return $paths;
+    }
+
     /**
      * Returns parent context
      *
@@ -5412,24 +5463,43 @@ abstract class context extends stdClass implements IteratorAggregate {
                 $ACCESSLIB_PRIVATE->dirtycontexts = array();
             }
         } else {
+            if (!isset($USER->access['time'])) {
+                // Nothing has been loaded yet, so we do not need to check dirty flags now.
+                return;
+            }
+
+            // From skodak: No idea why -2 is there, server cluster time difference maybe...
+            $changedsince = $USER->access['time'] - 2;
+
             if (!isset($ACCESSLIB_PRIVATE->dirtycontexts)) {
-                if (!isset($USER->access['time'])) {
-                    // nothing was loaded yet, we do not need to check dirty contexts now
-                    return;
-                }
-                // no idea why -2 is there, server cluster time difference maybe... (skodak)
-                $ACCESSLIB_PRIVATE->dirtycontexts = get_cache_flags('accesslib/dirtycontexts', $USER->access['time']-2);
+                $ACCESSLIB_PRIVATE->dirtycontexts = get_cache_flags('accesslib/dirtycontexts', $changedsince);
+            }
+
+            if (!isset($ACCESSLIB_PRIVATE->dirtyusers[$USER->id])) {
+                $ACCESSLIB_PRIVATE->dirtyusers[$USER->id] = get_cache_flag('accesslib/dirtyusers', $USER->id, $changedsince);
             }
         }
 
-        foreach ($ACCESSLIB_PRIVATE->dirtycontexts as $path=>$unused) {
-            if ($path === $this->_path or strpos($this->_path, $path.'/') === 0) {
-                // reload all capabilities of USER and others - preserving loginas, roleswitches, etc
-                // and then cleanup any marks of dirtyness... at least from our short term memory! :-)
-                reload_all_capabilities();
-                break;
+        $dirty = false;
+
+        if (!empty($ACCESSLIB_PRIVATE->dirtyusers[$USER->id])) {
+            $dirty = true;
+        } else if (!empty($ACCESSLIB_PRIVATE->dirtycontexts)) {
+            $paths = $this->get_parent_context_paths(true);
+
+            foreach ($paths as $path) {
+                if (isset($ACCESSLIB_PRIVATE->dirtycontexts[$path])) {
+                    $dirty = true;
+                    break;
+                }
             }
         }
+
+        if ($dirty) {
+            // Reload all capabilities of USER and others - preserving loginas, roleswitches, etc.
+            // Then cleanup any marks of dirtyness... at least from our short term memory!
+            reload_all_capabilities();
+        }
     }
 
     /**
index ab97abb..a9ab97a 100644 (file)
@@ -354,6 +354,7 @@ class behat_util extends testing_util {
 
         // Reset all static caches.
         accesslib_clear_all_caches(true);
+        accesslib_reset_role_cache();
         // Reset the nasty strings list used during the last test.
         nasty_strings::reset_used_strings();
 
index c7d1a81..2101c71 100644 (file)
         <FIELD NAME="stamp" TYPE="char" LENGTH="255" NOTNULL="true" SEQUENCE="false"/>
         <FIELD NAME="parent" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
         <FIELD NAME="sortorder" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="999" SEQUENCE="false"/>
+        <FIELD NAME="idnumber" TYPE="char" LENGTH="100" NOTNULL="false" SEQUENCE="false"/>
       </FIELDS>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
       <INDEXES>
         <INDEX NAME="contextid" UNIQUE="false" FIELDS="contextid" COMMENT="links to context table"/>
         <INDEX NAME="contextidstamp" UNIQUE="true" FIELDS="contextid, stamp"/>
+        <INDEX NAME="contextididnumber" UNIQUE="true" FIELDS="contextid, idnumber"/>
       </INDEXES>
     </TABLE>
     <TABLE NAME="question" COMMENT="The questions themselves">
         <FIELD NAME="timemodified" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="time that question was last modified"/>
         <FIELD NAME="createdby" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false" COMMENT="userid of person who created this question"/>
         <FIELD NAME="modifiedby" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false" COMMENT="userid of person who last edited this question"/>
+        <FIELD NAME="idnumber" TYPE="char" LENGTH="100" NOTNULL="false" SEQUENCE="false"/>
       </FIELDS>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
       </KEYS>
       <INDEXES>
         <INDEX NAME="qtype" UNIQUE="false" FIELDS="qtype"/>
+        <INDEX NAME="categoryidnumber" UNIQUE="true" FIELDS="category, idnumber"/>
       </INDEXES>
     </TABLE>
     <TABLE NAME="question_answers" COMMENT="Answers, with a fractional grade (0-1) and feedback">
       </INDEXES>
     </TABLE>
   </TABLES>
-</XMLDB>
\ No newline at end of file
+</XMLDB>
index df3883a..7f6a1f1 100644 (file)
@@ -2329,12 +2329,75 @@ function xmldb_main_upgrade($oldversion) {
     }
 
     if ($oldversion < 2018091200.00) {
-        if (!file_exists($CFG->dirroot.'/cache/stores/memcache/settings.php')) {
+        if (!file_exists($CFG->dirroot . '/cache/stores/memcache/settings.php')) {
             unset_all_config_for_plugin('cachestore_memcache');
         }
 
         upgrade_main_savepoint(true, 2018091200.00);
     }
 
+    if ($oldversion < 2018091400.01) {
+        if (!isset($CFG->messagingallusers)) {
+            // For existing instances, $CFG->messagingallusers would be same value $CFG->messaging has.
+            if (isset($CFG->messaging)) {
+                set_config('messagingallusers', $CFG->messaging);
+            } else {
+                // When $CFG->messaging is not set, default value for $CFG->messaging should be true,
+                // so $CFG->messagingallusers value should be true as well.
+                set_config('messagingallusers', 1);
+            }
+        }
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2018091400.01);
+    }
+
+    if ($oldversion < 2018091700.01) {
+        // Remove unused setting.
+        unset_config('messaginghidereadnotifications');
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2018091700.01);
+    }
+
+    // Add idnumber fields to question and question_category tables.
+    // This is done in four parts to aid error recovery during upgrade, should that occur.
+    if ($oldversion < 2018092100.01) {
+        $table = new xmldb_table('question');
+        $field = new xmldb_field('idnumber', XMLDB_TYPE_CHAR, '100', null, null, null, null, 'modifiedby');
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+        upgrade_main_savepoint(true, 2018092100.01);
+    }
+
+    if ($oldversion < 2018092100.02) {
+        $table = new xmldb_table('question');
+        $index = new xmldb_index('categoryidnumber', XMLDB_INDEX_UNIQUE, array('category, idnumber'));
+        if (!$dbman->index_exists($table, $index)) {
+            $dbman->add_index($table, $index);
+        }
+        upgrade_main_savepoint(true, 2018092100.02);
+    }
+
+    if ($oldversion < 2018092100.03) {
+        $table = new xmldb_table('question_categories');
+        $field = new xmldb_field('idnumber', XMLDB_TYPE_CHAR, '100', null, null, null, null, 'sortorder');
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+        upgrade_main_savepoint(true, 2018092100.03);
+    }
+
+    if ($oldversion < 2018092100.04) {
+        $table = new xmldb_table('question_categories');
+        $index = new xmldb_index('contextididnumber', XMLDB_INDEX_UNIQUE, array('contextid, idnumber'));
+