Merge branch 'MDL-69114-39' of git://github.com/bmbrands/moodle into MOODLE_39_STABLE
authorSara Arjona <sara@moodle.com>
Wed, 26 Aug 2020 14:28:49 +0000 (16:28 +0200)
committerSara Arjona <sara@moodle.com>
Wed, 26 Aug 2020 14:28:49 +0000 (16:28 +0200)
178 files changed:
.travis.yml
admin/environment.xml
admin/index.php
admin/renderer.php
admin/tool/dataprivacy/classes/data_request.php
admin/tool/dataprivacy/createdatarequest.php
admin/tool/dataprivacy/createdatarequest_form.php
admin/tool/dataprivacy/lang/en/tool_dataprivacy.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
auth/ldap/tests/plugin_test.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/moodle2/restore_stepslib.php
backup/util/dbops/restore_dbops.class.php
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
badges/classes/form/badge.php
badges/classes/form/external_backpack.php
badges/classes/output/external_backpacks_page.php
badges/templates/external_backpacks_page.mustache
badges/tests/behat/backpack.feature
blocks/recentlyaccessedcourses/amd/build/main.min.js
blocks/recentlyaccessedcourses/amd/build/main.min.js.map
blocks/recentlyaccessedcourses/amd/src/main.js
blocks/recentlyaccesseditems/classes/external/recentlyaccesseditems_item_exporter.php
blocks/timeline/templates/view.mustache
calendar/classes/external/week_day_exporter.php
calendar/templates/month_detailed.mustache
calendar/templates/month_mini.mustache
cohort/index.php
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/templates/bankcontent.mustache
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/classes/editcategory_form.php
course/externallib.php
course/format/topics/backup/moodle2/restore_format_topics_plugin.class.php
course/format/weeks/backup/moodle2/restore_format_weeks_plugin.class.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]
group/import.php
group/import_form.php
h5p/classes/api.php
h5p/classes/editor_framework.php
h5p/classes/framework.php
h5p/tests/generator_test.php
lang/en/calendar.php
lang/en/contentbank.php
lang/en/error.php
lang/en/group.php
lang/en/mimetypes.php
lib/amd/build/showhidesettings.min.js
lib/amd/build/showhidesettings.min.js.map
lib/amd/src/showhidesettings.js
lib/classes/dataformat.php
lib/classes/filetypes.php
lib/db/install.xml
lib/db/upgrade.php
lib/dml/oci_native_moodle_database.php
lib/editor/atto/plugins/undo/yui/build/moodle-atto_undo-button/moodle-atto_undo-button-debug.js
lib/editor/atto/plugins/undo/yui/build/moodle-atto_undo-button/moodle-atto_undo-button-min.js
lib/editor/atto/plugins/undo/yui/build/moodle-atto_undo-button/moodle-atto_undo-button.js
lib/editor/atto/plugins/undo/yui/src/button/js/button.js
lib/filelib.php
lib/form/tests/filetypes_util_test.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/tablelib.php
lib/templates/settings_link_page.mustache
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
message/templates/message_index.mustache
mod/assign/feedback/editpdf/ajax.php
mod/assign/feedback/editpdf/classes/document_services.php
mod/assign/locallib.php
mod/assign/templates/grading_navigation.mustache
mod/assign/templates/grading_navigation_user_selector.mustache
mod/book/edit_form.php
mod/chat/chat_ajax.php
mod/chat/gui_ajax/index.php
mod/chat/gui_ajax/module.js
mod/chat/gui_ajax/theme/bubble/chat.css
mod/chat/gui_ajax/theme/compact/chat.css
mod/chat/gui_basic/index.php
mod/feedback/classes/responses_table.php
mod/forum/classes/grades/forum_gradeitem.php
mod/forum/classes/local/renderers/discussion.php
mod/forum/classes/privacy/provider.php
mod/forum/classes/task/send_user_digests.php
mod/forum/db/install.xml
mod/forum/db/upgrade.php
mod/forum/lib.php
mod/forum/templates/local/grades/local/grader/grading.mustache
mod/forum/templates/local/grades/local/grader/navigation.mustache
mod/forum/templates/local/grades/local/grader/user_picker.mustache
mod/forum/templates/local/grades/local/grader/user_picker/user.mustache
mod/forum/tests/behat/portfolio_export.feature [new file with mode: 0644]
mod/forum/tests/maildigest_test.php
mod/forum/version.php
mod/lesson/classes/local/numeric/helper.php [new file with mode: 0644]
mod/lesson/lang/en/lesson.php
mod/lesson/locallib.php
mod/lesson/pagetypes/numerical.php
mod/lesson/tests/behat/lesson_numerical_question_with_locale.feature [new file with mode: 0644]
mod/lesson/tests/numeric_helper_test.php [new file with mode: 0644]
mod/lti/locallib.php
mod/lti/service/memberships/classes/local/service/memberships.php
mod/quiz/accessrule/seb/db/install.xml
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
theme/boost/scss/moodle/blocks.scss
theme/boost/scss/moodle/core.scss
theme/boost/scss/moodle/message.scss
theme/boost/scss/moodle/modules.scss
theme/boost/style/moodle.css
theme/classic/style/moodle.css
user/edit.php
user/editadvanced.php
user/externallib.php
user/index.php
user/tests/behat/full_name_display.feature
user/tests/behat/table_column_visibility.feature [new file with mode: 0644]
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 6f6fb81..3c6365f 100644 (file)
       </CUSTOM_CHECK>
     </CUSTOM_CHECKS>
   </MOODLE>
+  <MOODLE version="3.10" requires="3.5">
+    <UNICODE level="required">
+      <FEEDBACK>
+        <ON_ERROR message="unicoderequired" />
+      </FEEDBACK>
+    </UNICODE>
+    <DATABASE level="required">
+      <VENDOR name="mariadb" version="10.2.29" />
+      <VENDOR name="mysql" version="5.7" />
+      <VENDOR name="postgres" version="9.6" />
+      <VENDOR name="mssql" version="11.0" />
+      <VENDOR name="oracle" version="11.2" />
+    </DATABASE>
+    <PHP version="7.2.0" level="required">
+    </PHP>
+    <PCREUNICODE level="optional">
+      <FEEDBACK>
+        <ON_CHECK message="pcreunicodewarning" />
+      </FEEDBACK>
+    </PCREUNICODE>
+    <PHP_EXTENSIONS>
+      <PHP_EXTENSION name="iconv" level="required">
+        <FEEDBACK>
+          <ON_ERROR message="iconvrequired" />
+        </FEEDBACK>
+      </PHP_EXTENSION>
+      <PHP_EXTENSION name="mbstring" level="required">
+        <FEEDBACK>
+          <ON_ERROR message="mbstringrequired" />
+        </FEEDBACK>
+      </PHP_EXTENSION>
+      <PHP_EXTENSION name="curl" level="required">
+        <FEEDBACK>
+          <ON_ERROR message="curlrequired" />
+        </FEEDBACK>
+      </PHP_EXTENSION>
+      <PHP_EXTENSION name="openssl" level="required">
+        <FEEDBACK>
+          <ON_ERROR message="opensslrequired" />
+        </FEEDBACK>
+      </PHP_EXTENSION>
+      <PHP_EXTENSION name="tokenizer" level="optional">
+        <FEEDBACK>
+          <ON_CHECK message="tokenizerrecommended" />
+        </FEEDBACK>
+      </PHP_EXTENSION>
+      <PHP_EXTENSION name="xmlrpc" level="optional">
+        <FEEDBACK>
+          <ON_CHECK message="xmlrpcrecommended" />
+        </FEEDBACK>
+      </PHP_EXTENSION>
+      <PHP_EXTENSION name="soap" level="optional">
+        <FEEDBACK>
+          <ON_CHECK message="soaprecommended" />
+        </FEEDBACK>
+      </PHP_EXTENSION>
+      <PHP_EXTENSION name="ctype" level="required">
+        <FEEDBACK>
+          <ON_ERROR message="ctyperequired" />
+        </FEEDBACK>
+      </PHP_EXTENSION>
+      <PHP_EXTENSION name="zip" level="required">
+        <FEEDBACK>
+          <ON_ERROR message="ziprequired" />
+        </FEEDBACK>
+      </PHP_EXTENSION>
+      <PHP_EXTENSION name="zlib" level="required">
+      </PHP_EXTENSION>
+      <PHP_EXTENSION name="gd" level="required">
+        <FEEDBACK>
+          <ON_ERROR message="gdrequired" />
+        </FEEDBACK>
+      </PHP_EXTENSION>
+      <PHP_EXTENSION name="simplexml" level="required">
+        <FEEDBACK>
+          <ON_ERROR message="simplexmlrequired" />
+        </FEEDBACK>
+      </PHP_EXTENSION>
+      <PHP_EXTENSION name="spl" level="required">
+        <FEEDBACK>
+          <ON_ERROR message="splrequired" />
+        </FEEDBACK>
+      </PHP_EXTENSION>
+      <PHP_EXTENSION name="pcre" level="required">
+      </PHP_EXTENSION>
+      <PHP_EXTENSION name="dom" level="required">
+      </PHP_EXTENSION>
+      <PHP_EXTENSION name="xml" level="required">
+      </PHP_EXTENSION>
+      <PHP_EXTENSION name="xmlreader" level="required">
+      </PHP_EXTENSION>
+      <PHP_EXTENSION name="intl" level="required">
+        <FEEDBACK>
+          <ON_ERROR message="intlrequired" />
+        </FEEDBACK>
+      </PHP_EXTENSION>
+      <PHP_EXTENSION name="json" level="required">
+      </PHP_EXTENSION>
+      <PHP_EXTENSION name="hash" level="required"/>
+      <PHP_EXTENSION name="fileinfo" level="required"/>
+    </PHP_EXTENSIONS>
+    <PHP_SETTINGS>
+      <PHP_SETTING name="memory_limit" value="96M" level="required">
+        <FEEDBACK>
+          <ON_ERROR message="settingmemorylimit" />
+        </FEEDBACK>
+      </PHP_SETTING>
+      <PHP_SETTING name="file_uploads" value="1" level="optional">
+        <FEEDBACK>
+          <ON_CHECK message="settingfileuploads" />
+        </FEEDBACK>
+      </PHP_SETTING>
+      <PHP_SETTING name="opcache.enable" value="1" level="optional">
+        <FEEDBACK>
+          <ON_CHECK message="opcacherecommended" />
+        </FEEDBACK>
+      </PHP_SETTING>
+    </PHP_SETTINGS>
+    <CUSTOM_CHECKS>
+      <CUSTOM_CHECK file="lib/upgradelib.php" function="check_database_storage_engine" level="required">
+        <FEEDBACK>
+          <ON_ERROR message="unsupporteddbstorageengine" />
+        </FEEDBACK>
+      </CUSTOM_CHECK>
+      <CUSTOM_CHECK file="question/engine/upgrade/upgradelib.php" function="quiz_attempts_upgraded" level="required">
+        <FEEDBACK>
+          <ON_ERROR message="quizattemptsupgradedmessage" />
+        </FEEDBACK>
+      </CUSTOM_CHECK>
+      <CUSTOM_CHECK file="lib/upgradelib.php" function="check_slasharguments" level="optional">
+        <FEEDBACK>
+          <ON_CHECK message="slashargumentswarning" />
+        </FEEDBACK>
+      </CUSTOM_CHECK>
+      <CUSTOM_CHECK file="lib/upgradelib.php" function="check_database_tables_row_format" level="optional">
+        <FEEDBACK>
+          <ON_CHECK message="unsupporteddbtablerowformat" />
+        </FEEDBACK>
+      </CUSTOM_CHECK>
+      <CUSTOM_CHECK file="lib/upgradelib.php" function="check_unoconv_version" level="optional">
+        <FEEDBACK>
+          <ON_CHECK message="unoconvwarning" />
+        </FEEDBACK>
+      </CUSTOM_CHECK>
+      <CUSTOM_CHECK file="lib/upgradelib.php" function="check_libcurl_version" level="optional">
+        <FEEDBACK>
+          <ON_CHECK message="libcurlwarning" />
+        </FEEDBACK>
+      </CUSTOM_CHECK>
+      <CUSTOM_CHECK file="lib/upgradelib.php" function="check_mysql_file_format" level="required">
+        <FEEDBACK>
+          <ON_ERROR message="unsupporteddbfileformat" />
+        </FEEDBACK>
+      </CUSTOM_CHECK>
+      <CUSTOM_CHECK file="lib/upgradelib.php" function="check_mysql_file_per_table" level="required">
+        <FEEDBACK>
+          <ON_ERROR message="unsupporteddbfilepertable" />
+        </FEEDBACK>
+      </CUSTOM_CHECK>
+      <CUSTOM_CHECK file="lib/upgradelib.php" function="check_mysql_large_prefix" level="required">
+        <FEEDBACK>
+          <ON_ERROR message="unsupporteddblargeprefix" />
+        </FEEDBACK>
+      </CUSTOM_CHECK>
+      <CUSTOM_CHECK file="lib/upgradelib.php" function="check_is_https" level="optional">
+        <FEEDBACK>
+          <ON_CHECK message="ishttpswarning" />
+        </FEEDBACK>
+      </CUSTOM_CHECK>
+      <CUSTOM_CHECK file="lib/upgradelib.php" function="check_mysql_incomplete_unicode_support" level="optional">
+        <FEEDBACK>
+          <ON_CHECK message="incompleteunicodesupport" />
+        </FEEDBACK>
+      </CUSTOM_CHECK>
+      <CUSTOM_CHECK file="lib/upgradelib.php" function="check_sixtyfour_bits" level="optional">
+        <FEEDBACK>
+          <ON_CHECK message="sixtyfourbitswarning" />
+        </FEEDBACK>
+      </CUSTOM_CHECK>
+    </CUSTOM_CHECKS>
+  </MOODLE>
 </COMPATIBILITY_MATRIX>
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 6a94fad..02edc7c 100644 (file)
@@ -26,10 +26,11 @@ namespace tool_dataprivacy;
 
 defined('MOODLE_INTERNAL') || die();
 
