Merge branch 'MDL-26401-39' of git://github.com/lameze/moodle into MOODLE_39_STABLE
authorAndrew Nicols <andrew@nicols.co.uk>
Wed, 19 Aug 2020 00:52:02 +0000 (08:52 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Wed, 19 Aug 2020 00:52:02 +0000 (08:52 +0800)
95 files changed:
.travis.yml
admin/index.php
admin/renderer.php
admin/tool/dataprivacy/lib.php
admin/tool/mobile/classes/output/subscription.php
admin/tool/mobile/lang/en/tool_mobile.php
admin/tool/mobile/settings.php
admin/tool/usertours/pix/t/filler.png [new file with mode: 0644]
auth/db/auth.php
auth/db/lang/en/auth_db.php
availability/yui/build/moodle-core_availability-form/moodle-core_availability-form-debug.js
availability/yui/build/moodle-core_availability-form/moodle-core_availability-form-min.js
availability/yui/build/moodle-core_availability-form/moodle-core_availability-form.js
availability/yui/src/form/js/form.js
backup/util/ui/amd/build/async_backup.min.js
backup/util/ui/amd/build/async_backup.min.js.map
backup/util/ui/amd/src/async_backup.js
blocks/recentlyaccesseditems/classes/external/recentlyaccesseditems_item_exporter.php
calendar/classes/external/week_day_exporter.php
calendar/templates/month_detailed.mustache
calendar/templates/month_mini.mustache
contentbank/amd/build/actions.min.js
contentbank/amd/build/actions.min.js.map
contentbank/amd/src/actions.js
contentbank/classes/content.php
contentbank/classes/contentbank.php
contentbank/classes/contenttype.php
contentbank/classes/external/rename_content.php
contentbank/tests/content_test.php
contentbank/tests/contentbank_test.php
contentbank/tests/contenttype_test.php
contentbank/tests/external/rename_content_test.php
contentbank/tests/fixtures/testable_content.php
contentbank/upload.php
course/externallib.php
course/modedit.php
course/tests/externallib_test.php
customfield/field/date/pix/checked.png [new file with mode: 0644]
customfield/field/date/pix/checked.svg [new file with mode: 0644]
customfield/field/date/pix/notchecked.png [new file with mode: 0644]
customfield/field/date/pix/notchecked.svg [new file with mode: 0644]
lang/en/calendar.php
lang/en/contentbank.php
lang/en/error.php
lib/amd/build/showhidesettings.min.js
lib/amd/build/showhidesettings.min.js.map
lib/amd/src/showhidesettings.js
lib/dml/oci_native_moodle_database.php
lib/outputrenderers.php
lib/table/amd/build/dynamic.min.js
lib/table/amd/build/dynamic.min.js.map
lib/table/amd/src/dynamic.js
lib/tests/behat/behat_app.php
lib/upgradelib.php
message/amd/build/message_drawer_view_conversation.min.js
message/amd/build/message_drawer_view_conversation.min.js.map
message/amd/src/message_drawer_view_conversation.js
mod/assign/templates/grading_navigation.mustache
mod/assign/templates/grading_navigation_user_selector.mustache
mod/forum/classes/grades/forum_gradeitem.php
mod/forum/classes/task/send_user_digests.php
mod/forum/lib.php
mod/forum/tests/maildigest_test.php
mod/lti/locallib.php
mod/quiz/lib.php
mod/quiz/report/attemptsreport_table.php
mod/quiz/report/responses/last_responses_table.php
mod/quiz/tests/generator_test.php
mod/workshop/form/rubric/styles.css
pix/e/cancel_solid_circle.png [new file with mode: 0644]
pix/e/cancel_solid_circle.svg [new file with mode: 0644]
pix/i/breadcrumbdivider.png [new file with mode: 0644]
pix/i/breadcrumbdivider.svg [new file with mode: 0644]
pix/i/home.png [new file with mode: 0644]
pix/i/menubars.png [new file with mode: 0644]
pix/i/menubars.svg [new file with mode: 0644]
pix/i/next.png [new file with mode: 0644]
pix/i/next.svg [new file with mode: 0644]
pix/i/previous.png [new file with mode: 0644]
pix/i/previous.svg [new file with mode: 0644]
pix/i/privatefiles.png [new file with mode: 0644]
pix/i/section.png [new file with mode: 0644]
pix/i/star-o.png [new file with mode: 0644]
pix/i/star-o.svg [new file with mode: 0644]
pix/t/collapsedcaret.png [new file with mode: 0644]
pix/t/collapsedcaret.svg [new file with mode: 0644]
pix/t/downlong.png [new file with mode: 0644]
pix/t/downlong.svg [new file with mode: 0644]
pix/t/tags.png [new file with mode: 0644]
pix/t/uplong.png [new file with mode: 0644]
pix/t/uplong.svg [new file with mode: 0644]
question/type/ddimageortext/styles.css
tag/templates/tagcloud.mustache
user/tests/behat/full_name_display.feature
version.php

index 67d70d5..40161c2 100644 (file)
@@ -174,7 +174,9 @@ before_script:
         # Enable test external resources
         sed -i \
           -e "/require_once/i \\define('TEST_EXTERNAL_FILES_HTTP_URL', 'http://127.0.0.1:8080');" \
+          -e "/require_once/i \\define('TEST_EXTERNAL_FILES_HTTPS_URL', 'http://127.0.0.1:8080');" \
           config.php ;
+
         # Redis cache store tests
         sed -i \
           -e "/require_once/i \\define('TEST_CACHESTORE_REDIS_TESTSERVERS', '127.0.0.1');" \
@@ -254,8 +256,6 @@ script:
       if [ "$TASK" = 'PHPUNIT' ];
       then
         vendor/bin/phpunit --fail-on-risky --disallow-test-output --verbose;
-        EXTTESTS_HITS=$(docker logs exttests 2>&1 | grep -Fv -e 'AH00558' -e '[pid 1]' | wc -l)
-        echo -e "\nTest local resources number of hits: ${EXTTESTS_HITS}.\n"
       fi
 
     - >
@@ -292,3 +292,11 @@ script:
           exit 1 ;
         fi
       fi
+
+after_script:
+    - >
+      if [ "$TASK" = 'PHPUNIT' ];
+      then
+        EXTTESTS_HITS=$(docker logs exttests 2>&1 | grep -Fv -e 'AH00558' -e '[pid 1]' | wc -l)
+        echo -e "\nTest local resources number of hits: ${EXTTESTS_HITS}.\n"
+      fi
index 19b3522..f9643fa 100644 (file)
@@ -509,7 +509,8 @@ if (!$cache and $version > $CFG->version) {  // upgrade
         // Always verify plugin dependencies!
         $failed = array();
         if (!core_plugin_manager::instance()->all_plugins_ok($version, $failed, $CFG->branch)) {
-            echo $output->unsatisfied_dependencies_page($version, $failed, $PAGE->url);
+            echo $output->unsatisfied_dependencies_page($version, $failed, new moodle_url($PAGE->url,
+                array('confirmplugincheck' => 0)));
             die();
         }
         unset($failed);
@@ -701,7 +702,8 @@ if (!$cache and moodle_needs_upgrading()) {
         $failed = array();
         if (!$pluginman->all_plugins_ok($version, $failed, $CFG->branch)) {
             $output = $PAGE->get_renderer('core', 'admin');
-            echo $output->unsatisfied_dependencies_page($version, $failed, $PAGE->url);
+            echo $output->unsatisfied_dependencies_page($version, $failed, new moodle_url($PAGE->url,
+                array('confirmplugincheck' => 0)));
             die();
         }
         unset($failed);
index cc766ce..69af954 100644 (file)
@@ -1110,7 +1110,7 @@ class core_admin_renderer extends plugin_renderer_base {
 
                 if (!empty($installabortable[$plugin->component])) {
                     $status .= $this->output->single_button(
-                        new moodle_url($this->page->url, array('abortinstall' => $plugin->component)),
+                        new moodle_url($this->page->url, array('abortinstall' => $plugin->component, 'confirmplugincheck' => 0)),
                         get_string('cancelinstallone', 'core_plugin'),
                         'post',
                         array('class' => 'actionbutton cancelinstallone d-block mt-1')
@@ -1209,7 +1209,7 @@ class core_admin_renderer extends plugin_renderer_base {
 
         if ($installabortable) {
             $out .= $this->output->single_button(
-                new moodle_url($this->page->url, array('abortinstallx' => 1)),
+                new moodle_url($this->page->url, array('abortinstallx' => 1, 'confirmplugincheck' => 0)),
                 get_string('cancelinstallall', 'core_plugin', count($installabortable)),
                 'post',
                 array('class' => 'singlebutton cancelinstallall mr-1')
index c069301..5b5f28a 100644 (file)
@@ -95,7 +95,7 @@ function tool_dataprivacy_myprofile_navigation(tree $tree, $user, $iscurrentuser
         $showsummary = true;
     }
 
-    if ($showsummary) {
+    if ($showsummary && $iscurrentuser) {
         $summaryurl = new moodle_url('/admin/tool/dataprivacy/summary.php');
         $summarynode = new core_user\output\myprofile\node('privacyandpolicies', 'retentionsummary',
             get_string('dataretentionsummary', 'tool_dataprivacy'), null, $summaryurl);
index d8deaca..572a91a 100644 (file)
@@ -184,6 +184,13 @@ class subscription implements \renderable, \templatable {
                                     'type' => 'danger', 'message' => get_string('subscriptionfeaturenotapplied', 'tool_mobile')];
                             }
                             break;
+                        // Check QR automatic login.
+                        case 'qrautomaticlogin':
+                            if ($ms->qrcodetype == \tool_mobile\api::QR_CODE_LOGIN) {
+                                $feature['message'] = [
+                                    'type' => 'danger', 'message' => get_string('subscriptionfeaturenotapplied', 'tool_mobile')];
+                            }
+                            break;
                     }
                 }
             }
index bc7fc89..952e99b 100644 (file)
@@ -114,7 +114,7 @@ $string['qrcodeformobileapploginabout'] = 'Scan the QR code with your mobile app
 $string['qrcodeformobileappurlabout'] = 'Scan the QR code with your mobile app to fill in the site URL in your app.';
 $string['qrsiteadminsnotallowed'] = 'For security reasons login via QR code is not allowed for site administrators or if you are logged in as another user.';
 $string['qrcodetype'] = 'QR code access';
-$string['qrcodetype_desc'] = 'A QR code can be provided for mobile app users to scan and either have the site URL filled in or be automatically logged in without having to enter their credentials.';
+$string['qrcodetype_desc'] = 'A QR code can be provided for mobile app users to scan. This can be used to fill in the site URL, or where the site is secured using HTTPS, to automatically log the user in without having to enter their username and password.';
 $string['qrcodetypeurl'] = 'QR code with site URL';
 $string['qrcodetypelogin'] = 'QR code with automatic login';
 $string['readingthisemailgettheapp'] = 'Reading this in an email? <a href="{$a}">Download the mobile app and receive notifications on your mobile device</a>.';
index c2cee8c..19a8798 100644 (file)
@@ -94,11 +94,17 @@ if ($hassiteconfig) {
     $options = [
         tool_mobile\api::QR_CODE_DISABLED => new lang_string('qrcodedisabled', 'tool_mobile'),
         tool_mobile\api::QR_CODE_URL => new lang_string('qrcodetypeurl', 'tool_mobile'),
-        tool_mobile\api::QR_CODE_LOGIN => new lang_string('qrcodetypelogin', 'tool_mobile'),
     ];
+    $qrcodetypedefault = tool_mobile\api::QR_CODE_URL;
+
+    if (is_https()) {   // Allow QR login for https sites.
+        $options[tool_mobile\api::QR_CODE_LOGIN] = new lang_string('qrcodetypelogin', 'tool_mobile');
+        $qrcodetypedefault = tool_mobile\api::QR_CODE_LOGIN;
+    }
+
     $temp->add(new admin_setting_configselect('tool_mobile/qrcodetype',
                 new lang_string('qrcodetype', 'tool_mobile'),
-                new lang_string('qrcodetype_desc', 'tool_mobile'), tool_mobile\api::QR_CODE_LOGIN, $options));
+                new lang_string('qrcodetype_desc', 'tool_mobile'), $qrcodetypedefault, $options));
 
     $temp->add(new admin_setting_configtext('tool_mobile/forcedurlscheme',
                 new lang_string('forcedurlscheme_key', 'tool_mobile'),
diff --git a/admin/tool/usertours/pix/t/filler.png b/admin/tool/usertours/pix/t/filler.png
new file mode 100644 (file)
index 0000000..055c8fb
Binary files /dev/null and b/admin/tool/usertours/pix/t/filler.png differ
index 5c2fe8c..13da0f4 100644 (file)
@@ -603,9 +603,12 @@ class auth_plugin_db extends auth_plugin_base {
             }
         }
         if (!empty($update)) {
-            $authdb->Execute("UPDATE {$this->config->table}
-                                 SET ".implode(',', $update)."
-                               WHERE {$this->config->fielduser}='".$this->ext_addslashes($extusername)."'");
+            $sql = "UPDATE {$this->config->table}
+                       SET ".implode(',', $update)."
+                     WHERE {$this->config->fielduser} = ?";
+            if (!$authdb->Execute($sql, array($this->ext_addslashes($extusername)))) {
+                print_error('auth_dbupdateerror', 'auth_db');
+            }
         }
         $authdb->Close();
         return true;
index d3f8a83..75c5c26 100644 (file)
@@ -74,5 +74,6 @@ $string['auth_dbcannotconnect'] = 'Cannot connect to external database.';
 $string['auth_dbcannotreadtable'] = 'Cannot read external table.';
 $string['auth_dbtableempty'] = 'External table is empty.';
 $string['auth_dbcolumnlist'] = 'External table contains the following columns:<br />{$a}';
+$string['auth_dbupdateerror'] = 'Error updating external database.';
 $string['pluginname'] = 'External database';
 $string['privacy:metadata'] = 'The External database authentication plugin does not store any personal data.';
index ed45e94..a98f863 100644 (file)
Binary files a/availability/yui/build/moodle-core_availability-form/moodle-core_availability-form-debug.js and b/availability/yui/build/moodle-core_availability-form/moodle-core_availability-form-debug.js differ
index dd09dd5..d2c2b71 100644 (file)
Binary files a/availability/yui/build/moodle-core_availability-form/moodle-core_availability-form-min.js and b/availability/yui/build/moodle-core_availability-form/moodle-core_availability-form-min.js differ
index ed45e94..a98f863 100644 (file)
Binary files a/availability/yui/build/moodle-core_availability-form/moodle-core_availability-form.js and b/availability/yui/build/moodle-core_availability-form/moodle-core_availability-form.js differ
index 842b07f..9083dae 100644 (file)
@@ -408,7 +408,7 @@ M.core_availability.List = function(json, root, parentRoot) {
         noneNode.appendChild(deleteIcon.span);
 
         // Also if it's not the root, none is actually invalid, so add a label.
-        noneNode.appendChild(Y.Node.create('<span class="mt-1 label label-warning">' +
+        noneNode.appendChild(Y.Node.create('<span class="mt-1 badge badge-warning">' +
                 M.util.get_string('invalid', 'availability') + '</span>'));
     }
 
@@ -922,7 +922,7 @@ M.core_availability.Item = function(json, root) {
 
     // Add the invalid marker (empty).
     this.node.appendChild(document.createTextNode(' '));
-    this.node.appendChild(Y.Node.create('<span class="label label-warning"/>'));
+    this.node.appendChild(Y.Node.create('<span class="badge badge-warning"/>'));
 };
 
 /**
@@ -958,7 +958,7 @@ M.core_availability.Item.prototype.fillErrors = function(errors) {
         errors.push('core_availability:item_unknowntype');
     }
     // If any errors were added, add the marker to this item.
-    var errorLabel = this.node.one('> .label-warning');
+    var errorLabel = this.node.one('> .badge-warning');
     if (errors.length !== before && !errorLabel.get('firstChild')) {
         errorLabel.appendChild(document.createTextNode(M.util.get_string('invalid', 'availability')));
     } else if (errors.length === before && errorLabel.get('firstChild')) {
index 15d852a..1511c10 100644 (file)
Binary files a/backup/util/ui/amd/build/async_backup.min.js and b/backup/util/ui/amd/build/async_backup.min.js differ
index 42387a1..bfacf49 100644 (file)
Binary files a/backup/util/ui/amd/build/async_backup.min.js.map and b/backup/util/ui/amd/build/async_backup.min.js.map differ
index 59a4bc9..4854ded 100644 (file)
@@ -524,7 +524,7 @@ define(['jquery', 'core/ajax', 'core/str', 'core/notification', 'core/templates'
      */
     function getAllCopyProgress() {
         var copyids = [];
-        var progressbars = $('.progress').find('.progress-bar').not('.complete');
+        var progressbars = $('.progress').find('.progress-bar[data-operation][data-backupid][data-restoreid]').not('.complete');
 
         progressbars.each(function() {
             let progressvars = {
index f26b43d..934433d 100644 (file)
@@ -49,13 +49,18 @@ class recentlyaccesseditems_item_exporter extends \core\external\exporter {
      * @return array Additional properties with values
      */
     protected function get_other_values(renderer_base $output) {
-        global $OUTPUT;
+        global $CFG;
+        require_once($CFG->libdir.'/modinfolib.php');
 
         return array(
-                'viewurl' => (new moodle_url('/mod/'.$this->data->modname.'/view.php',
-                        array('id' => $this->data->cmid)))->out(false),
-                'courseviewurl' => (new moodle_url('/course/view.php', array('id' => $this->data->courseid)))->out(false),
-                'icon' => $OUTPUT->image_icon('icon', get_string('pluginname', $this->data->modname), $this->data->modname)
+            'viewurl' => (new moodle_url('/mod/'.$this->data->modname.'/view.php',
+                array('id' => $this->data->cmid)))->out(false),
+            'courseviewurl' => (new moodle_url('/course/view.php', array('id' => $this->data->courseid)))->out(false),
+            'icon' => \html_writer::img(
+                get_fast_modinfo($this->data->courseid)->cms[$this->data->cmid]->get_icon_url(),
+                get_string('pluginname', $this->data->modname),
+                ['title' => get_string('pluginname', $this->data->modname), 'class' => 'icon']
+            )
         );
     }
 
@@ -111,4 +116,4 @@ class recentlyaccesseditems_item_exporter extends \core\external\exporter {
             )
         );
     }
-}
\ No newline at end of file
+}
index 98ad278..80ddbf0 100644 (file)
@@ -86,6 +86,9 @@ class week_day_exporter extends day_exporter {
                 'type' => PARAM_RAW,
                 'default' => '',
             ],
+            'daytitle' => [
+                'type' => PARAM_RAW,
+            ]
         ]);
 
         return $return;
@@ -104,6 +107,8 @@ class week_day_exporter extends day_exporter {
             $return['popovertitle'] = $popovertitle;
         }
 
+        $return['daytitle'] = $this->get_day_title();
+
         return $return;
     }
 
@@ -141,4 +146,24 @@ class week_day_exporter extends day_exporter {
 
         return $title;
     }
+
+    /**
+     * Get the title for this day.
+     *
+     * @return string
+     */
+    protected function get_day_title(): string {
+        $userdate = userdate($this->data[0], get_string('strftimedayshort'));
+
+        $numevents = count($this->related['events']);
+        if ($numevents == 1) {
+            $title = get_string('dayeventsone', 'calendar', $userdate);
+        } else if ($numevents) {
+            $title = get_string('dayeventsmany', 'calendar', ['num' => $numevents, 'day' => $userdate]);
+        } else {
+            $title = get_string('dayeventsnone', 'calendar', $userdate);
+        }
+
+        return $title;
+    }
 }
index a88f65e..ba01dfe 100644 (file)
@@ -46,8 +46,9 @@
         <thead>
             <tr>
                 {{# daynames }}
-                <th class="header text-xs-center" aria-label="{{fullname}}">
-                    {{shortname}}
+                <th class="header text-xs-center">
+                    <span class="sr-only">{{fullname}}</span>
+                    <span aria-hidden="true">{{shortname}}</span>
                 </th>
                 {{/ daynames }}
             </tr>
@@ -71,6 +72,7 @@
                         data-region="day"
                         data-new-event-timestamp="{{neweventtimestamp}}">
                         <div class="d-none d-md-block hidden-phone text-xs-center">
+                            <span class="sr-only">{{daytitle}}</span>
                             {{#hasevents}}
                                 <a data-action="view-day-link" href="#" class="aalink day" aria-label="{{viewdaylinktitle}}"
                                     data-year="{{date.year}}" data-month="{{date.mon}}" data-day="{{mday}}"
@@ -78,7 +80,7 @@
                                     data-timestamp="{{timestamp}}">{{mday}}</a>
                             {{/hasevents}}
                             {{^hasevents}}
-                                {{mday}}
+                                <span aria-hidden="true">{{mday}}</span>
                             {{/hasevents}}
                             {{#hasevents}}
                                 <div data-region="day-content">
                             {{/hasevents}}
                         </div>
                         <div class="d-md-none hidden-desktop hidden-tablet">
+                            <span class="sr-only">{{daytitle}}</span>
                             {{#hasevents}}
                                 <a data-action="view-day-link" href="#" class="day aalink" aria-label="{{viewdaylinktitle}}"
                                     data-year="{{date.year}}" data-month="{{date.mon}}" data-day="{{mday}}"
                                     data-timestamp="{{timestamp}}">{{mday}}</a>
                             {{/hasevents}}
                             {{^hasevents}}
-                                    {{mday}}
+                                <span aria-hidden="true">{{mday}}</span>
                             {{/hasevents}}
                         </div>
                     </td>
index ff10a74..4ff2656 100644 (file)
@@ -78,8 +78,9 @@
         <thead>
           <tr>
                 {{# daynames }}
-                <th class="header text-xs-center" scope="col" aria-label="{{fullname}}">
-                    {{shortname}}
+                <th class="header text-xs-center">
+                    <span class="sr-only">{{fullname}}</span>
+                    <span aria-hidden="true">{{shortname}}</span>
                 </th>
                 {{/ daynames }}
             </tr>
                         This is the timestamp for this month.
                         }} data-day-timestamp="{{timestamp}}"{{!
                     }}>{{!
-                        }}{{#popovertitle}}
+                        }}<span class="sr-only">{{daytitle}}</span>
+                        {{#popovertitle}}
                             {{< core_calendar/minicalendar_day_link }}
                                 {{$day}}{{mday}}{{/day}}
                                 {{$url}}{{viewdaylink}}{{/url}}
                             {{/ core_calendar/minicalendar_day_link }}
                         {{/popovertitle}}{{!
                         }}{{^popovertitle}}
-                            {{mday}}
+                            <span aria-hidden="true">{{mday}}</span>
                         {{/popovertitle}}{{!
                     }}</td>
                 {{/days}}
index 237c664..0f2b538 100644 (file)
Binary files a/contentbank/amd/build/actions.min.js and b/contentbank/amd/build/actions.min.js differ
index 79956ea..ee0807e 100644 (file)
Binary files a/contentbank/amd/build/actions.min.js.map and b/contentbank/amd/build/actions.min.js.map differ
index 1776292..176b127 100644 (file)
@@ -139,10 +139,26 @@ function($, Ajax, Notification, Str, Templates, Url, ModalFactory, ModalEvents)
                 });
             }).then(function(modal) {
                 modal.setSaveButtonText(saveButtonText);
-                modal.getRoot().on(ModalEvents.save, function() {
+                modal.getRoot().on(ModalEvents.save, function(e) {
                     // The action is now confirmed, sending an action for it.
-                    var newname = $("#newname").val();
-                    return renameContent(contentid, newname);
+                    var newname = $("#newname").val().trim();
+                    if (newname) {
+                        renameContent(contentid, newname);
+                    } else {
+                        var errorStrings = [
+                            {
+                                key: 'error',
+                            },
+                            {
+                                key: 'emptynamenotallowed',
+                                component: 'core_contentbank',
+                            },
+                        ];
+                        Str.get_strings(errorStrings).then(function(langStrings) {
+                            Notification.alert(langStrings[0], langStrings[1]);
+                        }).catch(Notification.exception);
+                        e.preventDefault();
+                    }
                 });
 
                 // Handle hidden event.
@@ -211,11 +227,11 @@ function($, Ajax, Notification, Str, Templates, Url, ModalFactory, ModalEvents)
         };
         var requestType = 'success';
         Ajax.call([request])[0].then(function(data) {
-            if (data) {
+            if (data.result) {
                 return 'contentrenamed';
             }
             requestType = 'error';
-            return 'contentnotrenamed';
+            return data.warnings[0].message;
 
         }).then(function(message) {
             var params = null;
index c9dad88..37e3385 100644 (file)
@@ -127,6 +127,7 @@ abstract class content {
      * @throws \coding_exception if not loaded.
      */
     public function set_name(string $name): bool {
+        $name = trim($name);
         if (empty($name)) {
             return false;
         }
@@ -237,6 +238,42 @@ abstract class content {
         return $this->content->configdata;
     }
 
+    /**
+     * Import a file as a valid content.
+     *
+     * By default, all content has a public file area to interact with the content bank
+     * repository. This method should be overridden by contentypes which does not simply
+     * upload to the public file area.
+     *
+     * If any, the method will return the final stored_file. This way it can be invoked
+     * as parent::import_file in case any plugin want to store the file in the public area
+     * and also parse it.
+     *
+     * @throws file_exception If file operations fail
+     * @param stored_file $file File to store in the content file area.
+     * @return stored_file|null the stored content file or null if the file is discarted.
+     */
+    public function import_file(stored_file $file): ?stored_file {
+        $originalfile = $this->get_file();
+        if ($originalfile) {
+            $originalfile->replace_file_with($file);
+            return $originalfile;
+        } else {
+            $itemid = $this->get_id();
+            $fs = get_file_storage();
+            $filerecord = [
+                'contextid' => $this->get_contextid(),
+                'component' => 'contentbank',
+                'filearea' => 'public',
+                'itemid' => $this->get_id(),
+                'filepath' => '/',
+                'filename' => $file->get_filename(),
+                'timecreated' => time(),
+            ];
+            return $fs->create_file_from_storedfile($filerecord, $file);
+        }
+    }
+
     /**
      * Returns the $file related to this content.
      *
index 326e304..307b949 100644 (file)
@@ -224,6 +224,8 @@ class contentbank {
     /**
      * Create content from a file information.
      *
+     * @throws file_exception If file operations fail
+     * @throws dml_exception if the content creation fails
      * @param \context $context Context where to upload the file and content.
      * @param int $userid Id of the user uploading the file.
      * @param stored_file $file The file to get information from
@@ -243,7 +245,7 @@ class contentbank {
         $record->name = $filename;
         $record->usercreated = $userid;
         $contentype = new $classname($context);
-        $content = $contentype->create_content($record);
+        $content = $contentype->upload_content($file, $record);
         $event = \core\event\contentbank_content_uploaded::create_from_record($content->get_content());
         $event->trigger();
         return $content;
index 6b6a140..699cee0 100644 (file)
@@ -27,6 +27,8 @@ namespace core_contentbank;
 use core\event\contentbank_content_created;
 use core\event\contentbank_content_deleted;
 use core\event\contentbank_content_viewed;
+use stored_file;
+use Exception;
 use moodle_url;
 
 /**
@@ -62,10 +64,11 @@ abstract class contenttype {
     /**
      * Fills content_bank table with appropiate information.
      *
+     * @throws dml_exception A DML specific exception is thrown for any creation error.
      * @param \stdClass $record An optional content record compatible object (default null)
      * @return content  Object with content bank information.
      */
-    public function create_content(\stdClass $record = null): ?content {
+    public function create_content(\stdClass $record = null): content {
         global $USER, $DB;
 
         $entry = new \stdClass();
@@ -79,15 +82,37 @@ abstract class contenttype {
         $entry->configdata = $record->configdata ?? '';
         $entry->instanceid = $record->instanceid ?? 0;
         $entry->id = $DB->insert_record('contentbank_content', $entry);
-        if ($entry->id) {
-            $classname = '\\'.$entry->contenttype.'\\content';
-            $content = new $classname($entry);
-            // Trigger an event for creating the content.
-            $event = contentbank_content_created::create_from_record($content->get_content());
-            $event->trigger();
-            return $content;
+        $classname = '\\'.$entry->contenttype.'\\content';
+        $content = new $classname($entry);
+        // Trigger an event for creating the content.
+        $event = contentbank_content_created::create_from_record($content->get_content());
+        $event->trigger();
+        return $content;
+    }
+
+    /**
+     * Create a new content from an uploaded file.
+     *
+     * @throws file_exception If file operations fail
+     * @throws dml_exception if the content creation fails
+     * @param stored_file $file the uploaded file
+     * @param \stdClass|null $record an optional content record
+     * @return content  Object with content bank information.
+     */
+    public function upload_content(stored_file $file, \stdClass $record = null): content {
+        if (empty($record)) {
+            $record = new \stdClass();
+            $record->name = $file->get_filename();
         }
-        return null;
+        $content = $this->create_content($record);
+        try {
+            $content->import_file($file);
+        } catch (Exception $e) {
+            $this->delete_content($content);
+            throw $e;
+        }
+
+        return $content;
     }
 
     /**
index 18d36f3..5cd9d75 100644 (file)
@@ -87,15 +87,24 @@ class rename_content extends external_api {
                 $content = new $contentclass($record);
                 // Check capability.
                 if ($contenttype->can_manage($content)) {
-                    // This content can be renamed.
-                    if ($contenttype->rename_content($content, $params['name'])) {
-                        $result = true;
-                    } else {
+                    if (empty(trim($name))) {
+                        // If name is empty don't try to rename and return a more detailed message.
                         $warnings[] = [
                             'item' => $contentid,
-                            'warningcode' => 'contentnotrenamed',
-                            'message' => get_string('contentnotrenamed', 'core_contentbank')
+                            'warningcode' => 'emptynamenotallowed',
+                            'message' => get_string('emptynamenotallowed', 'core_contentbank')
                         ];
+                    } else {
+                        // This content can be renamed.
+                        if ($contenttype->rename_content($content, $params['name'])) {
+                            $result = true;
+                        } else {
+                            $warnings[] = [
+                                'item' => $contentid,
+                                'warningcode' => 'contentnotrenamed',
+                                'message' => get_string('contentnotrenamed', 'core_contentbank')
+                            ];
+                        }
                     }
                 } else {
                     // The user has no permission to manage this content.
index 45c70c8..ddfff94 100644 (file)
@@ -81,7 +81,9 @@ class core_contenttype_content_testcase extends \advanced_testcase {
             'Name with symbols' => ['Follow us: @moodle', 'Follow us: @moodle'],
             'Name with tags' => ['This is <b>bold</b>', 'This is bold'],
             'Long name' => [str_repeat('a', 100), str_repeat('a', 100)],
-            'Too long name' => [str_repeat('a', 300), str_repeat('a', 255)]
+            'Too long name' => [str_repeat('a', 300), str_repeat('a', 255)],
+            'Empty name' => ['', 'Old name'],
+            'Blanks only' => ['  ', 'Old name'],
         ];
     }
 
@@ -189,4 +191,88 @@ class core_contenttype_content_testcase extends \advanced_testcase {
         $this->assertEquals($newcontext->id, $content->get_contextid());
         $this->assertEquals($newcontext->id, $file->get_contextid());
     }
+
+    /**
+     * Tests for 'import_file' behaviour when replacing a file.
+     *
+     * @covers ::import_file
+     */
+    public function test_import_file_replace(): void {
+        global $USER;
+        $this->resetAfterTest();
+        $this->setAdminUser();
+        $context = context_system::instance();
+
+        // Add some content to the content bank.
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_contentbank');
+        $contents = $generator->generate_contentbank_data('contenttype_testable', 3, 0, $context);
+        $content = reset($contents);
+
+        $originalfile = $content->get_file();
+
+        // Create a dummy file.
+        $filerecord = array(
+            'contextid' => $context->id,
+            'component' => 'contentbank',
+            'filearea' => 'draft',
+            'itemid' => $content->get_id(),
+            'filepath' => '/',
+            'filename' => 'example.txt'
+        );
+        $fs = get_file_storage();
+        $file = $fs->create_file_from_string($filerecord, 'Dummy content ');
+
+        $importedfile = $content->import_file($file);
+
+        $this->assertEquals($originalfile->get_filename(), $importedfile->get_filename());
+        $this->assertEquals($originalfile->get_filearea(), $importedfile->get_filearea());
+        $this->assertEquals($originalfile->get_filepath(), $importedfile->get_filepath());
+        $this->assertEquals($originalfile->get_mimetype(), $importedfile->get_mimetype());
+
+        $this->assertEquals($file->get_userid(), $importedfile->get_userid());
+        $this->assertEquals($file->get_contenthash(), $importedfile->get_contenthash());
+    }
+
+    /**
+     * Tests for 'import_file' behaviour when uploading a new file.
+     *
+     * @covers ::import_file
+     */
+    public function test_import_file_upload(): void {
+        global $USER;
+        $this->resetAfterTest();
+        $this->setAdminUser();
+        $context = context_system::instance();
+
+        $type = new contenttype($context);
+        $record = (object)[
+            'name' => 'content name',
+            'usercreated' => $USER->id,
+        ];
+        $content = $type->create_content($record);
+
+        // Create a dummy file.
+        $filerecord = array(
+            'contextid' => $context->id,
+            'component' => 'contentbank',
+            'filearea' => 'draft',
+            'itemid' => $content->get_id(),
+            'filepath' => '/',
+            'filename' => 'example.txt'
+        );
+        $fs = get_file_storage();
+        $file = $fs->create_file_from_string($filerecord, 'Dummy content ');
+
+        $importedfile = $content->import_file($file);
+
+        $this->assertEquals($file->get_filename(), $importedfile->get_filename());
+        $this->assertEquals($file->get_userid(), $importedfile->get_userid());
+        $this->assertEquals($file->get_mimetype(), $importedfile->get_mimetype());
+        $this->assertEquals($file->get_contenthash(), $importedfile->get_contenthash());
+        $this->assertEquals('public', $importedfile->get_filearea());
+        $this->assertEquals('/', $importedfile->get_filepath());
+
+        $contentfile = $content->get_file($file);
+        $this->assertEquals($importedfile->get_id(), $contentfile->get_id());
+    }
 }
index d347a72..cd22e80 100644 (file)
@@ -112,14 +112,14 @@ class core_contentbank_testcase extends advanced_testcase {
         $this->resetAfterTest();
 
         $cb = new contentbank();
-        $expectedsupporters = [$extension => $expected];
 
         $systemcontext = context_system::instance();
 
         // All contexts allowed for admins.
         $this->setAdminUser();
         $contextsupporters = $cb->load_context_supported_extensions($systemcontext);
-        $this->assertEquals($expectedsupporters, $contextsupporters);
+        $this->assertArrayHasKey($extension, $contextsupporters);
+        $this->assertEquals($expected, $contextsupporters[$extension]);
     }
 
     /**
@@ -161,7 +161,6 @@ class core_contentbank_testcase extends advanced_testcase {
         $this->resetAfterTest();
 
         $cb = new contentbank();
-        $expectedsupporters = [$extension => $expected];
 
         $course = $this->getDataGenerator()->create_course();
         $teacher = $this->getDataGenerator()->create_and_enrol($course, 'editingteacher');
@@ -170,7 +169,8 @@ class core_contentbank_testcase extends advanced_testcase {
 
         // Teachers has permission in their context to upload supported by H5P content type.
         $contextsupporters = $cb->load_context_supported_extensions($coursecontext);
-        $this->assertEquals($expectedsupporters, $contextsupporters);
+        $this->assertArrayHasKey($extension, $contextsupporters);
+        $this->assertEquals($expected, $contextsupporters[$extension]);
     }
 
     /**
index f3bc67b..c8ae908 100644 (file)
@@ -27,6 +27,8 @@ namespace core_contentbank;
 
 use stdClass;
 use context_system;
+use context_user;
+use Exception;
 use contenttype_testable\contenttype as contenttype;
 /**
  * Test for content bank contenttype class.
@@ -183,6 +185,111 @@ class core_contenttype_contenttype_testcase extends \advanced_testcase {
         $this->assertInstanceOf('\\contenttype_testable\\content', $content);
     }
 
+    /**
+     * Tests for behaviour of upload_content() with a file and a record.
+     *
+     * @dataProvider upload_content_provider
+     * @param bool $userecord if a predefined record has to be used.
+     *
+     * @covers ::upload_content
+     */
+    public function test_upload_content(bool $userecord): void {
+        global $USER;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $dummy = [
+            'contextid' => context_user::instance($USER->id)->id,
+            'component' => 'user',
+            'filearea' => 'draft',
+            'itemid' => 1,
+            'filepath' => '/',
+            'filename' => 'file.h5p',
+            'userid' => $USER->id,
+        ];
+        $fs = get_file_storage();
+        $dummyfile = $fs->create_file_from_string($dummy, 'Dummy content');
+
+        // Create content.
+        if ($userecord) {
+            $record = new stdClass();
+            $record->name = 'Test content';
+            $record->configdata = '';
+            $record->contenttype = '';
+            $checkname = $record->name;
+        } else {
+            $record = null;
+            $checkname = $dummyfile->get_filename();
+        }
+
+        $contenttype = new contenttype(context_system::instance());
+        $content = $contenttype->upload_content($dummyfile, $record);
+
+        $this->assertEquals('contenttype_testable', $content->get_content_type());
+        $this->assertEquals($checkname, $content->get_name());
+        $this->assertInstanceOf('\\contenttype_testable\\content', $content);
+
+        $file = $content->get_file();
+        $this->assertEquals($dummyfile->get_filename(), $file->get_filename());
+        $this->assertEquals($dummyfile->get_userid(), $file->get_userid());
+        $this->assertEquals($dummyfile->get_mimetype(), $file->get_mimetype());
+        $this->assertEquals($dummyfile->get_contenthash(), $file->get_contenthash());
+        $this->assertEquals('contentbank', $file->get_component());
+        $this->assertEquals('public', $file->get_filearea());
+        $this->assertEquals('/', $file->get_filepath());
+    }
+
+    /**
+     * Data provider for test_rename_content.
+     *
+     * @return  array
+     */
+    public function upload_content_provider() {
+        return [
+            'With record' => [true],
+            'Without record' => [false],
+        ];
+    }
+
+    /**
+     * Tests for behaviour of upload_content() with a file wrong file.
+     *
+     * @covers ::upload_content
+     */
+    public function test_upload_content_exception(): void {
+        global $USER, $DB;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        // The testing contenttype thows exception if filename is "error.*".
+        $dummy = [
+            'contextid' => context_user::instance($USER->id)->id,
+            'component' => 'user',
+            'filearea' => 'draft',
+            'itemid' => 1,
+            'filepath' => '/',
+            'filename' => 'error.txt',
+            'userid' => $USER->id,
+        ];
+        $fs = get_file_storage();
+        $dummyfile = $fs->create_file_from_string($dummy, 'Dummy content');
+
+        $contenttype = new contenttype(context_system::instance());
+        $cbcontents = $DB->count_records('contentbank_content');
+
+        // We need to capture the exception to check no content is created.
+        try {
+            $content = $contenttype->upload_content($dummyfile);
+            $this->assertTrue(false);
+        } catch (Exception $e) {
+            $this->assertTrue(true);
+        }
+        $this->assertEquals($cbcontents, $DB->count_records('contentbank_content'));
+        $this->assertEquals(1, $DB->count_records('files', ['contenthash' => $dummyfile->get_contenthash()]));
+    }
+
     /**
      * Test the behaviour of can_delete().
      */
@@ -268,12 +375,14 @@ class core_contenttype_contenttype_testcase extends \advanced_testcase {
      */
     public function rename_content_provider() {
         return [
-            'Standard name' => ['New name', 'New name'],
-            'Name with digits' => ['Today is 17/04/2017', 'Today is 17/04/2017'],
-            'Name with symbols' => ['Follow us: @moodle', 'Follow us: @moodle'],
-            'Name with tags' => ['This is <b>bold</b>', 'This is bold'],
-            'Long name' => [str_repeat('a', 100), str_repeat('a', 100)],
-            'Too long name' => [str_repeat('a', 300), str_repeat('a', 255)]
+            'Standard name' => ['New name', 'New name', true],
+            'Name with digits' => ['Today is 17/04/2017', 'Today is 17/04/2017', true],
+            'Name with symbols' => ['Follow us: @moodle', 'Follow us: @moodle', true],
+            'Name with tags' => ['This is <b>bold</b>', 'This is bold', true],
+            'Long name' => [str_repeat('a', 100), str_repeat('a', 100), true],
+            'Too long name' => [str_repeat('a', 300), str_repeat('a', 255), true],
+            'Empty name' => ['', 'Test content ', false],
+            'Blanks only' => ['  ', 'Test content ', false],
         ];
     }
 
@@ -283,10 +392,11 @@ class core_contenttype_contenttype_testcase extends \advanced_testcase {
      * @dataProvider    rename_content_provider
      * @param   string  $newname    The name to set
      * @param   string   $expected   The name result
+     * @param   bool   $result   The bolean result expected when renaming
      *
      * @covers ::rename_content
      */
-    public function test_rename_content(string $newname, string $expected) {
+    public function test_rename_content(string $newname, string $expected, bool $result) {
         global $DB;
 
         $this->resetAfterTest();
@@ -307,9 +417,8 @@ class core_contenttype_contenttype_testcase extends \advanced_testcase {
 
         // Check the content is renamed as expected by a user with permission.
         $renamed = $contenttype->rename_content($content, $newname);
-        $this->assertTrue($renamed);
+        $this->assertEquals($result, $renamed);
         $record = $DB->get_record('contentbank_content', ['id' => $content->get_id()]);
-        $this->assertNotEquals($oldname, $record->name);
         $this->assertEquals($expected, $record->name);
     }
 
index 6a9ea67..d369bb2 100644 (file)
@@ -52,12 +52,14 @@ class rename_content_testcase extends \externallib_advanced_testcase {
      */
     public function rename_content_provider() {
         return [
-            'Standard name' => ['New name', 'New name'],
-            'Name with digits' => ['Today is 17/04/2017', 'Today is 17/04/2017'],
-            'Name with symbols' => ['Follow us: @moodle', 'Follow us: @moodle'],
-            'Name with tags' => ['This is <b>bold</b>', 'This is bold'],
-            'Long name' => [str_repeat('a', 100), str_repeat('a', 100)],
-            'Too long name' => [str_repeat('a', 300), str_repeat('a', 255)]
+            'Standard name' => ['New name', 'New name', true],
+            'Name with digits' => ['Today is 17/04/2017', 'Today is 17/04/2017', true],
+            'Name with symbols' => ['Follow us: @moodle', 'Follow us: @moodle', true],
+            'Name with tags' => ['This is <b>bold</b>', 'This is bold', true],
+            'Long name' => [str_repeat('a', 100), str_repeat('a', 100), true],
+            'Too long name' => [str_repeat('a', 300), str_repeat('a', 255), true],
+            'Empty name' => ['', 'Test content ', false],
+            'Blanks only' => ['  ', 'Test content ', false],
         ];
     }
 
@@ -66,11 +68,12 @@ class rename_content_testcase extends \externallib_advanced_testcase {
      *
      * @dataProvider    rename_content_provider
      * @param   string  $newname    The name to set
-     * @param   string   $expected   The name result
+     * @param   string   $expectedname   The name result
+     * @param   bool   $expectedresult   The bolean result expected when renaming
      *
      * @covers ::execute
      */
-    public function test_rename_content_with_permission(string $newname, string $expected) {
+    public function test_rename_content_with_permission(string $newname, string $expectedname, bool $expectedresult) {
         global $DB;
         $this->resetAfterTest();
 
@@ -91,10 +94,9 @@ class rename_content_testcase extends \externallib_advanced_testcase {
         // Call the WS and check the content is renamed as expected.
         $result = rename_content::execute($content->get_id(), $newname);
         $result = external_api::clean_returnvalue(rename_content::execute_returns(), $result);
-        $this->assertTrue($result['result']);
+        $this->assertEquals($expectedresult, $result['result']);
         $record = $DB->get_record('contentbank_content', ['id' => $content->get_id()]);
-        $this->assertNotEquals($oldname, $record->name);
-        $this->assertEquals($expected, $record->name);
+        $this->assertEquals($expectedname, $record->name);
 
         // Call the WS using an unexisting contentid and check an error is thrown.
         $this->expectException(\invalid_response_exception::class);
index d379dea..3d3ed4a 100644 (file)
@@ -25,6 +25,9 @@
 
 namespace contenttype_testable;
 
+use file_exception;
+use stored_file;
+
 /**
  * Testable content plugin class.
  *
@@ -33,4 +36,21 @@ namespace contenttype_testable;
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class content extends \core_contentbank\content {
+
+    /**
+     * Import a file as a valid content.
+     *
+     * This method will thow an error if the filename is "error.*"
+     *
+     * @param stored_file $file File to store in the content file area.
+     * @return stored_file|null the stored content file or null if the file is discarted.
+     * @throws file_exception if the filename contains the word "error"
+     */
+    public function import_file(stored_file $file): ?stored_file {
+        $filename = $file->get_filename();
+        if (strrpos($filename, 'error') !== false) {
+            throw new file_exception('yourerrorthanks', 'contenttype_test');
+        }
+        return parent::import_file($file);
+    }
 }
index c4626f0..00cc40c 100644 (file)
@@ -25,6 +25,8 @@
 require('../config.php');
 require_once("$CFG->dirroot/contentbank/files_form.php");
 
+use core\output\notification;
+
 require_login();
 
 $contextid = optional_param('contextid', \context_system::instance()->id, PARAM_INT);
@@ -68,6 +70,8 @@ file_prepare_standard_filemanager($data, 'files', $options, $context, 'contentba
 
 $mform = new contentbank_files_form(null, ['contextid' => $contextid, 'data' => $data, 'options' => $options]);
 
+$error = '';
+
 if ($mform->is_cancelled()) {
     redirect($returnurl);
 } else if ($formdata = $mform->get_data()) {
@@ -79,16 +83,20 @@ if ($mform->is_cancelled()) {
     if (!empty($files)) {
         $file = reset($files);
         $content = $cb->create_content_from_file($context, $USER->id, $file);
-        file_save_draft_area_files($formdata->file, $contextid, 'contentbank', 'public', $content->get_id());
         $viewurl = new \moodle_url('/contentbank/view.php', ['id' => $content->get_id(), 'contextid' => $contextid]);
         redirect($viewurl);
+    } else {
+        $error = get_string('errornofile', 'contentbank');
     }
-    redirect($returnurl);
 }
 
 echo $OUTPUT->header();
 echo $OUTPUT->box_start('generalbox');
 
+if (!empty($error)) {
+    echo $OUTPUT->notification($error, notification::NOTIFY_ERROR);
+}
+
 $mform->display();
 
 echo $OUTPUT->box_end();
index fe3e80b..74fa987 100644 (file)
@@ -855,6 +855,13 @@ class core_course_external extends external_api {
             }
             require_capability('moodle/course:create', $context);
 
+            // Fullname and short name are required to be non-empty.
+            if (trim($course['fullname']) === '') {
+                throw new moodle_exception('errorinvalidparam', 'webservice', '', 'fullname');
+            } else if (trim($course['shortname']) === '') {
+                throw new moodle_exception('errorinvalidparam', 'webservice', '', 'shortname');
+            }
+
             // Make sure lang is valid
             if (array_key_exists('lang', $course)) {
                 if (empty($availablelangs[$course['lang']])) {
@@ -1040,14 +1047,20 @@ class core_course_external extends external_api {
                     $course['category'] = $course['categoryid'];
                 }
 
-                // Check if the user can change fullname.
+                // Check if the user can change fullname, and the new value is non-empty.
                 if (array_key_exists('fullname', $course) && ($oldcourse->fullname != $course['fullname'])) {
                     require_capability('moodle/course:changefullname', $context);
+                    if (trim($course['fullname']) === '') {
+                        throw new moodle_exception('errorinvalidparam', 'webservice', '', 'fullname');
+                    }
                 }
 
-                // Check if the user can change shortname.
+                // Check if the user can change shortname, and the new value is non-empty.
                 if (array_key_exists('shortname', $course) && ($oldcourse->shortname != $course['shortname'])) {
                     require_capability('moodle/course:changeshortname', $context);
+                    if (trim($course['shortname']) === '') {
+                        throw new moodle_exception('errorinvalidparam', 'webservice', '', 'shortname');
+                    }
                 }
 
                 // Check if the user can change the idnumber.
index 0388708..4fa0524 100644 (file)
@@ -143,7 +143,12 @@ $mform->set_data($data);
 
 if ($mform->is_cancelled()) {
     if ($return && !empty($cm->id)) {
-        redirect("$CFG->wwwroot/mod/$module->name/view.php?id=$cm->id");
+        $urlparams = [
+            'id' => $cm->id, // We always need the activity id.
+            'forceview' => 1, // Stop file downloads in resources.
+        ];
+        $activityurl = new moodle_url("/mod/$module->name/view.php", $urlparams);
+        redirect($activityurl);
     } else {
         redirect(course_get_url($course, $cw->section, array('sr' => $sectionreturn)));
     }
index 7b2fccc..57e6108 100644 (file)
@@ -551,6 +551,80 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $createdsubcats = core_course_external::create_courses($courses);
     }
 
+    /**
+     * Data provider for testing empty fields produce expected exceptions
+     *
+     * @see test_create_courses_empty_field
+     * @see test_update_courses_empty_field
+     *
+     * @return array
+     */
+    public function course_empty_field_provider(): array {
+        return [
+            [[
+                'fullname' => '',
+                'shortname' => 'ws101',
+            ], 'fullname'],
+            [[
+                'fullname' => ' ',
+                'shortname' => 'ws101',
+            ], 'fullname'],
+            [[
+                'fullname' => 'Web Services',
+                'shortname' => '',
+            ], 'shortname'],
+            [[
+                'fullname' => 'Web Services',
+                'shortname' => ' ',
+            ], 'shortname'],
+        ];
+    }
+
+    /**
+     * Test creating courses with empty fields throws an exception
+     *
+     * @param array $course
+     * @param string $expectedemptyfield
+     *
+     * @dataProvider course_empty_field_provider
+     */
+    public function test_create_courses_empty_field(array $course, string $expectedemptyfield): void {
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        // Create a category for the new course.
+        $course['categoryid'] = $this->getDataGenerator()->create_category()->id;
+
+        $this->expectException(moodle_exception::class);
+        $this->expectExceptionMessageRegExp("/{$expectedemptyfield}/");
+        core_course_external::create_courses([$course]);
+    }
+
+    /**
+     * Test updating courses with empty fields returns warnings
+     *
+     * @param array $course
+     * @param string $expectedemptyfield
+     *
+     * @dataProvider course_empty_field_provider
+     */
+    public function test_update_courses_empty_field(array $course, string $expectedemptyfield): void {
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        // Create a course to update.
+        $course['id'] = $this->getDataGenerator()->create_course()->id;
+
+        $result = core_course_external::update_courses([$course]);
+        $result = core_course_external::clean_returnvalue(core_course_external::update_courses_returns(), $result);
+
+        $this->assertCount(1, $result['warnings']);
+
+        $warning = reset($result['warnings']);
+        $this->assertEquals('errorinvalidparam', $warning['warningcode']);
+        $this->assertContains($expectedemptyfield, $warning['message']);
+    }
+
     /**
      * Test delete_courses
      */
diff --git a/customfield/field/date/pix/checked.png b/customfield/field/date/pix/checked.png
new file mode 100644 (file)
index 0000000..4da4b72
Binary files /dev/null and b/customfield/field/date/pix/checked.png differ
diff --git a/customfield/field/date/pix/checked.svg b/customfield/field/date/pix/checked.svg
new file mode 100644 (file)
index 0000000..6b37948
--- /dev/null
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16px" height="16px" viewBox="0 0 16 16" version="1.1" preserveAspectRatio="xMinYMid meet">
+<g id="surface1">
+<path style=" stroke:none;fill-rule:nonzero;fill:rgb(60%,60%,60%);fill-opacity:1;" d="M 13.144531 8.304688 L 13.144531 11.144531 C 13.144531 11.851562 12.890625 12.457031 12.386719 12.960938 C 11.886719 13.460938 11.28125 13.714844 10.570312 13.714844 L 3.144531 13.714844 C 2.433594 13.714844 1.828125 13.460938 1.324219 12.960938 C 0.824219 12.457031 0.570312 11.851562 0.570312 11.144531 L 0.570312 3.714844 C 0.570312 3.007812 0.824219 2.398438 1.324219 1.898438 C 1.828125 1.394531 2.433594 1.144531 3.144531 1.144531 L 10.570312 1.144531 C 10.945312 1.144531 11.292969 1.21875 11.617188 1.367188 C 11.707031 1.40625 11.757812 1.476562 11.777344 1.570312 C 11.792969 1.671875 11.769531 1.757812 11.695312 1.832031 L 11.257812 2.269531 C 11.199219 2.328125 11.132812 2.355469 11.054688 2.355469 C 11.035156 2.355469 11.007812 2.351562 10.972656 2.339844 C 10.835938 2.304688 10.703125 2.285156 10.570312 2.285156 L 3.144531 2.285156 C 2.75 2.285156 2.414062 2.425781 2.132812 2.707031 C 1.855469 2.984375 1.714844 3.320312 1.714844 3.714844 L 1.714844 11.144531 C 1.714844 11.535156 1.855469 11.871094 2.132812 12.152344 C 2.414062 12.429688 2.75 12.570312 3.144531 12.570312 L 10.570312 12.570312 C 10.964844 12.570312 11.300781 12.429688 11.582031 12.152344 C 11.859375 11.871094 12 11.535156 12 11.144531 L 12 8.875 C 12 8.796875 12.027344 8.730469 12.082031 8.679688 L 12.652344 8.105469 C 12.710938 8.046875 12.78125 8.019531 12.855469 8.019531 C 12.894531 8.019531 12.929688 8.027344 12.964844 8.042969 C 13.082031 8.09375 13.144531 8.179688 13.144531 8.304688 Z M 15.207031 3.9375 L 7.9375 11.207031 C 7.792969 11.347656 7.625 11.417969 7.429688 11.417969 C 7.230469 11.417969 7.0625 11.347656 6.917969 11.207031 L 3.082031 7.367188 C 2.9375 7.222656 2.867188 7.054688 2.867188 6.855469 C 2.867188 6.660156 2.9375 6.492188 3.082031 6.347656 L 4.0625 5.367188 C 4.207031 5.222656 4.375 5.152344 4.570312 5.152344 C 4.769531 5.152344 4.9375 5.222656 5.082031 5.367188 L 7.429688 7.714844 L 13.207031 1.9375 C 13.347656 1.792969 13.519531 1.722656 13.714844 1.722656 C 13.910156 1.722656 14.082031 1.792969 14.222656 1.9375 L 15.207031 2.917969 C 15.347656 3.0625 15.417969 3.230469 15.417969 3.429688 C 15.417969 3.625 15.347656 3.792969 15.207031 3.9375 Z M 15.207031 3.9375 "/>
+</g>
+</svg>
diff --git a/customfield/field/date/pix/notchecked.png b/customfield/field/date/pix/notchecked.png
new file mode 100644 (file)
index 0000000..02f53fb
Binary files /dev/null and b/customfield/field/date/pix/notchecked.png differ
diff --git a/customfield/field/date/pix/notchecked.svg b/customfield/field/date/pix/notchecked.svg
new file mode 100644 (file)
index 0000000..a4519ac
--- /dev/null
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16px" height="16px" viewBox="0 0 16 16" version="1.1" preserveAspectRatio="xMinYMid meet">
+<g id="surface1">
+<path style=" stroke:none;fill-rule:nonzero;fill:rgb(60%,60%,60%);fill-opacity:1;" d="M 11.714844 2.285156 L 4.285156 2.285156 C 3.894531 2.285156 3.554688 2.425781 3.277344 2.707031 C 2.996094 2.984375 2.855469 3.320312 2.855469 3.714844 L 2.855469 11.144531 C 2.855469 11.535156 2.996094 11.871094 3.277344 12.152344 C 3.554688 12.429688 3.894531 12.570312 4.285156 12.570312 L 11.714844 12.570312 C 12.105469 12.570312 12.445312 12.429688 12.722656 12.152344 C 13.003906 11.871094 13.144531 11.535156 13.144531 11.144531 L 13.144531 3.714844 C 13.144531 3.320312 13.003906 2.984375 12.722656 2.707031 C 12.445312 2.425781 12.105469 2.285156 11.714844 2.285156 Z M 14.285156 3.714844 L 14.285156 11.144531 C 14.285156 11.851562 14.035156 12.457031 13.53125 12.960938 C 13.027344 13.460938 12.421875 13.714844 11.714844 13.714844 L 4.285156 13.714844 C 3.578125 13.714844 2.972656 13.460938 2.46875 12.960938 C 1.964844 12.457031 1.714844 11.851562 1.714844 11.144531 L 1.714844 3.714844 C 1.714844 3.007812 1.964844 2.398438 2.46875 1.898438 C 2.972656 1.394531 3.578125 1.144531 4.285156 1.144531 L 11.714844 1.144531 C 12.421875 1.144531 13.027344 1.394531 13.53125 1.898438 C 14.035156 2.398438 14.285156 3.007812 14.285156 3.714844 Z M 14.285156 3.714844 "/>
+</g>
+</svg>
index 6f1c4f9..e10e573 100644 (file)
@@ -52,6 +52,9 @@ $string['courses'] = 'Courses';
 $string['customexport'] = 'Custom range ({$a->timestart} - {$a->timeend})';
 $string['daily'] = 'Daily';
 $string['day'] = 'Day';
+$string['dayeventsmany'] = '{$a->num} events, {$a->day}';
+$string['dayeventsnone'] = 'No events, {$a}';
+$string['dayeventsone'] = '1 event, {$a}';
 $string['daynext'] = 'Next day';
 $string['dayprev'] = 'Previous day';
 $string['dayviewfor'] = 'Day view for:';
index 7c63e1e..6411ece 100644 (file)
@@ -33,12 +33,14 @@ $string['contentrenamed'] = 'The content has been renamed.';
 $string['contentsmoved'] = 'Content bank contents moved to {$a}.';
 $string['contenttypenoaccess'] = 'You cannot view this {$a} instance.';
 $string['contenttypenoedit'] = 'You can not edit this content';
+$string['emptynamenotallowed'] = 'Empty name is not allowed';
 $string['eventcontentcreated'] = 'Content created';
 $string['eventcontentdeleted'] = 'Content deleted';
 $string['eventcontentupdated'] = 'Content updated';
 $string['eventcontentuploaded'] = 'Content uploaded';
 $string['eventcontentviewed'] = 'Content viewed';
 $string['errordeletingcontentfromcategory'] = 'Error deleting content from category {$a}.';
+$string['errornofile'] = 'A compatible file is needed to create a content';
 $string['deletecontent'] = 'Delete content';
 $string['deletecontentconfirm'] = 'Are you sure you want to delete the content <em>\'{$a->name}\'</em> and all associated files? This action cannot be undone.';
 $string['displaydetails'] = 'Display content bank with file details';
index 9590201..3530052 100644 (file)
@@ -223,6 +223,7 @@ $string['ddlxmlfileerror'] = 'XML database file errors found';
 $string['destinationcmnotexit'] = 'The destination course module does not exist';
 $string['detectedbrokenplugin'] = 'Plugin "{$a}" is defective or outdated, can not continue, sorry.';
 $string['dmlexceptiononinstall'] = '<p>A database error has occurred [{$a->errorcode}].<br />{$a->debuginfo}</p>';
+$string['dmlparseexception'] = 'Error parsing SQL query';
 $string['dmlreadexception'] = 'Error reading from database';
 $string['dmltransactionexception'] = 'Database transaction error';
 $string['dmlwriteexception'] = 'Error writing to database';
index 08c3954..1f99ffc 100644 (file)
Binary files a/lib/amd/build/showhidesettings.min.js and b/lib/amd/build/showhidesettings.min.js differ
index e8e2cfb..2d9f178 100644 (file)
Binary files a/lib/amd/build/showhidesettings.min.js.map and b/lib/amd/build/showhidesettings.min.js.map differ
index d06db8f..ba5731b 100644 (file)
@@ -249,15 +249,6 @@ define(['jquery'], function($) {
         return $('[name="' + name + '"],[name="' + name + '[]"]');
     }
 
-    /**
-     * Find the name of the given element
-     * @param {EventTarget} el
-     * @returns {String}
-     */
-    function getElementName(el) {
-        return $(el).attr('name').replace(/\[]/, '');
-    }
-
     /**
      * Check to see whether a particular condition is met
      * @param {*|jQuery|HTMLElement} $dependon
@@ -273,29 +264,23 @@ define(['jquery'], function($) {
     }
 
     /**
-     * Show / hide the elements that depend on the element(s) with the given name
-     * OR (if no dependonname given) the element(s) with the same name as the element that
-     * triggered the event e.
-     * @param {Event} e
-     * @param {String} dependonname (optional)
+     * Show / hide the elements that depend on some elements.
      */
-    function updateDependencies(e, dependonname) {
-        dependonname = dependonname || getElementName(e.currentTarget);
-        var $dependon = getElementsByName(dependonname);
-        if (!dependencies.hasOwnProperty(dependonname)) {
-            return;
-        }
-        // Process all dependency conditions related to the updated element.
+    function updateDependencies() {
+        // Process all dependency conditions.
         var toHide = {};
-        $.each(dependencies[dependonname], function(condition, values) {
-            $.each(values, function(value, elements) {
-                var hide = checkDependency($dependon, condition, value);
-                $.each(elements, function(idx, elToHide) {
-                    if (toHide.hasOwnProperty(elToHide)) {
-                        toHide[elToHide] = toHide[elToHide] || hide;
-                    } else {
-                        toHide[elToHide] = hide;
-                    }
+        $.each(dependencies, function(dependonname) {
+            var dependon = getElementsByName(dependonname);
+            $.each(dependencies[dependonname], function(condition, values) {
+                $.each(values, function(value, elements) {
+                    var hide = checkDependency(dependon, condition, value);
+                    $.each(elements, function(idx, elToHide) {
+                        if (toHide.hasOwnProperty(elToHide)) {
+                            toHide[elToHide] = toHide[elToHide] || hide;
+                        } else {
+                            toHide[elToHide] = hide;
+                        }
+                    });
                 });
             });
         });
@@ -323,9 +308,9 @@ define(['jquery'], function($) {
             var $el = getElementsByName(depname);
             if ($el.length) {
                 $el.on('change', updateDependencies);
-                updateDependencies(null, depname);
             }
         });
+        updateDependencies();
     }
 
     /**
index 5bffeb6..ce5ae22 100644 (file)
@@ -345,14 +345,16 @@ class oci_native_moodle_database extends moodle_database {
 
     /**
      * Prepare the statement for execution
-     * @throws dml_connection_exception
+     *
      * @param string $sql
      * @return resource
+     *
+     * @throws dml_exception
      */
     protected function parse_query($sql) {
         $stmt = oci_parse($this->oci, $sql);
         if ($stmt == false) {
-            throw new dml_connection_exception('Can not parse sql query'); //TODO: maybe add better info
+            throw new dml_exception('dmlparseexception', null, $this->get_last_error());
         }
         return $stmt;
     }
index 8802a9e..299a0b1 100644 (file)
@@ -1053,7 +1053,7 @@ class core_renderer extends renderer_base {
         $course = $this->page->course;
         if (\core\session\manager::is_loggedinas()) {
             $realuser = \core\session\manager::get_realuser();
-            $fullname = fullname($realuser, true);
+            $fullname = fullname($realuser);
             if ($withlinks) {
                 $loginastitle = get_string('loginas');
                 $realuserinfo = " [<a href=\"$CFG->wwwroot/course/loginas.php?id=$course->id&amp;sesskey=".sesskey()."\"";
@@ -1074,7 +1074,7 @@ class core_renderer extends renderer_base {
         } else if (isloggedin()) {
             $context = context_course::instance($course->id);
 
-            $fullname = fullname($USER, true);
+            $fullname = fullname($USER);
             // Since Moodle 2.0 this link always goes to the public profile page (not the course profile page)
             if ($withlinks) {
                 $linktitle = get_string('viewprofile');
index 457bab1..2b10d71 100644 (file)
Binary files a/lib/table/amd/build/dynamic.min.js and b/lib/table/amd/build/dynamic.min.js differ
index e2275f1..3f58a60 100644 (file)
Binary files a/lib/table/amd/build/dynamic.min.js.map and b/lib/table/amd/build/dynamic.min.js.map differ
index a0246dd..1004a8e 100644 (file)
@@ -156,14 +156,6 @@ export const updateTable = (tableRoot, {
         tableRoot.dataset.tableLastInitial = lastInitial;
     }
 
-    if (pageNumber !== null) {
-        if (tableRoot.dataset.tablePageNumber != pageNumber) {
-            tableConfigChanged = true;
-        }
-
-        tableRoot.dataset.tablePageNumber = pageNumber;
-    }
-
     if (pageSize !== null) {
         if (tableRoot.dataset.tablePageSize != pageSize) {
             tableConfigChanged = true;
@@ -183,6 +175,12 @@ export const updateTable = (tableRoot, {
         tableRoot.dataset.tableFilters = filterJson;
     }
 
+    // Reset to page 1 when table content is being altered by filtering or sorting.
+    // This ensures the table page being loaded always exists, and gives a consistent experience.
+    if (tableConfigChanged) {
+        pageNumber = 1;
+    }
+
     // Update hidden columns.
     if (hiddenColumns) {
         const columnJson = JSON.stringify(hiddenColumns);
@@ -194,6 +192,14 @@ export const updateTable = (tableRoot, {
         tableRoot.dataset.tableHiddenColumns = columnJson;
     }
 
+    if (pageNumber !== null) {
+        if (tableRoot.dataset.tablePageNumber != pageNumber) {
+            tableConfigChanged = true;
+        }
+
+        tableRoot.dataset.tablePageNumber = pageNumber;
+    }
+
     // Refresh.
     if (refreshContent && tableConfigChanged) {
         return refreshTableContent(tableRoot)
index ce9f392..e474ffc 100644 (file)
@@ -283,7 +283,7 @@ class behat_app extends behat_base {
         global $CFG;
 
         // Visit the Ionic URL and wait for it to load.
-        $this->execute('behat_general::i_visit', [$url]);
+        $this->getSession()->visit($url);
         $this->spin(
                 function($context, $args) {
                     $title = $context->getSession()->getPage()->find('xpath', '//title');
index 1961725..62931e7 100644 (file)
@@ -2439,6 +2439,7 @@ function check_upgrade_key($upgradekeyhash) {
     if (isset($CFG->config_php_settings['upgradekey'])) {
         if ($upgradekeyhash === null or $upgradekeyhash !== sha1($CFG->config_php_settings['upgradekey'])) {
             if (!$PAGE->headerprinted) {
+                $PAGE->set_title(get_string('upgradekeyreq', 'admin'));
                 $output = $PAGE->get_renderer('core', 'admin');
                 echo $output->upgradekey_form_page(new moodle_url('/admin/index.php', array('cache' => 0)));
                 die();
index 53c1f62..daba441 100644 (file)
Binary files a/message/amd/build/message_drawer_view_conversation.min.js and b/message/amd/build/message_drawer_view_conversation.min.js differ
index dac3fc9..ceb2204 100644 (file)
Binary files a/message/amd/build/message_drawer_view_conversation.min.js.map and b/message/amd/build/message_drawer_view_conversation.min.js.map differ
index 03854ff..34d00fb 100644 (file)
@@ -1282,6 +1282,32 @@ function(
             });
     };
 
+    /**
+     * Create a plain version of an HTML text.
+     *
+     * This texts is used as a message preview while is sent to the server. This way
+     * it is possible to prevent self-xss.
+     *
+     * @param {String} text Text to send.
+     * @return {String} The plain text version of the text.
+     */
+    const previewText = function(text) {
+        // Remove all script and styles from text (we don't want it there).
+        let plaintext = text.replace(/<style([\s\S]*?)<\/style>/gi, '');
+        plaintext = plaintext.replace(/<script([\s\S]*?)<\/script>/gi, '');
+        // Beautify a bit the output adding some line breaks.
+        plaintext = plaintext.replace(/<\/div>/ig, '\n');
+        plaintext = plaintext.replace(/<\/li>/ig, '\n');
+        plaintext = plaintext.replace(/<li>/ig, '  *  ');
+        plaintext = plaintext.replace(/<\/ul>/ig, '\n');
+        plaintext = plaintext.replace(/<\/p>/ig, '\n');
+        plaintext = plaintext.replace(/<br[^>]*>/gi, '\n');
+        // Remove all remaining tags and convert line breaks into html.
+        plaintext = plaintext.replace(/<[^>]+>/ig, '');
+        plaintext = plaintext.replace(/\n+/ig, '\n');
+        return plaintext.replace(/\n/ig, '<br>');
+    };
+
     /**
      * Buffers messages to be sent to the server. We use a buffer here to allow the
      * user to freely input messages without blocking the interface for them.
@@ -1292,14 +1318,22 @@ function(
      */
     var sendMessage = function(text) {
         var id = 'temp' + Date.now();
+        // Render a preview version of the message while sending.
+        let loadingmessage = {
+            id: id,
+            useridfrom: viewState.loggedInUserId,
+            text:  previewText(text),
+            timecreated: null
+        };
+        var newState = StateManager.addMessages(viewState, [loadingmessage]);
+        render(newState);
+        // Send the real message.
         var message = {
             id: id,
             useridfrom: viewState.loggedInUserId,
             text: text,
             timecreated: null
         };
-        var newState = StateManager.addMessages(viewState, [message]);
-        render(newState);
         sendMessageBuffer.push(message);
         processSendMessageBuffer();
     };
index ab2d9e5..207533e 100644 (file)
@@ -75,7 +75,6 @@
 {{/duedate}}
 </div>
 
-</span>
 </div>
 
 {{!
@@ -97,7 +96,7 @@
 </div>
 {{#js}}
 require(['mod_assign/grading_navigation', 'core/tooltip'], function(GradingNavigation, ToolTip) {
-    var nav = new GradingNavigation('[data-region="user-selector"]');
-    var tooltip = new ToolTip('[data-region="assignment-tooltip"]');
+    new GradingNavigation('[data-region="user-selector"]');
+    new ToolTip('[data-region="assignment-tooltip"]');
 });
 {{/js}}
index 30378ce..f370b3b 100644 (file)
@@ -44,7 +44,7 @@
     </small>
 </span>
 
-<span data-region="configure-filters" id="filter-configuration-{{uniqid}}" class="card card-large p-2">
+<div data-region="configure-filters" id="filter-configuration-{{uniqid}}" class="card card-large p-2">
     <form>
         <span class="row px-3 py-1">
             <label class="text-right w-25 p-2 m-0" for="filter-general-{{uniqid}}">
@@ -81,7 +81,7 @@
         </span>
         {{/hasmarkingworkflow}}
     </form>
-</span>
+</div>
 
 <a href="#" data-region="user-filters" title="{{#str}}changefilters, mod_assign{{/str}}" aria-expanded="false" aria-controls="filter-configuration-{{uniqid}}">
     <span class="accesshide">
index 42b1421..0094e57 100644 (file)
@@ -265,9 +265,12 @@ class forum_gradeitem extends component_gradeitem {
 
         $DB->update_record($this->get_table_name(), $grade);
 
-        // Update in the gradebook.
+        // Update in the gradebook (note that 'cmidnumber' is required in order to update grades).
         $mapper = forum_container::get_legacy_data_mapper_factory()->get_forum_data_mapper();
-        forum_update_grades($mapper->to_legacy_object($this->forum), $grade->userid);
+        $forumrecord = $mapper->to_legacy_object($this->forum);
+        $forumrecord->cmidnumber = $this->forum->get_course_module_record()->idnumber;
+
+        forum_update_grades($forumrecord, $grade->userid);
 
         return true;
     }
index a625333..54c4491 100644 (file)
@@ -506,7 +506,7 @@ class send_user_digests extends \core\task\adhoc_task {
             $this->log("Adding post {$post->id} in format {$maildigest} without HTML", 2);
         }
 
-        if ($maildigest == 1 && $CFG->forum_usermarksread) {
+        if ($maildigest == 1 && !$CFG->forum_usermarksread) {
             // Create an array of postid's for this user to mark as read.
             $this->markpostsasread[] = $post->id;
         }
index e146293..79cc80f 100644 (file)
@@ -808,13 +808,13 @@ function forum_print_recent_activity($course, $viewfullnames, $timestart) {
 function forum_update_grades($forum, $userid = 0): void {
     global $CFG, $DB;
     require_once($CFG->libdir.'/gradelib.php');
-    $cm = get_coursemodule_from_instance('forum', $forum->id);
-    $forum->cmidnumber = $cm->idnumber;
 
     $ratings = null;
     if ($forum->assessed) {
         require_once($CFG->dirroot.'/rating/lib.php');
 
+        $cm = get_coursemodule_from_instance('forum', $forum->id);
+
         $rm = new rating_manager();
         $ratings = $rm->get_user_grades((object) [
             'component' => 'mod_forum',
index e725f75..efd8019 100644 (file)
@@ -694,4 +694,118 @@ class mod_forum_maildigest_testcase extends advanced_testcase {
         $digesttime = usergetmidnight(time(), \core_date::get_server_timezone()) + ($CFG->digestmailtime * 3600);
         $this->assertLessThanOrEqual($digesttime, $task->nextruntime);
     }
+
+    /**
+     * The sending of a digest marks posts as read if automatic message read marking is set.
+     */
+    public function test_cron_digest_marks_posts_read() {
+        global $DB, $CFG;
+
+        $this->resetAfterTest(true);
+
+        // Disable the 'Manual message read marking' option.
+        $CFG->forum_usermarksread = false;
+
+        // Set up a basic user enrolled in a course.
+        $userhelper = $this->helper_setup_user_in_course();
+        $user = $userhelper->user;
+        $course1 = $userhelper->courses->course1;
+        $forum1 = $userhelper->forums->forum1;
+        $posts = [];
+
+        // Set the tested user's default maildigest, trackforums, read tracking settings.
+        $DB->set_field('user', 'maildigest', 1, ['id' => $user->id]);
+        $DB->set_field('user', 'trackforums', 1, ['id' => $user->id]);
+        set_user_preference('forum_markasreadonnotification', 1, $user->id);
+
+        // Set the maildigest preference for forum1 to default.
+        forum_set_user_maildigest($forum1, -1, $user);
+
+        // Add 5 discussions to forum 1.
+        for ($i = 0; $i < 5; $i++) {
+            list($discussion, $post) = $this->helper_post_to_forum($forum1, $user, ['mailnow' => 1]);
+            $posts[] = $post;
+        }
+
+        // There should be unread posts for the forum.
+        $expectedposts = [
+            $forum1->id => (object) [
+                'id' => $forum1->id,
+                'unread' => count($posts),
+            ],
+        ];
+        $this->assertEquals($expectedposts, forum_tp_get_course_unread_posts($user->id, $course1->id));
+
+        // One digest mail should be sent and no other messages.
+        $expect = [
+            (object) [
+                'userid' => $user->id,
+                'messages' => 0,
+                'digests' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_digests_and_assert($user, $posts);
+
+        // Verify that there are no unread posts for any forums.
+        $this->assertEmpty(forum_tp_get_course_unread_posts($user->id, $course1->id));
+    }
+
+    /**
+     * The sending of a digest does not mark posts as read when manual message read marking is set.
+     */
+    public function test_cron_digest_leaves_posts_unread() {
+        global $DB, $CFG;
+
+        $this->resetAfterTest(true);
+
+        // Enable the 'Manual message read marking' option.
+        $CFG->forum_usermarksread = true;
+
+        // Set up a basic user enrolled in a course.
+        $userhelper = $this->helper_setup_user_in_course();
+        $user = $userhelper->user;
+        $course1 = $userhelper->courses->course1;
+        $forum1 = $userhelper->forums->forum1;
+        $posts = [];
+
+        // Set the tested user's default maildigest, trackforums, read tracking settings.
+        $DB->set_field('user', 'maildigest', 1, ['id' => $user->id]);
+        $DB->set_field('user', 'trackforums', 1, ['id' => $user->id]);
+        set_user_preference('forum_markasreadonnotification', 1, $user->id);
+
+        // Set the maildigest preference for forum1 to default.
+        forum_set_user_maildigest($forum1, -1, $user);
+
+        // Add 5 discussions to forum 1.
+        for ($i = 0; $i < 5; $i++) {
+            list($discussion, $post) = $this->helper_post_to_forum($forum1, $user, ['mailnow' => 1]);
+            $posts[] = $post;
+        }
+
+        // There should be unread posts for the forum.
+        $expectedposts = [
+            $forum1->id => (object) [
+                'id' => $forum1->id,
+                'unread' => count($posts),
+            ],
+        ];
+        $this->assertEquals($expectedposts, forum_tp_get_course_unread_posts($user->id, $course1->id));
+
+        // One digest mail should be sent and no other messages.
+        $expect = [
+            (object) [
+                'userid' => $user->id,
+                'messages' => 0,
+                'digests' => 1,
+            ],
+        ];
+        $this->queue_tasks_and_assert($expect);
+
+        $this->send_digests_and_assert($user, $posts);
+
+        // Verify that there are still the same unread posts for the forum.
+        $this->assertEquals($expectedposts, forum_tp_get_course_unread_posts($user->id, $course1->id));
+    }
 }
index ff43403..a5db3ec 100644 (file)
@@ -2388,12 +2388,12 @@ function lti_get_shared_secrets_by_key($key) {
     // Look up the shared secret for the specified key in both the types_config table (for configured tools)
     // And in the lti resource table for ad-hoc tools.
     $lti13 = LTI_VERSION_1P3;
-    $query = "SELECT t2.value
+    $query = "SELECT " . $DB->sql_compare_text('t2.value', 256) . " AS value
                 FROM {lti_types_config} t1
                 JOIN {lti_types_config} t2 ON t1.typeid = t2.typeid
                 JOIN {lti_types} type ON t2.typeid = type.id
               WHERE t1.name = 'resourcekey'
-                AND t1.value = :key1
+                AND " . $DB->sql_compare_text('t1.value', 256) . " = :key1
                 AND t2.name = 'password'
                 AND type.state = :configured1
                 AND type.ltiversion <> :ltiversion
index 786c6ef..c1d975c 100644 (file)
@@ -84,7 +84,7 @@ function quiz_add_instance($quiz) {
     $cmid = $quiz->coursemodule;
 
     // Process the options from the form.
-    $quiz->created = time();
+    $quiz->timecreated = time();
     $result = quiz_process_options($quiz);
     if ($result && is_string($result)) {
         return $result;
index c0b798a..e4ae349 100644 (file)
@@ -385,8 +385,13 @@ abstract class quiz_attempts_report_table extends table_sql {
 
     /**
      * Get any fields that might be needed when sorting on date for a particular slot.
+     *
+     * Note: these values are only used for sorting. The values displayed are taken
+     * from $this->lateststeps loaded in load_extra_data().
+     *
      * @param int $slot the slot for the column we want.
      * @param string $alias the table alias for latest state information relating to that slot.
+     * @return string definitions of extra fields to add to the SELECT list of the query.
      */
     protected function get_required_latest_state_fields($slot, $alias) {
         return '';
index 8ce209b..831add7 100644 (file)
@@ -114,14 +114,7 @@ class quiz_last_responses_table extends quiz_attempts_report_table {
         if (!isset($this->lateststeps[$attempt->usageid][$slot])) {
             return '-';
         }
-        $stepdata = $this->lateststeps[$attempt->usageid][$slot];
-
-        if (property_exists($stepdata, $field . 'full')) {
-            $value = $stepdata->{$field . 'full'};
-        } else {
-            $value = $stepdata->$field;
-        }
-        return $value;
+        return $this->lateststeps[$attempt->usageid][$slot]->$field;
     }
 
     public function other_cols($colname, $attempt) {
@@ -158,20 +151,8 @@ class quiz_last_responses_table extends quiz_attempts_report_table {
      */
     protected function get_required_latest_state_fields($slot, $alias) {
         global $DB;
-        $sortableresponse = $DB->sql_order_by_text("{$alias}.questionsummary");
-        if ($sortableresponse === "{$alias}.questionsummary") {
-            // Can just order by text columns. No complexity needed.
-            return "{$alias}.questionsummary AS question{$slot},
-                    {$alias}.rightanswer AS right{$slot},
-                    {$alias}.responsesummary AS response{$slot}";
-        } else {
-            // Work-around required.
-            return $DB->sql_order_by_text("{$alias}.questionsummary") . " AS question{$slot},
-                    {$alias}.questionsummary AS question{$slot}full,
-                    " . $DB->sql_order_by_text("{$alias}.rightanswer") . " AS right{$slot},
-                    {$alias}.rightanswer AS right{$slot}full,
-                    " . $DB->sql_order_by_text("{$alias}.responsesummary") . " AS response{$slot},
-                    {$alias}.responsesummary AS response{$slot}full";
-        }
+        return $DB->sql_order_by_text("{$alias}.questionsummary") . " AS question{$slot},
+                " . $DB->sql_order_by_text("{$alias}.rightanswer") . " AS right{$slot},
+                " . $DB->sql_order_by_text("{$alias}.responsesummary") . " AS response{$slot}";
     }
 }
index 4ae705a..94e7ca7 100644 (file)
@@ -49,7 +49,8 @@ class mod_quiz_generator_testcase extends advanced_testcase {
 
         $generator->create_instance(array('course'=>$SITE->id));
         $generator->create_instance(array('course'=>$SITE->id));
-        $quiz = $generator->create_instance(array('course'=>$SITE->id));
+        $createtime = time();
+        $quiz = $generator->create_instance(array('course' => $SITE->id, 'timecreated' => 0));
         $this->assertEquals(3, $DB->count_records('quiz'));
 
         $cm = get_coursemodule_from_instance('quiz', $quiz->id);
@@ -59,5 +60,8 @@ class mod_quiz_generator_testcase extends advanced_testcase {
 
         $context = context_module::instance($cm->id);
         $this->assertEquals($quiz->cmid, $context->instanceid);
+
+        $this->assertEqualsWithDelta($createtime,
+                $DB->get_field('quiz', 'timecreated', ['id' => $cm->instance]), 2);
     }
 }
index b8ff382..3413fbc 100644 (file)
     display: none;
 }
 
-.path-mod-workshop #id_rubric-grid-wrapper .rubric-grid {
+.path-mod-workshop .mform.frozen #id_rubric-grid-wrapper,
+.path-mod-workshop #id_rubric-grid-wrapper {
     margin-left: auto;
     margin-right: auto;
+    width: 100%;
+}
+
+.path-mod-workshop .mform.frozen #id_rubric-grid-wrapper .checkbox,
+.path-mod-workshop .assessmentform.rubric.grid #id_rubric-grid-wrapper .checkbox {
+    max-width: 100%;
+    flex: 0 0 100%;
+    text-align: left;
 }
 
 .path-mod-workshop .mform.frozen #id_rubric-grid-wrapper .fitem .felement,
diff --git a/pix/e/cancel_solid_circle.png b/pix/e/cancel_solid_circle.png
new file mode 100644 (file)
index 0000000..fe20a7d
Binary files /dev/null and b/pix/e/cancel_solid_circle.png differ
diff --git a/pix/e/cancel_solid_circle.svg b/pix/e/cancel_solid_circle.svg
new file mode 100644 (file)
index 0000000..391d793
--- /dev/null
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16px" height="16px" viewBox="0 0 16 16" version="1.1" preserveAspectRatio="xMinYMid meet">
+<g id="surface1">
+<path style=" stroke:none;fill-rule:nonzero;fill:rgb(60%,60%,60%);fill-opacity:1;" d="M 11.402344 10.019531 C 11.402344 9.863281 11.34375 9.730469 11.230469 9.617188 L 9.617188 8 L 11.230469 6.382812 C 11.34375 6.269531 11.402344 6.136719 11.402344 5.980469 C 11.402344 5.820312 11.34375 5.683594 11.230469 5.570312 L 10.429688 4.769531 C 10.316406 4.65625 10.179688 4.597656 10.019531 4.597656 C 9.863281 4.597656 9.730469 4.65625 9.617188 4.769531 L 8 6.382812 L 6.382812 4.769531 C 6.269531 4.65625 6.136719 4.597656 5.980469 4.597656 C 5.820312 4.597656 5.683594 4.65625 5.570312 4.769531 L 4.769531 5.570312 C 4.65625 5.683594 4.597656 5.820312 4.597656 5.980469 C 4.597656 6.136719 4.65625 6.269531 4.769531 6.382812 L 6.382812 8 L 4.769531 9.617188 C 4.65625 9.730469 4.597656 9.863281 4.597656 10.019531 C 4.597656 10.179688 4.65625 10.316406 4.769531 10.429688 L 5.570312 11.230469 C 5.683594 11.34375 5.820312 11.402344 5.980469 11.402344 C 6.136719 11.402344 6.269531 11.34375 6.382812 11.230469 L 8 9.617188 L 9.617188 11.230469 C 9.730469 11.34375 9.863281 11.402344 10.019531 11.402344 C 10.179688 11.402344 10.316406 11.34375 10.429688 11.230469 L 11.230469 10.429688 C 11.34375 10.316406 11.402344 10.179688 11.402344 10.019531 Z M 14.855469 8 C 14.855469 9.242188 14.550781 10.390625 13.9375 11.441406 C 13.324219 12.492188 12.492188 13.324219 11.441406 13.9375 C 10.390625 14.550781 9.242188 14.855469 8 14.855469 C 6.757812 14.855469 5.609375 14.550781 4.558594 13.9375 C 3.507812 13.324219 2.675781 12.492188 2.0625 11.441406 C 1.449219 10.390625 1.144531 9.242188 1.144531 8 C 1.144531 6.757812 1.449219 5.609375 2.0625 4.558594 C 2.675781 3.507812 3.507812 2.675781 4.558594 2.0625 C 5.609375 1.449219 6.757812 1.144531 8 1.144531 C 9.242188 1.144531 10.390625 1.449219 11.441406 2.0625 C 12.492188 2.675781 13.324219 3.507812 13.9375 4.558594 C 14.550781 5.609375 14.855469 6.757812 14.855469 8 Z M 14.855469 8 "/>
+</g>
+</svg>
diff --git a/pix/i/breadcrumbdivider.png b/pix/i/breadcrumbdivider.png
new file mode 100644 (file)
index 0000000..8615579
Binary files /dev/null and b/pix/i/breadcrumbdivider.png differ
diff --git a/pix/i/breadcrumbdivider.svg b/pix/i/breadcrumbdivider.svg
new file mode 100644 (file)
index 0000000..b977e55
--- /dev/null
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16px" height="16px" viewBox="0 0 16 16" version="1.1" preserveAspectRatio="xMinYMid meet">
+<g id="surface1">
+<path style=" stroke:none;fill-rule:nonzero;fill:rgb(60%,60%,60%);fill-opacity:1;" d="M 10.457031 8.570312 C 10.457031 8.648438 10.425781 8.71875 10.367188 8.777344 L 6.207031 12.9375 C 6.144531 12.996094 6.078125 13.027344 6 13.027344 C 5.921875 13.027344 5.855469 12.996094 5.792969 12.9375 L 5.347656 12.492188 C 5.289062 12.429688 5.257812 12.363281 5.257812 12.285156 C 5.257812 12.207031 5.289062 12.140625 5.347656 12.082031 L 8.855469 8.570312 L 5.347656 5.0625 C 5.289062 5.003906 5.257812 4.933594 5.257812 4.855469 C 5.257812 4.78125 5.289062 4.710938 5.347656 4.652344 L 5.792969 4.207031 C 5.855469 4.144531 5.921875 4.117188 6 4.117188 C 6.078125 4.117188 6.144531 4.144531 6.207031 4.207031 L 10.367188 8.367188 C 10.425781 8.425781 10.457031 8.492188 10.457031 8.570312 Z M 10.457031 8.570312 "/>
+</g>
+</svg>
diff --git a/pix/i/home.png b/pix/i/home.png
new file mode 100644 (file)
index 0000000..13bd55e
Binary files /dev/null and b/pix/i/home.png differ
diff --git a/pix/i/menubars.png b/pix/i/menubars.png
new file mode 100644 (file)
index 0000000..574e3f1
Binary files /dev/null and b/pix/i/menubars.png differ
diff --git a/pix/i/menubars.svg b/pix/i/menubars.svg
new file mode 100644 (file)
index 0000000..e49d8b6
--- /dev/null
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16px" height="16px" viewBox="0 0 16 16" version="1.1" preserveAspectRatio="xMinYMid meet">
+<g id="surface1">
+<path style=" stroke:none;fill-rule:nonzero;fill:rgb(60%,60%,60%);fill-opacity:1;" d="M 14.855469 12 L 14.855469 13.144531 C 14.855469 13.296875 14.800781 13.429688 14.6875 13.542969 C 14.574219 13.65625 14.441406 13.714844 14.285156 13.714844 L 1.714844 13.714844 C 1.558594 13.714844 1.425781 13.65625 1.3125 13.542969 C 1.199219 13.429688 1.144531 13.296875 1.144531 13.144531 L 1.144531 12 C 1.144531 11.84375 1.199219 11.710938 1.3125 11.597656 C 1.425781 11.484375 1.558594 11.429688 1.714844 11.429688 L 14.285156 11.429688 C 14.441406 11.429688 14.574219 11.484375 14.6875 11.597656 C 14.800781 11.710938 14.855469 11.84375 14.855469 12 Z M 14.855469 7.429688 L 14.855469 8.570312 C 14.855469 8.726562 14.800781 8.859375 14.6875 8.972656 C 14.574219 9.085938 14.441406 9.144531 14.285156 9.144531 L 1.714844 9.144531 C 1.558594 9.144531 1.425781 9.085938 1.3125 8.972656 C 1.199219 8.859375 1.144531 8.726562 1.144531 8.570312 L 1.144531 7.429688 C 1.144531 7.273438 1.199219 7.140625 1.3125 7.027344 C 1.425781 6.914062 1.558594 6.855469 1.714844 6.855469 L 14.285156 6.855469 C 14.441406 6.855469 14.574219 6.914062 14.6875 7.027344 C 14.800781 7.140625 14.855469 7.273438 14.855469 7.429688 Z M 14.855469 2.855469 L 14.855469 4 C 14.855469 4.15625 14.800781 4.289062 14.6875 4.402344 C 14.574219 4.515625 14.441406 4.570312 14.285156 4.570312 L 1.714844 4.570312 C 1.558594 4.570312 1.425781 4.515625 1.3125 4.402344 C 1.199219 4.289062 1.144531 4.15625 1.144531 4 L 1.144531 2.855469 C 1.144531 2.703125 1.199219 2.570312 1.3125 2.457031 C 1.425781 2.34375 1.558594 2.285156 1.714844 2.285156 L 14.285156 2.285156 C 14.441406 2.285156 14.574219 2.34375 14.6875 2.457031 C 14.800781 2.570312 14.855469 2.703125 14.855469 2.855469 Z M 14.855469 2.855469 "/>
+</g>
+</svg>
diff --git a/pix/i/next.png b/pix/i/next.png
new file mode 100644 (file)
index 0000000..edf29f3
Binary files /dev/null and b/pix/i/next.png differ
diff --git a/pix/i/next.svg b/pix/i/next.svg
new file mode 100644 (file)
index 0000000..9bca809
--- /dev/null
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16px" height="16px" viewBox="0 0 16 16" version="1.1" preserveAspectRatio="xMinYMid meet">
+<g id="surface1">
+<path style=" stroke:none;fill-rule:nonzero;fill:rgb(60%,60%,60%);fill-opacity:1;" d="M 12.167969 7.832031 L 5.542969 14.457031 C 5.429688 14.570312 5.296875 14.625 5.144531 14.625 C 4.988281 14.625 4.855469 14.570312 4.742188 14.457031 L 3.257812 12.972656 C 3.144531 12.859375 3.089844 12.726562 3.089844 12.570312 C 3.089844 12.417969 3.144531 12.28125 3.257812 12.167969 L 8 7.429688 L 3.257812 2.6875 C 3.144531 2.574219 3.089844 2.441406 3.089844 2.285156 C 3.089844 2.132812 3.144531 1.996094 3.257812 1.882812 L 4.742188 0.402344 C 4.855469 0.289062 4.988281 0.230469 5.144531 0.230469 C 5.296875 0.230469 5.429688 0.289062 5.542969 0.402344 L 12.167969 7.027344 C 12.28125 7.140625 12.339844 7.273438 12.339844 7.429688 C 12.339844 7.582031 12.28125 7.71875 12.167969 7.832031 Z M 12.167969 7.832031 "/>
+</g>
+</svg>
diff --git a/pix/i/previous.png b/pix/i/previous.png
new file mode 100644 (file)
index 0000000..ce91b37
Binary files /dev/null and b/pix/i/previous.png differ
diff --git a/pix/i/previous.svg b/pix/i/previous.svg
new file mode 100644 (file)
index 0000000..91c294a
--- /dev/null
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16px" height="16px" viewBox="0 0 16 16" version="1.1" preserveAspectRatio="xMinYMid meet">
+<g id="surface1">
+<path style=" stroke:none;fill-rule:nonzero;fill:rgb(60%,60%,60%);fill-opacity:1;" d="M 12.742188 2.6875 L 8 7.429688 L 12.742188 12.167969 C 12.855469 12.28125 12.910156 12.417969 12.910156 12.570312 C 12.910156 12.726562 12.855469 12.859375 12.742188 12.972656 L 11.257812 14.457031 C 11.144531 14.570312 11.011719 14.625 10.855469 14.625 C 10.703125 14.625 10.570312 14.570312 10.457031 14.457031 L 3.832031 7.832031 C 3.71875 7.71875 3.660156 7.582031 3.660156 7.429688 C 3.660156 7.273438 3.71875 7.140625 3.832031 7.027344 L 10.457031 0.402344 C 10.570312 0.289062 10.703125 0.230469 10.855469 0.230469 C 11.011719 0.230469 11.144531 0.289062 11.257812 0.402344 L 12.742188 1.882812 C 12.855469 1.996094 12.910156 2.132812 12.910156 2.285156 C 12.910156 2.441406 12.855469 2.574219 12.742188 2.6875 Z M 12.742188 2.6875 "/>
+</g>
+</svg>
diff --git a/pix/i/privatefiles.png b/pix/i/privatefiles.png
new file mode 100644 (file)
index 0000000..cee9a28
Binary files /dev/null and b/pix/i/privatefiles.png differ
diff --git a/pix/i/section.png b/pix/i/section.png
new file mode 100644 (file)
index 0000000..ec0b98a
Binary files /dev/null and b/pix/i/section.png differ
diff --git a/pix/i/star-o.png b/pix/i/star-o.png
new file mode 100644 (file)
index 0000000..f015f89
Binary files /dev/null and b/pix/i/star-o.png differ
diff --git a/pix/i/star-o.svg b/pix/i/star-o.svg
new file mode 100644 (file)
index 0000000..77599ef
--- /dev/null
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16px" height="16px" viewBox="0 0 16 16" version="1.1" preserveAspectRatio="xMinYMid meet">
+<g id="surface1">
+<path style=" stroke:none;fill-rule:nonzero;fill:rgb(60%,60%,60%);fill-opacity:1;" d="M 10.722656 8.964844 L 13.457031 6.3125 L 9.6875 5.757812 L 8 2.347656 L 6.3125 5.757812 L 2.542969 6.3125 L 5.277344 8.964844 L 4.625 12.722656 L 8 10.945312 L 11.367188 12.722656 Z M 15.429688 5.777344 C 15.429688 5.90625 15.351562 6.050781 15.195312 6.207031 L 11.957031 9.367188 L 12.722656 13.832031 C 12.730469 13.871094 12.730469 13.929688 12.730469 14.007812 C 12.730469 14.304688 12.609375 14.457031 12.367188 14.457031 C 12.253906 14.457031 12.132812 14.417969 12.007812 14.347656 L 8 12.242188 L 3.992188 14.347656 C 3.859375 14.417969 3.742188 14.457031 3.632812 14.457031 C 3.507812 14.457031 3.414062 14.414062 3.351562 14.324219 C 3.289062 14.238281 3.257812 14.132812 3.257812 14.007812 C 3.257812 13.972656 3.265625 13.914062 3.277344 13.832031 L 4.042969 9.367188 L 0.792969 6.207031 C 0.644531 6.042969 0.570312 5.902344 0.570312 5.777344 C 0.570312 5.554688 0.738281 5.417969 1.070312 5.367188 L 5.554688 4.714844 L 7.5625 0.652344 C 7.675781 0.40625 7.820312 0.285156 8 0.285156 C 8.179688 0.285156 8.324219 0.40625 8.4375 0.652344 L 10.445312 4.714844 L 14.929688 5.367188 C 15.261719 5.417969 15.429688 5.554688 15.429688 5.777344 Z M 15.429688 5.777344 "/>
+</g>
+</svg>
diff --git a/pix/t/collapsedcaret.png b/pix/t/collapsedcaret.png
new file mode 100644 (file)
index 0000000..756464a
Binary files /dev/null and b/pix/t/collapsedcaret.png differ
diff --git a/pix/t/collapsedcaret.svg b/pix/t/collapsedcaret.svg
new file mode 100644 (file)
index 0000000..967a5a2
--- /dev/null
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16px" height="16px" viewBox="0 0 16 16" version="1.1" preserveAspectRatio="xMinYMid meet">
+<g id="surface1">
+<path style=" stroke:none;fill-rule:nonzero;fill:rgb(60%,60%,60%);fill-opacity:1;" d="M 10.285156 8 C 10.285156 8.15625 10.230469 8.289062 10.117188 8.402344 L 6.117188 12.402344 C 6.003906 12.515625 5.867188 12.570312 5.714844 12.570312 C 5.558594 12.570312 5.425781 12.515625 5.3125 12.402344 C 5.199219 12.289062 5.144531 12.15625 5.144531 12 L 5.144531 4 C 5.144531 3.84375 5.199219 3.710938 5.3125 3.597656 C 5.425781 3.484375 5.558594 3.429688 5.714844 3.429688 C 5.867188 3.429688 6.003906 3.484375 6.117188 3.597656 L 10.117188 7.597656 C 10.230469 7.710938 10.285156 7.84375 10.285156 8 Z M 10.285156 8 "/>
+</g>
+</svg>
diff --git a/pix/t/downlong.png b/pix/t/downlong.png
new file mode 100644 (file)
index 0000000..397c653
Binary files /dev/null and b/pix/t/downlong.png differ
diff --git a/pix/t/downlong.svg b/pix/t/downlong.svg
new file mode 100644 (file)
index 0000000..f936434
--- /dev/null
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16px" height="16px" viewBox="0 0 16 16" version="1.1" preserveAspectRatio="xMinYMid meet">
+<g id="surface1">
+<path style=" stroke:none;fill-rule:nonzero;fill:rgb(60%,60%,60%);fill-opacity:1;" d="M 11.402344 11.597656 C 11.449219 11.710938 11.433594 11.816406 11.355469 11.910156 L 8.230469 15.339844 C 8.171875 15.398438 8.105469 15.429688 8.027344 15.429688 C 7.945312 15.429688 7.871094 15.398438 7.8125 15.339844 L 4.644531 11.910156 C 4.566406 11.816406 4.550781 11.710938 4.597656 11.597656 C 4.652344 11.484375 4.738281 11.429688 4.855469 11.429688 L 6.855469 11.429688 L 6.855469 0.285156 C 6.855469 0.203125 6.882812 0.132812 6.9375 0.0820312 C 6.992188 0.0273438 7.058594 0 7.144531 0 L 8.855469 0 C 8.941406 0 9.007812 0.0273438 9.0625 0.0820312 C 9.117188 0.132812 9.144531 0.203125 9.144531 0.285156 L 9.144531 11.429688 L 11.144531 11.429688 C 11.269531 11.429688 11.355469 11.484375 11.402344 11.597656 Z M 11.402344 11.597656 "/>
+</g>
+</svg>
diff --git a/pix/t/tags.png b/pix/t/tags.png
new file mode 100644 (file)
index 0000000..becf992
Binary files /dev/null and b/pix/t/tags.png differ
diff --git a/pix/t/uplong.png b/pix/t/uplong.png
new file mode 100644 (file)
index 0000000..111a14a
Binary files /dev/null and b/pix/t/uplong.png differ
diff --git a/pix/t/uplong.svg b/pix/t/uplong.svg
new file mode 100644 (file)
index 0000000..0622f88
--- /dev/null
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16px" height="16px" viewBox="0 0 16 16" version="1.1" preserveAspectRatio="xMinYMid meet">
+<g id="surface1">
+<path style=" stroke:none;fill-rule:nonzero;fill:rgb(60%,60%,60%);fill-opacity:1;" d="M 11.402344 4.402344 C 11.347656 4.515625 11.261719 4.570312 11.144531 4.570312 L 9.144531 4.570312 L 9.144531 15.714844 C 9.144531 15.796875 9.117188 15.867188 9.0625 15.917969 C 9.007812 15.972656 8.941406 16 8.855469 16 L 7.144531 16 C 7.058594 16 6.992188 15.972656 6.9375 15.917969 C 6.882812 15.867188 6.855469 15.796875 6.855469 15.714844 L 6.855469 4.570312 L 4.855469 4.570312 C 4.730469 4.570312 4.644531 4.515625 4.597656 4.402344 C 4.550781 4.289062 4.566406 4.183594 4.644531 4.089844 L 7.769531 0.660156 C 7.828125 0.601562 7.894531 0.570312 7.972656 0.570312 C 8.054688 0.570312 8.128906 0.601562 8.1875 0.660156 L 11.355469 4.089844 C 11.433594 4.183594 11.449219 4.289062 11.402344 4.402344 Z M 11.402344 4.402344 "/>
+</g>
+</svg>
index 3b93673..b0df2fd 100644 (file)
@@ -26,6 +26,7 @@ form.mform fieldset#id_previewareaheader div.ddarea {
 .que.ddimageortext div.droparea .dropzones {
     position: absolute;
     top: 0;
+    /*rtl:ignore*/
     left: 0;
 }
 
index b1e86ba..fde088d 100644 (file)
         {{#tags}}
             <li>
                 <a href="{{viewurl}}" class="{{#isstandard}}standardtag{{/isstandard}} s{{size}}"
-                    {{#count}}title="{{#str}}numberofentries, blog, {{count}}{{/str}}{{/count}}">
+                    {{#count}}title="{{#str}}numberofentries, blog, {{count}}{{/str}}"{{/count}}>
                         {{#flag}}
-                            <span class="flagged-tag">{{name}}</span></a>
+                            <span class="flagged-tag">{{name}}</span>
                         {{/flag}}
                         {{^flag}}
-                            {{name}}</a>
+                            {{name}}
                         {{/flag}}
+                </a>
             </li>
         {{/tags}}
     </ul>
index 83b1cd7..f056317 100644 (file)
@@ -53,6 +53,7 @@ Feature: Users' names are displayed across the site according to the user policy
     When I follow "Profile" in the user menu
     Then I should see "Gronya,Beecham" in the ".usermenu" "css_element"
     And I should see "Gronya,Beecham" in the ".page-context-header" "css_element"
+    And I should see "You are logged in as Gronya,Beecham" in the "page-footer" "region"
     And I log out
 
   Scenario: As an admin, 'fullnamedisplay' should be used when using the 'log in as' function
@@ -60,7 +61,9 @@ Feature: Users' names are displayed across the site according to the user policy
     When I navigate to "Users > Accounts > Browse list of users" in site administration
     And I follow "Jane, Nina, Niamh, Cholmondely"
     And I follow "Log in as"
-    Then I should see "You are logged in as Nee,Chumlee"
+    Then I should see "You are logged in as Nee,Chumlee" in the ".usermenu" "css_element"
+    And I should see "You are logged in as Jane, Nina, Niamh, Cholmondely" in the "region-main" "region"
+    And I should see "You are logged in as Nee,Chumlee" in the "page-footer" "region"
     And I log out
 
   Scenario: As an admin, 'fullnamedisplay' should be used when viewing another user's site profile
index 11d2845..e2315b2 100644 (file)
@@ -29,9 +29,9 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$version  = 2020061501.06;              // 20200615      = branching date YYYYMMDD - do not modify!
+$version  = 2020061501.07;              // 20200615      = branching date YYYYMMDD - do not modify!
                                         //         RR    = release increments - 00 in DEV branches.
                                         //           .XX = incremental changes.
-$release  = '3.9.1+ (Build: 20200807)'; // Human-friendly version name
+$release  = '3.9.1+ (Build: 20200814)'; // Human-friendly version name
 $branch   = '39';                       // This version's branch.
 $maturity = MATURITY_STABLE;             // This version's maturity level.