Merge branch 'MDL-52318-master' of https://github.com/snake/moodle
authorAndrew Nicols <andrew@nicols.co.uk>
Wed, 11 Jul 2018 00:33:13 +0000 (08:33 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Wed, 11 Jul 2018 00:33:13 +0000 (08:33 +0800)
56 files changed:
admin/tool/dataprivacy/amd/build/data_request_modal.min.js
admin/tool/dataprivacy/amd/build/events.min.js
admin/tool/dataprivacy/amd/build/requestactions.min.js
admin/tool/dataprivacy/amd/src/data_request_modal.js
admin/tool/dataprivacy/amd/src/events.js
admin/tool/dataprivacy/amd/src/requestactions.js
admin/tool/dataprivacy/classes/api.php
admin/tool/dataprivacy/classes/external.php
admin/tool/dataprivacy/classes/external/data_request_exporter.php
admin/tool/dataprivacy/classes/output/data_requests_table.php
admin/tool/dataprivacy/classes/output/my_data_requests_page.php
admin/tool/dataprivacy/db/services.php
admin/tool/dataprivacy/lang/en/tool_dataprivacy.php
admin/tool/dataprivacy/templates/data_request_modal.mustache
admin/tool/dataprivacy/tests/behat/contact_privacy_officer.feature [new file with mode: 0644]
admin/tool/dataprivacy/tests/behat/manage_data_requests.feature [new file with mode: 0644]
admin/tool/dataprivacy/version.php
auth/db/auth.php
auth/shibboleth/logout.php
backup/util/helper/backup_cron_helper.class.php
backup/util/helper/tests/cronhelper_test.php
course/templates/activity_navigation.mustache
filter/manage.php
lib/amd/build/tag.min.js
lib/amd/src/tag.js
lib/form/url.php
lib/ltiprovider/src/ToolProvider/ToolProvider.php
lib/navigationlib.php
message/amd/build/message_area_contacts.min.js
message/amd/src/message_area_contacts.js
message/externallib.php
message/lib.php
message/tests/externallib_test.php
mod/assign/gradingtable.php
mod/assign/lang/en/assign.php
mod/assign/locallib.php
mod/assign/renderable.php
mod/assign/renderer.php
mod/assign/styles.css
mod/assign/tests/behat/assign_hidden.feature [new file with mode: 0644]
mod/assign/tests/behat/grading_status.feature
mod/choice/lang/en/choice.php
mod/choice/lib.php
mod/choice/tests/lib_test.php
mod/quiz/index.php
mod/quiz/locallib.php
mod/quiz/tests/locallib_test.php
question/classes/bank/column_base.php
question/tests/fixtures/testable_core_question_column.php [new file with mode: 0644]
question/tests/question_bank_column_test.php [new file with mode: 0644]
repository/filepicker.js
tag/manage.php
tag/templates/add_tag_collection.mustache [new file with mode: 0644]
tag/templates/add_tags.mustache [new file with mode: 0644]
tag/templates/combine_tags.mustache [new file with mode: 0644]
tag/tests/behat/edit_tag.feature

index 7e9bda8..e8b22a2 100644 (file)
Binary files a/admin/tool/dataprivacy/amd/build/data_request_modal.min.js and b/admin/tool/dataprivacy/amd/build/data_request_modal.min.js differ
index 1d4e973..0ecae4c 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 586a2da..c405d17 100644 (file)
Binary files a/admin/tool/dataprivacy/amd/build/requestactions.min.js and b/admin/tool/dataprivacy/amd/build/requestactions.min.js differ
index abf717b..5f841c5 100644 (file)
@@ -29,6 +29,7 @@ define(['jquery', 'core/notification', 'core/custom_interaction_events', 'core/m
         var SELECTORS = {
             APPROVE_BUTTON: '[data-action="approve"]',
             DENY_BUTTON: '[data-action="deny"]',
+            COMPLETE_BUTTON: '[data-action="complete"]'
         };
 
         /**
@@ -38,14 +39,6 @@ define(['jquery', 'core/notification', 'core/custom_interaction_events', 'core/m
          */
         var ModalDataRequest = function(root) {
             Modal.call(this, root);
-
-            if (!this.getFooter().find(SELECTORS.APPROVE_BUTTON).length) {
-                Notification.exception({message: 'No approve button found'});
-            }
-
-            if (!this.getFooter().find(SELECTORS.DENY_BUTTON).length) {
-                Notification.exception({message: 'No deny button found'});
-            }
         };
 
         ModalDataRequest.TYPE = 'tool_dataprivacy-data_request';
@@ -80,6 +73,16 @@ define(['jquery', 'core/notification', 'core/custom_interaction_events', 'core/m
                     data.originalEvent.preventDefault();
                 }
             }.bind(this));
+
+            this.getModal().on(CustomEvents.events.activate, SELECTORS.COMPLETE_BUTTON, function(e, data) {
+                var completeEvent = $.Event(DataPrivacyEvents.complete);
+                this.getRoot().trigger(completeEvent, this);
+
+                if (!completeEvent.isDefaultPrevented()) {
+                    this.hide();
+                    data.originalEvent.preventDefault();
+                }
+            }.bind(this));
         };
 
         // Automatically register with the modal registry the first time this module is imported so that you can create modals
index 9398dc4..4e7ff77 100644 (file)
@@ -26,5 +26,6 @@ define([], function() {
     return {
         approve: 'tool_dataprivacy-data_request:approve',
         deny: 'tool_dataprivacy-data_request:deny',
+        complete: 'tool_dataprivacy-data_request:complete'
     };
 });
index c0941f8..4f3c406 100644 (file)
@@ -39,11 +39,13 @@ function($, Ajax, Notification, Str, ModalFactory, ModalEvents, Templates, Modal
      * @type {{APPROVE_REQUEST: string}}
      * @type {{DENY_REQUEST: string}}
      * @type {{VIEW_REQUEST: string}}
+     * @type {{MARK_COMPLETE: string}}
      */
     var ACTIONS = {
         APPROVE_REQUEST: '[data-action="approve"]',
         DENY_REQUEST: '[data-action="deny"]',
-        VIEW_REQUEST: '[data-action="view"]'
+        VIEW_REQUEST: '[data-action="view"]',
+        MARK_COMPLETE: '[data-action="complete"]'
     };
 
     /**
@@ -73,16 +75,9 @@ function($, Ajax, Notification, Str, ModalFactory, ModalEvents, Templates, Modal
             };
 
             var promises = Ajax.call([request]);
-            var modalTitle = '';
-            var modalType = ModalFactory.types.DEFAULT;
             $.when(promises[0]).then(function(data) {
                 if (data.result) {
-                    // Check if the status is awaiting approval.
-                    if (data.result.status == 2) {
-                        modalType = ModalDataRequest.TYPE;
-                    }
-                    modalTitle = data.result.typename;
-                    return Templates.render('tool_dataprivacy/request_details', data.result);
+                    return data.result;
                 }
                 // Fail.
                 Notification.addNotification({
@@ -91,35 +86,51 @@ function($, Ajax, Notification, Str, ModalFactory, ModalEvents, Templates, Modal
                 });
                 return false;
 
-            }).then(function(html) {
+            }).then(function(data) {
+                var body = Templates.render('tool_dataprivacy/request_details', data);
+                var templateContext = {
+                    approvedeny: data.approvedeny,
+                    canmarkcomplete: data.canmarkcomplete
+                };
                 return ModalFactory.create({
-                    title: modalTitle,
-                    body: html,
-                    type: modalType,
-                    large: true
-                }).then(function(modal) {
-                    // Handle approve event.
-                    modal.getRoot().on(DataPrivacyEvents.approve, function() {
-                        showConfirmation(DataPrivacyEvents.approve, requestId);
-                    });
-
-                    // Handle deny event.
-                    modal.getRoot().on(DataPrivacyEvents.deny, function() {
-                        showConfirmation(DataPrivacyEvents.deny, requestId);
-                    });
-
-                    // Handle hidden event.
-                    modal.getRoot().on(ModalEvents.hidden, function() {
-                        // Destroy when hidden.
-                        modal.destroy();
-                    });
-
-                    return modal;
+                    title: data.typename,
+                    body: body,
+                    type: ModalDataRequest.TYPE,
+                    large: true,
+                    templateContext: templateContext
+                });
+
+            }).then(function(modal) {
+                // Handle approve event.
+                modal.getRoot().on(DataPrivacyEvents.approve, function() {
+                    showConfirmation(DataPrivacyEvents.approve, requestId);
+                });
+
+                // Handle deny event.
+                modal.getRoot().on(DataPrivacyEvents.deny, function() {
+                    showConfirmation(DataPrivacyEvents.deny, requestId);
                 });
-            }).done(function(modal) {
+
+                // Handle send event.
+                modal.getRoot().on(DataPrivacyEvents.complete, function() {
+                    var params = {
+                        'requestid': requestId
+                    };
+                    handleSave('tool_dataprivacy_mark_complete', params);
+                });
+
+                // Handle hidden event.
+                modal.getRoot().on(ModalEvents.hidden, function() {
+                    // Destroy when hidden.
+                    modal.destroy();
+                });
+
                 // Show the modal!
                 modal.show();
-            }).fail(Notification.exception);
+
+                return;
+
+            }).catch(Notification.exception);
         });
 
         $(ACTIONS.APPROVE_REQUEST).click(function(e) {
@@ -135,6 +146,11 @@ function($, Ajax, Notification, Str, ModalFactory, ModalEvents, Templates, Modal
             var requestId = $(this).data('requestid');
             showConfirmation(DataPrivacyEvents.deny, requestId);
         });
+
+        $(ACTIONS.MARK_COMPLETE).click(function(e) {
+            e.preventDefault();
+            showConfirmation(DataPrivacyEvents.complete, $(this).data('requestid'));
+        });
     };
 
     /**
@@ -146,6 +162,9 @@ function($, Ajax, Notification, Str, ModalFactory, ModalEvents, Templates, Modal
     function showConfirmation(action, requestId) {
         var keys = [];
         var wsfunction = '';
+        var params = {
+            'requestid': requestId
+        };
         switch (action) {
             case DataPrivacyEvents.approve:
                 keys = [
@@ -173,6 +192,19 @@ function($, Ajax, Notification, Str, ModalFactory, ModalEvents, Templates, Modal
                 ];
                 wsfunction = 'tool_dataprivacy_deny_data_request';
                 break;
+            case DataPrivacyEvents.complete:
+                keys = [
+                    {
+                        key: 'markcomplete',
+                        component: 'tool_dataprivacy'
+                    },
+                    {
+                        key: 'confirmcompletion',
+                        component: 'tool_dataprivacy'
+                    }
+                ];
+                wsfunction = 'tool_dataprivacy_mark_complete';
+                break;
         }
 
         var modalTitle = '';
@@ -189,26 +221,7 @@ function($, Ajax, Notification, Str, ModalFactory, ModalEvents, Templates, Modal
 
             // Handle save event.
             modal.getRoot().on(ModalEvents.save, function() {
-                // Confirm the request.
-                var params = {
-                    'requestid': requestId
-                };
-
-                var request = {
-                    methodname: wsfunction,
-                    args: params
-                };
-
-                Ajax.call([request])[0].done(function(data) {
-                    if (data.result) {
-                        window.location.reload();
-                    } else {
-                        Notification.addNotification({
-                            message: data.warnings[0].message,
-                            type: 'error'
-                        });
-                    }
-                }).fail(Notification.exception);
+                handleSave(wsfunction, params);
             });
 
             // Handle hidden event.
@@ -217,9 +230,39 @@ function($, Ajax, Notification, Str, ModalFactory, ModalEvents, Templates, Modal
                 modal.destroy();
             });
 
-            return modal;
-        }).done(function(modal) {
             modal.show();
+
+            return;
+
+        }).catch(Notification.exception);
+    }
+
+    /**
+     * Calls a web service function and reloads the page on success and shows a notification.
+     * Displays an error notification, otherwise.
+     *
+     * @param {String} wsfunction The web service function to call.
+     * @param {Object} params The parameters for the web service functoon.
+     */
+    function handleSave(wsfunction, params) {
+        // Confirm the request.
+        var request = {
+            methodname: wsfunction,
+            args: params
+        };
+
+        Ajax.call([request])[0].done(function(data) {
+            if (data.result) {
+                // On success, reload the page so that the data request table will be updated.
+                // TODO: Probably in the future, better to reload the table or the target data request via AJAX.
+                window.location.reload();
+            } else {
+                // Add the notification.
+                Notification.addNotification({
+                    message: data.warnings[0].message,
+                    type: 'error'
+                });
+            }
         }).fail(Notification.exception);
     }
 
index 42acb42..9aaea28 100644 (file)
@@ -24,6 +24,7 @@
 namespace tool_dataprivacy;
 
 use coding_exception;
+use context_course;
 use context_system;
 use core\invalid_persistent_exception;
 use core\message\message;
@@ -426,7 +427,22 @@ class api {
         if ($dpoid) {
             $datarequest->set('dpo', $dpoid);
         }
-        $datarequest->set('dpocomment', $comment);
+        // Update the comment if necessary.
+        if (!empty(trim($comment))) {
+            $params = [
+                'date' => userdate(time()),
+                'comment' => $comment
+            ];
+            $commenttosave = get_string('datecomment', 'tool_dataprivacy', $params);
+            // Check if there's an existing DPO comment.
+            $currentcomment = trim($datarequest->get('dpocomment'));
+            if ($currentcomment) {
+                // Append the new comment to the current comment and give them 1 line space in between.
+                $commenttosave = $currentcomment . PHP_EOL . PHP_EOL . $commenttosave;
+            }
+            $datarequest->set('dpocomment', $commenttosave);
+        }
+
         return $datarequest->update();
     }
 
@@ -521,7 +537,6 @@ class api {
      * @param data_request $request The data request
      * @return int|false
      * @throws coding_exception
-     * @throws dml_exception
      * @throws moodle_exception
      */
     public static function notify_dpo($dpo, data_request $request) {
index 6587c40..e14e072 100644 (file)
@@ -31,6 +31,7 @@ use context_helper;
 use context_system;
 use context_user;
 use core\invalid_persistent_exception;
+use core\notification;
 use core_user;
 use dml_exception;
 use external_api;
@@ -144,7 +145,7 @@ class external extends external_api {
     }
 
     /**
-     * Deny a data request.
+     * Make a general enquiry to a DPO.
      *
      * @since Moodle 3.5
      * @param string $message The message to be sent to the DPO.
@@ -210,7 +211,7 @@ class external extends external_api {
     }
 
     /**
-     * Parameter description for deny_data_request().
+     * Parameter description for contact_dpo().
      *
      * @since Moodle 3.5
      * @return external_description
@@ -222,6 +223,70 @@ class external extends external_api {
         ]);
     }
 
+    /**
+     * Parameter description for mark_complete().
+     *
+     * @since Moodle 3.5.2
+     * @return external_function_parameters
+     */
+    public static function mark_complete_parameters() {
+        return new external_function_parameters([
+            'requestid' => new external_value(PARAM_INT, 'The request ID', VALUE_REQUIRED)
+        ]);
+    }
+
+    /**
+     * Mark a user's general enquiry's status as complete.
+     *
+     * @since Moodle 3.5.2
+     * @param int $requestid The request ID of the general enquiry.
+     * @return array
+     * @throws coding_exception
+     * @throws invalid_parameter_exception
+     * @throws invalid_persistent_exception
+     * @throws restricted_context_exception
+     * @throws dml_exception
+     * @throws moodle_exception
+     */
+    public static function mark_complete($requestid) {
+        global $USER;
+
+        $warnings = [];
+        $params = external_api::validate_parameters(self::mark_complete_parameters(), [
+            'requestid' => $requestid,
+        ]);
+        $requestid = $params['requestid'];
+
+        // Validate context.
+        $context = context_system::instance();
+        self::validate_context($context);
+
+        $message = get_string('markedcomplete', 'tool_dataprivacy');
+        // Update the data request record.
+        if ($result = api::update_request_status($requestid, api::DATAREQUEST_STATUS_COMPLETE, $USER->id, $message)) {
+            // Add notification in the session to be shown when the page is reloaded on the JS side.
+            notification::success(get_string('requestmarkedcomplete', 'tool_dataprivacy'));
+        }
+
+        return [
+            'result' => $result,
+            'warnings' => $warnings
+        ];
+    }
+
+    /**
+     * Parameter description for mark_complete().
+     *
+     * @since Moodle 3.5.2
+     * @return external_description
+     */
+    public static function mark_complete_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().
      *
@@ -258,9 +323,9 @@ class external extends external_api {
         // Validate context.
         $context = context_system::instance();
         self::validate_context($context);
+        $requestpersistent = new data_request($requestid);
         require_capability('tool/dataprivacy:managedatarequests', $context);
 
-        $requestpersistent = new data_request($requestid);
         $exporter = new data_request_exporter($requestpersistent, ['context' => $context]);
         $renderer = $PAGE->get_renderer('tool_dataprivacy');
         $result = $exporter->export($renderer);
@@ -326,6 +391,9 @@ class external extends external_api {
         $result = false;
         if ($requestexists) {
             $result = api::approve_data_request($requestid);
+
+            // Add notification in the session to be shown when the page is reloaded on the JS side.
+            notification::success(get_string('requestapproved', 'tool_dataprivacy'));
         } else {
             $warnings[] = [
                 'item' => $requestid,
@@ -395,6 +463,9 @@ class external extends external_api {
         $result = false;
         if ($requestexists) {
             $result = api::deny_data_request($requestid);
+
+            // Add notification in the session to be shown when the page is reloaded on the JS side.
+            notification::success(get_string('requestdenied', 'tool_dataprivacy'));
         } else {
             $warnings[] = [
                 'item' => $requestid,
index 9996349..93b33e3 100644 (file)
@@ -102,6 +102,16 @@ class data_request_exporter extends persistent_exporter {
                 'optional' => true,
                 'default' => false
             ],
+            'approvedeny' => [
+                'type' => PARAM_BOOL,
+                'optional' => true,
+                'default' => false
+            ],
+            'canmarkcomplete' => [
+                'type' => PARAM_BOOL,
+                'optional' => true,
+                'default' => false
+            ],
         ];
     }
 
@@ -140,14 +150,19 @@ class data_request_exporter extends persistent_exporter {
 
         $values['messagehtml'] = text_to_html($this->persistent->get('comments'));
 
-        $values['typename'] = helper::get_request_type_string($this->persistent->get('type'));
-        $values['typenameshort'] = helper::get_shortened_request_type_string($this->persistent->get('type'));
+        $requesttype = $this->persistent->get('type');
+        $values['typename'] = helper::get_request_type_string($requesttype);
+        $values['typenameshort'] = helper::get_shortened_request_type_string($requesttype);
 
         $values['canreview'] = false;
+        $values['approvedeny'] = false;
         $values['statuslabel'] = helper::get_request_status_string($this->persistent->get('status'));
+
         switch ($this->persistent->get('status')) {
             case api::DATAREQUEST_STATUS_PENDING:
                 $values['statuslabelclass'] = 'label-default';
+                // Request can be manually completed for general enquiry requests.
+                $values['canmarkcomplete'] = $requesttype == api::DATAREQUEST_TYPE_OTHERS;
                 break;
             case api::DATAREQUEST_STATUS_PREPROCESSING:
                 $values['statuslabelclass'] = 'label-default';
@@ -156,6 +171,8 @@ class data_request_exporter extends persistent_exporter {
                 $values['statuslabelclass'] = 'label-info';
                 // DPO can review the request once it's ready.
                 $values['canreview'] = true;
+                // Whether the DPO can approve or deny the request.
+                $values['approvedeny'] = in_array($requesttype, [api::DATAREQUEST_TYPE_EXPORT, api::DATAREQUEST_TYPE_DELETE]);
                 break;
             case api::DATAREQUEST_STATUS_APPROVED:
                 $values['statuslabelclass'] = 'label-info';
index 97918d9..d8b0644 100644 (file)
@@ -180,16 +180,32 @@ class data_requests_table extends table_sql {
         $actiontext = get_string('viewrequest', 'tool_dataprivacy');
         $actions[] = new action_menu_link_secondary($actionurl, null, $actiontext, $actiondata);
 
-        if ($status == api::DATAREQUEST_STATUS_AWAITING_APPROVAL) {
-            // Approve.
-            $actiondata['data-action'] = 'approve';
-            $actiontext = get_string('approverequest', 'tool_dataprivacy');
-            $actions[] = new action_menu_link_secondary($actionurl, null, $actiontext, $actiondata);
-
-            // Deny.
-            $actiondata['data-action'] = 'deny';
-            $actiontext = get_string('denyrequest', 'tool_dataprivacy');
-            $actions[] = new action_menu_link_secondary($actionurl, null, $actiontext, $actiondata);
+        switch ($status) {
+            case api::DATAREQUEST_STATUS_PENDING:
+                // Add action to mark a general enquiry request as complete.
+                if ($data->type == api::DATAREQUEST_TYPE_OTHERS) {
+                    $actiondata['data-action'] = 'complete';
+                    $nameemail = (object)[
+                        'name' => $data->foruser->fullname,
+                        'email' => $data->foruser->email
+                    ];
+                    $actiondata['data-requestid'] = $data->id;
+                    $actiondata['data-replytoemail'] = get_string('nameemail', 'tool_dataprivacy', $nameemail);
+                    $actiontext = get_string('markcomplete', 'tool_dataprivacy');
+                    $actions[] = new action_menu_link_secondary($actionurl, null, $actiontext, $actiondata);
+                }
+                break;
+            case api::DATAREQUEST_STATUS_AWAITING_APPROVAL:
+                // Approve.
+                $actiondata['data-action'] = 'approve';
+                $actiontext = get_string('approverequest', 'tool_dataprivacy');
+                $actions[] = new action_menu_link_secondary($actionurl, null, $actiontext, $actiondata);
+
+                // Deny.
+                $actiondata['data-action'] = 'deny';
+                $actiontext = get_string('denyrequest', 'tool_dataprivacy');
+                $actions[] = new action_menu_link_secondary($actionurl, null, $actiontext, $actiondata);
+                break;
         }
 
         $actionsmenu = new action_menu($actions);
index 25229f0..c5e18a1 100644 (file)
@@ -82,6 +82,7 @@ class my_data_requests_page implements renderable, templatable {
             $requestid = $request->get('id');
             $status = $request->get('status');
             $userid = $request->get('userid');
+            $type = $request->get('type');
 
             $usercontext = context_user::instance($userid, IGNORE_MISSING);
             if (!$usercontext) {
@@ -107,7 +108,8 @@ class my_data_requests_page implements renderable, templatable {
                     $item->statuslabelclass = 'label-success';
                     $item->statuslabel = get_string('statuscomplete', 'tool_dataprivacy');
                     $cancancel = false;
-                    $candownload = true;
+                    // Show download links only for export-type data requests.
+                    $candownload = $type == api::DATAREQUEST_TYPE_EXPORT;
                     break;
                 case api::DATAREQUEST_STATUS_CANCELLED:
                 case api::DATAREQUEST_STATUS_REJECTED:
index b72b201..9c71e8c 100644 (file)
@@ -43,6 +43,16 @@ $functions = [
         'ajax'          => true,
         'loginrequired' => true,
     ],
+    'tool_dataprivacy_mark_complete' => [
+        'classname'     => 'tool_dataprivacy\external',
+        'methodname'    => 'mark_complete',
+        'classpath'     => '',
+        'description'   => 'Mark a user\'s general enquiry as complete',
+        'type'          => 'write',
+        'capabilities'  => 'tool/dataprivacy:managedatarequests',
+        'ajax'          => true,
+        'loginrequired' => true,
+    ],
     'tool_dataprivacy_get_data_request' => [
         'classname'     => 'tool_dataprivacy\external',
         'methodname'    => 'get_data_request',
index aa64047..b01a0b6 100644 (file)
@@ -46,6 +46,7 @@ $string['categoryupdated'] = 'Category updated';
 $string['close'] = 'Close';
 $string['compliant'] = 'Compliant';
 $string['confirmapproval'] = 'Do you really want to approve this data request?';
+$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['contactdataprotectionofficer'] = 'Contact the privacy officer';
@@ -70,6 +71,7 @@ $string['dataregistryinfo'] = 'The data registry enables categories (types of da
 $string['datarequestcreatedforuser'] = 'Data request created for {$a}';
 $string['datarequestemailsubject'] = 'Data request: {$a}';
 $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.';
@@ -151,6 +153,8 @@ $string['httpwarning'] = 'Any data downloaded from this site may not be encrypte
 $string['inherit'] = 'Inherit';
 $string['lawfulbases'] = 'Lawful bases';
 $string['lawfulbases_help'] = 'Select at least one option that will serve as the lawful basis for processing personal data. For details on these lawful bases, please see <a href="https://gdpr-info.eu/art-6-gdpr/" target="_blank">GDPR Art. 6.1</a>';
+$string['markcomplete'] = 'Mark as complete';
+$string['markedcomplete'] = 'Your enquiry has been marked as complete by the privacy officer.';
 $string['messageprovider:contactdataprotectionofficer'] = 'Data requests';
 $string['messageprovider:datarequestprocessingresults'] = 'Data request processing results';
 $string['messageprovider:notifyexceptions'] = 'Data requests exceptions notifications';
@@ -197,12 +201,15 @@ $string['purposeslist'] = 'List of data purposes';
 $string['purposeupdated'] = 'Purpose updated';
 $string['replyto'] = 'Reply to';
 $string['requestactions'] = 'Actions';
+$string['requestapproved'] = 'The request has been approved';
 $string['requestby'] = 'Requested by';
 $string['requestbydetail'] = 'Requested by:';
 $string['requestcomments'] = 'Comments';
 $string['requestcomments_help'] = 'This box enables you to enter any further details about your data request.';
+$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['requeststatus'] = 'Status';
 $string['requestsubmitted'] = 'Your request has been submitted to the privacy officer';
 $string['requesttype'] = 'Type';
index cae5022..e5a1ff9 100644 (file)
 }}
 {{< core/modal }}
     {{$footer}}
-        <button type="button" class="btn btn-primary" data-action="approve">{{#str}} approve, tool_dataprivacy {{/str}}</button>
-        <button type="button" class="btn btn-secondary" data-action="deny">{{#str}} deny, tool_dataprivacy {{/str}}</button>
+        {{#approvedeny}}
+            <button type="button" class="btn btn-primary" data-action="approve">{{#str}} approve, tool_dataprivacy {{/str}}</button>
+            <button type="button" class="btn btn-secondary" data-action="deny">{{#str}} deny, tool_dataprivacy {{/str}}</button>
+        {{/approvedeny}}
+        {{#canmarkcomplete}}
+            <button type="button" class="btn btn-primary" data-action="complete">{{#str}} markcomplete, tool_dataprivacy {{/str}}</button>
+        {{/canmarkcomplete}}
     {{/footer}}
 {{/ core/modal }}
diff --git a/admin/tool/dataprivacy/tests/behat/contact_privacy_officer.feature b/admin/tool/dataprivacy/tests/behat/contact_privacy_officer.feature
new file mode 100644 (file)
index 0000000..e015d19
--- /dev/null
@@ -0,0 +1,26 @@
+@tool @tool_dataprivacy
+Feature: Contact the privacy officer
+  As a user
+  In order to reach out to the site's privacy officer
+  I need to be able to contact the site's privacy officer in Moodle
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email          |
+      | student1 | Student   | 1        | s1@example.com |
+    And I log in as "admin"
+    And I set the following administration settings values:
+      | contactdataprotectionofficer | 1 |
+    And I log out
+
+  @javascript
+  Scenario: Contacting the privacy officer
+    Given I log in as "student1"
+    And I follow "Profile" in the user menu
+    And I should see "Contact the privacy officer"
+    And I click on "Contact the privacy officer" "link"
+    And I set the field "Message" to "Hello DPO!"
+    And I press "Send"
+    And I should see "Your request has been submitted to the privacy officer"
+    And I click on "Data requests" "link"
+    And I should see "Hello DPO!" in the "General inquiry" "table_row"
diff --git a/admin/tool/dataprivacy/tests/behat/manage_data_requests.feature b/admin/tool/dataprivacy/tests/behat/manage_data_requests.feature
new file mode 100644 (file)
index 0000000..bdc43e7
--- /dev/null
@@ -0,0 +1,58 @@
+@tool @tool_dataprivacy
+Feature: Manage data requests
+  As the privacy officer
+  In order to address the privacy-related requests
+  I need to be able to manage the data requests of the site's users
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email          |
+      | student1 | John      | Doe      | s1@example.com |
+      | student2 | Jane      | Doe      | s2@example.com |
+    And I log in as "admin"
+    And I set the following administration settings values:
+      | contactdataprotectionofficer | 1 |
+    And I log out
+
+  @javascript
+  Scenario: Marking general enquiries as complete
+    Given I log in as "student1"
+    And I follow "Profile" in the user menu
+    And I should see "Contact the privacy officer"
+    And I click on "Contact the privacy officer" "link"
+    And I set the field "Message" to "Hi PO! Can others access my information on your site?"
+    And I press "Send"
+    And I should see "Your request has been submitted to the privacy officer"
+    And I log out
+    And I log in as "student2"
+    And I follow "Profile" in the user menu
+    And I click on "Contact the privacy officer" "link"
+    And I set the field "Message" to "Dear Mr. Privacy Officer, I'd like to know more about GDPR. Thanks!"
+    And I press "Send"
+    And I should see "Your request has been submitted to the privacy officer"
+    And I log out
+    When I log in as "admin"
+    And I navigate to "Users > Privacy and policies > Data requests" in site administration
+    Then I should see "Hi PO!" in the "John Doe" "table_row"
+    And I should see "Dear Mr. Privacy Officer" in the "Jane Doe" "table_row"
+    And I click on "Actions" "link" in the "John Doe" "table_row"
+    And I should see "View the request"
+    And I should see "Mark as complete"
+    And I choose "View the request" in the open action menu
+    And I should see "Hi PO! Can others access my information on your site?"
+    And I press "Mark as complete"
+    And I wait until the page is ready
+    And I should see "Complete" in the "John Doe" "table_row"
+    And I click on "Actions" "link" in the "John Doe" "table_row"
+    And I should see "View the request"
+    But I should not see "Mark as complete"
+    And I press key "27" in ".moodle-actionmenu" "css_element"
+    And I click on "Actions" "link" in the "Jane Doe" "table_row"
+    And I choose "Mark as complete" in the open action menu
+    And I should see "Do you really want to mark this user enquiry as complete?"
+    And I press "Mark as complete"
+    And I wait until the page is ready
+    And I should see "Complete" in the "Jane Doe" "table_row"
+    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"
index 7b7bde4..01b8b2b 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die;
 
-$plugin->version   = 2018051401;
+$plugin->version   = 2018051402;
 $plugin->requires  = 2018050800;        // Moodle 3.5dev (Build 2018031600) and upwards.
 $plugin->component = 'tool_dataprivacy';
index bcb26df..5c2fe8c 100644 (file)
@@ -473,6 +473,12 @@ class auth_plugin_db extends auth_plugin_base {
                     set_user_preference('auth_forcepasswordchange', 1, $id);
                     set_user_preference('create_password',          1, $id);
                 }
+
+                // Save custom profile fields here.
+                require_once($CFG->dirroot . '/user/profile/lib.php');
+                $user->id = $id;
+                profile_save_data($user);
+
                 // Make sure user context is present.
                 context_user::instance($id);
             }
index 7bb0bec..83f9234 100644 (file)
@@ -120,11 +120,17 @@ WSDL;
 }
 /******************************************************************************/
 
-function LogoutNotification($SessionID){
+/**
+ * Handles SOAP Back-channel logout notification
+ *
+ * @param string $spsessionid SP-provided Shibboleth Session ID
+ * @return SoapFault or void if everything was fine
+ */
+function LogoutNotification($spsessionid) {
 
     global $CFG, $SESSION, $DB;
 
-    // Delete session of user using $SessionID
+    // Delete session of user using $spsessionid.
     if(empty($CFG->dbsessions)) {
 
         // File session
@@ -140,13 +146,13 @@ function LogoutNotification($SessionID){
                         // Read session file data
                         $data = file($dir.'/'.$file);
                         if (isset($data[0])){
-                            $user_session = unserializesession($data[0]);
+                            $usersession = unserializesession($data[0]);
 
                             // Check if we have found session that shall be deleted
-                            if (isset($user_session['SESSION']) && isset($user_session['SESSION']->shibboleth_session_id)){
+                            if (isset($usersession['SESSION']) && isset($usersession['SESSION']->shibboleth_session_id)) {
 
                                 // If there is a match, delete file
-                                if ($user_session['SESSION']->shibboleth_session_id == $SessionID){
+                                if ($usersession['SESSION']->shibboleth_session_id == $spsessionid) {
                                     // Delete session file
                                     if (!unlink($dir.'/'.$file)){
                                         return new SoapFault('LogoutError', 'Could not delete Moodle session file.');
@@ -160,34 +166,25 @@ function LogoutNotification($SessionID){
             }
         }
     } else {
-        // DB Session
-        //TODO: this needs to be rewritten to use new session stuff
-        if (!empty($CFG->sessiontimeout)) {
-            $ADODB_SESS_LIFE   = $CFG->sessiontimeout;
-        }
-
-            if ($user_session_data = $DB->get_records_sql('SELECT sesskey, sessdata FROM {sessions2} WHERE expiry > NOW()')) {
-            foreach ($user_session_data as $session_data) {
-
-                // Get user session
-                $user_session = adodb_unserialize( urldecode($session_data->sessdata) );
-
-                if (isset($user_session['SESSION']) && isset($user_session['SESSION']->shibboleth_session_id)){
-
-                    // If there is a match, delete file
-                    if ($user_session['SESSION']->shibboleth_session_id == $SessionID){
-                        // Delete this session entry
-                        if (ADODB_Session::destroy($session_data->sesskey) !== true){
-                            return new SoapFault('LogoutError', 'Could not delete Moodle session entry in database.');
-                        }
+        // DB Sessions.
+        $sessions = $DB->get_records_sql(
+            'SELECT userid, sessdata FROM {sessions} WHERE timemodified > ?',
+            array(time() - $CFG->sessiontimeout)
+        );
+        foreach ($sessions as $session) {
+            // Get user session from DB.
+            if (session_decode(base64_decode($session->sessdata))) {
+                if (isset($_SESSION['SESSION']) && isset($_SESSION['SESSION']->shibboleth_session_id)) {
+                    // If there is a match, kill the session.
+                    if ($_SESSION['SESSION']->shibboleth_session_id == trim($spsessionid)) {
+                        // Delete this user's sessions.
+                        \core\session\manager::kill_user_sessions($session->userid);
                     }
                 }
             }
         }
     }
-
-    // If now SoapFault was thrown the function will return OK as the SP assumes
-
+    // If no SoapFault was thrown, the function will return OK as the SP assumes.
 }
 
 /*****************************************************************************/
index fc62cfc..e61b89a 100644 (file)
@@ -730,9 +730,16 @@ abstract class backup_cron_automated_helper {
     protected static function is_course_modified($courseid, $since) {
         $logmang = get_log_manager();
         $readers = $logmang->get_readers('core\log\sql_reader');
-        $where = "courseid = :courseid and timecreated > :since and crud <> 'r'";
         $params = array('courseid' => $courseid, 'since' => $since);
-        foreach ($readers as $reader) {
+
+        foreach ($readers as $readerpluginname => $reader) {
+            $where = "courseid = :courseid and timecreated > :since and crud <> 'r'";
+
+            // Prevent logs of prevous backups causing a false positive.
+            if ($readerpluginname != 'logstore_legacy') {
+                $where .= " and target <> 'course_backup'";
+            }
+
             if ($reader->get_events_select_count($where, $params)) {
                 return true;
             }
index 320daac..274e6dd 100644 (file)
@@ -27,6 +27,8 @@ defined('MOODLE_INTERNAL') || die();
 
 global $CFG;
 require_once($CFG->dirroot . '/backup/util/helper/backup_cron_helper.class.php');
+require_once($CFG->dirroot . '/backup/util/interfaces/checksumable.class.php');
+require_once("$CFG->dirroot/backup/backup.class.php");
 
 /**
  * Unit tests for backup cron helper
@@ -320,6 +322,48 @@ class backup_cron_helper_testcase extends advanced_testcase {
         $this->assertArrayHasKey('1000432000', $backupfiles);
         $this->assertEquals('file3.mbz', $backupfiles['1000432000']);
     }
+
+    /**
+     * Test {@link backup_cron_automated_helper::is_course_modified}.
+     */
+    public function test_is_course_modified() {
+        $this->resetAfterTest();
+        $this->preventResetByRollback();
+
+        set_config('enabled_stores', 'logstore_standard', 'tool_log');
+        set_config('buffersize', 0, 'logstore_standard');
+        set_config('logguests', 1, 'logstore_standard');
+
+        $course = $this->getDataGenerator()->create_course();
+
+        // New courses should be backed up.
+        $this->assertTrue(testable_backup_cron_automated_helper::testable_is_course_modified($course->id, 0));
+
+        $timepriortobackup = time();
+        $this->waitForSecond();
+        $otherarray = [
+            'format' => backup::FORMAT_MOODLE,
+            'mode' => backup::MODE_GENERAL,
+            'interactive' => backup::INTERACTIVE_YES,
+            'type' => backup::TYPE_1COURSE,
+        ];
+        $event = \core\event\course_backup_created::create([
+            'objectid' => $course->id,
+            'context'  => context_course::instance($course->id),
+            'other'    => $otherarray
+        ]);
+        $event->trigger();
+
+        // If the only action since last backup was a backup then no backup.
+        $this->assertFalse(testable_backup_cron_automated_helper::testable_is_course_modified($course->id, $timepriortobackup));
+
+        $course->groupmode = SEPARATEGROUPS;
+        $course->groupmodeforce = true;
+        update_course($course);
+
+        // Updated courses should be backed up.
+        $this->assertTrue(testable_backup_cron_automated_helper::testable_is_course_modified($course->id, $timepriortobackup));
+    }
 }
 
 /**
@@ -340,4 +384,17 @@ class testable_backup_cron_automated_helper extends backup_cron_automated_helper
     public static function testable_get_backups_to_delete($backupfiles, $now) {
         return parent::get_backups_to_delete($backupfiles, $now);
     }
+
+    /**
+     * Provides access to protected method get_backups_to_remove.
+     *
+     * @param int $courseid course id to check
+     * @param int $since timestamp, from which to check
+     *
+     * @return bool true if the course was modified, false otherwise. This also returns false if no readers are enabled. This is
+     * intentional, since we cannot reliably determine if any modification was made or not.
+     */
+    public static function testable_is_course_modified($courseid, $since) {
+        return parent::is_course_modified($courseid, $since);
+    }
 }
index e6439d3..5391a20 100644 (file)
@@ -64,7 +64,7 @@
         }
     }
 }}
-<div class="m-t-2 m-b-1">
+<div class="m-t-2 m-b-1 activity-navigation">
 {{< core/columns-1to1to1}}
     {{$column1}}
         <div class="pull-left">
index 2f12478..bff72f8 100644 (file)
@@ -202,7 +202,8 @@ if (empty($availablefilters)) {
 
     echo html_writer::table($table);
     echo html_writer::start_tag('div', array('class'=>'buttons'));
-    echo html_writer::empty_tag('input', array('type'=>'submit', 'name'=>'savechanges', 'value'=>get_string('savechanges')));
+    $submitattr = ['type' => 'submit', 'name' => 'savechanges', 'value' => get_string('savechanges'), 'class' => 'btn btn-primary'];
+    echo html_writer::empty_tag('input', $submitattr);
     echo html_writer::end_tag('div');
     echo html_writer::end_tag('div');
     echo html_writer::end_tag('form');
index fd51f0b..432ce12 100644 (file)
Binary files a/lib/amd/build/tag.min.js and b/lib/amd/build/tag.min.js differ
index 1609564..e6b12f1 100644 (file)
@@ -22,8 +22,8 @@
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  * @since      3.0
  */
-define(['jquery', 'core/ajax', 'core/templates', 'core/notification', 'core/str', 'core/yui'],
-        function($, ajax, templates, notification, str, Y) {
+define(['jquery', 'core/ajax', 'core/templates', 'core/notification', 'core/str', 'core/modal_factory', 'core/modal_events'],
+        function($, ajax, templates, notification, str, ModalFactory, ModalEvents) {
     return /** @alias module:core/tag */ {
 
         /**
@@ -142,50 +142,61 @@ define(['jquery', 'core/ajax', 'core/templates', 'core/notification', 'core/str'
                     return;
                 }
                 var tempElement = $("<input type='hidden'/>").attr('name', this.name);
+                var saveButtonText = '';
+                var tagOptions = [];
+                tags.each(function() {
+                    var tagid = $(this).val(),
+                        tagname = $('.inplaceeditable[data-itemtype=tagname][data-itemid=' + tagid + ']').attr('data-value');
+                    tagOptions.push({
+                        id: tagid,
+                        name: tagname
+                    });
+                });
+
                 str.get_strings([
                     {key: 'combineselected', component: 'tag'},
-                    {key: 'selectmaintag', component: 'tag'},
-                    {key: 'continue'},
-                    {key: 'cancel'},
-                ]).done(function(s) {
-                    var el = $('<div><form id="combinetags_form" class="form-inline">' +
-                        '<p class="description"></p><p class="options"></p>' +
-                        '<p class="mdl-align"><input type="submit" class="btn btn-primary" id="combinetags_submit"/>' +
-                        '<input type="button" class="btn btn-secondary" id="combinetags_cancel"/></p>' +
-                        '</form></div>');
-                    el.find('.description').html(s[1]);
-                    el.find('#combinetags_submit').attr('value', s[2]);
-                    el.find('#combinetags_cancel').attr('value', s[3]);
-                    var fldset = el.find('.options');
-                    tags.each(function() {
-                        var tagid = $(this).val(),
-                            tagname = $('.inplaceeditable[data-itemtype=tagname][data-itemid=' + tagid + ']').attr('data-value');
-                        fldset.append($('<input type="radio" name="maintag" id="combinetags_maintag_' + tagid + '" value="' +
-                            tagid + '"/><label for="combinetags_maintag_' + tagid + '">' + tagname + '</label><br>'));
+                    {key: 'continue'}
+                ]).then(function(langStrings) {
+                    var modalTitle = langStrings[0];
+                    saveButtonText = langStrings[1];
+                    var templateContext = {
+                        tags: tagOptions
+                    };
+                    return ModalFactory.create({
+                        title: modalTitle,
+                        body: templates.render('core_tag/combine_tags', templateContext),
+                        type: ModalFactory.types.SAVE_CANCEL
                     });
-                    // TODO: MDL-57778 Convert to core/modal.
-                    Y.use('moodle-core-notification-dialogue', function() {
-                        var panel = new M.core.dialogue({
-                            draggable: true,
-                            modal: true,
-                            closeButton: true,
-                            headerContent: s[0],
-                            bodyContent: el.html()
-                        });
-                        panel.show();
-                        $('#combinetags_form input[type=radio]').first().focus().prop('checked', true);
-                        $('#combinetags_form #combinetags_cancel').on('click', function() {
-                            panel.destroy();
-                        });
-                        $('#combinetags_form').on('submit', function() {
-                            tempElement.appendTo(form);
-                            var maintag = $('input[name=maintag]:checked', '#combinetags_form').val();
-                            $("<input type='hidden'/>").attr('name', 'maintag').attr('value', maintag).appendTo(form);
-                            form.submit();
-                            return false;
-                        });
+                }).then(function(modal) {
+                    modal.setSaveButtonText(saveButtonText);
+
+                    // Handle save event.
+                    modal.getRoot().on(ModalEvents.save, function(e) {
+                        e.preventDefault();
+
+                        // Append this temp element in the form in the tags list, not the form in the modal. Confusing, right?!?
+                        tempElement.appendTo(form);
+                        // Get the selected tag from the modal.
+                        var maintag = $('input[name=maintag]:checked', '#combinetags_form').val();
+                        // Append this in the tags list form.
+                        $("<input type='hidden'/>").attr('name', 'maintag').attr('value', maintag).appendTo(form);
+                        // Submit the tags list form.
+                        form.submit();
                     });
-                });
+
+                    // Handle hidden event.
+                    modal.getRoot().on(ModalEvents.hidden, function() {
+                        // Destroy when hidden.
+                        modal.destroy();
+                    });
+
+                    modal.show();
+                    // Tick the first option.
+                    $('#combinetags_form input[type=radio]').first().focus().prop('checked', true);
+
+                    return;
+
+                }).catch(notification.exception);
             });
 
             // When user changes tag name to some name that already exists suggest to combine the tags.
@@ -212,39 +223,69 @@ define(['jquery', 'core/ajax', 'core/templates', 'core/notification', 'core/str'
             // Form for adding standard tags.
             $('body').on('click', 'a[data-action=addstandardtag]', function(e) {
                 e.preventDefault();
+
+                var saveButtonText = '';
                 str.get_strings([
                     {key: 'addotags', component: 'tag'},
-                    {key: 'inputstandardtags', component: 'tag'},
-                    {key: 'continue'},
-                    {key: 'cancel'},
-                ]).done(function(s) {
-                    var el = $('<div><form id="addtags_form" class="form-inline" method="POST">' +
-                        '<input type="hidden" name="action" value="addstandardtag"/>' +
-                        '<input type="hidden" name="sesskey" value="' + M.cfg.sesskey + '"/>' +
-                        '<p><label for="id_tagslist">' + s[1] + '</label>' +
-                        '<input type="text" id="id_tagslist" name="tagslist"/></p>' +
-                        '<p class="mdl-align"><input type="submit" class="btn btn-primary" id="addtags_submit"/>' +
-                        '<input type="button" class="btn btn-secondary" id="addtags_cancel"/></p>' +
-                        '</form></div>');
-                    el.find('#addtags_form').attr('action', window.location.href);
-                    el.find('#addtags_submit').attr('value', s[2]);
-                    el.find('#addtags_cancel').attr('value', s[3]);
-                    // TODO: MDL-57778 Convert to core/modal.
-                    Y.use('moodle-core-notification-dialogue', function() {
-                        var panel = new M.core.dialogue({
-                            draggable: true,
-                            modal: true,
-                            closeButton: true,
-                            headerContent: s[0],
-                            bodyContent: el.html()
-                        });
-                        panel.show();
-                        $('#addtags_form input[type=text]').focus();
-                        $('#addtags_form #addtags_cancel').on('click', function() {
-                            panel.destroy();
+                    {key: 'continue'}
+                ]).then(function(langStrings) {
+                    var modalTitle = langStrings[0];
+                    saveButtonText = langStrings[1];
+                    var templateContext = {
+                        actionurl: window.location.href,
+                        sesskey: M.cfg.sesskey
+                    };
+                    return ModalFactory.create({
+                        title: modalTitle,
+                        body: templates.render('core_tag/add_tags', templateContext),
+                        type: ModalFactory.types.SAVE_CANCEL
+                    });
+                }).then(function(modal) {
+                    modal.setSaveButtonText(saveButtonText);
+
+                    // Handle save event.
+                    modal.getRoot().on(ModalEvents.save, function(e) {
+                        var tagsInput = $(e.currentTarget).find('#id_tagslist');
+                        var name = tagsInput.val().trim();
+
+                        // Set the text field's value to the trimmed value.
+                        tagsInput.val(name);
+
+                        // Add submit event listener to the form.
+                        var tagsForm = $('#addtags_form');
+                        tagsForm.on('submit', function(e) {
+                            // Validate the form.
+                            var form = $('#addtags_form');
+                            if (form[0].checkValidity() === false) {
+                                e.preventDefault();
+                                e.stopPropagation();
+                            }
+                            form.addClass('was-validated');
+
+                            // BS2 compatibility.
+                            $('[data-region="tagslistinput"]').addClass('error');
+                            var errorMessage = $('#id_tagslist_error_message');
+                            errorMessage.removeAttr('hidden');
+                            errorMessage.addClass('help-block');
                         });
+
+                        // Try to submit the form.
+                        tagsForm.submit();
+
+                        return false;
                     });
-                });
+
+                    // Handle hidden event.
+                    modal.getRoot().on(ModalEvents.hidden, function() {
+                        // Destroy when hidden.
+                        modal.destroy();
+                    });
+
+                    modal.show();
+
+                    return;
+
+                }).catch(notification.exception);
             });
         },
 
@@ -282,52 +323,74 @@ define(['jquery', 'core/ajax', 'core/templates', 'core/notification', 'core/str'
 
             $('body').on('click', '.addtagcoll > a', function(e) {
                 e.preventDefault();
-                var href = $(this).attr('data-url') + '&sesskey=' + M.cfg.sesskey;
-                str.get_strings([
-                        {key: 'addtagcoll', component: 'tag'},
-                        {key: 'name'},
-                        {key: 'searchable', component: 'tag'},
-                        {key: 'create'},
-                        {key: 'cancel'},
-                    ]).done(function(s) {
-                        var el = $('<div><form id="addtagcoll_form" class="form-inline">' +
-                            '<p><label for="addtagcoll_name"></label>: ' +
-                            '<input id="addtagcoll_name" type="text"/></p>' +
-                            '<p><label for="addtagcoll_searchable"></label>: ' +
-                            '<input id="addtagcoll_searchable" type="checkbox" value="1" checked/></p>' +
-                            '<p class="mdl-align"><input type="submit" class="btn btn-primary" id="addtagcoll_submit"/>' +
-                            '<input type="button" class="btn btn-secondary" id="addtagcoll_cancel"/></p>' +
-                            '</form></div>');
-                        el.find('label[for="addtagcoll_name"]').html(s[1]);
-                        el.find('label[for="addtagcoll_searchable"]').html(s[2]);
-                        el.find('#addtagcoll_submit').attr('value', s[3]);
-                        el.find('#addtagcoll_cancel').attr('value', s[4]);
-                        // TODO: MDL-57778 Convert to core/modal.
-                        Y.use('moodle-core-notification-dialogue', function() {
-                            var panel = new M.core.dialogue({
-                                draggable: true,
-                                modal: true,
-                                closeButton: true,
-                                headerContent: s[0],
-                                bodyContent: el.html()
-                            });
-                            panel.show();
-                            $('#addtagcoll_form #addtagcoll_name').focus();
-                            $('#addtagcoll_form #addtagcoll_cancel').on('click', function() {
-                                panel.destroy();
-                            });
-                            $('#addtagcoll_form').on('submit', function() {
-                                var name = $('#addtagcoll_form #addtagcoll_name').val();
-                                var searchable = $('#addtagcoll_form #addtagcoll_searchable').prop('checked') ? 1 : 0;
-                                if (String(name).length > 0) {
-                                    window.location.href = href + "&name=" + encodeURIComponent(name) + "&searchable=" + searchable;
-                                }
-                                return false;
-                            });
-                        });
+                var keys = [
+                    {
+                        key: 'addtagcoll',
+                        component: 'tag'
+                    },
+                    {
+                        key: 'create'
                     }
-                );
+                ];
+
+                var href = $(this).attr('data-url');
+                var saveButtonText = '';
+                str.get_strings(keys).then(function(langStrings) {
+                    var modalTitle = langStrings[0];
+                    saveButtonText = langStrings[1];
+                    var templateContext = {
+                        actionurl: href,
+                        sesskey: M.cfg.sesskey
+                    };
+                    return ModalFactory.create({
+                        title: modalTitle,
+                        body: templates.render('core_tag/add_tag_collection', templateContext),
+                        type: ModalFactory.types.SAVE_CANCEL
+                    });
+                }).then(function(modal) {
+                    modal.setSaveButtonText(saveButtonText);
+
+                    // Handle save event.
+                    modal.getRoot().on(ModalEvents.save, function(e) {
+                        var collectionInput = $(e.currentTarget).find('#addtagcoll_name');
+                        var name = collectionInput.val().trim();
+                        // Set the text field's value to the trimmed value.
+                        collectionInput.val(name);
+
+                        // Add submit event listener to the form.
+                        var form = $('#addtagcoll_form');
+                        form.on('submit', function(e) {
+                            // Validate the form.
+                            if (form[0].checkValidity() === false) {
+                                e.preventDefault();
+                                e.stopPropagation();
+                            }
+                            form.addClass('was-validated');
+
+                            // BS2 compatibility.
+                            $('[data-region="addtagcoll_nameinput"]').addClass('error');
+                            var errorMessage = $('#id_addtagcoll_name_error_message');
+                            errorMessage.removeAttr('hidden');
+                            errorMessage.addClass('help-block');
+                        });
+
+                        // Try to submit the form.
+                        form.submit();
+
+                        return false;
+                    });
+
+                    // Handle hidden event.
+                    modal.getRoot().on(ModalEvents.hidden, function() {
+                        // Destroy when hidden.
+                        modal.destroy();
+                    });
+
+                    modal.show();
+
+                    return;
 
+                }).catch(notification.exception);
             });
 
             $('body').on('click', '.tag-collections-table .action_delete', function(e) {
index fffdfcc..93f0cba 100644 (file)
@@ -148,7 +148,7 @@ class MoodleQuickForm_url extends HTML_QuickForm_text implements templatable {
         if (count($options->repositories) > 0) {
             $straddlink = get_string('choosealink', 'repository');
             $str .= <<<EOD
-<button id="filepicker-button-js-{$clientid}" class="visibleifjs btn btn-secondary">
+<button type="button" id="filepicker-button-js-{$clientid}" class="visibleifjs btn btn-secondary">
 $straddlink
 </button>
 EOD;
index 8994eff..33d94da 100644 (file)
@@ -92,7 +92,7 @@ class ToolProvider
  *
  * @var array $LTI_RESOURCE_LINK_SETTING_NAMES
  */
-    private static $LTI_RESOURCE_LINK_SETTING_NAMES = array('lis_result_sourcedid', 'lis_outcome_service_url',
+    private static $LTI_RESOURCE_LINK_SETTING_NAMES = array('lis_course_section_sourcedid', 'lis_result_sourcedid', 'lis_outcome_service_url',
                                                             'ext_ims_lis_basic_outcome_url', 'ext_ims_lis_resultvalue_sourcedids',
                                                             'ext_ims_lis_memberships_id', 'ext_ims_lis_memberships_url',
                                                             'ext_ims_lti_tool_setting', 'ext_ims_lti_tool_setting_id', 'ext_ims_lti_tool_setting_url',
index f8208c8..3ec8ccc 100644 (file)
@@ -2730,7 +2730,12 @@ class global_navigation extends navigation_node {
         }
         if ($navoptions->grades) {
             $url = new moodle_url('/grade/report/index.php', array('id'=>$course->id));
-            $gradenode = $coursenode->add(get_string('grades'), $url, self::TYPE_SETTING, null, 'grades', new pix_icon('i/grades', ''));
+            $gradenode = $coursenode->add(get_string('grades'), $url, self::TYPE_SETTING, null,
+                'grades', new pix_icon('i/grades', ''));
+            // If the page type matches the grade part, then make the nav drawer grade node (incl. all sub pages) active.
+            if (strpos($this->page->pagetype, 'grade-') === 0) {
+                $gradenode->make_active();
+            }
         }
 
         return true;
index 1fe8926..d5078a2 100644 (file)
Binary files a/message/amd/build/message_area_contacts.min.js and b/message/amd/build/message_area_contacts.min.js differ
index 4d409cc..c2eb102 100644 (file)
@@ -553,15 +553,13 @@ define(['jquery', 'core/ajax', 'core/templates', 'core/notification', 'core/cust
          * @return {String} The altered text
          */
         Contacts.prototype._getContactText = function(text) {
-            // Remove the HTML tags to render the contact text.
-            text = $(document.createElement('div')).html(text).text();
-
             if (text.length > this._messageLength) {
                 text = text.substr(0, this._messageLength - 3);
                 text += '...';
             }
 
-            return text;
+            // Text node prevents script injection through HTML entities.
+            return document.createTextNode(text);
         };
 
         /**
index 26c4ccd..5151adb 100644 (file)
@@ -1537,8 +1537,6 @@ class core_message_external extends external_api {
                     }
                 }
 
-                $message->useridto = $useridto;
-
                 // We need to get the user from the query.
                 if (empty($userfromfullname)) {
                     // Check for non-reply and support users.
index 9143d13..48c3686 100644 (file)
@@ -688,14 +688,16 @@ function message_get_messages($useridto, $useridfrom = 0, $notifications = -1, $
     // If the 'useridto' value is empty then we are going to retrieve messages sent by the useridfrom to any user.
     if (empty($useridto)) {
         $userfields = get_all_user_name_fields(true, 'u', '', 'userto');
+        $messageuseridtosql = 'u.id as useridto';
     } else {
         $userfields = get_all_user_name_fields(true, 'u', '', 'userfrom');
+        $messageuseridtosql = "$useridto as useridto";
     }
 
     // Create the SQL we will be using.
     $messagesql = "SELECT mr.*, $userfields, 0 as notification, '' as contexturl, '' as contexturlname,
                           mua.timecreated as timeusertodeleted, mua2.timecreated as timeread,
-                          mua3.timecreated as timeuserfromdeleted
+                          mua3.timecreated as timeuserfromdeleted, $messageuseridtosql
                      FROM {messages} mr
                INNER JOIN {message_conversations} mc
                        ON mc.id = mr.conversationid
index f5475d7..9ae8b7e 100644 (file)
@@ -671,6 +671,74 @@ class core_message_externallib_testcase extends externallib_advanced_testcase {
 
     }
 
+    /**
+     * Test get_messages where we want all messages from a user, sent to any user.
+     */
+    public function test_get_messages_useridto_all() {
+        $this->resetAfterTest(true);
+
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+        $user3 = self::getDataGenerator()->create_user();
+
+        $this->setUser($user1);
+
+        // Send a message from user 1 to two other users.
+        $this->send_message($user1, $user2, 'some random text 1', 0, 1);
+        $this->send_message($user1, $user3, 'some random text 2', 0, 2);
+
+        // Get messages sent from user 1.
+        $messages = core_message_external::get_messages(0, $user1->id, 'conversations', false, false, 0, 0);
+        $messages = external_api::clean_returnvalue(core_message_external::get_messages_returns(), $messages);
+
+        // Confirm the data is correct.
+        $messages = $messages['messages'];
+        $this->assertCount(2, $messages);
+
+        $message1 = array_shift($messages);
+        $message2 = array_shift($messages);
+
+        $this->assertEquals($user1->id, $message1['useridfrom']);
+        $this->assertEquals($user2->id, $message1['useridto']);
+
+        $this->assertEquals($user1->id, $message2['useridfrom']);
+        $this->assertEquals($user3->id, $message2['useridto']);
+    }
+
+    /**
+     * Test get_messages where we want all messages to a user, sent by any user.
+     */
+    public function test_get_messages_useridfrom_all() {
+        $this->resetAfterTest();
+
+        $user1 = self::getDataGenerator()->create_user();
+        $user2 = self::getDataGenerator()->create_user();
+        $user3 = self::getDataGenerator()->create_user();
+
+        $this->setUser($user1);
+
+        // Send a message to user 1 from two other users.
+        $this->send_message($user2, $user1, 'some random text 1', 0, 1);
+        $this->send_message($user3, $user1, 'some random text 2', 0, 2);
+
+        // Get messages sent to user 1.
+        $messages = core_message_external::get_messages($user1->id, 0, 'conversations', false, false, 0, 0);
+        $messages = external_api::clean_returnvalue(core_message_external::get_messages_returns(), $messages);
+
+        // Confirm the data is correct.
+        $messages = $messages['messages'];
+        $this->assertCount(2, $messages);
+
+        $message1 = array_shift($messages);
+        $message2 = array_shift($messages);
+
+        $this->assertEquals($user2->id, $message1['useridfrom']);
+        $this->assertEquals($user1->id, $message1['useridto']);
+
+        $this->assertEquals($user3->id, $message2['useridfrom']);
+        $this->assertEquals($user1->id, $message2['useridto']);
+    }
+
     /**
      * Test get_blocked_users.
      */
index 3f2e075..fc7bb37 100644 (file)
@@ -1084,7 +1084,11 @@ class assign_grading_table extends table_sql implements renderable {
             // Add status of "grading" if markflow is not enabled.
             if (!$instance->markingworkflow) {
                 if ($row->grade !== null && $row->grade >= 0) {
-                    $o .= $this->output->container(get_string('graded', 'assign'), 'submissiongraded');
+                    if ($row->timemarked < $row->timesubmitted) {
+                        $o .= $this->output->container(get_string('gradedfollowupsubmit', 'assign'), 'gradingreminder');
+                    } else {
+                        $o .= $this->output->container(get_string('graded', 'assign'), 'submissiongraded');
+                    }
                 } else if (!$timesubmitted || $status == ASSIGN_SUBMISSION_STATUS_NEW) {
                     $now = time();
                     if ($due && ($now > $due)) {
index cb0deb3..557f188 100644 (file)
@@ -146,7 +146,7 @@ $string['disabled'] = 'Disabled';
 $string['downloadall'] = 'Download all submissions';
 $string['download all submissions'] = 'Download all submissions in a zip file.';
 $string['downloadasfolders'] = 'Download submissions in folders';
-$string['downloadasfolders_help'] = 'If the assignment submission is more than a single file, then submissions may be downloaded in folders. Each submission is put in a separate folder, with the folder structure kept for any subfolders, and files are not renamed.';
+$string['downloadasfolders_help'] = 'Assignment submissions may be downloaded in folders. Each submission is then put in a separate folder, with the folder structure kept for any subfolders, and files are not renamed.';
 $string['downloadselectedsubmissions'] = 'Download selected submissions';
 $string['duedate'] = 'Due date';
 $string['duedatecolon'] = 'Due date: {$a}';
@@ -247,6 +247,7 @@ $string['filterrequiregrading'] = 'Requires grading';
 $string['filtersubmitted'] = 'Submitted';
 $string['graded'] = 'Graded';
 $string['gradedby'] = 'Graded by';
+$string['gradedfollowupsubmit'] = 'Graded - follow up submission received';
 $string['gradedon'] = 'Graded on';
 $string['gradebelowzero'] = 'Grade must be greater than or equal to zero.';
 $string['gradeabovemaximum'] = 'Grade must be less than or equal to {$a}.';
index 75f4ccd..55f734c 100644 (file)
@@ -4532,10 +4532,10 @@ class assign {
                                                       $this->show_intro(),
                                                       $this->get_course_module()->id,
                                                       $title, '', $postfix));
-        if ($userid == $USER->id) {
-            // We only show this if it their submission.
-            $o .= $this->plagiarism_print_disclosure();
-        }
+
+        // Show plagiarism disclosure for any user submitter.
+        $o .= $this->plagiarism_print_disclosure();
+
         $data = new stdClass();
         $data->userid = $userid;
         if (!$mform) {
@@ -5326,12 +5326,14 @@ class assign {
     public function get_assign_grading_summary_renderable($activitygroup = null) {
 
         $instance = $this->get_instance();
+        $cm = $this->get_course_module();
 
         $draft = ASSIGN_SUBMISSION_STATUS_DRAFT;
         $submitted = ASSIGN_SUBMISSION_STATUS_SUBMITTED;
+        $isvisible = $cm->visible;
 
         if ($activitygroup === null) {
-            $activitygroup = groups_get_activity_group($this->get_course_module());
+            $activitygroup = groups_get_activity_group($cm);
         }
 
         if ($instance->teamsubmission) {
@@ -5349,7 +5351,8 @@ class assign {
                                                   $this->count_submissions_need_grading($activitygroup),
                                                   $instance->teamsubmission,
                                                   $warnofungroupedusers,
-                                                  $this->can_grade());
+                                                  $this->can_grade(),
+                                                  $isvisible);
         } else {
             // The active group has already been updated in groups_print_activity_menu().
             $countparticipants = $this->count_participants($activitygroup);
@@ -5364,8 +5367,8 @@ class assign {
                                                   $this->count_submissions_need_grading($activitygroup),
                                                   $instance->teamsubmission,
                                                   false,
-                                                  $this->can_grade());
-
+                                                  $this->can_grade(),
+                                                  $isvisible);
         }
 
         return $summary;
index 7e550a3..604f11c 100644 (file)
@@ -750,6 +750,8 @@ class assign_grading_summary implements renderable {
     public $warnofungroupedusers = false;
     /** @var boolean cangrade - Can the current user grade students? */
     public $cangrade = false;
+    /** @var boolean isvisible - Is the assignment's context module visible to students? */
+    public $isvisible = true;
 
     /**
      * constructor
@@ -765,6 +767,7 @@ class assign_grading_summary implements renderable {
      * @param int $submissionsneedgradingcount
      * @param bool $teamsubmission
      * @param bool $cangrade
+     * @param bool $isvisible
      */
     public function __construct($participantcount,
                                 $submissiondraftsenabled,
@@ -777,7 +780,8 @@ class assign_grading_summary implements renderable {
                                 $submissionsneedgradingcount,
                                 $teamsubmission,
                                 $warnofungroupedusers,
-                                $cangrade = true) {
+                                $cangrade = true,
+                                $isvisible = true) {
         $this->participantcount = $participantcount;
         $this->submissiondraftsenabled = $submissiondraftsenabled;
         $this->submissiondraftscount = $submissiondraftscount;
@@ -790,6 +794,7 @@ class assign_grading_summary implements renderable {
         $this->teamsubmission = $teamsubmission;
         $this->warnofungroupedusers = $warnofungroupedusers;
         $this->cangrade = $cangrade;
+        $this->isvisible = $isvisible;
     }
 }
 
index f461e21..6a1b81a 100644 (file)
@@ -267,6 +267,10 @@ class mod_assign_renderer extends plugin_renderer_base {
         $o .= $this->output->box_start('boxaligncenter gradingsummarytable');
         $t = new html_table();
 
+        // Visibility Status.
+        $this->add_table_row_tuple($t, get_string('hiddenfromstudents'),
+            (!$summary->isvisible) ? get_string('yes') : get_string('no'));
+
         // Status.
         if ($summary->teamsubmission) {
             if ($summary->warnofungroupedusers) {
index 8579c48..b0a3786 100644 (file)
     background-color: #cfefcf;
 }
 
+.path-mod-assign td.gradingreminder,
+.path-mod-assign div.gradingreminder {
+    color: black;
+    background-color: #efcfcf;
+}
+
 .path-mod-assign .gradingtable .c0 {
     display: none;
 }
diff --git a/mod/assign/tests/behat/assign_hidden.feature b/mod/assign/tests/behat/assign_hidden.feature
new file mode 100644 (file)
index 0000000..378867e
--- /dev/null
@@ -0,0 +1,38 @@
+@mod @mod_assign @javascript
+Feature: When a Teacher hides an assignment from view for students it should consistently indicate it is hidden.
+
+  Scenario: Grade multiple students on one page
+    Given the following "courses" exist:
+      | fullname | shortname | category | groupmode |
+      | Course 1 | C1 | 0 | 1 |
+    And the following "users" exist:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | 1 | teacher1@example.com |
+      | student1 | Student | 1 | student1@example.com |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+      | student1 | C1 | student |
+    When I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+    And I add a "Assignment" to section "1" and I fill the form with:
+      | Assignment name | Test hidden assignment |
+    And I open "Test hidden assignment" actions menu
+    And I choose "Hide" in the open action menu
+    And I follow "Test hidden assignment"
+    And I should see "Test hidden assignment"
+    And I should see "Yes" in the "Hidden from students" "table_row"
+    And I log out
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+    And I add a "Assignment" to section "2" and I fill the form with:
+      | Assignment name | Test visible assignment |
+    And I follow "Test visible assignment"
+    And I should see "Test visible assignment"
+    And I should see "No" in the "Hidden from students" "table_row"
+    And I log out
+    And I log in as "student1"
+    And I am on "Course 1" course homepage
+    And I should not see "Test hidden assignment"
+    And I should see "Test visible assignment"
+    And I log out
index 6c97b65..a06ab54 100644 (file)
@@ -150,3 +150,36 @@ Feature: View the grading status of an assignment
     And I should see "Graded" in the "Grading status" "table_row"
     And I should see "Great job! Lol, not really."
     And I log out
+    # Student makes a subsequent submission.
+    And I log in as "student1"
+    And I am on "Course 1" course homepage
+    And I follow "Test assignment name"
+    And I press "Edit submission"
+    And I set the following fields to these values:
+      | Online text | I'm the student's second submission |
+    And I press "Save changes"
+    And I log out
+    # Teacher marks the submission again after noticing the 'Graded - follow up submission received'.
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I follow "Test assignment name"
+    And I navigate to "View all submissions" in current page administration
+    And I should see "Graded - follow up submission received" in the "Student 1" "table_row"
+    And I wait "10" seconds
+    And I click on "Grade" "link" in the "Student 1" "table_row"
+    And I set the field "Grade out of 100" to "99.99"
+    And I set the field "Feedback comments" to "Even better job! Really."
+    And I press "Save changes"
+    And I press "Ok"
+    And I click on "Edit settings" "link"
+    And I follow "Test assignment name"
+    And I navigate to "View all submissions" in current page administration
+    And I should see "Graded" in the "Student 1" "table_row"
+    And I log out
+    # View the grading status as a student again.
+    And I log in as "student1"
+    And I am on "Course 1" course homepage
+    And I follow "Test assignment name"
+    And I should see "Graded" in the "Grading status" "table_row"
+    And I should see "Even better job! Really."
+    And I log out
index 8b2a52d..02bf3bd 100644 (file)
@@ -51,7 +51,7 @@ $string['choice:addinstance'] = 'Add a new choice';
 $string['choiceclose'] = 'Allow responses until';
 $string['choice:deleteresponses'] = 'Modify and delete responses';
 $string['choice:downloadresponses'] = 'Download responses';
-$string['choicefull'] = 'This choice is full and there are no available places.';
+$string['choicefull'] = 'One or more of the options you have selected have already been filled. Your response has not been saved. Please make another selection.';
 $string['choice:choose'] = 'Record a choice';
 $string['choicecloseson'] = 'Choice closes on {$a}';
 $string['choicename'] = 'Choice name';
index bf548a0..3953d43 100644 (file)
@@ -312,7 +312,7 @@ function choice_modify_responses($userids, $answerids, $newoptionid, $choice, $c
  * Process user submitted answers for a choice,
  * and either updating them or saving new answers.
  *
- * @param int $formanswer users submitted answers.
+ * @param int|array $formanswer the id(s) of the user submitted choice options.
  * @param object $choice the selected choice.
  * @param int $userid user identifier.
  * @param object $course current course.
@@ -361,6 +361,12 @@ function choice_user_submit_response($formanswer, $choice, $userid, $course, $cm
     }
 
     $current = $DB->get_records('choice_answers', array('choiceid' => $choice->id, 'userid' => $userid));
+
+    // Array containing [answerid => optionid] mapping.
+    $existinganswers = array_map(function($answer) {
+        return $answer->optionid;
+    }, $current);
+
     $context = context_module::instance($cm->id);
 
     $choicesexceeded = false;
@@ -404,7 +410,13 @@ function choice_user_submit_response($formanswer, $choice, $userid, $course, $cm
                 }
             }
         }
+
         foreach ($countanswers as $opt => $count) {
+            // Ignore the user's existing answers when checking whether an answer count has been exceeded.
+            // A user may wish to update their response with an additional choice option and shouldn't be competing with themself!
+            if (in_array($opt, $existinganswers)) {
+                continue;
+            }
             if ($count >= $choice->maxanswers[$opt]) {
                 $choicesexceeded = true;
                 break;
@@ -418,10 +430,8 @@ function choice_user_submit_response($formanswer, $choice, $userid, $course, $cm
     if (!($choice->limitanswers && $choicesexceeded)) {
         if ($current) {
             // Update an existing answer.
-            $existingchoices = array();
             foreach ($current as $c) {
                 if (in_array($c->optionid, $formanswers)) {
-                    $existingchoices[] = $c->optionid;
                     $DB->set_field('choice_answers', 'timemodified', time(), array('id' => $c->id));
                 } else {
                     $deletedanswersnapshots[] = $c;
@@ -431,7 +441,7 @@ function choice_user_submit_response($formanswer, $choice, $userid, $course, $cm
 
             // Add new ones.
             foreach ($formanswers as $f) {
-                if (!in_array($f, $existingchoices)) {
+                if (!in_array($f, $existinganswers)) {
                     $newanswer = new stdClass();
                     $newanswer->optionid = $f;
                     $newanswer->choiceid = $choice->id;
@@ -460,14 +470,9 @@ function choice_user_submit_response($formanswer, $choice, $userid, $course, $cm
             }
         }
     } else {
-        // Check to see if current choice already selected - if not display error.
-        $currentids = array_keys($current);
-
-        if (array_diff($currentids, $formanswers) || array_diff($formanswers, $currentids) ) {
-            // Release lock before error.
-            $choicelock->release();
-            print_error('choicefull', 'choice', $continueurl);
-        }
+        // This is a choice with limited options, and one of the options selected has just run over its limit.
+        $choicelock->release();
+        print_error('choicefull', 'choice', $continueurl);
     }
 
     // Release lock.
index 950ea17..eb452e1 100644 (file)
@@ -785,4 +785,198 @@ class mod_choice_lib_testcase extends externallib_advanced_testcase {
         $this->assertNull($min);
         $this->assertNull($max);
     }
+
+    /**
+     * Test choice_user_submit_response for a choice with specific options.
+     * Options:
+     * allowmultiple: false
+     * limitanswers: false
+     */
+    public function test_choice_user_submit_response_no_multiple_no_limits() {
+        global $DB;
+        $this->resetAfterTest(true);
+
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course();
+        $user = $generator->create_user();
+        $user2 = $generator->create_user();
+
+        // User must be enrolled in the course for choice limits to be honoured properly.
+        $role = $DB->get_record('role', ['shortname' => 'student']);
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, $role->id);
+        $this->getDataGenerator()->enrol_user($user2->id, $course->id, $role->id);
+
+        // Create choice, with updates allowed and a two options both limited to 1 response each.
+        $choice = $generator->get_plugin_generator('mod_choice')->create_instance([
+            'course' => $course->id,
+            'allowupdate' => false,
+            'limitanswers' => false,
+            'allowmultiple' => false,
+            'option' => ['red', 'green'],
+        ]);
+        $cm = get_coursemodule_from_instance('choice', $choice->id);
+
+        // Get the choice, with options and limits included.
+        $choicewithoptions = choice_get_choice($choice->id);
+        $optionids = array_keys($choicewithoptions->option);
+
+        // Now, save an response which includes the first option.
+        $this->assertNull(choice_user_submit_response($optionids[0], $choicewithoptions, $user->id, $course, $cm));
+
+        // Confirm that saving again without changing the selected option will not throw a 'choice full' exception.
+        $this->assertNull(choice_user_submit_response($optionids[1], $choicewithoptions, $user->id, $course, $cm));
+
+        // Confirm that saving a response for student 2 including the first option is allowed.
+        $this->assertNull(choice_user_submit_response($optionids[0], $choicewithoptions, $user2->id, $course, $cm));
+
+        // Confirm that trying to save multiple options results in an exception.
+        $this->expectException('moodle_exception');
+        choice_user_submit_response([$optionids[1], $optionids[1]], $choicewithoptions, $user->id, $course, $cm);
+    }
+
+    /**
+     * Test choice_user_submit_response for a choice with specific options.
+     * Options:
+     * allowmultiple: true
+     * limitanswers: false
+     */
+    public function test_choice_user_submit_response_multiples_no_limits() {
+        global $DB;
+        $this->resetAfterTest(true);
+
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course();
+        $user = $generator->create_user();
+        $user2 = $generator->create_user();
+
+        // User must be enrolled in the course for choice limits to be honoured properly.
+        $role = $DB->get_record('role', ['shortname' => 'student']);
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, $role->id);
+        $this->getDataGenerator()->enrol_user($user2->id, $course->id, $role->id);
+
+        // Create choice, with updates allowed and a two options both limited to 1 response each.
+        $choice = $generator->get_plugin_generator('mod_choice')->create_instance([
+            'course' => $course->id,
+            'allowupdate' => false,
+            'allowmultiple' => true,
+            'limitanswers' => false,
+            'option' => ['red', 'green'],
+        ]);
+        $cm = get_coursemodule_from_instance('choice', $choice->id);
+
+        // Get the choice, with options and limits included.
+        $choicewithoptions = choice_get_choice($choice->id);
+        $optionids = array_keys($choicewithoptions->option);
+
+        // Save a response which includes the first option only.
+        $this->assertNull(choice_user_submit_response([$optionids[0]], $choicewithoptions, $user->id, $course, $cm));
+
+        // Confirm that adding an option to the response is allowed.
+        $this->assertNull(choice_user_submit_response([$optionids[0], $optionids[1]], $choicewithoptions, $user->id, $course, $cm));
+
+        // Confirm that saving a response for student 2 including the first option is allowed.
+        $this->assertNull(choice_user_submit_response($optionids[0], $choicewithoptions, $user2->id, $course, $cm));
+
+        // Confirm that removing an option from the response is allowed.
+        $this->assertNull(choice_user_submit_response([$optionids[0]], $choicewithoptions, $user->id, $course, $cm));
+
+        // Confirm that removing all options from the response is not allowed via this method.
+        $this->expectException('moodle_exception');
+        choice_user_submit_response([], $choicewithoptions, $user->id, $course, $cm);
+    }
+
+    /**
+     * Test choice_user_submit_response for a choice with specific options.
+     * Options:
+     * allowmultiple: false
+     * limitanswers: true
+     */
+    public function test_choice_user_submit_response_no_multiples_limits() {
+        global $DB;
+        $this->resetAfterTest(true);
+
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course();
+        $user = $generator->create_user();
+        $user2 = $generator->create_user();
+
+        // User must be enrolled in the course for choice limits to be honoured properly.
+        $role = $DB->get_record('role', ['shortname' => 'student']);
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, $role->id);
+        $this->getDataGenerator()->enrol_user($user2->id, $course->id, $role->id);
+
+        // Create choice, with updates allowed and a two options both limited to 1 response each.
+        $choice = $generator->get_plugin_generator('mod_choice')->create_instance([
+            'course' => $course->id,
+            'allowupdate' => false,
+            'allowmultiple' => false,
+            'limitanswers' => true,
+            'option' => ['red', 'green'],
+            'limit' => [1, 1]
+        ]);
+        $cm = get_coursemodule_from_instance('choice', $choice->id);
+
+        // Get the choice, with options and limits included.
+        $choicewithoptions = choice_get_choice($choice->id);
+        $optionids = array_keys($choicewithoptions->option);
+
+        // Save a response which includes the first option only.
+        $this->assertNull(choice_user_submit_response($optionids[0], $choicewithoptions, $user->id, $course, $cm));
+
+        // Confirm that changing the option in the response is allowed.
+        $this->assertNull(choice_user_submit_response($optionids[1], $choicewithoptions, $user->id, $course, $cm));
+
+        // Confirm that limits are respected by trying to save the same option as another user.
+        $this->expectException('moodle_exception');
+        choice_user_submit_response($optionids[1], $choicewithoptions, $user2->id, $course, $cm);
+    }
+
+    /**
+     * Test choice_user_submit_response for a choice with specific options.
+     * Options:
+     * allowmultiple: true
+     * limitanswers: true
+     */
+    public function test_choice_user_submit_response_multiples_limits() {
+        global $DB;
+        $this->resetAfterTest(true);
+
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course();
+        $user = $generator->create_user();
+        $user2 = $generator->create_user();
+
+        // User must be enrolled in the course for choice limits to be honoured properly.
+        $role = $DB->get_record('role', ['shortname' => 'student']);
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, $role->id);
+        $this->getDataGenerator()->enrol_user($user2->id, $course->id, $role->id);
+
+        // Create choice, with updates allowed and a two options both limited to 1 response each.
+        $choice = $generator->get_plugin_generator('mod_choice')->create_instance([
+            'course' => $course->id,
+            'allowupdate' => false,
+            'allowmultiple' => true,
+            'limitanswers' => true,
+            'option' => ['red', 'green'],
+            'limit' => [1, 1]
+        ]);
+        $cm = get_coursemodule_from_instance('choice', $choice->id);
+
+        // Get the choice, with options and limits included.
+        $choicewithoptions = choice_get_choice($choice->id);
+        $optionids = array_keys($choicewithoptions->option);
+
+        // Now, save a response which includes the first option only.
+        $this->assertNull(choice_user_submit_response([$optionids[0]], $choicewithoptions, $user->id, $course, $cm));
+
+        // Confirm that changing the option in the response is allowed.
+        $this->assertNull(choice_user_submit_response([$optionids[1]], $choicewithoptions, $user->id, $course, $cm));
+
+        // Confirm that adding an option to the response is allowed.
+        $this->assertNull(choice_user_submit_response([$optionids[0], $optionids[1]], $choicewithoptions, $user->id, $course, $cm));
+
+        // Confirm that limits are respected by trying to save the same option as another user.
+        $this->expectException('moodle_exception');
+        choice_user_submit_response($optionids[1], $choicewithoptions, $user2->id, $course, $cm);
+    }
 }
index 3882882..f15c303 100644 (file)
@@ -55,17 +55,13 @@ if (!$quizzes = get_all_instances_in_course("quiz", $course)) {
     die;
 }
 
-// Check if we need the closing date header.
-$showclosingheader = false;
+// Check if we need the feedback header.
 $showfeedback = false;
 foreach ($quizzes as $quiz) {
-    if ($quiz->timeclose!=0) {
-        $showclosingheader=true;
-    }
     if (quiz_has_feedback($quiz)) {
         $showfeedback=true;
     }
-    if ($showclosingheader && $showfeedback) {
+    if ($showfeedback) {
         break;
     }
 }
@@ -74,10 +70,8 @@ foreach ($quizzes as $quiz) {
 $headings = array(get_string('name'));
 $align = array('left');
 
-if ($showclosingheader) {
-    array_push($headings, get_string('quizcloses', 'quiz'));
-    array_push($align, 'left');
-}
+array_push($headings, get_string('quizcloses', 'quiz'));
+array_push($align, 'left');
 
 if (course_format_uses_sections($course->format)) {
     array_unshift($headings, get_string('sectionname', 'format_'.$course->format));
@@ -147,14 +141,10 @@ foreach ($quizzes as $quiz) {
             format_string($quiz->name, true) . '</a>';
 
     // Close date.
-    if ($quiz->timeclose) {
-        if (($timeclosedates[$quiz->id]->usertimeclose == 0) AND ($timeclosedates[$quiz->id]->usertimelimit == 0)) {
-            $data[] = get_string('noclose', 'quiz');
-        } else {
-            $data[] = userdate($timeclosedates[$quiz->id]->usertimeclose);
-        }
-    } else if ($showclosingheader) {
-        $data[] = '';
+    if (($timeclosedates[$quiz->id]->usertimeclose != 0)) {
+        $data[] = userdate($timeclosedates[$quiz->id]->usertimeclose);
+    } else {
+        $data[] = get_string('noclose', 'quiz');
     }
 
     if ($showing == 'stats') {
index d0c0f7f..f6c72d6 100644 (file)
@@ -1211,18 +1211,17 @@ function quiz_get_user_image_options() {
 
 /**
  * Return an user's timeclose for all quizzes in a course, hereby taking into account group and user overrides.
- * The query used herein is very similar to the one in function quiz_get_attempt_usertime_sql, so, in case you
- * would change either one of them, make sure to apply your changes to both.
  *
  * @param int $courseid the course id.
- * @return object An object with quizids and unixdates of the most lenient close overrides, if any.
+ * @return object An object with of all quizids and close unixdates in this course, taking into account the most lenient
+ * overrides, if existing and 0 if no close date is set.
  */
 function quiz_get_user_timeclose($courseid) {
     global $DB, $USER;
 
     // For teacher and manager/admins return timeclose.
     if (has_capability('moodle/course:update', context_course::instance($courseid))) {
-        $sql = "SELECT quiz.id, quiz.timeclose AS usertimeclose, COALESCE(quiz.timelimit, 0) AS usertimelimit
+        $sql = "SELECT quiz.id, quiz.timeclose AS usertimeclose
                   FROM {quiz} quiz
                  WHERE quiz.course = :courseid";
 
@@ -1230,34 +1229,22 @@ function quiz_get_user_timeclose($courseid) {
         return $results;
     }
 
-    // The multiple qgo JOINS are necessary because we want timeclose/timelimit = 0 (unlimited) to supercede
-    // any other group override.
-
     $sql = "SELECT q.id,
-  COALESCE(v.oneclose, v.twoclose, v.threeclose, q.timeclose, 0) AS usertimeclose,
-  COALESCE(v.onelimit, v.twolimit, v.threelimit, q.timelimit, 0) AS usertimelimit
+  COALESCE(v.userclose, v.groupclose, q.timeclose, 0) AS usertimeclose
   FROM (
-      SELECT quiz.id AS quizid,
-             MAX(quo.timeclose) AS oneclose, MAX(qgo1.timeclose) AS twoclose, MAX(qgo2.timeclose) AS threeclose,
-             MAX(quo.timelimit) AS onelimit, MAX(qgo3.timelimit) AS twolimit, MAX(qgo4.timelimit) AS threelimit
+      SELECT quiz.id as quizid,
+             MAX(quo.timeclose) AS userclose, MAX(qgo.timeclose) AS groupclose
        FROM {quiz} quiz
-  LEFT JOIN {quiz_overrides} quo ON quo.quiz = quiz.id
-  LEFT JOIN {groups_members} gm ON gm.userid = quo.userid
-  LEFT JOIN {quiz_overrides} qgo1 ON qgo1.timeclose = 0 AND qgo1.quiz = quiz.id
-  LEFT JOIN {quiz_overrides} qgo2 ON qgo2.timeclose > 0 AND qgo2.quiz = quiz.id
-  LEFT JOIN {quiz_overrides} qgo3 ON qgo3.timelimit = 0 AND qgo3.quiz = quiz.id
-  LEFT JOIN {quiz_overrides} qgo4 ON qgo4.timelimit > 0 AND qgo4.quiz = quiz.id
-                                  AND qgo1.groupid = gm.groupid
-                                  AND qgo2.groupid = gm.groupid
-                                  AND qgo3.groupid = gm.groupid
-                                  AND qgo4.groupid = gm.groupid
+  LEFT JOIN {quiz_overrides} quo on quiz.id = quo.quiz AND quo.userid = :userid
+  LEFT JOIN {groups_members} gm ON gm.userid = :useringroupid
+  LEFT JOIN {quiz_overrides} qgo on quiz.id = qgo.quiz AND qgo.groupid = gm.groupid
       WHERE quiz.course = :courseid
-            AND ((quo.userid = :userid) OR ((gm.userid IS NULL) AND (quo.userid IS NULL)))
    GROUP BY quiz.id) v
-  JOIN {quiz} q ON q.id = v.quizid";
+       JOIN {quiz} q ON q.id = v.quizid";
 
-    $results = $DB->get_records_sql($sql, array('courseid' => $courseid, 'userid' => $USER->id));
+    $results = $DB->get_records_sql($sql, array('userid' => $USER->id, 'useringroupid' => $USER->id, 'courseid' => $courseid));
     return $results;
+
 }
 
 /**
index 6dfb819..453d9d1 100644 (file)
@@ -311,6 +311,7 @@ class mod_quiz_locallib_testcase extends advanced_testcase {
         // Create generator, course and quizzes.
         $student1 = $this->getDataGenerator()->create_user();
         $student2 = $this->getDataGenerator()->create_user();
+        $student3 = $this->getDataGenerator()->create_user();
         $teacher = $this->getDataGenerator()->create_user();
         $course = $this->getDataGenerator()->create_course();
         $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
@@ -323,6 +324,7 @@ class mod_quiz_locallib_testcase extends advanced_testcase {
 
         $student1id = $student1->id;
         $student2id = $student2->id;
+        $student3id = $student3->id;
         $teacherid = $teacher->id;
 
         // Users enrolments.
@@ -330,6 +332,7 @@ class mod_quiz_locallib_testcase extends advanced_testcase {
         $teacherrole = $DB->get_record('role', array('shortname' => 'editingteacher'));
         $this->getDataGenerator()->enrol_user($student1id, $course->id, $studentrole->id, 'manual');
         $this->getDataGenerator()->enrol_user($student2id, $course->id, $studentrole->id, 'manual');
+        $this->getDataGenerator()->enrol_user($student3id, $course->id, $studentrole->id, 'manual');
         $this->getDataGenerator()->enrol_user($teacherid, $course->id, $teacherrole->id, 'manual');
 
         // Create groups.
@@ -357,14 +360,31 @@ class mod_quiz_locallib_testcase extends advanced_testcase {
         $object = new stdClass();
         $object->id = $quiz1->id;
         $object->usertimeclose = $basetimestamp + 10800; // The overriden timeclose for quiz 1.
-        $object->usertimelimit = 0;
 
         $comparearray[$quiz1->id] = $object;
 
         $object = new stdClass();
         $object->id = $quiz2->id;
         $object->usertimeclose = $basetimestamp + 7200; // The unchanged timeclose for quiz 2.
-        $object->usertimelimit = 0;
+
+        $comparearray[$quiz2->id] = $object;
+
+        $this->assertEquals($comparearray, quiz_get_user_timeclose($course->id));
+
+        // Let's test quiz 1 closes in two hours (the original value) for user student 3 since member of no group.
+        $this->setUser($student3id);
+        $params = new stdClass();
+
+        $comparearray = array();
+        $object = new stdClass();
+        $object->id = $quiz1->id;
+        $object->usertimeclose = $basetimestamp + 7200; // The original timeclose for quiz 1.
+
+        $comparearray[$quiz1->id] = $object;
+
+        $object = new stdClass();
+        $object->id = $quiz2->id;
+        $object->usertimeclose = $basetimestamp + 7200; // The original timeclose for quiz 2.
 
         $comparearray[$quiz2->id] = $object;
 
@@ -386,14 +406,12 @@ class mod_quiz_locallib_testcase extends advanced_testcase {
         $object = new stdClass();
         $object->id = $quiz1->id;
         $object->usertimeclose = $basetimestamp + 14400; // The overriden timeclose for quiz 1.
-        $object->usertimelimit = 0;
 
         $comparearray[$quiz1->id] = $object;
 
         $object = new stdClass();
         $object->id = $quiz2->id;
         $object->usertimeclose = $basetimestamp + 7200; // The unchanged timeclose for quiz 2.
-        $object->usertimelimit = 0;
 
         $comparearray[$quiz2->id] = $object;
 
@@ -407,14 +425,12 @@ class mod_quiz_locallib_testcase extends advanced_testcase {
         $object = new stdClass();
         $object->id = $quiz1->id;
         $object->usertimeclose = $basetimestamp + 7200; // The unchanged timeclose for quiz 1.
-        $object->usertimelimit = 0;
 
         $comparearray[$quiz1->id] = $object;
 
         $object = new stdClass();
         $object->id = $quiz2->id;
         $object->usertimeclose = $basetimestamp + 7200; // The unchanged timeclose for quiz 2.
-        $object->usertimelimit = 0;
 
         $comparearray[$quiz2->id] = $object;
 
index b300ec7..9994b9f 100644 (file)
@@ -84,7 +84,7 @@ abstract class column_base {
             $links = array();
             foreach ($sortable as $subsort => $details) {
                 $links[] = $this->make_sort_link($name . '-' . $subsort,
-                        $details['title'], '', !empty($details['reverse']));
+                        $details['title'], isset($details['tip']) ? $details['tip'] : '', !empty($details['reverse']));
             }
             echo '<div class="sorters">' . implode(' / ', $links) . '</div>';
         } else if ($sortable) {
diff --git a/question/tests/fixtures/testable_core_question_column.php b/question/tests/fixtures/testable_core_question_column.php
new file mode 100644 (file)
index 0000000..d467124
--- /dev/null
@@ -0,0 +1,66 @@
+<?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/>.
+
+/**
+ * Helper class to to test column_base class.
+ *
+ * @package core_question
+ * @copyright 2018 Huong Nguyen <huongnv13@gmail.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Helper class to to test column_base class.
+ *
+ * @package core_question
+ * @copyright 2018 Huong Nguyen <huongnv13@gmail.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class testable_core_question_column extends \core_question\bank\column_base {
+
+    /** @var array sortable columns. */
+    private $sortable = [];
+
+    /**
+     * Output the column header cell.
+     */
+    public function is_sortable() {
+        return $this->sortable;
+    }
+
+    /**
+     * Set the sortable columns for testing.
+     *
+     * @param array $sortable
+     */
+    public function set_sortable(array $sortable) {
+        $this->sortable = $sortable;
+    }
+
+    protected function display_content($question, $rowclasses) {
+        echo 'Test Column';
+    }
+
+    public function get_name() {
+        return 'test_column';
+    }
+
+    protected function get_title() {
+        return 'Test Column';
+    }
+}
diff --git a/question/tests/question_bank_column_test.php b/question/tests/question_bank_column_test.php
new file mode 100644 (file)
index 0000000..a263a6f
--- /dev/null
@@ -0,0 +1,109 @@
+<?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 tests for the question bank column class.
+ *
+ * @package core_question
+ * @copyright 2018 Huong Nguyen <huongnv13@gmail.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/question/editlib.php');
+require_once($CFG->dirroot . '/question/tests/fixtures/testable_core_question_column.php');
+
+/**
+ * Unit tests for the question bank column class.
+ *
+ * @package core_question
+ * @copyright 2018 Huong Nguyen <huongnv13@gmail.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class question_bank_column_testcase extends advanced_testcase {
+
+    /**
+     * Test function display_header multiple sorts with no custom tooltips.
+     *
+     */
+    public function test_column_header_multi_sort_no_tooltips() {
+        $this->resetAfterTest();
+        $course = $this->getDataGenerator()->create_course();
+        $questionbank = new core_question\bank\view(
+                new question_edit_contexts(context_course::instance($course->id)),
+                new moodle_url('/'),
+                $course
+        );
+        $columnbase = new testable_core_question_column($questionbank);
+
+        $sortable = [
+                'apple' => [
+                        'field' => 'apple',
+                        'title' => 'Apple'
+                ],
+                'banana' => [
+                        'field' => 'banana',
+                        'title' => 'Banana'
+                ]
+        ];
+        $columnbase->set_sortable($sortable);
+
+        ob_start();
+        $columnbase->display_header();
+        $output = ob_get_clean();
+
+        $this->assertContains(' title="Sort by Apple ascending">Apple</a>', $output);
+        $this->assertContains(' title="Sort by Banana ascending">Banana</a>', $output);
+    }
+
+    /**
+     * Test function display_header multiple sorts with custom tooltips.
+     *
+     */
+    public function test_column_header_multi_sort_with_tooltips() {
+        $this->resetAfterTest();
+        $course = $this->getDataGenerator()->create_course();
+        $questionbank = new core_question\bank\view(
+                new question_edit_contexts(context_course::instance($course->id)),
+                new moodle_url('/'),
+                $course
+        );
+        $columnbase = new testable_core_question_column($questionbank);
+
+        $sortable = [
+                'apple' => [
+                        'field' => 'apple',
+                        'title' => 'Apple',
+                        'tip' => 'Apple Tooltips'
+                ],
+                'banana' => [
+                        'field' => 'banana',
+                        'title' => 'Banana',
+                        'tip' => 'Banana Tooltips'
+                ]
+        ];
+        $columnbase->set_sortable($sortable);
+
+        ob_start();
+        $columnbase->display_header();
+        $output = ob_get_clean();
+
+        $this->assertContains(' title="Sort by Apple Tooltips ascending">Apple</a>', $output);
+        $this->assertContains(' title="Sort by Banana Tooltips ascending">Banana</a>', $output);
+    }
+}
index 102f052..1cd66ea 100644 (file)
@@ -678,14 +678,17 @@ M.core_filepicker.init = function(Y, options) {
                     'repository_id': this.active_repo.id,
                     'callback': function(id, o, args) {
                         scope.hide();
+                        // Add an arbitrary parameter to the URL to force browsers to re-load the new image even
+                        // if the file name has not changed.
+                        var urlimage = data.existingfile.url + "?time=" + (new Date()).getTime();
                         if (scope.options.editor_target && scope.options.env == 'editor') {
                             // editor needs to update url
-                            scope.options.editor_target.value = data.existingfile.url;
+                            scope.options.editor_target.value = urlimage;
                             scope.options.editor_target.onchange();
                         }
                         var fileinfo = {'client_id':scope.options.client_id,
-                                'url':data.existingfile.url,
-                                'file':data.existingfile.filename};
+                            'url': urlimage,
+                            'file': data.existingfile.filename};
                         var formcallback_scope = scope.options.magicscope ? scope.options.magicscope : scope;
                         scope.options.formcallback.apply(formcallback_scope, [fileinfo]);
                     }
index dfc6496..3f75762 100644 (file)
@@ -81,7 +81,7 @@ switch($action) {
     case 'colladd':
         require_sesskey();
         $name = required_param('name', PARAM_NOTAGS);
-        $searchable = required_param('searchable', PARAM_BOOL);
+        $searchable = optional_param('searchable', false, PARAM_BOOL);
         core_tag_collection::create(array('name' => $name, 'searchable' => $searchable));
         redirect($manageurl);
         break;
diff --git a/tag/templates/add_tag_collection.mustache b/tag/templates/add_tag_collection.mustache
new file mode 100644 (file)
index 0000000..7d766ce
--- /dev/null
@@ -0,0 +1,61 @@
+{{!
+    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 core_tag/add_tag_collection
+
+    Renders the form for adding tag collections.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * none
+
+    Context variables required for this template:
+    * sesskey string The session key
+
+    Example context (json):
+    {
+        "actionurl" : "#",
+        "sesskey" : "UniqueSesskey1a2b3c"
+    }
+
+}}
+<form id="addtagcoll_form" method="post" action="{{actionurl}}" class="needs-validation">
+    <input type="hidden" name="sesskey" value="{{sesskey}}"/>
+    <div class="form-group control-group" data-region="addtagcoll_nameinput">
+        <label for="addtagcoll_name">
+            {{#str}}name{{/str}}
+            <span>
+                <abbr class="initialism text-danger" title="{{#str}}required{{/str}}">
+                    {{#pix}}req, core, {{#str}}required{{/str}}{{/pix}}
+                </abbr>
+            </span>
+        </label>
+        {{! Enclosing these controls in a div with the controls class for BS2 compatibility when showing validation errors.}}
+        <div class="controls">
+            <input id="addtagcoll_name" type="text" class="form-control" name="name" aria-required="true" required/>
+            <div class="form-control-feedback invalid-feedback" id="id_addtagcoll_name_error_message" hidden="hidden">
+                {{#str}}required{{/str}}
+            </div>
+        </div>
+    </div>
+    <div class="form-group form-check">
+        <input id="addtagcoll_searchable" name="searchable" type="checkbox" value="1" checked class="form-check-input"/>
+        <label for="addtagcoll_searchable" class="form-check-label">{{#str}}searchable, tag{{/str}}</label>
+    </div>
+</form>
diff --git a/tag/templates/add_tags.mustache b/tag/templates/add_tags.mustache
new file mode 100644 (file)
index 0000000..be3cb6f
--- /dev/null
@@ -0,0 +1,58 @@
+{{!
+    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 core_tag/add_tags
+
+    Renders the form for adding tags.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * none
+
+    Context variables required for this template:
+    * sesskey string The session key
+
+    Example context (json):
+    {
+        "actionurl" : "#",
+        "sesskey" : "UniqueSesskey1a2b3c"
+    }
+
+}}
+<form id="addtags_form" method="post" action="{{actionurl}}" class="needs-validation">
+    <input type="hidden" name="action" value="addstandardtag"/>
+    <input type="hidden" name="sesskey" value="{{sesskey}}"/>
+    <div class="form-group control-group" data-region="tagslistinput">
+        <label for="id_tagslist">
+            {{#str}}inputstandardtags, tag{{/str}}
+            <span>
+                <abbr class="initialism text-danger" title="{{#str}}required{{/str}}">
+                    {{#pix}}req, core, {{#str}}required{{/str}}{{/pix}}
+                </abbr>
+            </span>
+        </label>
+        {{! Enclosing these controls in a div with the controls class for BS2 compatibility when showing validation errors.}}
+        <div class="controls">
+            <input type="text" id="id_tagslist" class="form-control" name="tagslist" aria-required="true" required />
+            <div class="form-control-feedback invalid-feedback" id="id_tagslist_error_message" hidden>
+                {{#str}}required{{/str}}
+            </div>
+        </div>
+    </div>
+</form>
diff --git a/tag/templates/combine_tags.mustache b/tag/templates/combine_tags.mustache
new file mode 100644 (file)
index 0000000..835dcf0
--- /dev/null
@@ -0,0 +1,60 @@
+{{!
+    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 core_tag/combine_tags
+
+    Renders the form for combining tags.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * none
+
+    Context variables required for this template:
+    * tags Array The list of tags to combine.
+
+    Example context (json):
+    {
+        "tags": [
+            {
+                "id": 1,
+                "name": "Cat"
+            },
+            {
+                "id": 2,
+                "name": "Dog"
+            },
+            {
+                "id": 3,
+                "name": "Mouse"
+            }
+        ]
+    }
+
+}}
+<form id="combinetags_form">
+    <div class="description">{{#str}}selectmaintag, tag{{/str}}</div>
+    <div class="form-group options form-inline">
+        {{#tags}}
+            <div class="form-check w-100 justify-content-start">
+                <input type="radio" class="form-check-input" name="maintag" id="combinetags_maintag_{{id}}" value="{{id}}"/>
+                <label class="form-check-label" for="combinetags_maintag_{{id}}">{{name}}</label>
+            </div>
+        {{/tags}}
+    </div>
+</form>
index 342b401..29ce521 100644 (file)
@@ -222,7 +222,7 @@ Feature: Users can edit tags to add description or rename
       | Select tag Turtle | 1 |
     And I press "Combine selected"
     And I should see "Select the tag that will be used after combining"
-    And I click on "//form[@id='combinetags_form']//input[@type='radio'][3]" "xpath_element"
+    And I click on "Turtle" "radio" in the "#combinetags_form" "css_element"
     And I press "Continue"
     Then I should see "Tags are combined"
     And I should not see "Dog"