+use lang_string;
 use core\persistent;
 
 /**
- * Class for loading/storing competencies from the DB.
+ * Class for loading/storing data requests from the DB.
  *
  * @copyright  2018 Jun Pataleta
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
@@ -62,6 +63,7 @@ class data_request extends persistent {
             ],
             'comments' => [
                 'type' => PARAM_TEXT,
+                'message' => new lang_string('errorinvalidrequestcomments', 'tool_dataprivacy'),
                 'default' => ''
             ],
             'commentsformat' => [
@@ -75,7 +77,10 @@ class data_request extends persistent {
                 'default' => FORMAT_PLAIN
             ],
             'userid' => [
-                'default' => 0,
+                'default' => function() {
+                    global $USER;
+                    return $USER->id;
+                },
                 'type' => PARAM_INT
             ],
             'requestedby' => [
index c29f197..a81df11 100644 (file)
@@ -67,8 +67,8 @@ if (!$manage && !\tool_dataprivacy\api::can_contact_dpo()) {
     redirect($returnurl, get_string('contactdpoviaprivacypolicy', 'tool_dataprivacy'), 0, \core\output\notification::NOTIFY_ERROR);
 }
 
-$mform = new tool_dataprivacy_data_request_form($url->out(false), ['manage' => !empty($manage)]);
-$mform->set_data(['type' => $requesttype]);
+$mform = new tool_dataprivacy_data_request_form($url->out(false), ['manage' => !empty($manage),
+    'persistent' => new \tool_dataprivacy\data_request(0, (object) ['type' => $requesttype])]);
 
 // Data request cancelled.
 if ($mform->is_cancelled()) {
index c91213c..c708a88 100644 (file)
@@ -23,6 +23,7 @@
  */
 
 use tool_dataprivacy\api;
+use tool_dataprivacy\data_request;
 use tool_dataprivacy\local\helper;
 
 defined('MOODLE_INTERNAL') || die();
@@ -36,7 +37,10 @@ require_once($CFG->libdir.'/formslib.php');
  * @license http://www.gnu.org/copyleft/gpl.html GNU Public License
  * @package tool_dataprivacy
  */
-class tool_dataprivacy_data_request_form extends moodleform {
+class tool_dataprivacy_data_request_form extends \core\form\persistent {
+
+    /** @var string Name of the persistent class. */
+    protected static $persistentclass = data_request::class;
 
     /** @var bool Flag to indicate whether this form is being rendered for managing data requests or for regular requests. */
     protected $manage = false;
@@ -96,14 +100,13 @@ class tool_dataprivacy_data_request_form extends moodleform {
             api::DATAREQUEST_TYPE_EXPORT => get_string('requesttypeexport', 'tool_dataprivacy'),
             api::DATAREQUEST_TYPE_DELETE => get_string('requesttypedelete', 'tool_dataprivacy')
         ];
+
         $mform->addElement('select', 'type', get_string('requesttype', 'tool_dataprivacy'), $options);
-        $mform->setType('type', PARAM_INT);
         $mform->addHelpButton('type', 'requesttype', 'tool_dataprivacy');
 
         // Request comments text area.
         $textareaoptions = ['cols' => 60, 'rows' => 10];
         $mform->addElement('textarea', 'comments', get_string('requestcomments', 'tool_dataprivacy'), $textareaoptions);
-        $mform->setType('type', PARAM_ALPHANUM);
         $mform->addHelpButton('comments', 'requestcomments', 'tool_dataprivacy');
 
         // Action buttons.
@@ -129,34 +132,49 @@ class tool_dataprivacy_data_request_form extends moodleform {
         }
     }
 
+    /**
+     * Get the default data. Unset the default userid if managing data requests
+     *
+     * @return stdClass
+     */
+    protected function get_default_data() {
+        $data = parent::get_default_data();
+        if ($this->manage) {
+            unset($data->userid);
+        }
+
+        return $data;
+    }
+
     /**
      * Form validation.
      *
-     * @param array $data
+     * @param stdClass $data
      * @param array $files
+     * @param array $errors
      * @return array
      * @throws coding_exception
      * @throws dml_exception
      */
-    public function validation($data, $files) {
+    public function extra_validation($data, $files, array &$errors) {
         global $USER;
-        $errors = [];
 
         $validrequesttypes = [
             api::DATAREQUEST_TYPE_EXPORT,
             api::DATAREQUEST_TYPE_DELETE
         ];
-        if (!in_array($data['type'], $validrequesttypes)) {
+        if (!in_array($data->type, $validrequesttypes)) {
             $errors['type'] = get_string('errorinvalidrequesttype', 'tool_dataprivacy');
         }
 
-        if (api::has_ongoing_request($data['userid'], $data['type'])) {
+        $userid = $data->userid;
+
+        if (api::has_ongoing_request($userid, $data->type)) {
             $errors['type'] = get_string('errorrequestalreadyexists', 'tool_dataprivacy');
         }
 
         // Check if current user can create data deletion request.
-        $userid = $data['userid'];
-        if ($data['type'] == api::DATAREQUEST_TYPE_DELETE) {
+        if ($data->type == api::DATAREQUEST_TYPE_DELETE) {
             if ($userid == $USER->id) {
                 if (!api::can_create_data_deletion_request_for_self()) {
                     $errors['type'] = get_string('errorcannotrequestdeleteforself', 'tool_dataprivacy');
index b5f3e60..63d33fe 100644 (file)
@@ -135,6 +135,7 @@ $string['effectiveretentionperioduser'] = '{$a} (since the last time the user ac
 $string['emailsalutation'] = 'Dear {$a},';
 $string['errorcannotrequestdeleteforself'] = 'You don\'t have permission to create deletion request for yourself.';
 $string['errorcannotrequestdeleteforother'] = 'You don\'t have permission to create deletion request for this user.';
+$string['errorinvalidrequestcomments'] = 'Please ensure your comment contains plain text only.';
 $string['errorinvalidrequestcreationmethod'] = 'Invalid request creation method!';
 $string['errorinvalidrequeststatus'] = 'Invalid request status!';
 $string['errorinvalidrequesttype'] = 'Invalid request type!';
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 17ee9dd..dced889 100644 (file)
@@ -108,16 +108,19 @@ class auth_ldap_plugin_testcase extends advanced_testcase {
         $o['ou']          = 'users';
         ldap_add($connection, 'ou='.$o['ou'].','.$topdn, $o);
 
+        $createdusers = array();
         for ($i=1; $i<=5; $i++) {
             $this->create_ldap_user($connection, $topdn, $i);
+            $createdusers[] = 'username' . $i;
         }
 
         // Set up creators group.
+        $assignedroles = array('username1', 'username2');
         $o = array();
         $o['objectClass'] = array('posixGroup');
         $o['cn']          = 'creators';
         $o['gidNumber']   = 1;
-        $o['memberUid']   = array('username1', 'username2');
+        $o['memberUid']   = $assignedroles;
         ldap_add($connection, 'cn='.$o['cn'].','.$topdn, $o);
 
         $creatorrole = $DB->get_record('role', array('shortname'=>'coursecreator'));
@@ -174,15 +177,23 @@ class auth_ldap_plugin_testcase extends advanced_testcase {
         // Check events, 5 users created with 2 users having roles.
         $this->assertCount(7, $events);
         foreach ($events as $index => $event) {
-            $usercreatedindex = array(0, 2, 4, 5, 6);
-            $roleassignedindex = array (1, 3);
-            if (in_array($index, $usercreatedindex)) {
-                $this->assertInstanceOf('\core\event\user_created', $event);
-            }
-            if (in_array($index, $roleassignedindex)) {
-                $this->assertInstanceOf('\core\event\role_assigned', $event);
+            $username = $DB->get_field('user', 'username', array('id' => $event->relateduserid)); // Get username.
+
+            if ($event->eventname === '\core\event\user_created') {
+                $this->assertContains($username, $createdusers);
+                unset($events[$index]); // Remove matching event.
+
+            } else if ($event->eventname === '\core\event\role_assigned') {
+                $this->assertContains($username, $assignedroles);
+                unset($events[$index]); // Remove matching event.
+
+            } else {
+                $this->fail('Unexpected event found: ' . $event->eventname);
             }
         }
+        // If all the user_created and role_assigned events have matched
+        // then the $events array should be now empty.
+        $this->assertCount(0, $events);
 
         $this->assertEquals(5, $DB->count_records('user', array('auth'=>'ldap')));
         $this->assertEquals(2, $DB->count_records('role_assignments'));
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 223b849..baf3b04 100644 (file)
@@ -122,7 +122,7 @@ class restore_gradebook_structure_step extends restore_structure_step {
         }
 
         // Identify the backup we're dealing with.
-        $backuprelease = floatval($this->get_task()->get_info()->backup_release); // The major version: 2.9, 3.0, ...
+        $backuprelease = $this->get_task()->get_info()->backup_release; // The major version: 2.9, 3.0, 3.10...
         $backupbuild = 0;
         preg_match('/(\d{8})/', $this->get_task()->get_info()->moodle_release, $matches);
         if (!empty($matches[1])) {
@@ -132,7 +132,7 @@ class restore_gradebook_structure_step extends restore_structure_step {
         // On older versions the freeze value has to be converted.
         // We do this from here as it is happening right before the file is read.
         // This only targets the backup files that can contain the legacy freeze.
-        if ($backupbuild > 20150618 && ($backuprelease < 3.0 || $backupbuild < 20160527)) {
+        if ($backupbuild > 20150618 && (version_compare($backuprelease, '3.0', '<') || $backupbuild < 20160527)) {
             $this->rewrite_step_backup_file_for_legacy_freeze($fullpath);
         }
 
@@ -505,8 +505,7 @@ class restore_gradebook_structure_step extends restore_structure_step {
         $gradebookcalculationsfreeze = get_config('core', 'gradebook_calculations_freeze_' . $this->get_courseid());
         preg_match('/(\d{8})/', $this->get_task()->get_info()->moodle_release, $matches);
         $backupbuild = (int)$matches[1];
-        // The function floatval will return a float even if there is text mixed with the release number.
-        $backuprelease = floatval($this->get_task()->get_info()->backup_release);
+        $backuprelease = $this->get_task()->get_info()->backup_release; // The major version: 2.9, 3.0, 3.10...
 
         // Extra credits need adjustments only for backups made between 2.8 release (20141110) and the fix release (20150619).
         if (!$gradebookcalculationsfreeze && $backupbuild >= 20141110 && $backupbuild < 20150619) {
@@ -521,7 +520,7 @@ class restore_gradebook_structure_step extends restore_structure_step {
         // Courses from before 3.1 (20160518) may have a letter boundary problem and should be checked for this issue.
         // Backups from before and including 2.9 could have a build number that is greater than 20160518 and should
         // be checked for this problem.
-        if (!$gradebookcalculationsfreeze && ($backupbuild < 20160518 || $backuprelease <= 2.9)) {
+        if (!$gradebookcalculationsfreeze && ($backupbuild < 20160518 || version_compare($backuprelease, '2.9', '<='))) {
             require_once($CFG->libdir . '/db/upgradelib.php');
             upgrade_course_letter_boundary($this->get_courseid());
         }
@@ -4631,11 +4630,11 @@ class restore_create_categories_and_questions extends restore_structure_step {
 
         // Before 3.5, question categories could be created at top level.
         // From 3.5 onwards, all question categories should be a child of a special category called the "top" category.
-        $backuprelease = floatval($this->get_task()->get_info()->backup_release);
+        $backuprelease = $this->get_task()->get_info()->backup_release; // The major version: 2.9, 3.0, 3.10...
         preg_match('/(\d{8})/', $this->get_task()->get_info()->moodle_release, $matches);
         $backupbuild = (int)$matches[1];
         $before35 = false;
-        if ($backuprelease < 3.5 || $backupbuild < 20180205) {
+        if (version_compare($backuprelease, '3.5', '<') || $backupbuild < 20180205) {
             $before35 = true;
         }
         if (empty($mapping->info->parent) && $before35) {
@@ -4892,11 +4891,11 @@ class restore_move_module_questions_categories extends restore_execution_step {
     protected function define_execution() {
         global $DB;
 
-        $backuprelease = floatval($this->task->get_info()->backup_release);
+        $backuprelease = $this->task->get_info()->backup_release; // The major version: 2.9, 3.0, 3.10...
         preg_match('/(\d{8})/', $this->task->get_info()->moodle_release, $matches);
         $backupbuild = (int)$matches[1];
         $after35 = false;
-        if ($backuprelease >= 3.5 && $backupbuild > 20180205) {
+        if (version_compare($backuprelease, '3.5', '>=') && $backupbuild > 20180205) {
             $after35 = true;
         }
 
index eef73fc..a5ca451 100644 (file)
@@ -581,11 +581,11 @@ abstract class restore_dbops {
         $rc = restore_controller_dbops::load_controller($restoreid);
         $restoreinfo = $rc->get_info();
         $rc->destroy(); // Always need to destroy.
-        $backuprelease = floatval($restoreinfo->backup_release);
+        $backuprelease = $restoreinfo->backup_release; // The major version: 2.9, 3.0, 3.10...
         preg_match('/(\d{8})/', $restoreinfo->moodle_release, $matches);
         $backupbuild = (int)$matches[1];
         $after35 = false;
-        if ($backuprelease >= 3.5 && $backupbuild > 20180205) {
+        if (version_compare($backuprelease, '3.5', '>=') && $backupbuild > 20180205) {
             $after35 = true;
         }
 
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 523403b..6626b58 100644 (file)
@@ -70,7 +70,7 @@ class badge extends moodleform {
         $mform->addRule('description', null, 'required');
 
         $str = $action == 'new' ? get_string('badgeimage', 'badges') : get_string('newimage', 'badges');
-        $imageoptions = array('maxbytes' => 262144, 'accepted_types' => array('web_image'));
+        $imageoptions = array('maxbytes' => 262144, 'accepted_types' => array('optimised_image'));
         $mform->addElement('filepicker', 'image', $str, null, $imageoptions);
 
         if ($action == 'new') {
index f92febd..e5c495c 100644 (file)
@@ -80,16 +80,16 @@ class external_backpack extends \moodleform {
         $issuercontact = $CFG->badges_defaultissuercontact;
         $mform->addElement('static', 'issuerinfo', get_string('defaultissuercontact', 'core_badges'), $issuercontact);
 
-        if ($backpack && $backpack->apiversion != OPEN_BADGES_V2P1) {
-            $mform->addElement('passwordunmask', 'password', get_string('defaultissuerpassword', 'core_badges'));
-            $mform->setType('password', PARAM_RAW);
-            $mform->addHelpButton('password', 'defaultissuerpassword', 'badges');
-            $mform->hideIf('password', 'apiversion', 'eq', 1);
-        } else {
-            $oauth2options = badges_get_oauth2_service_options();
-            $mform->addElement('select', 'oauth2_issuerid', get_string('oauth2issuer', 'core_badges'), $oauth2options);
-            $mform->setType('oauth2_issuerid', PARAM_INT);
-        }
+        $mform->addElement('passwordunmask', 'password', get_string('defaultissuerpassword', 'core_badges'));
+        $mform->setType('password', PARAM_RAW);
+        $mform->addHelpButton('password', 'defaultissuerpassword', 'badges');
+        $mform->hideIf('password', 'apiversion', 'neq', 2);
+
+        $oauth2options = badges_get_oauth2_service_options();
+        $mform->addElement('select', 'oauth2_issuerid', get_string('oauth2issuer', 'core_badges'), $oauth2options);
+        $mform->setType('oauth2_issuerid', PARAM_INT);
+        $mform->hideIf('oauth2_issuerid', 'apiversion', 'neq', '2.1');
+
         if ($backpack) {
             $this->set_data($backpack);
         }
index 28da925..32c38db 100644 (file)
@@ -67,11 +67,6 @@ class external_backpacks_page implements \renderable {
         foreach ($this->backpacks as $backpack) {
             $exporter = new backpack_exporter($backpack);
             $backpack = $exporter->export($output);
-            if ($backpack->apiversion == OPEN_BADGES_V2 || $backpack->apiversion == OPEN_BADGES_V2P1) {
-                $backpack->canedit = true;
-            } else {
-                $backpack->canedit = false;
-            }
             $backpack->cantest = ($backpack->apiversion == OPEN_BADGES_V2);
             $backpack->iscurrent = ($backpack->id == $CFG->badges_site_backpack);
 
index 0f9df01..bfdcc5e 100644 (file)
@@ -25,7 +25,7 @@
     Example context (json):
     {
         "backpacks": [
-            {"backpackweburl": "http://localhost/", "sitebackpack": true, "canedit": false, "cantest": true}
+            {"backpackweburl": "http://localhost/", "sitebackpack": true, "cantest": true}
         ]
     }
 }}
@@ -50,9 +50,7 @@
             <td> {{{backpackweburl}}} </td>
             <td> {{#sitebackpack}}Yes{{/sitebackpack}} </td>
             <td>
-            {{#canedit}}
                 <a href="{{baseurl}}?id={{id}}&action=edit">{{#pix}}t/edit, core,{{#str}}editsettings{{/str}}{{/pix}}</a>
-            {{/canedit}}
             {{^iscurrent}}
                 <a href="{{baseurl}}?id={{id}}&action=delete" role="button" data-action="deletebackpack">
                     {{#pix}}t/delete, core,{{#str}}delete{{/str}}{{/pix}}
index 67376f0..ea02aa7 100644 (file)
@@ -107,7 +107,8 @@ Feature: Backpack badges
     And I set the field "backpackweburl" to "http://backpackweburl.cat"
     And I press "Save changes"
     Then I should see "http://backpackweburl.cat"
-    And "Delete" "button" should exist
+    And "Delete" "icon" should exist in the "http://backpackweburl.cat" "table_row"
+    And "Edit settings" "icon" should exist in the "http://backpackweburl.cat" "table_row"
 
   @javascript
   Scenario: Remove a site backpack
index 4d8e69f..74a1b36 100644 (file)
Binary files a/blocks/recentlyaccessedcourses/amd/build/main.min.js and b/blocks/recentlyaccessedcourses/amd/build/main.min.js differ
index 6fdffbc..3876cd1 100644 (file)
Binary files a/blocks/recentlyaccessedcourses/amd/build/main.min.js.map and b/blocks/recentlyaccessedcourses/amd/build/main.min.js.map differ
index ca3186b..baa8d18 100644 (file)
@@ -208,6 +208,11 @@ define(
                 start = start >= 0 ? start : 0;
             }
 
+            // At least show one card.
+            if (availableVisibleCards === 0) {
+                availableVisibleCards = 1;
+            }
+
             var coursesToShow = allCourses.slice(start, start + availableVisibleCards);
             // Create an id for the list of courses we expect to be displayed.
             var newVisibleCoursesId = coursesToShow.reduce(function(carry, course) {
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 ce96132..a6ceef3 100644 (file)
@@ -57,7 +57,7 @@
             data-midnight="{{midnight}}"
             data-limit="2"
             data-offset="0"
-            data-days-limit="30"
+            data-days-limit="{{dayslimit}}"
             data-days-offset="0"
             data-no-events-url="{{urls.noevents}}"
             id="view_courses_{{uniqid}}"
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 f611f98..0341bf9 100644 (file)
@@ -154,7 +154,7 @@ foreach($cohorts['cohorts'] as $cohort) {
         $cohortmanager = has_capability('moodle/cohort:manage', $cohortcontext);
         $cohortcanassign = has_capability('moodle/cohort:assign', $cohortcontext);
 
-        $urlparams = array('id' => $cohort->id, 'returnurl' => $baseurl->out_as_local_url());
+        $urlparams = array('id' => $cohort->id, 'returnurl' => $baseurl->out_as_local_url(false));
         $showhideurl = new moodle_url('/cohort/edit.php', $urlparams + array('sesskey' => sesskey()));
         if ($cohortmanager) {
             if ($cohort->visible) {
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 4801a7f..6819447 100644 (file)
@@ -125,7 +125,7 @@ data-region="contentbank">
                     <div class="cb-type cb-column d-flex last">
                         <div class="title">{{#str}} type, contentbank {{/str}}</div>
                         <button class="btn btn-sm cb-btnsort dir-none ml-auto" data-string="type" data-action="sorttype"
-                        title="{{#str}} sortbyx, core, {{#str}} size, contentbank {{/str}} {{/str}}">
+                        title="{{#str}} sortbyx, core, {{#str}} type, contentbank {{/str}} {{/str}}">
                             <span class="default">{{#pix}} t/sort, core, {{#str}}sort, core {{/str}} {{/pix}}</span>
                             <span class="desc">{{#pix}} t/sort_desc, core, {{#str}}desc, core{{/str}} {{/pix}}</span>
                             <span class="asc">{{#pix}} t/sort_asc, core, {{#str}}asc, core{{/str}} {{/pix}}</span>
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 2e58af3..f11d28c 100644 (file)
@@ -75,6 +75,7 @@ class core_course_editcategory_form extends moodleform {
 
         $mform->addElement('editor', 'description_editor', get_string('description'), null,
             $this->get_description_editor_options());
+        $mform->setType('description_editor', PARAM_RAW);
 
         if (!empty($CFG->allowcategorythemes)) {
             $themes = array(''=>get_string('forceno'));
@@ -105,7 +106,8 @@ class core_course_editcategory_form extends moodleform {
         return array(
             'maxfiles'  => EDITOR_UNLIMITED_FILES,
             'maxbytes'  => $CFG->maxbytes,
-            'trusttext' => true,
+            'trusttext' => false,
+            'noclean'   => true,
             'context'   => $context,
             'subdirs'   => file_area_contains_subdirs($context, 'coursecat', 'description', $itemid),
         );
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 0e918c9..8807ed8 100644 (file)
@@ -48,8 +48,8 @@ class restore_format_topics_plugin extends restore_format_plugin {
      */
     protected function need_restore_numsections() {
         $backupinfo = $this->step->get_task()->get_info();
-        $backuprelease = $backupinfo->backup_release;
-        return version_compare($backuprelease, '3.3', 'lt');
+        $backuprelease = $backupinfo->backup_release; // The major version: 2.9, 3.0, 3.10...
+        return version_compare($backuprelease, '3.3', '<');
     }
 
     /**
index e0ec9c8..64e3b33 100644 (file)
@@ -48,8 +48,8 @@ class restore_format_weeks_plugin extends restore_format_plugin {
      */
     protected function is_pre_33_backup() {
         $backupinfo = $this->step->get_task()->get_info();
-        $backuprelease = $backupinfo->backup_release;
-        return version_compare($backuprelease, '3.3', 'lt');
+        $backuprelease = $backupinfo->backup_release; // The major version: 2.9, 3.0, 3.10...
+        return version_compare($backuprelease, '3.3', '<');
     }
 
     /**
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 2f563a2..58f405b 100644 (file)
@@ -49,30 +49,37 @@ $PAGE->set_pagelayout('admin');
 
 $returnurl = new moodle_url('/group/index.php', array('id'=>$id));
 
-$mform_post = new groups_import_form(null, array('id'=>$id));
+$importform = new groups_import_form(null, ['id' => $id]);
 
 // If a file has been uploaded, then process it
-if ($mform_post->is_cancelled()) {
+if ($importform->is_cancelled()) {
     redirect($returnurl);
 
-} else if ($mform_post->get_data()) {
+} else if ($formdata = $importform->get_data()) {
     echo $OUTPUT->header();
 
-    $csv_encode = '/\&\#44/';
-    if (isset($CFG->CSV_DELIMITER)) {
-        $csv_delimiter = $CFG->CSV_DELIMITER;
+    $text = $importform->get_file_content('userfile');
+    $text = preg_replace('!\r\n?!', "\n", $text);
 
-        if (isset($CFG->CSV_ENCODE)) {
-            $csv_encode = '/\&\#' . $CFG->CSV_ENCODE . '/';
-        }
-    } else {
-        $csv_delimiter = ",";
+    $rawlines = explode("\n", $text);
+
+    require_once($CFG->libdir . '/csvlib.class.php');
+    $importid = csv_import_reader::get_new_iid('groupimport');
+    $csvimport = new csv_import_reader($importid, 'groupimport');
+    $delimiter = $formdata->delimiter_name;
+    $encoding = $formdata->encoding;
+    $readcount = $csvimport->load_csv_content($text, $encoding, $delimiter);
+
+    if ($readcount === false) {
+        print_error('csvfileerror', 'error', $PAGE->url, $csvimport->get_error());
+    } else if ($readcount == 0) {
+        print_error('csvemptyfile', 'error', $PAGE->url, $csvimport->get_error());
+    } else if ($readcount == 1) {
+        print_error('csvnodata', 'error', $PAGE->url);
     }
 
-    $text = $mform_post->get_file_content('userfile');
-    $text = preg_replace('!\r\n?!',"\n",$text);
+    $csvimport->init();
 
-    $rawlines = explode("\n", $text);
     unset($text);
 
     // make arrays of valid fields for error checking
@@ -88,12 +95,12 @@ if ($mform_post->is_cancelled()) {
         );
 
     // --- get header (field names) ---
-    $header = explode($csv_delimiter, array_shift($rawlines));
+    $header = explode($csvimport::get_delimiter($delimiter), array_shift($rawlines));
     // check for valid field names
     foreach ($header as $i => $h) {
         $h = trim($h); $header[$i] = $h; // remove whitespace
         if (!(isset($required[$h]) or isset($optionalDefaults[$h]) or isset($optional[$h]))) {
-                print_error('invalidfieldname', 'error', 'import.php?id='.$id, $h);
+                print_error('invalidfieldname', 'error', $PAGE->url, $h);
             }
         if (isset($required[$h])) {
             $required[$h] = 2;
@@ -102,32 +109,28 @@ if ($mform_post->is_cancelled()) {
     // check for required fields
     foreach ($required as $key => $value) {
         if ($value < 2) {
-            print_error('fieldrequired', 'error', 'import.php?id='.$id, $key);
+            print_error('fieldrequired', 'error', $PAGE->url, $key);
         }
     }
     $linenum = 2; // since header is line 1
 
-    foreach ($rawlines as $rawline) {
+    while ($line = $csvimport->next()) {
 
         $newgroup = new stdClass();//to make Martin happy
         foreach ($optionalDefaults as $key => $value) {
             $newgroup->$key = current_language(); //defaults to current language
         }
-        //Note: commas within a field should be encoded as &#44 (for comma separated csv files)
-        //Note: semicolon within a field should be encoded as &#59 (for semicolon separated csv files)
-        $line = explode($csv_delimiter, $rawline);
         foreach ($line as $key => $value) {
-            //decode encoded commas
-            $record[$header[$key]] = preg_replace($csv_encode, $csv_delimiter, trim($value));
+            $record[$header[$key]] = trim($value);
         }
-        if (trim($rawline) !== '') {
+        if (trim(implode($line)) !== '') {
             // add a new group to the database
 
             // add fields to object $user
             foreach ($record as $name => $value) {
                 // check for required values
                 if (isset($required[$name]) and !$value) {
-                    print_error('missingfield', 'error', 'import.php?id='.$id, $name);
+                    print_error('missingfield', 'error', $PAGE->url, $name);
                 } else if ($name == "groupname") {
                     $newgroup->name = $value;
                 } else {
@@ -232,6 +235,7 @@ if ($mform_post->is_cancelled()) {
         }
     }
 
+    $csvimport->close();
     echo $OUTPUT->single_button($returnurl, get_string('continue'), 'get');
     echo $OUTPUT->footer();
     die;
@@ -240,5 +244,5 @@ if ($mform_post->is_cancelled()) {
 /// Print the form
 echo $OUTPUT->header();
 echo $OUTPUT->heading_with_help($strimportgroups, 'importgroups', 'core_group');
-$mform_post ->display();
+$importform->display();
 echo $OUTPUT->footer();
index e877b5f..ce13446 100644 (file)
@@ -27,6 +27,7 @@ if (!defined('MOODLE_INTERNAL')) {
 }
 
 require_once($CFG->libdir.'/formslib.php');
+require_once($CFG->libdir . '/csvlib.class.php');
 
 /**
  * Groups import form class
@@ -56,6 +57,19 @@ class groups_import_form extends moodleform {
         $mform->addElement('hidden', 'id');
         $mform->setType('id', PARAM_INT);
 
+        $choices = csv_import_reader::get_delimiter_list();
+        $mform->addElement('select', 'delimiter_name', get_string('csvdelimiter', 'group'), $choices);
+        if (array_key_exists('cfg', $choices)) {
+            $mform->setDefault('delimiter_name', 'cfg');
+        } else if (get_string('listsep', 'langconfig') == ';') {
+            $mform->setDefault('delimiter_name', 'semicolon');
+        } else {
+            $mform->setDefault('delimiter_name', 'comma');
+        }
+
+        $choices = core_text::get_encodings();
+        $mform->addElement('select', 'encoding', get_string('encoding', 'group'), $choices);
+        $mform->setDefault('encoding', 'UTF-8');
         $this->add_action_buttons(true, get_string('importgroups', 'core_group'));
 
         $this->set_data($data);
index 6cd3484..cab3476 100644 (file)
@@ -164,6 +164,7 @@ class api {
             unset($library->major_version);
             $library->minorVersion = (int) $library->minorversion;
             unset($library->minorversion);
+            $library->metadataSettings = json_decode($library->metadatasettings);
 
             // If we already add this library means that it is an old version,as the previous query was sorted by version.
             if (isset($added[$library->name])) {
index c4b575c..8d7f9b4 100644 (file)
@@ -228,7 +228,7 @@ class editor_framework implements H5peditorStorage {
         if ($libraries !== null) {
             // Get details for the specified libraries.
             $librariesin = [];
-            $fields = 'title, runnable';
+            $fields = 'title, runnable, metadatasettings';
 
             foreach ($libraries as $library) {
                 $params = [
@@ -242,11 +242,12 @@ class editor_framework implements H5peditorStorage {
                 if ($details) {
                     $library->title = $details->title;
                     $library->runnable = $details->runnable;
+                    $library->metadataSettings = json_decode($details->metadatasettings);
                     $librariesin[] = $library;
                 }
             }
         } else {
-            $fields = 'id, machinename as name, title, majorversion, minorversion';
+            $fields = 'id, machinename as name, title, majorversion, minorversion, metadatasettings';
             $librariesin = api::get_contenttype_libraries($fields);
         }
 
index 2e4a2ca..4b05422 100644 (file)
@@ -685,6 +685,9 @@ class framework implements \H5PFrameworkInterface {
      *                           - dropLibraryCss(optional): list of associative arrays containing:
      *                             - machineName: machine name for the librarys that are to drop their css
      *                           - semantics(optional): Json describing the content structure for the library
+     *                           - metadataSettings(optional): object containing:
+     *                             - disable: 1 if metadata is disabled completely
+     *                             - disableExtraTitleField: 1 if the title field is hidden in the form
      * @param bool $new Whether it is a new or existing library.
      */
     public function saveLibraryData(&$librarydata, $new = true) {
@@ -722,6 +725,7 @@ class framework implements \H5PFrameworkInterface {
             'addto' => isset($librarydata['addTo']) ? json_encode($librarydata['addTo']) : null,
             'coremajor' => isset($librarydata['coreApi']['majorVersion']) ? $librarydata['coreApi']['majorVersion'] : null,
             'coreminor' => isset($librarydata['coreApi']['majorVersion']) ? $librarydata['coreApi']['minorVersion'] : null,
+            'metadatasettings' => isset($librarydata['metadataSettings']) ? $librarydata['metadataSettings'] : null,
         );
 
         if ($new) {
index 478ab57..dc266f5 100644 (file)
@@ -246,6 +246,7 @@ class generator_testcase extends \advanced_testcase {
             'addto' => '/regex11/',
             'coremajor' => null,
             'coreminor' => null,
+            'metadatasettings' => null,
         ];
 
         $this->assertEquals($expected, $data);
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 5a6c463..3530052 100644 (file)
@@ -195,10 +195,12 @@ $string['coursemisconf'] = 'Course is misconfigured';
 $string['courserequestdisabled'] = 'Sorry, but course requests have been disabled by the administrator.';
 $string['csvcolumnduplicates'] = 'Duplicate columns detected';
 $string['csvemptyfile'] = 'The CSV file is empty';
+$string['csvfileerror'] = 'There is something wrong with the format of the CSV file. Please check the number of headings and columns match, and that the delimiter and file encoding are correct: {$a}';
 $string['csvfewcolumns'] = 'Not enough columns, please verify the delimiter setting';
 $string['csvinvalidcols'] = '<b>Invalid CSV file:</b> First line must include "Header Fields" and the file must be type of <br />"Expanded Fields/Comma Separated"<br />or<br /> "Expanded Fields with CAVV Result Code/Comma Separated"';
 $string['csvinvalidcolsnum'] = 'Invalid CSV file - each line must include 49 or 70 fields';
 $string['csvloaderror'] = 'An error occurred while loading the CSV file: {$a}';
+$string['csvnodata'] = 'Invalid CSV file - The CSV file has headers but does not contain any data.';
 $string['csvweirdcolumns'] = 'Invalid CSV file format - number of columns is not constant!';
 $string['dbconnectionfailed'] = '<p>Error: Database connection failed</p>
 <p>It is possible that the database is overloaded or otherwise not running properly.</p>
@@ -221,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 9f32f85..ab1ce3e 100644 (file)
@@ -43,6 +43,7 @@ $string['creategrouping'] = 'Create grouping';
 $string['creategroupinselectedgrouping'] = 'Create group in grouping';
 $string['createingrouping'] = 'Grouping of auto-created groups';
 $string['createorphangroup'] = 'Create orphan group';
+$string['csvdelimiter'] = 'CSV delimiter';
 $string['databaseupgradegroups'] = 'Groups version is now {$a}';
 $string['defaultgrouping'] = 'Default grouping';
 $string['defaultgroupingname'] = 'Grouping';
@@ -59,6 +60,7 @@ $string['editgroupsettings'] = 'Edit group settings';
 $string['editusersgroupsa'] = 'Edit groups for "{$a}"';
 $string['enablemessaging'] = 'Group messaging';
 $string['enablemessaging_help'] = 'If enabled, group members can send messages to the others in their group via the messaging drawer.';
+$string['encoding'] = 'Encoding';
 $string['enrolmentkey'] = 'Enrolment key';
 $string['enrolmentkey_help'] = 'An enrolment key enables access to the course to be restricted to only those who know the key. If a group enrolment key is specified, then not only will entering that key let the user into the course, but it will also automatically make them a member of this group.
 
index cbcd329..83f4257 100644 (file)
@@ -74,6 +74,7 @@ $string['group:html_track'] = 'HTML track files';
 $string['group:html_video'] = 'Video files natively supported by browsers';
 $string['group:image'] = 'Image files';
 $string['group:media_source'] = 'Streaming media';
+$string['group:optimised_image'] = 'Image files to be optimised, such as badges';
 $string['group:presentation'] = 'Presentation files';
 $string['group:sourcecode'] = 'Source code';
 $string['group:spreadsheet'] = 'Spreadsheet files';
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 42ae23d..be2f9ec 100644 (file)
  * Class containing utility methods for dataformats
  *
  * @package     core
- * @copyright   2020 Moodle Pty Ltd <support@moodle.com>
- * @author      2020 Paul Holden <paulh@moodle.com>
+ * @copyright   2020 Paul Holden <paulh@moodle.com>
  * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- * @license     Moodle Workplace License, distribution is restricted, contact support@moodle.com
  */
 
 namespace core;
@@ -33,15 +31,13 @@ use core_php_time_limit;
  * Dataformat utility class
  *
  * @package     core
- * @copyright   2020 Moodle Pty Ltd <support@moodle.com>
- * @author      2020 Paul Holden <paulh@moodle.com>
+ * @copyright   2020 Paul Holden <paulh@moodle.com>
  * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- * @license     Moodle Workplace License, distribution is restricted, contact support@moodle.com
  */
 class dataformat {
 
     /**
-     * Return an instead of a dataformat writer from given dataformat type
+     * Return an instance of a dataformat writer from given dataformat type
      *
      * @param string $dataformat
      * @return dataformat\base
index e1e0416..8d32b35 100644 (file)
@@ -107,7 +107,8 @@ abstract class core_filetypes {
                     'groups' => array('spreadsheet')),
             'gslides' => array('type' => 'application/vnd.google-apps.presentation', 'icon' => 'powerpoint',
                     'groups' => array('presentation')),
-            'gif' => array('type' => 'image/gif', 'icon' => 'gif', 'groups' => array('image', 'web_image'), 'string' => 'image'),
+            'gif' => array('type' => 'image/gif', 'icon' => 'gif', 'groups' => array('image', 'web_image', 'optimised_image'),
+                'string' => 'image'),
             'gtar' => array('type' => 'application/x-gtar', 'icon' => 'archive',
                     'groups' => array('archive'), 'string' => 'archive'),
             'tgz' => array('type' => 'application/g-zip', 'icon' => 'archive', 'groups' => array('archive'), 'string' => 'archive'),
@@ -136,9 +137,12 @@ abstract class core_filetypes {
             'jmt' => array('type' => 'text/xml', 'icon' => 'markup'),
             'jmx' => array('type' => 'text/xml', 'icon' => 'markup'),
             'jnlp' => array('type' => 'application/x-java-jnlp-file', 'icon' => 'markup'),
-            'jpe' => array('type' => 'image/jpeg', 'icon' => 'jpeg', 'groups' => array('image', 'web_image'), 'string' => 'image'),
-            'jpeg' => array('type' => 'image/jpeg', 'icon' => 'jpeg', 'groups' => array('image', 'web_image'), 'string' => 'image'),
-            'jpg' => array('type' => 'image/jpeg', 'icon' => 'jpeg', 'groups' => array('image', 'web_image'), 'string' => 'image'),
+            'jpe' => array('type' => 'image/jpeg', 'icon' => 'jpeg', 'groups' => array('image', 'web_image', 'optimised_image'),
+                'string' => 'image'),
+            'jpeg' => array('type' => 'image/jpeg', 'icon' => 'jpeg', 'groups' => array('image', 'web_image', 'optimised_image'),
+                'string' => 'image'),
+            'jpg' => array('type' => 'image/jpeg', 'icon' => 'jpeg', 'groups' => array('image', 'web_image', 'optimised_image'),
+                'string' => 'image'),
             'jqz' => array('type' => 'text/xml', 'icon' => 'markup'),
             'js' => array('type' => 'application/x-javascript', 'icon' => 'text', 'groups' => array('web_file')),
             'json' => array('type' => 'application/json', 'icon' => 'text'),
@@ -206,7 +210,8 @@ abstract class core_filetypes {
             'php' => array('type' => 'text/plain', 'icon' => 'sourcecode'),
             'pic' => array('type' => 'image/pict', 'icon' => 'image', 'groups' => array('image'), 'string' => 'image'),
             'pict' => array('type' => 'image/pict', 'icon' => 'image', 'groups' => array('image'), 'string' => 'image'),
-            'png' => array('type' => 'image/png', 'icon' => 'png', 'groups' => array('image', 'web_image'), 'string' => 'image'),
+            'png' => array('type' => 'image/png', 'icon' => 'png', 'groups' => array('image', 'web_image', 'optimised_image'),
+                'string' => 'image'),
             'pps' => array('type' => 'application/vnd.ms-powerpoint', 'icon' => 'powerpoint', 'groups' => array('presentation')),
             'ppt' => array('type' => 'application/vnd.ms-powerpoint', 'icon' => 'powerpoint', 'groups' => array('presentation')),
             'pptx' => array('type' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
index 8f91417..a12fa68 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="lib/db" VERSION="20200504" COMMENT="XMLDB file for core Moodle tables"
+<XMLDB PATH="lib/db" VERSION="20200804" COMMENT="XMLDB file for core Moodle tables"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="../../lib/xmldb/xmldb.xsd"
 >
         <FIELD NAME="addto" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="Plugin configuration data"/>
         <FIELD NAME="coremajor" TYPE="int" LENGTH="4" NOTNULL="false" SEQUENCE="false" COMMENT="H5P core API major version required"/>
         <FIELD NAME="coreminor" TYPE="int" LENGTH="4" NOTNULL="false" SEQUENCE="false" COMMENT="H5P core API minor version required"/>
+        <FIELD NAME="metadatasettings" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="Library metadata settings"/>
       </FIELDS>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
       </INDEXES>
     </TABLE>
   </TABLES>
-</XMLDB>
\ No newline at end of file
+</XMLDB>
index 32298db..7677467 100644 (file)
@@ -2194,8 +2194,11 @@ function xmldb_main_upgrade($oldversion) {
             $DB->delete_records('competency_userevidencecomp', ['userevidenceid' => $userevidence->id]);
             $DB->delete_records('competency_userevidence', ['id' => $userevidence->id]);
 
-            $context = context_user::instance($userevidence->userid);
-            $fs->delete_area_files($context->id, 'core_competency', 'userevidence', $userevidence->id);
+            if ($record = $DB->get_record('context', ['contextlevel' => CONTEXT_USER, 'instanceid' => $userevidence->userid],
+                    '*', IGNORE_MISSING)) {
+                // Delete all orphaned user evidences files.
+                $fs->delete_area_files($record->id, 'core_competency', 'userevidence', $userevidence->userid);
+            }
         }
 
         $sql = "SELECT cp.id
@@ -2527,5 +2530,64 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2020061501.04);
     }
 
+    if ($oldversion < 2020061501.09) {
+        // Delete all user evidence files from users that have been deleted.
+        $sql = "SELECT DISTINCT f.*
+                  FROM {files} f
+             LEFT JOIN {context} c ON f.contextid = c.id
+                 WHERE f.component = :component
+                   AND f.filearea = :filearea
+                   AND c.id IS NULL";
+        $stalefiles = $DB->get_records_sql($sql, ['component' => 'core_competency', 'filearea' => 'userevidence']);
+
+        $fs = get_file_storage();
+        foreach ($stalefiles as $stalefile) {
+            $fs->get_file_instance($stalefile)->delete();
+        }
+
+        upgrade_main_savepoint(true, 2020061501.09);
+    }
+
+    if ($oldversion < 2020061501.11) {
+
+        // Define field metadatasettings to be added to h5p_libraries.
+        $table = new xmldb_table('h5p_libraries');
+        $field = new xmldb_field('metadatasettings', XMLDB_TYPE_TEXT, null, null, null, null, null, 'coreminor');
+
+        // Conditionally launch add field metadatasettings.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Get installed library files that have no metadata settings value.
+        $params = [
+            'component' => 'core_h5p',
+            'filearea' => 'libraries',
+            'filename' => 'library.json',
+        ];
+        $sql = "SELECT l.id, f.id as fileid
+                  FROM {files} f
+             LEFT JOIN {h5p_libraries} l ON f.itemid = l.id
+                 WHERE f.component = :component
+                       AND f.filearea = :filearea
+                       AND f.filename = :filename";
+        $libraries = $DB->get_records_sql($sql, $params);
+
+        // Update metadatasettings field when the attribute is present in the library.json file.
+        $fs = get_file_storage();
+        foreach ($libraries as $library) {
+            $jsonfile = $fs->get_file_by_id($library->fileid);
+            $jsoncontent = json_decode($jsonfile->get_content());
+            if (isset($jsoncontent->metadataSettings)) {
+                unset($library->fileid);
+                $library->metadatasettings = json_encode($jsoncontent->metadataSettings);
+                $DB->update_record('h5p_libraries', $library);
+            }
+        }
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2020061501.11);
+    }
+
     return true;
 }
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 1a3e43e..81c39d1 100644 (file)
Binary files a/lib/editor/atto/plugins/undo/yui/build/moodle-atto_undo-button/moodle-atto_undo-button-debug.js and b/lib/editor/atto/plugins/undo/yui/build/moodle-atto_undo-button/moodle-atto_undo-button-debug.js differ
index fc5786d..7031821 100644 (file)
Binary files a/lib/editor/atto/plugins/undo/yui/build/moodle-atto_undo-button/moodle-atto_undo-button-min.js and b/lib/editor/atto/plugins/undo/yui/build/moodle-atto_undo-button/moodle-atto_undo-button-min.js differ
index 1a3e43e..81c39d1 100644 (file)
Binary files a/lib/editor/atto/plugins/undo/yui/build/moodle-atto_undo-button/moodle-atto_undo-button.js and b/lib/editor/atto/plugins/undo/yui/build/moodle-atto_undo-button/moodle-atto_undo-button.js differ
index e72ef44..aa3ce8a 100644 (file)
@@ -255,6 +255,12 @@ Y.namespace('M.atto_undo').Button = Y.Base.create('button', Y.M.editor_atto.Edit
      */
     _redoHandler: function(e) {
         e.preventDefault();
+
+        // Don't do anything if redo stack is empty.
+        if (this._redoStack.length === 0) {
+            return;
+        }
+
         var html = this._getHTML(),
             redo = this._getRedo();
 
index 2a42f8e..fd0c43e 100644 (file)
@@ -1639,6 +1639,7 @@ function download_file_content($url, $headers=null, $postdata=null, $fullrespons
  *     commonly used in moodle the following groups:
  *       - web_image - image that can be included as <img> in HTML
  *       - image - image that we can parse using GD to find it's dimensions, also used for portfolio format
+ *       - optimised_image - image that will be processed and optimised
  *       - video - file that can be imported as video in text editor
  *       - audio - file that can be imported as audio in text editor
  *       - archive - we can extract files from this archive
index 7fb1b5f..355fefe 100644 (file)
@@ -302,10 +302,10 @@ class filetypes_util_testcase extends advanced_testcase {
         // All these three files are in both "image" and also "web_image"
         // groups. We display both groups.
         $data = $util->data_for_browser('jpg png gif', true, '.gif');
-        $this->assertEquals(2, count($data));
+        $this->assertEquals(3, count($data));
         $this->assertTrue($data[0]->key !== $data[1]->key);
         foreach ($data as $group) {
-            $this->assertTrue(($group->key === 'image' || $group->key === 'web_image'));
+            $this->assertTrue(($group->key === 'image' || $group->key === 'web_image' || $group->key === 'optimised_image'));
             $this->assertEquals(3, count($group->types));
             $this->assertFalse($group->selectable);
             foreach ($group->types as $ext) {
@@ -317,11 +317,11 @@ class filetypes_util_testcase extends advanced_testcase {
             }
         }
 
-        // There is a group web_image which is a subset of the group image. The
-        // file extensions that fall into both groups will be displayed twice.
+        // The groups web_image and optimised_image are a subset of the group image. The
+        // file extensions that fall into these groups will be displayed thrice.
         $data = $util->data_for_browser('web_image');
         foreach ($data as $group) {
-            $this->assertTrue(($group->key === 'image' || $group->key === 'web_image'));
+            $this->assertTrue(($group->key === 'image' || $group->key === 'web_image' || $group->key === 'optimised_image'));
         }
 
         // Check that "All file types" are displayed first.
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 11ddc73..2b10d71 100644 (file)
Binary files a/lib/table/amd/build/dynamic.min.js and b/lib/table/amd/build/dynamic.min.js differ
index 089870b..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 899809b..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)
@@ -336,12 +342,13 @@ export const getLastInitial = tableRoot => getTableData(tableRoot).tableLastInit
  * @param {HTMLElement} tableRoot
  * @param {String} columnToHide
  * @param {Bool} refreshContent
+ * @returns {Promise}
  */
 export const hideColumn = (tableRoot, columnToHide, refreshContent = true) => {
     const hiddenColumns = JSON.parse(tableRoot.dataset.tableHiddenColumns);
     hiddenColumns.push(columnToHide);
 
-    updateTable(tableRoot, {hiddenColumns}, refreshContent);
+    return updateTable(tableRoot, {hiddenColumns}, refreshContent);
 };
 
 /**
@@ -350,12 +357,13 @@ export const hideColumn = (tableRoot, columnToHide, refreshContent = true) => {
  * @param {HTMLElement} tableRoot
  * @param {String} columnToShow
  * @param {Bool} refreshContent
+ * @returns {Promise}
  */
 export const showColumn = (tableRoot, columnToShow, refreshContent = true) => {
     let hiddenColumns = JSON.parse(tableRoot.dataset.tableHiddenColumns);
     hiddenColumns = hiddenColumns.filter(columnName => columnName !== columnToShow);
 
-    updateTable(tableRoot, {hiddenColumns}, refreshContent);
+    return updateTable(tableRoot, {hiddenColumns}, refreshContent);
 };
 
 /**
index 78bade9..63510a6 100644 (file)
@@ -1444,23 +1444,19 @@ class flexible_table {
             }
         }
 
-        // Now, update the column attributes for collapsed columns
-        foreach (array_keys($this->columns) as $column) {
-            if (!empty($this->prefs['collapse'][$column])) {
-                $this->column_style[$column]['width'] = '10px';
-            }
-        }
+        $this->set_hide_show_preferences();
+        $this->set_sorting_preferences();
+        $this->set_initials_preferences();
 
-        // Now, update the column attributes for collapsed columns
+        // Now, reduce the width of collapsed columns and remove the width from columns that should be expanded.
         foreach (array_keys($this->columns) as $column) {
             if (!empty($this->prefs['collapse'][$column])) {
                 $this->column_style[$column]['width'] = '10px';
+            } else {
+                unset($this->column_style[$column]['width']);
             }
         }
 
-        $this->set_sorting_preferences();
-        $this->set_initials_preferences();
-
         if (empty($this->baseurl)) {
             debugging('You should set baseurl when using flexible_table.');
             global $PAGE;
index c763f4b..2991753 100644 (file)
@@ -82,8 +82,8 @@
                             <hr>
                             <div class="row">
                                 <div class="col-sm-3">
-                                    {{#action}}<h4><a href="{{action}}">{{text}}</a><h4>{{/action}}
-                                    {{^action}}<h4>{{text}}<h4>{{/action}}
+                                    {{#action}}<h4><a href="{{action}}">{{text}}</a></h4>{{/action}}
+                                    {{^action}}<h4>{{text}}</h4>{{/action}}
                                 </div>
                                 <div class="col">
                                     <ul class="list-unstyled">
                 <div class="container">
                     <div class="row">
                         <div class="col-sm-3">
-                            {{#action}}<h4><a href="{{action}}">{{text}}</a><h4>{{/action}}
-                            {{^action}}<h4>{{text}}<h4>{{/action}}
+                            {{#action}}<h4><a href="{{action}}">{{text}}</a></h4>{{/action}}
+                            {{^action}}<h4>{{text}}</h4>{{/action}}
                         </div>
                         <div class="col-sm-9">
                             <ul class="list-unstyled">
                                 <hr>
                                 <div class="row">
                                     <div class="col-sm-3">
-                                        {{#action}}<h4><a href="{{action}}">{{text}}</a><h4>{{/action}}
-                                        {{^action}}<h4>{{text}}<h4>{{/action}}
+                                        {{#action}}<h4><a href="{{action}}">{{text}}</a></h4>{{/action}}
+                                        {{^action}}<h4>{{text}}</h4>{{/action}}
                                     </div>
                                     <div class="col-sm-9">
                                         <ul class="list-unstyled">
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 f47b39c..33e3ab6 100644 (file)
@@ -43,7 +43,7 @@
 >
     <div class="container-fluid">
         <div class="row-fluid h-100 no-gutters">
-            <div class="col-4 d-flex flex-column">
+            <div class="col-4 d-flex flex-column conversationcontainer">
                 <div class="border-right h-100">
                     <div class="panel-header-container" data-region="panel-header-container">
                         {{> core_message/message_drawer_view_overview_header }}
index fd9cd4c..432bcff 100644 (file)
@@ -125,14 +125,14 @@ if ($action === 'pollconversions') {
             $annotations = page_editor::get_annotations($grade->id, $index, $draft);
             $page->annotations = $annotations;
             $response->pages[] = $page;
-
-            $component = 'assignfeedback_editpdf';
-            $filearea = document_services::PAGE_IMAGE_FILEAREA;
-            $filepath = '/';
-            $fs = get_file_storage();
-            $files = $fs->get_directory_files($context->id, $component, $filearea, $grade->id, $filepath);
-            $response->pageready = count($files);
         }
+
+        $component = 'assignfeedback_editpdf';
+        $filearea = document_services::PAGE_IMAGE_FILEAREA;
+        $filepath = '/';
+        $fs = get_file_storage();
+        $files = $fs->get_directory_files($context->id, $component, $filearea, $grade->id, $filepath);
+        $response->pageready = count($files);
     }
 
     echo json_encode($response);
index 9c02a10..f417141 100644 (file)
@@ -576,7 +576,9 @@ EOD;
             }
         }
 
-        if (empty($pages)) {
+        $totalpagesforattempt = self::page_number_for_attempt($assignment, $userid, $attemptnumber, false);
+        // Here we are comparing the total number of images against the total number of pages from the combined PDF.
+        if (empty($pages) || count($pages) != $totalpagesforattempt) {
             if ($readonly) {
                 // This should never happen, there should be a version of the pages available
                 // whenever we are requesting the readonly version.
index 4b50121..825b708 100644 (file)
@@ -5129,18 +5129,33 @@ class assign {
         require_once($CFG->dirroot . '/mod/assign/submissionconfirmform.php');
 
         // Check that all of the submission plugins are ready for this submission.
+        // Also check whether there is something to be submitted as well against atleast one.
         $notifications = array();
         $submission = $this->get_user_submission($USER->id, false);
+        if ($this->get_instance()->teamsubmission) {
+            $submission = $this->get_group_submission($USER->id, 0, false);
+        }
+
         $plugins = $this->get_submission_plugins();
+        $hassubmission = false;
         foreach ($plugins as $plugin) {
             if ($plugin->is_enabled() && $plugin->is_visible()) {
                 $check = $plugin->precheck_submission($submission);
                 if ($check !== true) {
                     $notifications[] = $check;
                 }
+
+                if (is_object($submission) && !$plugin->is_empty($submission)) {
+                    $hassubmission = true;
+                }
             }
         }
 
+        // If there are no submissions and no existing notifications to be displayed the stop.
+        if (!$hassubmission && !$notifications) {
+            $notifications[] = get_string('addsubmission_help', 'assign');
+        }
+
         $data = new stdClass();
         $adminconfig = $this->get_admin_config();
         $requiresubmissionstatement = $this->get_instance()->requiresubmissionstatement;
@@ -5338,10 +5353,20 @@ class assign {
                     $gradefordisplay = $this->display_grade($gradebookgrade->grade, false);
                 }
                 $gradeddate = $gradebookgrade->dategraded;
-                if (isset($grade->grader) && $grade->grader > 0) {
-                    $grader = $DB->get_record('user', array('id' => $grade->grader));
-                } else if (isset($gradebookgrade->usermodified) && $gradebookgrade->usermodified > 0) {
-                    $grader = $DB->get_record('user', array('id' => $gradebookgrade->usermodified));
+
+                // Only display the grader if it is in the right state.
+                if (in_array($gradingstatus, [ASSIGN_GRADING_STATUS_GRADED, ASSIGN_MARKING_WORKFLOW_STATE_RELEASED])){
+                    if (isset($grade->grader) && $grade->grader > 0) {
+                        $grader = $DB->get_record('user', array('id' => $grade->grader));
+                    } else if (isset($gradebookgrade->usermodified)
+                        && $gradebookgrade->usermodified > 0
+                        && has_capability('mod/assign:grade', $this->get_context(), $gradebookgrade->usermodified)) {
+                        // Grader not provided. Check that usermodified is a user who can grade.
+                        // Case 1: When an assignment is reopened an empty assign_grade is created so the feedback
+                        // plugin can know which attempt it's referring to. In this case, usermodifed is a student.
+                        // Case 2: When an assignment's grade is overrided via the gradebook, usermodified is a grader
+                        $grader = $DB->get_record('user', array('id' => $gradebookgrade->usermodified));
+                    }
                 }
             }
 
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 9a8cbf4..e681e56 100644 (file)
@@ -55,9 +55,11 @@ class book_chapter_edit_form extends moodleform {
             );
         }
 
-        $mform->addElement('text', 'title', get_string('chaptertitle', 'mod_book'), array('size'=>'30'));
+        $mform->addElement('text', 'title', get_string('chaptertitle', 'mod_book'),
+            ['size' => '30', 'maxlength' => '255']);
         $mform->setType('title', PARAM_RAW);
         $mform->addRule('title', null, 'required', null, 'client');
+        $mform->addRule('title', get_string('maximumchars', '', 255), 'maxlength', 255, 'client');
 
         $mform->addElement('advcheckbox', 'subchapter', get_string('subchapter', 'mod_book'), $disabledmsg);
 
index 40681f7..b925808 100644 (file)
@@ -22,7 +22,7 @@ require_once(__DIR__ . '/lib.php');
 $action       = optional_param('action', '', PARAM_ALPHANUM);
 $beepid       = optional_param('beep', '', PARAM_RAW);
 $chatsid      = required_param('chat_sid', PARAM_ALPHANUM);
-$theme        = required_param('theme', PARAM_ALPHANUMEXT);
+$theme        = required_param('chat_theme', PARAM_ALPHANUMEXT);
 $chatmessage  = optional_param('chat_message', '', PARAM_RAW);
 $chatlasttime = optional_param('chat_lasttime', 0, PARAM_INT);
 $chatlastrow  = optional_param('chat_lastrow', 1, PARAM_INT);
index d1a15af..248f8b5 100644 (file)
@@ -19,7 +19,7 @@ require_once('../lib.php');
 
 $id      = required_param('id', PARAM_INT);
 $groupid = optional_param('groupid', 0, PARAM_INT); // Only for teachers.
-$theme   = optional_param('theme', 'course_theme', PARAM_SAFEDIR); // The value course_theme == the current theme.
+$theme   = optional_param('chat_theme', 'course_theme', PARAM_SAFEDIR); // The value course_theme == the current theme.
 
 $url = new moodle_url('/mod/chat/gui_ajax/index.php', array('id' => $id));
 if ($groupid !== 0) {
@@ -97,13 +97,15 @@ echo $OUTPUT->box(html_writer::tag('h2',  get_string('messages', 'chat'), array(
         '<ul id="messages-list"></ul>', '', 'chat-messages');
 $table = new html_table();
 $table->data = array(
-    array('<div class="form-inline"><label class="accesshide" for="input-message">'.get_string('entermessage', 'chat').' </label>'.
+    array('<div class="form-inline"><div class="d-flex"><label class="accesshide" for="input-message">'.
+          get_string('entermessage', 'chat').' </label>'.
           '<span class="form-group"><input type="text" disabled="true" class="form-control" ' .
           'id="input-message" value="Loading..." size="48" /></span>'.
           '<span class="form-group"><input type="button" id="button-send" class="btn btn-secondary mx-1" ' .
-          'value="'.get_string('send', 'chat').'" /></span> <span class="form-group"><a id="choosetheme" href="###">'.
+          'value="'.get_string('send', 'chat').'" />' .$OUTPUT->help_icon('usingchat', 'chat'). '</span></div>' .
+          ' <div class="form-group d-flex ml-auto"><a id="choosetheme" href="###">'.
           get_string('themes').
-          ' &raquo; </a></span></div>'));
+          ' &raquo; </a></div></div>'));
 echo $OUTPUT->box(html_writer::tag('h2',  get_string('composemessage', 'chat'), array('class' => 'accesshide')) .
         html_writer::table($table), '', 'chat-input-area');
 echo $OUTPUT->box('', '', 'chat-notify');
index 758df34..231392f 100644 (file)
@@ -91,7 +91,7 @@ M.mod_chat_ajax.init = function(Y, cfg) {
                     action : 'init',
                     chat_init : 1,
                     chat_sid : this.cfg.sid,
-                    theme : this.theme
+                    chat_theme : this.cfg.theme
                 }),
                 on : {
                     success : function(tid, outcome) {
@@ -117,15 +117,21 @@ M.mod_chat_ajax.init = function(Y, cfg) {
             // Create and initalise theme changing menu.
             this.thememenu = new Y.YUI2.widget.Menu('basicmenu', {xy:[0,0]});
             this.thememenu.addItems([
-                {text: M.util.get_string('bubble', 'mod_chat'), url: this.cfg.chaturl + '&theme=bubble'},
-                {text: M.util.get_string('compact', 'mod_chat'), url: this.cfg.chaturl + '&theme=compact'},
-                {text: M.util.get_string('coursetheme', 'mod_chat'), url: this.cfg.chaturl + '&theme=course_theme'}
+                {text: M.util.get_string('bubble', 'mod_chat'), url: this.cfg.chaturl + '&chat_theme=bubble'},
+                {text: M.util.get_string('compact', 'mod_chat'), url: this.cfg.chaturl + '&chat_theme=compact'},
+                {text: M.util.get_string('coursetheme', 'mod_chat'), url: this.cfg.chaturl + '&chat_theme=course_theme'}
             ]);
             this.thememenu.render(document.body);
             Y.one('#choosetheme').on('click', function(e) {
                 this.moveTo((e.pageX - 20), (e.pageY - 20));
                 this.show();
             }, this.thememenu);
+
+            // Set the data-placement for the help-icon to display all the content.
+            this.helpicon = Y.one('#button-send + a');
+            this.dataset = this.helpicon.get('dataset');
+            this.dataset.placement = 'top';
+            this.helpicon.set('dataset', this.dataset);
         },
 
         append_message : function(key, message, row) {
@@ -145,7 +151,7 @@ M.mod_chat_ajax.init = function(Y, cfg) {
                 var data = {
                     chat_message : (!beep) ? this.messageinput.get('value') : '',
                     chat_sid : this.cfg.sid,
-                    theme : this.cfg.theme
+                    chat_theme : this.cfg.theme
                 };
                 if (beep) {
                     data.beep = beep
@@ -193,7 +199,7 @@ M.mod_chat_ajax.init = function(Y, cfg) {
                     chat_lastrow : this.cfg.chat_lastrow || false,
                     chat_lasttime : this.cfg.chat_lasttime,
                     chat_sid : this.cfg.sid,
-                    theme : this.cfg.theme
+                    chat_theme : this.cfg.theme
                 }),
                 on : {
                     success : this.update_messages_callback
index 035eb93..0c4a55f 100644 (file)
@@ -242,3 +242,8 @@ Dual licensed under MIT and GNU GPLv2 © Nicolas Gallagher
     border-width: 9px 0 9px 21px;
     border-color: transparent #fff;
 }
+
+#button-send + a > i.text-info {
+    /* stylelint-disable-next-line declaration-no-important */
+    color: black !important;
+}
index e2b463c..5b99be8 100644 (file)
@@ -95,3 +95,8 @@
     background: #e3e8f0;
     height: 100%;
 }
+
+#button-send + a > i.text-info {
+    /* stylelint-disable-next-line declaration-no-important */
+    color: black !important;
+}
index 0c3f631..9001f7d 100644 (file)
@@ -149,7 +149,9 @@ echo $OUTPUT->box_end();
 echo '<div id="send">';
 echo '<form id="editing" method="post" action="index.php">';
 
-echo '<h2><label for="message">'.get_string('sendmessage', 'message').'</label></h2>';
+echo '<h2><label for="message">' . get_string('sendmessage', 'message');
+echo $OUTPUT->help_icon('usingchat', 'chat');
+echo '</label></h2>';
 echo '<div class="mb-1">';
 echo '<input type="text" id="message" class="form-control" name="message" value="'.s($refreshedmessage, true).'" size="60" />';
 echo '</div><div class="mb-1">';
index 383a322..663c3c5 100644 (file)
@@ -194,7 +194,11 @@ class mod_feedback_responses_table extends table_sql {
         if (preg_match('/^val(\d+)$/', $column, $matches)) {
             $items = $this->feedbackstructure->get_items();
             $itemobj = feedback_get_item_class($items[$matches[1]]->typ);
-            return trim($itemobj->get_printval($items[$matches[1]], (object) ['value' => $row->$column] ));
+            $printval = $itemobj->get_printval($items[$matches[1]], (object) ['value' => $row->$column]);
+            if ($this->is_downloading()) {
+                $printval = html_entity_decode($printval, ENT_QUOTES);
+            }
+            return trim($printval);
         }
         return $row->$column;
     }
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 82ac800..47bcfad 100644 (file)
@@ -37,6 +37,7 @@ use mod_forum\local\factories\url as url_factory;
 use mod_forum\local\factories\vault as vault_factory;
 use mod_forum\local\managers\capability as capability_manager;
 use mod_forum\local\renderers\posts as posts_renderer;
+use forum_portfolio_caller;
 use core\output\notification;
 use context;
 use context_module;
@@ -52,6 +53,7 @@ use stdClass;
 use url_select;
 
 require_once($CFG->dirroot . '/mod/forum/lib.php');
+require_once($CFG->dirroot . '/mod/forum/locallib.php');
 
 /**
  * Discussion renderer class.
@@ -213,7 +215,7 @@ class discussion {
                 'movediscussion' => null,
                 'pindiscussion' => null,
                 'neighbourlinks' => $this->get_neighbour_links_html(),
-                'exportdiscussion' => !empty($CFG->enableportfolios) ? $this->get_export_discussion_html() : null
+                'exportdiscussion' => !empty($CFG->enableportfolios) ? $this->get_export_discussion_html($user) : null
             ]
         ]);
 
@@ -355,15 +357,18 @@ class discussion {
     /**
      * Get the HTML to render the export discussion button.
      *
-     * @return string|null
+     * @param   stdClass $user The user viewing the discussion
+     * @return  string|null
      */
-    private function get_export_discussion_html() : ?string {
+    private function get_export_discussion_html(stdClass $user) : ?string {
         global $CFG;
 
-        require_once($CFG->libdir . '/portfoliolib.php');
-        $discussion = $this->discussion;
+        if (!$this->capabilitymanager->can_export_discussions($user)) {
+            return null;
+        }
+
         $button = new \portfolio_add_button();
-        $button->set_callback_options('forum_portfolio_caller', ['discussionid' => $discussion->get_id()], 'mod_forum');
+        $button->set_callback_options('forum_portfolio_caller', ['discussionid' => $this->discussion->get_id()], 'mod_forum');
         $button = $button->to_html(PORTFOLIO_ADD_FULL_FORM, get_string('exportdiscussion', 'mod_forum'));
         return $button ?: null;
     }
index 06466e6..a7c9687 100644 (file)
@@ -37,6 +37,8 @@ use tool_dataprivacy\context_instance;
 
 defined('MOODLE_INTERNAL') || die();
 
+require_once($CFG->dirroot . '/grade/grading/lib.php');
+
 /**
  * Implementation of the privacy subsystem plugin provider for the forum activity module.
  *
@@ -690,35 +692,44 @@ class provider implements
     protected static function export_all_posts(int $userid, array $mappings) {
         global $DB;
 
-        // Find all of the posts, and post subscriptions for this forum.
-        list($foruminsql, $forumparams) = $DB->get_in_or_equal(array_keys($mappings), SQL_PARAMS_NAMED);
-        $ratingsql = \core_rating\privacy\provider::get_sql_join('rat', 'mod_forum', 'post', 'p.id', $userid);
-        $sql = "SELECT
-                    p.discussion AS id,
-                    f.id AS forumid,
-                    d.name,
-                    d.groupid
-                  FROM {forum} f
-                  JOIN {forum_discussions} d ON d.forum = f.id
-                  JOIN {forum_posts} p ON p.discussion = d.id
-             LEFT JOIN {forum_read} fr ON fr.postid = p.id AND fr.userid = :readuserid
-            {$ratingsql->join}
-                 WHERE f.id ${foruminsql} AND
-                (
-                    p.userid = :postuserid OR
-                    p.privatereplyto = :privatereplyrecipient OR
-                    fr.id IS NOT NULL OR
-                    {$ratingsql->userwhere}
-                )
-              GROUP BY f.id, p.discussion, d.name, d.groupid
-        ";
+        $commonsql = "SELECT p.discussion AS id, f.id AS forumid, d.name, d.groupid
+                        FROM {forum} f
+                        JOIN {forum_discussions} d ON d.forum = f.id
+                        JOIN {forum_posts} p ON p.discussion = d.id";
+
+        // All discussions with posts authored by the user or containing private replies to the user.
+        list($foruminsql1, $forumparams1) = $DB->get_in_or_equal(array_keys($mappings), SQL_PARAMS_NAMED);
+        $sql1 = "{$commonsql}
+                       WHERE f.id {$foruminsql1}
+                         AND (p.userid = :postuserid OR p.privatereplyto = :privatereplyrecipient)";
+
+        // All discussions with the posts marked as read by the user.
+        list($foruminsql2, $forumparams2) = $DB->get_in_or_equal(array_keys($mappings), SQL_PARAMS_NAMED);
+        $sql2 = "{$commonsql}
+                        JOIN {forum_read} fr ON fr.postid = p.id
+                       WHERE f.id {$foruminsql2}
+                         AND fr.userid = :readuserid";
+
+        // All discussions with ratings provided by the user.
+        list($foruminsql3, $forumparams3) = $DB->get_in_or_equal(array_keys($mappings), SQL_PARAMS_NAMED);
+        $ratingsql = \core_rating\privacy\provider::get_sql_join('rat', 'mod_forum', 'post', 'p.id', $userid, true);
+        $sql3 = "{$commonsql}
+                 {$ratingsql->join}
+                       WHERE f.id {$foruminsql3}
+                         AND {$ratingsql->userwhere}";
+
+        $sql = "SELECT *
+                  FROM ({$sql1} UNION {$sql2} UNION {$sql3}) united
+              GROUP BY id, forumid, name, groupid";
 
         $params = [
-            'postuserid'    => $userid,
-            'readuserid'    => $userid,
+            'postuserid' => $userid,
+            'readuserid' => $userid,
             'privatereplyrecipient' => $userid,
         ];
-        $params += $forumparams;
+        $params += $forumparams1;
+        $params += $forumparams2;
+        $params += $forumparams3;
         $params += $ratingsql->params;
 
         $discussions = $DB->get_records_sql($sql, $params);
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 ef7545d..bbda97a 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="mod/forum/db" VERSION="20191001" COMMENT="XMLDB file for Moodle mod/forum"
+<XMLDB PATH="mod/forum/db" VERSION="20200508" COMMENT="XMLDB file for Moodle mod/forum"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="../../../lib/xmldb/xmldb.xsd"
 >
@@ -99,6 +99,7 @@
         <INDEX NAME="userid" UNIQUE="false" FIELDS="userid"/>
         <INDEX NAME="created" UNIQUE="false" FIELDS="created"/>
         <INDEX NAME="mailed" UNIQUE="false" FIELDS="mailed"/>
+        <INDEX NAME="privatereplyto" UNIQUE="false" FIELDS="privatereplyto" COMMENT="The field is used in certain queries (such as privacy requests) to search for private replies to the user."/>
       </INDEXES>
     </TABLE>
     <TABLE NAME="forum_queue" COMMENT="For keeping track of posts that will be mailed in digest form">
index 7d36e04..deb13e3 100644 (file)
@@ -246,5 +246,17 @@ function xmldb_forum_upgrade($oldversion) {
     // Automatically generated Moodle v3.9.0 release upgrade line.
     // Put any upgrade step following this.
 
+    if ($oldversion < 2020061501) {
+        // Add index privatereplyto (not unique) to the forum_posts table.
+        $table = new xmldb_table('forum_posts');
+        $index = new xmldb_index('privatereplyto', XMLDB_INDEX_NOTUNIQUE, ['privatereplyto']);
+
+        if (!$dbman->index_exists($table, $index)) {
+            $dbman->add_index($table, $index);
+        }
+
+        upgrade_mod_savepoint(true, 2020061501, 'forum');
+    }
+
     return true;
 }
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 61c7c5e..a4280de 100644 (file)
@@ -92,8 +92,8 @@
                     {{> mod_forum/local/grades/local/grader/user_picker_placeholder }}
                 </div>
             </div>
-            <div class="body-container position-relative d-flex flex-column overflow-auto" data-region="body-container">
-                <div class="grader-grading-panel-display pt-3 overflow-auto" data-region="grading-panel-container">
+            <div class="body-container position-relative d-flex flex-column" data-region="body-container">
+                <div class="grader-grading-panel-display pt-3" data-region="grading-panel-container">
                     <h4 class="mb-0 h5 px-3 font-weight-normal">
                         {{#pix}} i/grading, core {{/pix}}{{#str}} gradingmodulename, core_grades, {{moduleName}} {{/str}}
                     </h4>
index 44bf942..37db5b8 100644 (file)
@@ -34,7 +34,7 @@
         "moduleName": "Chef the Forum"
     }
 }}
-<nav id="nav-container-{{uniqid}}" class="grader-grading_navigation navbar" aria-label="{{#str}} forumgradingnavigation, mod_forum {{/str}}">
+<nav id="nav-container-{{uniqid}}" class="grader-grading_navigation navbar px-0 px-sm-3" aria-label="{{#str}} forumgradingnavigation, mod_forum {{/str}}">
     <div class="d-none d-sm-flex align-items-center">
         <a href="{{{courseUrl}}}" class="btn btn-link px-2 colour-inherit">
             <h5 class="d-inline px-0 mb-0">{{courseName}}</h5>
@@ -47,7 +47,7 @@
         <h5 class="d-inline px-2 mb-0 font-weight-bold">{{#str}}grading, forum{{/str}}</h5>
     </div>
 
-    <div class="ml-auto">
+    <div class="ml-1 ml-sm-auto">
         <button
             class="btn btn-icon icon-no-margin drawer-button mr-1 active"
             data-action="expand-grading-drawer"
@@ -60,7 +60,7 @@
             <span class="dir-rtl-hide" aria-hidden="true">{{#pix}} show-grader-panel, mod_forum, {{#str}} showgraderpanel, mod_forum {{/str}} {{/pix}}</span>
         </button>
         <button
-            class="btn btn-icon icon-no-margin drawer-button mr-1"
+            class="btn btn-icon icon-no-margin drawer-button mr-1 d-none d-sm-inline-block"
             aria-label="{{#str}} hidegraderpanel, mod_forum {{/str}}"
             data-action="collapse-grading-drawer"
             aria-controls="grading-drawer-{{uniqid}}"
             <span aria-hidden="true">{{#pix}} hide-grader-panel, mod_forum, {{#str}} hidegraderpanel, mod_forum {{/str}} {{/pix}}</span>
         </button>
         <button
-            class="btn btn-primary font-weight-bold ml-2 px-4"
+            class="btn btn-primary font-weight-bold ml-sm-2 px-4"
             data-action="savegrade"
         >
             {{#str}} save {{/str}}
         </button>
         <button
-            class="btn btn-secondary font-weight-bold ml-2 px-4"
+            class="btn btn-secondary font-weight-bold ml-1 ml-sm-2 px-4"
             aria-label="{{#str}} closegrader, mod_forum {{/str}}"
             data-action="closegrader"
             type="button"
         >
             {{#str}} close, mod_forum {{/str}}
         </button>
-        <div class="btn-group">
-        <button
+        <div class="btn-group d-none d-sm-inline-block">
+            <button
                 class="btn btn-icon text-muted icon-no-margin icon-size-3 ml-2"
                 type="button"
                 id="grader-actions-menu-{{uniqid}}"
index 43b5386..525ab55 100644 (file)
@@ -37,7 +37,7 @@
 
 <div class="d-flex align-items-center user-picker-container mb-2 py-2">
     <div aria-live="polite" data-region="user_picker/current_user"  class="sr-only"></div>
-    <div class="d-flex align-items-center" data-region="user_picker/user"></div>
+    <div class="d-flex align-items-center overflow-hidden" data-region="user_picker/user"></div>
     <div class="ml-auto flex-shrink-0">
         <nav aria-label="{{#str}} usernavigation, mod_forum {{/str}}">
             <ul class="pagination mb-0">
index ef04cfe..488f68d 100644 (file)
@@ -45,7 +45,7 @@
     aria-hidden="true"
 >
 {{/profileimage}}
-<div>
+<div class="overflow-hidden">
     <h5
         class="mb-0 font-weight-bold user-full-name text-truncate"
         data-region="name"
diff --git a/mod/forum/tests/behat/portfolio_export.feature b/mod/forum/tests/behat/portfolio_export.feature
new file mode 100644 (file)
index 0000000..0bf6df2
--- /dev/null
@@ -0,0 +1,44 @@
+@mod @mod_forum
+Feature: Ensure only users with appropriate permissions can export forum discussions.
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email                 |
+      | teacher1 | Teacher   | 1        | teacher1@example.com  |
+      | student1 | Student   | 1        | student1@example.com  |
+    And the following "courses" exist:
+      | fullname | shortname  | category  |
+      | Course 1 | C1         | 0         |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+      | student1 | C1 | student |
+
+  Scenario: A teacher can export discussions to a portfolio.
+    Given the following "activities" exist:
+      | activity   | name                   | intro             | course | idnumber     | groupmode |
+      | forum      | Test forum 1           | Test forum 2      | C1     | forum        | 0         |
+    And I log in as "admin"
+    And the following config values are set as admin:
+      | enableportfolios | 1 |
+    And I navigate to "Plugins > Portfolios > Manage portfolios" in site administration
+    And I set portfolio instance "File download" to "Enabled and visible"
+    And I click on "Save" "button"
+    And I log out
+    When I log in as "student1"
+    And I am on "Course 1" course homepage
+    And I follow "Test forum 1"
+    And I add a new discussion to "Test forum 1" forum with:
+      | Subject | Discussion 1 |
+      | Message | Test post message |
+    And I reload the page
+    And I follow "Discussion 1"
+    Then "Export whole discussion to portfolio" "button" should not exist
+    And I log out
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I follow "Test forum 1"
+    And I follow "Discussion 1"
+    And "Export whole discussion to portfolio" "button" should exist
+    And I press "Export whole discussion to portfolio"
+    And I should see "Exporting to portfolio"
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 7eddcd8..7935556 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2020061500;       // The current module version (Date: YYYYMMDDXX)
+$plugin->version   = 2020061501;       // The current module version (Date: YYYYMMDDXX)
 $plugin->requires  = 2020060900;       // Requires this Moodle version
 $plugin->component = 'mod_forum';      // Full name of the plugin (used for diagnostics)
diff --git a/mod/lesson/classes/local/numeric/helper.php b/mod/lesson/classes/local/numeric/helper.php
new file mode 100644 (file)
index 0000000..01ad8f8
--- /dev/null
@@ -0,0 +1,79 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Lesson's numeric helper lib.
+ *
+ * Contains any helper functions for the numeric pagetyep
+ *
+ * @package    mod_lesson
+ * @copyright  2020 Peter Dias <peter@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace mod_lesson\local\numeric;
+
+/**
+ * Lesson numeric page helper
+ *
+ * @copyright  2020 Peter Dias<peter@moodle.com>
+ * @package core_lesson
+ */
+class helper {
+
+    /**
+     * Helper function to unformat a given numeric value from locale specific values with n:n signifying ranges to standards
+     * with decimal point numbers/ranges
+     *
+     * @param string $value The value to be formatted
+     * @return string|float|bool $formattedvalue unformatted value
+     *              String - If it is a range it will return a value e.g. 2:4
+     *              Float - if it's a properly formatted float
+     *              Null - If empty and could not be converted
+     */
+    public static function lesson_unformat_numeric_value(string $value) {
+        if (strpos($value, ':')) {
+            list($min, $max) = explode(':', $value);
+            $formattedvalue = unformat_float($min) . ':' . unformat_float($max);
+        } else {
+            $formattedvalue = unformat_float($value);
+        }
+
+        return $formattedvalue;
+    }
+
+    /**
+     * Helper function to format a given value into locale specific values with n:n signifying ranges
+     *
+     * @param string|number $value The value to be formatted
+     * @return string $formattedvalue Formatted value OR $value if not numeric
+     */
+    public static function lesson_format_numeric_value($value) : string {
+        $formattedvalue = $value;
+        if (strpos($value, ':')) {
+            list($min, $max) = explode(':', $value);
+            $formattedvalue = $min . ':' . $max;
+            if (is_numeric($min) && is_numeric($max)) {
+                $formattedvalue = format_float($min, strlen($min), true, true) . ':'
+                    . format_float($max, strlen($max), true, true);
+            }
+        } else {
+            $formattedvalue = is_numeric($value) ? format_float($value, strlen($value), true, true) : $value;
+        }
+
+        return $formattedvalue;
+    }
+
+}
index fc14d26..997176c 100644 (file)
@@ -399,6 +399,8 @@ $string['numberofpagesviewed'] = 'Number of questions answered: {$a}';
 $string['numberofpagesviewedheader'] = 'Number of questions answered';
 $string['numberofpagesviewednotice'] = 'Number of questions answered: {$a->nquestions} (You should answer at least {$a->minquestions})';
 $string['numerical'] = 'Numerical';
+$string['numericanswer_help'] = 'You can specify a number, or a range of numbers by using colon. For example 2:5 means any answer between 2 and 5 including them are correct.';
+$string['numericanswer'] = 'Numeric answer';
 $string['offlinedatamessage'] = 'You have worked on this attempt using a mobile device. Data was last saved to this site {$a} ago. Please check that you do not have any unsaved work.';
 $string['ongoing'] = 'Display ongoing score';
 $string['ongoing_help'] = 'If enabled, each page will display the student\'s current points earned out of the total possible thus far.';
index 30a8d4b..9b1cb3a 100644 (file)
@@ -1458,9 +1458,11 @@ abstract class lesson_add_page_form_base extends moodleform {
      * @param string $label, null means default
      * @param bool $required
      * @param string $format
+     * @param array $help Add help text via the addHelpButton. Must be an array which contains the string identifier and
+     *                      component as it's elements
      * @return void
      */
-    protected final function add_answer($count, $label = null, $required = false, $format= '') {
+    protected final function add_answer($count, $label = null, $required = false, $format= '', array $help = []) {
         if ($label === null) {
             $label = get_string('answer', 'lesson');
         }
@@ -1473,13 +1475,17 @@ abstract class lesson_add_page_form_base extends moodleform {
             $this->_form->setDefault('answer_editor['.$count.']', array('text' => '', 'format' => FORMAT_HTML));
         } else {
             $this->_form->addElement('text', 'answer_editor['.$count.']', $label,
-                    array('size' => '50', 'maxlength' => '200'));
+                array('size' => '50', 'maxlength' => '200'));
             $this->_form->setType('answer_editor['.$count.']', PARAM_TEXT);
         }
 
         if ($required) {
             $this->_form->addRule('answer_editor['.$count.']', get_string('required'), 'required', null, 'client');
         }
+
+        if ($help) {
+            $this->_form->addHelpButton("answer_editor[$count]", $help['identifier'], $help['component']);
+        }
     }
     /**
      * Convenience function: Adds an response editor
@@ -4530,6 +4536,7 @@ abstract class lesson_page extends lesson_base {
                     $this->answers[$i]->lessonid = $this->lesson->id;
                     $this->answers[$i]->pageid = $this->id;
                     $this->answers[$i]->timecreated = $this->timecreated;
+                    $this->answers[$i]->answer = null;
                 }
 
                 if (isset($properties->answer_editor[$i])) {
@@ -4542,6 +4549,9 @@ abstract class lesson_page extends lesson_base {
                         $this->answers[$i]->answer = $properties->answer_editor[$i];
                         $this->answers[$i]->answerformat = FORMAT_MOODLE;
                     }
+                } else {
+                    // If there is no data posted which means we want to reset the stored values.
+                    $this->answers[$i]->answer = null;
                 }
 
                 if (!empty($properties->response_editor[$i]) && is_array($properties->response_editor[$i])) {
index 014daac..ff7bfa2 100644 (file)
@@ -28,6 +28,8 @@ defined('MOODLE_INTERNAL') || die();
 /** Numerical question type */
 define("LESSON_PAGE_NUMERICAL",     "8");
 
+use mod_lesson\local\numeric\helper;
+
 class lesson_page_type_numerical extends lesson_page {
 
     protected $type = lesson_page::TYPE_QUESTION;
@@ -48,8 +50,9 @@ class lesson_page_type_numerical extends lesson_page {
         return $this->typeidstring;
     }
     public function display($renderer, $attempt) {
-        global $USER, $CFG, $PAGE;
-        $mform = new lesson_display_answer_form_shortanswer($CFG->wwwroot.'/mod/lesson/continue.php', array('contents'=>$this->get_contents(), 'lessonid'=>$this->lesson->id));
+        global $USER, $PAGE;
+        $mform = new lesson_display_answer_form_numerical(new moodle_url('/mod/lesson/continue.php'),
+            array('contents' => $this->get_contents(), 'lessonid' => $this->lesson->id));
         $data = new stdClass;
         $data->id = $PAGE->cm->id;
         $data->pageid = $this->properties->id;
@@ -109,10 +112,10 @@ class lesson_page_type_numerical extends lesson_page {
     }
 
     public function check_answer() {
-        global $CFG;
         $result = parent::check_answer();
 
-        $mform = new lesson_display_answer_form_shortanswer($CFG->wwwroot.'/mod/lesson/continue.php', array('contents'=>$this->get_contents()));
+        $mform = new lesson_display_answer_form_numerical(new moodle_url('/mod/lesson/continue.php'),
+            array('contents' => $this->get_contents()));
         $data = $mform->get_data();
         require_sesskey();
 
@@ -124,12 +127,11 @@ class lesson_page_type_numerical extends lesson_page {
         $result->response = '';
         $result->newpageid = 0;
 
-        if (!isset($data->answer) || !is_numeric($data->answer)) {
+        if (!isset($data->answer)) {
             $result->noanswer = true;
             return $result;
         } else {
-            // Just doing default PARAM_RAW, not doing PARAM_INT because it could be a float.
-            $result->useranswer = (float)$data->answer;
+            $result->useranswer = $data->answer;
         }
         $result->studentanswer = $result->userresponse = $result->useranswer;
         $answers = $this->get_answers();
@@ -201,7 +203,8 @@ class lesson_page_type_numerical extends lesson_page {
             } else {
                 $cells[] = '<label class="correct">' . get_string('answer', 'lesson') . ' ' . $i . '</label>:';
             }
-            $cells[] = format_text($answer->answer, $answer->answerformat, $options);
+            $formattedanswer = helper::lesson_format_numeric_value($answer->answer);
+            $cells[] = format_text($formattedanswer, $answer->answerformat, $options);
             $table->data[] = new html_table_row($cells);
 
             $cells = array();
@@ -258,7 +261,8 @@ class lesson_page_type_numerical extends lesson_page {
                     unset($stats["total"]);
                     foreach ($stats as $valentered => $ntimes) {
                         $data = '<input class="form-control" type="text" size="50" ' .
-                                'disabled="disabled" readonly="readonly" value="'.s($valentered).'" />';
+                                'disabled="disabled" readonly="readonly" value="'.
+                                s(format_float($valentered, strlen($valentered), true, true)).'" />';
                         $percent = $ntimes / $total * 100;
                         $percent = round($percent, 2);
                         $percent .= "% ".get_string("enteredthis", "lesson");
@@ -272,7 +276,8 @@ class lesson_page_type_numerical extends lesson_page {
                     empty($answerdata->answers)))) {
                 // Get in here when the user answered or for the last answer.
                 $data = '<input class="form-control" type="text" size="50" ' .
-                        'disabled="disabled" readonly="readonly" value="'.s($useranswer->useranswer).'">';
+                        'disabled="disabled" readonly="readonly" value="'.
+                        s(format_float($useranswer->useranswer, strlen($useranswer->useranswer), true, true)).'">';
                 if (isset($pagestats[$this->properties->id][$useranswer->useranswer])) {
                     $percent = $pagestats[$this->properties->id][$useranswer->useranswer] / $pagestats[$this->properties->id]["total"] * 100;
                     $percent = round($percent, 2);
@@ -321,6 +326,12 @@ class lesson_page_type_numerical extends lesson_page {
      */
     public function update_form_data(stdClass $data) : stdClass {
         $answercount = count($this->get_answers());
+
+        // If no answers provided, then we don't need to check anything.
+        if (!$answercount) {
+            return $data;
+        }
+
         // Check for other answer entry.
         $lastanswer = $data->{'answer_editor[' . ($answercount - 1) . ']'};
         if (strpos($lastanswer, LESSON_OTHER_ANSWERS) !== false) {
@@ -354,7 +365,10 @@ class lesson_add_page_form_numerical extends lesson_add_page_form_base {
         $answercount = $this->_customdata['lesson']->maxanswers;
         for ($i = 0; $i < $answercount; $i++) {
             $this->_form->addElement('header', 'answertitle'.$i, get_string('answer').' '.($i+1));
-            $this->add_answer($i, null, ($i < 1));
+            $this->add_answer($i, null, ($i < 1), '', [
+                    'identifier' => 'numericanswer',
+                    'component' => 'mod_lesson'
+            ]);
             $this->add_response($i);
             $this->add_jumpto($i, null, ($i == 0 ? LESSON_NEXTPAGE : LESSON_THISPAGE));
             $this->add_score($i, null, ($i===0)?1:0);
@@ -367,6 +381,64 @@ class lesson_add_page_form_numerical extends lesson_add_page_form_base {
         $this->add_jumpto($newcount, get_string('allotheranswersjump', 'lesson'), LESSON_NEXTPAGE);
         $this->add_score($newcount, get_string('allotheranswersscore', 'lesson'), 0);
     }
+
+    /**
+     * We call get data when storing the data into the db. Override to format the floats properly
+     *
+     * @return object|void
+     */
+    public function get_data() : ?stdClass {
+        $data = parent::get_data();
+
+        if (!empty($data->answer_editor)) {
+            foreach ($data->answer_editor as $key => $answer) {
+                $data->answer_editor[$key] = helper::lesson_unformat_numeric_value($answer);
+            }
+        }
+
+        return $data;
+    }
+
+    /**
+     * Return submitted data if properly submitted or returns NULL if validation fails or
+     * if there is no submitted data with formatted numbers
+     *
+     * @return object submitted data; NULL if not valid or not submitted or cancelled
+     */
+    public function get_submitted_data() : ?stdClass {
+        $data = parent::get_submitted_data();
+
+        if (!empty($data->answer_editor)) {
+            foreach ($data->answer_editor as $key => $answer) {
+                $data->answer_editor[$key] = helper::lesson_unformat_numeric_value($answer);
+            }
+        }
+
+        return $data;
+    }
+
+    /**
+     * Load in existing data as form defaults. Usually new entry defaults are stored directly in
+     * form definition (new entry form); this function is used to load in data where values
+     * already exist and data is being edited (edit entry form) after formatting numbers
+     *
+     *
+     * @param stdClass|array $defaults object or array of default values
+     */
+    public function set_data($defaults) {
+        if (is_object($defaults)) {
+            $defaults = (array) $defaults;
+        }
+
+        $editor = 'answer_editor';
+        foreach ($defaults as $key => $answer) {
+            if (substr($key, 0, strlen($editor)) == $editor) {
+                $defaults[$key] = helper::lesson_format_numeric_value($answer);
+            }
+        }
+
+        parent::set_data($defaults);
+    }
 }
 
 class lesson_display_answer_form_numerical extends moodleform {
@@ -402,8 +474,7 @@ class lesson_display_answer_form_numerical extends moodleform {
         $mform->addElement('hidden', 'pageid');
         $mform->setType('pageid', PARAM_INT);
 
-        $mform->addElement('text', 'answer', get_string('youranswer', 'lesson'), $attrs);
-        $mform->setType('answer', PARAM_FLOAT);
+        $mform->addElement('float', 'answer', get_string('youranswer', 'lesson'), $attrs);
 
         if ($hasattempt) {
             $this->add_action_buttons(null, get_string("nextpage", "lesson"));
@@ -411,5 +482,4 @@ class lesson_display_answer_form_numerical extends moodleform {
             $this->add_action_buttons(null, get_string("submit", "lesson"));
         }
     }
-
 }
diff --git a/mod/lesson/tests/behat/lesson_numerical_question_with_locale.feature b/mod/lesson/tests/behat/lesson_numerical_question_with_locale.feature
new file mode 100644 (file)
index 0000000..a3daa90
--- /dev/null
@@ -0,0 +1,135 @@
+@mod @mod_lesson
+Feature: In a lesson activity, I need to edit pages in the lesson taking into account locale settings
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | 1 | teacher1@example.com |
+      | student1 | Student | 1 | student1@example.com |
+    And the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1 | 0 |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+      | student1 | C1 | student |
+    And the following "language customisations" exist:
+      | component       | stringid | value |
+      | core_langconfig | decsep   | #     |
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+    And I add a "Lesson" to section "1" and I fill the form with:
+      | Name | Test lesson name |
+      | Description | Test lesson description |
+      | Allow student review | Yes |
+    And I follow "Test lesson name"
+    And I follow "Add a question page"
+    And I set the field "Select a question type" to "Numerical"
+    And I press "Add a question page"
+    And I set the following fields to these values:
+      | Page title | Hardest question ever |
+      | Page contents | 1 + 1? |
+      | id_answer_editor_0 | 2#87 |
+      | id_response_editor_0 | Correct answer |
+      | id_jumpto_0 | End of lesson |
+      | id_score_0 | 1 |
+      | id_answer_editor_1 | 2#1:2#8 |
+      | id_response_editor_1 | Incorrect answer |
+      | id_jumpto_1 | This page |
+      | id_score_1 | 0 |
+    And I press "Save page"
+    And I log out
+
+  Scenario: Edit a numerical question with the locale specific variables
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+    And I follow "Test lesson name"
+    And I click on "Edit" "link" in the "region-main" "region"
+    And I follow "Hardest question ever"
+    Then I should see "2#87"
+    And I should see "2#1:2#8"
+    And I log out
+
+  Scenario: View the detailed page of lesson
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+    And I follow "Test lesson name"
+    And I click on "Edit" "link" in the "region-main" "region"
+    And I click on "Expanded" "link" in the "region-main" "region"
+    Then I should see "2#87"
+    And I should see "2#1:2#8"
+    And I log out
+
+  Scenario: Attempt the lesson successfully as a student
+    Given I log in as "student1"
+    And I am on "Course 1" course homepage
+    And I follow "Test lesson name"
+    And I should see "1 + 1?"
+    And I set the following fields to these values:
+      | Your answer | 2#87 |
+    And I press "Submit"
+    Then I should see "Correct answer"
+    And I should not see "Incorrect answer"
+    And I press "Continue"
+    And I should see "Congratulations - end of lesson reached"
+    And I should see "Your score is 1 (out of 1)."
+    And I log out
+
+  Scenario: Attempt the lesson unsuccessfully as a student
+    Given I log in as "student1"
+    And I am on "Course 1" course homepage
+    And I follow "Test lesson name"
+    And I should see "1 + 1?"
+    And I set the following fields to these values:
+      | Your answer | 2#7 |
+    And I press "Submit"
+    Then I should not see "Correct answer"
+    And I should see "Incorrect answer"
+    And I press "Continue"
+    And I should see "Congratulations - end of lesson reached"
+    And I should see "Your score is 0 (out of 1)."
+    And I log out
+
+  Scenario: Attempt the lesson successfully as a student and review
+    Given I log in as "student1"
+    And I am on "Course 1" course homepage
+    And I follow "Test lesson name"
+    And I should see "1 + 1?"
+    And I set the following fields to these values:
+      | Your answer | 2#87 |
+    And I press "Submit"
+    Then I should see "Correct answer"
+    And I should not see "Incorrect answer"
+    And I press "Continue"
+    And I should see "Congratulations - end of lesson reached"
+    And I should see "Your score is 1 (out of 1)."
+    And I follow "Review lesson"
+    Then I should see "1 + 1?"
+    And the following fields match these values:
+      | Your answer | 2#87 |
+    And I log out
+
+  Scenario: Edit lesson question page with updated locale setting and wrong answer
+    Given I log in as "teacher1"
+    And the following "language customisations" exist:
+      | component       | stringid | value |
+      | core_langconfig | decsep   | ,     |
+    And I am on "Course 1" course homepage with editing mode on
+    And I follow "Test lesson name"
+    Then I click on "Edit" "link" in the "region-main" "region"
+    And I follow "Hardest question ever"
+    Then I should see "2,87"
+    And I should see "2,1:2,8"
+    And I log out
+    And I log in as "student1"
+    And I am on "Course 1" course homepage
+    And I follow "Test lesson name"
+    And I should see "1 + 1?"
+    And I set the following fields to these values:
+      | Your answer | 2,7 |
+    And I press "Submit"
+    And I should see "Incorrect answer"
+    And I should not see "Correct answer"
+    And I press "Continue"
+    And I should see "Congratulations - end of lesson reached"
+    And I should see "Your score is 0 (out of 1)."
diff --git a/mod/lesson/tests/numeric_helper_test.php b/mod/lesson/tests/numeric_helper_test.php
new file mode 100644 (file)
index 0000000..da93292
--- /dev/null
@@ -0,0 +1,170 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Unit tests for page types classes
+ *
+ * @package   mod_lesson
+ * @category  test
+ * @copyright 2020 Peter Dias
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU Public License
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+use mod_lesson\local\numeric\helper;
+
+/**
+ * This class contains the test cases for the numeric helper functions
+ *
+ * @copyright 2020 Peter Dias
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU Public License
+ */
+class mod_lesson_numeric_type_helper_test extends advanced_testcase {
+    /**
+     * Test the lesson_unformat_numeric_value function.
+     *
+     * @dataProvider lesson_unformat_dataprovider
+     * @param $decsep
+     * @param $tests
+     */
+    public function test_lesson_unformat_numeric_value($decsep, $tests) {
+        $this->define_local_decimal_separator($decsep);
+
+        foreach ($tests as $test) {
+            $this->assertEquals($test[1], helper::lesson_unformat_numeric_value($test[0]));
+        }
+    }
+
+    /**
+     * Test the lesson_format_numeric_value function.
+     *
+     * @dataProvider lesson_format_dataprovider
+     * @param $decsep
+     * @param $tests
+     */
+    public function test_lesson_format_numeric_value($decsep, $tests) {
+        $this->define_local_decimal_separator($decsep);
+
+        foreach ($tests as $test) {
+            $this->assertEquals($test[1], helper::lesson_format_numeric_value($test[0]));
+        }
+    }
+
+    /**
+     * Provide various cases for the unformat test function
+     *
+     * @return array
+     */
+    public function lesson_unformat_dataprovider() {
+        return [
+            "Using a decimal as a separator" => [
+                "decsep" => ".",
+                "test" => [
+                    ["2.1", 2.1],
+                    ["1:4.2", "1:4.2"],
+                    ["2,1", 2],
+                    ["1:4,2", "1:4"],
+                    ["", null]
+                ]
+            ],
+            "Using a comma as a separator" => [
+                "decsep" => ",",
+                "test" => [
+                    ["2,1", 2.1],
+                    ["1:4,2", "1:4.2"],
+                    ["2.1", 2.1],
+                    ["1:4.2", "1:4.2"],
+                ]
+            ],
+            "Using a X as a separator" => [
+                "decsep" => "X",
+                "test" => [
+                    ["2X1", 2.1],
+                    ["1:4X2", "1:4.2"],
+                    ["2.1", 2.1],
+                    ["1:4.2", "1:4.2"],
+                ]
+            ]
+        ];
+    }
+
+    /**
+     * Provide various cases for the unformat test function
+     *
+     * @return array
+     */
+    public function lesson_format_dataprovider() {
+        return [
+            "Using a decimal as a separator" => [
+                "decsep" => ".",
+                "test" => [
+                    ["2.1", 2.1],
+                    ["1:4.2", "1:4.2"],
+                    ["2,1", "2,1"],
+                    ["1:4,2", "1:4,2"]
+                ]
+            ],
+            "Using a comma as a separator" => [
+                "decsep" => ",",
+                "test" => [
+                    ["2,1", "2,1"],
+                    ["1:4,2", "1:4,2"],
+                    ["2.1", "2,1"],
+                    [2.1, "2,1"],
+                    ["1:4.2", "1:4,2"],
+                ]
+            ],
+            "Using a X as a separator" => [
+                "decsep" => "X",
+                "test" => [
+                    ["2X1", "2X1"],
+                    ["1:4X2", "1:4X2"],
+                    ["2.1", "2X1"],
+                    ["1:4.2", "1:4X2"],
+                ]
+            ]
+        ];
+    }
+
+
+    /**
+     * Define a local decimal separator.
+     *
+     * It is not possible to directly change the result of get_string in
+     * a unit test. Instead, we create a language pack for language 'xx' in
+     * dataroot and make langconfig.php with the string we need to change.
+     * The default example separator used here is 'X'; on PHP 5.3 and before this
+     * must be a single byte character due to PHP bug/limitation in
+     * number_format, so you can't use UTF-8 characters.
+     *
+     * @param string $decsep Separator character. Defaults to `'X'`.
+     */
+    protected function define_local_decimal_separator(string $decsep = 'X') {
+        global $SESSION, $CFG;
+
+        $SESSION->lang = 'xx';
+        $langconfig = "<?php\n\$string['decsep'] = '$decsep';";
+        $langfolder = $CFG->dataroot . '/lang/xx';
+        check_dir_exists($langfolder);
+        file_put_contents($langfolder . '/langconfig.php', $langconfig);
+
+        // Ensure the new value is picked up and not taken from the cache.
+        $stringmanager = get_string_manager();
+        $stringmanager->reset_caches(true);
+    }
+}
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 1205205..231e3cf 100644 (file)
@@ -466,7 +466,7 @@ class memberships extends \mod_lti\local\ltiservice\service_base {
             if (!is_null($lti)) {
                 $nextpage .= "&rlid={$lti->id}";
             }
-            $response->add_additional_header("Link: {$nextpage};rel=next");
+            $response->add_additional_header("Link: <{$nextpage}>; rel=\"next\"");
         }
 
         $response->set_content_type('application/vnd.ims.lti-nrps.v2.membershipcontainer+json');
index 1969ad3..ffbf932 100644 (file)
@@ -39,7 +39,7 @@
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
         <KEY NAME="quizid" TYPE="foreign-unique" FIELDS="quizid" REFTABLE="quiz" REFFIELDS="id"/>
         <KEY NAME="cmid" TYPE="foreign-unique" FIELDS="cmid" REFTABLE="course_modules" REFFIELDS="id"/>
-        <KEY NAME="templateid" TYPE="foreign" FIELDS="templateid" REFTABLE="quizacces_seb_template" REFFIELDS="id"/>
+        <KEY NAME="templateid" TYPE="foreign" FIELDS="templateid" REFTABLE="quizaccess_seb_template" REFFIELDS="id"/>
         <KEY NAME="usermodified" TYPE="foreign" FIELDS="usermodified" REFTABLE="user" REFFIELDS="id"/>
       </KEYS>
     </TABLE>
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..023bf31 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;
+}
+
+@media all and (-ms-high-contrast: none) {  /* IE10 & IE11 hack */
+    .path-mod-workshop .mform.frozen .rubric-grid,
+    .path-mod-workshop .assessmentform .rubric-grid {
+        width: 100%;
+        table-layout: fixed;
+    }
 }
 
 .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&nbs