Merge branch 'MDL-68567-master' of git://github.com/sarjona/moodle
authorJake Dallimore <jake@moodle.com>
Mon, 25 May 2020 03:36:48 +0000 (11:36 +0800)
committerJake Dallimore <jake@moodle.com>
Mon, 25 May 2020 03:36:48 +0000 (11:36 +0800)
134 files changed:
blocks/site_main_menu/tests/behat/add_url.feature
contentbank/amd/build/search.min.js
contentbank/amd/build/search.min.js.map
contentbank/amd/build/selectors.min.js
contentbank/amd/build/selectors.min.js.map
contentbank/amd/build/sort.min.js [new file with mode: 0644]
contentbank/amd/build/sort.min.js.map [new file with mode: 0644]
contentbank/amd/src/search.js
contentbank/amd/src/selectors.js
contentbank/amd/src/sort.js [new file with mode: 0644]
contentbank/classes/content.php
contentbank/classes/output/bankcontent.php
contentbank/templates/bankcontent.mustache
contentbank/templates/bankcontent/toolbar.mustache
contentbank/tests/behat/sort_content.feature [new file with mode: 0644]
course/tests/behat/activity_chooser.feature
course/tests/behat/restrict_available_activities.feature
grade/edit/outcome/course_form.html
install/lang/eu/error.php
install/lang/eu/install.php
lang/en/contentbank.php
lang/en/deprecated.txt
lang/en/message.php
lang/en/moodle.php
lib/db/access.php
lib/filestorage/file_archive.php
lib/filestorage/zip_archive.php
lib/form/templates/element-group-inline.mustache
lib/table/amd/build/dynamic.min.js
lib/table/amd/build/dynamic.min.js.map
lib/table/amd/build/local/dynamic/repository.min.js
lib/table/amd/build/local/dynamic/repository.min.js.map
lib/table/amd/src/dynamic.js
lib/table/amd/src/local/dynamic/repository.js
lib/table/classes/external/dynamic/fetch.php
lib/table/tests/external/dynamic/fetch_test.php
lib/tablelib.php
lib/tests/filestorage_zip_archive_test.php [new file with mode: 0644]
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/classes/api.php
mod/assign/locallib.php
mod/assign/tests/locallib_test.php
mod/h5pactivity/classes/external/get_results.php [new file with mode: 0644]
mod/h5pactivity/classes/local/manager.php
mod/h5pactivity/classes/local/report/results.php
mod/h5pactivity/db/services.php
mod/h5pactivity/tests/external/get_results_test.php [new file with mode: 0644]
mod/h5pactivity/tests/generator/lib.php
mod/h5pactivity/tests/local/manager_test.php
mod/h5pactivity/version.php
mod/lti/amd/build/contentitem.min.js
mod/lti/amd/build/contentitem.min.js.map
mod/lti/amd/src/contentitem.js
mod/lti/classes/local/ltiservice/service_base.php
mod/lti/lib.php
mod/lti/locallib.php
mod/lti/mod_form.php
mod/lti/service/gradebookservices/backup/moodle2/backup_ltiservice_gradebookservices_subplugin.class.php
mod/lti/service/gradebookservices/backup/moodle2/restore_ltiservice_gradebookservices_subplugin.class.php
mod/lti/service/gradebookservices/classes/local/resources/lineitem.php
mod/lti/service/gradebookservices/classes/local/resources/lineitems.php
mod/lti/service/gradebookservices/classes/local/service/gradebookservices.php
mod/lti/service/gradebookservices/db/install.xml
mod/lti/service/gradebookservices/db/upgrade.php [new file with mode: 0644]
mod/lti/service/gradebookservices/tests/gradebookservices_test.php [new file with mode: 0644]
mod/lti/service/gradebookservices/version.php
mod/lti/tests/lib_test.php
mod/lti/version.php
mod/quiz/attemptlib.php
mod/wiki/parser/markups/html.php
mod/wiki/tests/wikiparser_test.php
mod/workshop/assessment.php
mod/workshop/classes/privacy/provider.php
mod/workshop/exassessment.php
mod/workshop/exsubmission.php
mod/workshop/lang/en/workshop.php
mod/workshop/renderer.php
mod/workshop/submission.php
mod/workshop/tests/behat/workshop_section_remembered.feature [new file with mode: 0644]
mod/workshop/version.php
mod/workshop/view.php
question/behaviour/interactive/tests/walkthrough_test.php
question/engine/tests/helpers.php
question/format/xml/format.php
question/format/xml/tests/xmlformat_test.php
question/type/ddimageortext/amd/build/form.min.js
question/type/ddimageortext/amd/build/form.min.js.map
question/type/ddimageortext/amd/build/question.min.js
question/type/ddimageortext/amd/build/question.min.js.map
question/type/ddimageortext/amd/src/form.js
question/type/ddimageortext/amd/src/question.js
question/type/ddimageortext/edit_ddimageortext_form.php
question/type/ddimageortext/questiontype.php
question/type/ddimageortext/questiontypebase.php
question/type/ddimageortext/rendererbase.php
question/type/ddimageortext/styles.css
question/type/ddimageortext/tests/behat/behat_qtype_ddimageortext.php
question/type/ddmarker/amd/build/form.min.js
question/type/ddmarker/amd/build/form.min.js.map
question/type/ddmarker/amd/src/form.js
question/type/ddmarker/edit_ddmarker_form.php
question/type/ddmarker/questiontype.php
question/type/ddwtos/amd/build/ddwtos.min.js
question/type/ddwtos/amd/build/ddwtos.min.js.map
question/type/ddwtos/amd/src/ddwtos.js
question/type/ddwtos/styles.css
question/type/multichoice/backup/moodle1/lib.php
question/type/multichoice/backup/moodle2/backup_qtype_multichoice_plugin.class.php
question/type/multichoice/db/install.xml
question/type/multichoice/db/upgrade.php
question/type/multichoice/edit_multichoice_form.php
question/type/multichoice/lang/en/qtype_multichoice.php
question/type/multichoice/question.php
question/type/multichoice/questiontype.php
question/type/multichoice/renderer.php
question/type/multichoice/tests/behat/export.feature
question/type/multichoice/tests/fixtures/testquestion.moodle.xml
question/type/multichoice/tests/helper.php
question/type/multichoice/tests/question_single_test.php
question/type/multichoice/tests/upgradelibnewqe_test.php
question/type/multichoice/tests/walkthrough_test.php
question/type/multichoice/version.php
repository/recent/lang/en/repository_recent.php
repository/recent/lib.php
repository/recent/tests/generator/lib.php
repository/recent/tests/lib_test.php [new file with mode: 0644]
theme/boost/scss/moodle/contentbank.scss
theme/boost/style/moodle.css
theme/classic/style/moodle.css
user/selector/lib.php
user/tests/behat/course_preference.feature
version.php

index ac42715..1b2a7df 100644 (file)
@@ -16,4 +16,4 @@ Feature: Add URL to main menu block
       | External URL | http://www.google.com |
       | id_display | In pop-up |
     Then "google" "link" should exist in the "Main menu" "block"
-    And "Add an activity" "button" should exist in the "Main menu" "block"
+    And "Add an activity or resource" "button" should exist in the "Main menu" "block"
index 9090c9b..16c80b0 100644 (file)
Binary files a/contentbank/amd/build/search.min.js and b/contentbank/amd/build/search.min.js differ
index a3ddca1..0e06258 100644 (file)
Binary files a/contentbank/amd/build/search.min.js.map and b/contentbank/amd/build/search.min.js.map differ
index c7322b9..35b476a 100644 (file)
Binary files a/contentbank/amd/build/selectors.min.js and b/contentbank/amd/build/selectors.min.js differ
index 99b3b56..36d959f 100644 (file)
Binary files a/contentbank/amd/build/selectors.min.js.map and b/contentbank/amd/build/selectors.min.js.map differ
diff --git a/contentbank/amd/build/sort.min.js b/contentbank/amd/build/sort.min.js
new file mode 100644 (file)
index 0000000..5d0babf
Binary files /dev/null and b/contentbank/amd/build/sort.min.js differ
diff --git a/contentbank/amd/build/sort.min.js.map b/contentbank/amd/build/sort.min.js.map
new file mode 100644 (file)
index 0000000..4609a9e
Binary files /dev/null and b/contentbank/amd/build/sort.min.js.map differ
index bd5cb55..e604abc 100644 (file)
@@ -36,7 +36,7 @@ import {debounce} from 'core/utils';
 export const init = () => {
     const pendingPromise = new Pending();
 
-    const root = $(selectors.elements.main);
+    const root = $(selectors.regions.contentbank);
     registerListenerEvents(root);
 
     pendingPromise.resolve();
@@ -120,10 +120,10 @@ const toggleSearchResultsView = async(body, searchQuery) => {
  * @return {Array}
  */
 const filterContents = (body, searchTerm) => {
-    const contents = Array.from(body.find(selectors.elements.cbfile));
+    const contents = Array.from(body.find(selectors.elements.listitem));
     const searchResults = [];
     contents.forEach((content) => {
-        const contentName = content.getAttribute('data-file');
+        const contentName = content.getAttribute('data-name');
         if (searchTerm === '' || contentName.toLowerCase().includes(searchTerm.toLowerCase())) {
             // The content matches the search criteria so it should be displayed and hightlighted.
             searchResults.push(content);
index 080f85f..b8ca6a6 100644 (file)
@@ -37,18 +37,26 @@ const getDataSelector = (name, value) => {
 export default {
     regions: {
         cbcontentname: getDataSelector('region', 'cb-content-name'),
+        contentbank: getDataSelector('region', 'contentbank'),
+        filearea: getDataSelector('region', 'filearea')
     },
     actions: {
         search: getDataSelector('action', 'searchcontent'),
         clearSearch: getDataSelector('action', 'clearsearchcontent'),
+        viewgrid: getDataSelector('action', 'viewgrid'),
+        viewlist: getDataSelector('action', 'viewlist'),
+        sortname: getDataSelector('action', 'sortname'),
+        sortdate: getDataSelector('action', 'sortdate'),
+        sortsize: getDataSelector('action', 'sortsize'),
+        sorttype: getDataSelector('action', 'sorttype')
     },
     elements: {
-        cbfile: '.cb-file',
+        listitem: '.cb-listitem',
         cbnavbarbreadcrumb: '.cb-navbar-breadbrumb',
         cbnavbartotalsearch: '.cb-navbar-totalsearch',
         clearsearch: '.input-group-append .clear-icon',
-        main: '#region-main',
         searchicon: '.input-group-append .search-icon',
         searchinput: '#searchinput',
+        sortbutton: '.cb-btnsort'
     },
 };
diff --git a/contentbank/amd/src/sort.js b/contentbank/amd/src/sort.js
new file mode 100644 (file)
index 0000000..bfa61c8
--- /dev/null
@@ -0,0 +1,189 @@
+// 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/>.
+
+/**
+ * Content bank UI actions.
+ *
+ * @module     core_contentbank/sort
+ * @package    core_contentbank
+ * @copyright  2020 Bas Brands <bas@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+import selectors from 'core_contentbank/selectors';
+import {get_string as getString} from 'core/str';
+import Prefetch from 'core/prefetch';
+
+/**
+ * Set up the contentbank views.
+ *
+ * @method init
+ */
+export const init = () => {
+    const contentBank = document.querySelector(selectors.regions.contentbank);
+    Prefetch.prefetchStrings('contentbank', ['sortbyx', 'sortbyxreverse', 'contentname',
+        'lastmodified', 'size', 'type']);
+    registerListenerEvents(contentBank);
+};
+
+/**
+ * Register contentbank related event listeners.
+ *
+ * @method registerListenerEvents
+ * @param {HTMLElement} contentBank The DOM node of the content bank
+ */
+const registerListenerEvents = (contentBank) => {
+
+    // The search.
+    const fileArea = document.querySelector(selectors.regions.filearea);
+    const shownItems = fileArea.querySelectorAll(selectors.elements.listitem);
+
+    // The view buttons.
+    const viewGrid = contentBank.querySelector(selectors.actions.viewgrid);
+    const viewList = contentBank.querySelector(selectors.actions.viewlist);
+
+    viewGrid.addEventListener('click', () => {
+        contentBank.classList.remove('view-list');
+        contentBank.classList.add('view-grid');
+        viewGrid.classList.add('active');
+        viewList.classList.remove('active');
+    });
+
+    viewList.addEventListener('click', () => {
+        contentBank.classList.remove('view-grid');
+        contentBank.classList.add('view-list');
+        viewList.classList.add('active');
+        viewGrid.classList.remove('active');
+    });
+
+    // Sort by file name alphabetical
+    const sortByName = contentBank.querySelector(selectors.actions.sortname);
+    sortByName.addEventListener('click', () => {
+        const ascending = updateSortButtons(contentBank, sortByName);
+        updateSortOrder(fileArea, shownItems, 'data-file', ascending);
+    });
+
+    // Sort by date.
+    const sortByDate = contentBank.querySelector(selectors.actions.sortdate);
+    sortByDate.addEventListener('click', () => {
+        const ascending = updateSortButtons(contentBank, sortByDate);
+        updateSortOrder(fileArea, shownItems, 'data-timemodified', ascending);
+    });
+
+    // Sort by size.
+    const sortBySize = contentBank.querySelector(selectors.actions.sortsize);
+    sortBySize.addEventListener('click', () => {
+        const ascending = updateSortButtons(contentBank, sortBySize);
+        updateSortOrder(fileArea, shownItems, 'data-bytes', ascending);
+    });
+
+    // Sort by type
+    const sortByType = contentBank.querySelector(selectors.actions.sorttype);
+    sortByType.addEventListener('click', () => {
+        const ascending = updateSortButtons(contentBank, sortByType);
+        updateSortOrder(fileArea, shownItems, 'data-type', ascending);
+    });
+};
+
+/**
+ * Update the sort button view.
+ *
+ * @method updateSortButtons
+ * @param {HTMLElement} contentBank The DOM node of the contentbank button
+ * @param {HTMLElement} sortButton The DOM node of the sort button
+ * @return {Bool} sort ascending
+ */
+const updateSortButtons = (contentBank, sortButton) => {
+    const sortButtons = contentBank.querySelectorAll(selectors.elements.sortbutton);
+
+    sortButtons.forEach((button) => {
+        if (button !== sortButton) {
+            button.classList.remove('dir-asc');
+            button.classList.remove('dir-desc');
+            button.classList.add('dir-none');
+
+            updateButtonTitle(button, false);
+        }
+    });
+
+    let ascending = true;
+
+    if (sortButton.classList.contains('dir-none')) {
+        sortButton.classList.remove('dir-none');
+        sortButton.classList.add('dir-asc');
+    } else if (sortButton.classList.contains('dir-asc')) {
+        sortButton.classList.remove('dir-asc');
+        sortButton.classList.add('dir-desc');
+        ascending = false;
+    } else if (sortButton.classList.contains('dir-desc')) {
+        sortButton.classList.remove('dir-desc');
+        sortButton.classList.add('dir-asc');
+    }
+
+    updateButtonTitle(sortButton, ascending);
+
+    return ascending;
+};
+
+/**
+ * Update the button title.
+ *
+ * @method updateButtonTitle
+ * @param {HTMLElement} button Button to update
+ * @param {Bool} ascending Sort direction
+ * @return {Promise} string promise
+ */
+const updateButtonTitle = (button, ascending) => {
+
+    const sortString = (ascending ? 'sortbyxreverse' : 'sortbyx');
+
+    return getString(button.dataset.string, 'contentbank')
+    .then(columnName => {
+        return getString(sortString, 'core', columnName);
+    })
+    .then(sortByString => {
+        button.setAttribute('title', sortByString);
+        return sortByString;
+    })
+    .catch();
+};
+
+/**
+ * Update the sort order of the itemlist and update the DOM
+ *
+ * @method updateSortOrder
+ * @param {HTMLElement} fileArea the Dom container for the itemlist
+ * @param {Array} itemList Nodelist of Dom elements
+ * @param {String} attribute, the attribut to sort on
+ * @param {Bool} ascending, Sort Ascending
+ */
+const updateSortOrder = (fileArea, itemList, attribute, ascending) => {
+    const sortList = [].slice.call(itemList).sort(function(a, b) {
+
+        let aa = a.getAttribute(attribute);
+        let bb = b.getAttribute(attribute);
+        if (!isNaN(aa)) {
+           aa = parseInt(aa);
+           bb = parseInt(bb);
+        }
+
+        if (ascending) {
+            return aa > bb ? 1 : -1;
+        } else {
+            return aa < bb ? 1 : -1;
+        }
+    });
+    sortList.forEach(listItem => fileArea.appendChild(listItem));
+};
index 5e8c7bc..9765bfd 100644 (file)
@@ -85,6 +85,15 @@ abstract class content {
         return $this->content->contenttype;
     }
 
+    /**
+     * Returns $this->content->timemodified.
+     *
+     * @return int  $this->content->timemodified.
+     */
+    public function get_timemodified(): int {
+        return $this->content->timemodified;
+    }
+
     /**
      * Updates content_bank table with information in $this->content.
      *
index 2ea5c4d..6574b04 100644 (file)
@@ -75,18 +75,26 @@ class bankcontent implements renderable, templatable {
         global $PAGE;
 
         $PAGE->requires->js_call_amd('core_contentbank/search', 'init');
+        $PAGE->requires->js_call_amd('core_contentbank/sort', 'init');
 
         $data = new stdClass();
         $contentdata = array();
         foreach ($this->contents as $content) {
-            $record = $content->get_content();
+            $file = $content->get_file();
+            $filesize = $file ? $file->get_filesize() : 0;
+            $mimetype = $file ? get_mimetype_description($file) : '';
             $contenttypeclass = $content->get_content_type().'\\contenttype';
             $contenttype = new $contenttypeclass($this->context);
             $name = $content->get_name();
             $contentdata[] = array(
                 'name' => $name,
+                'title' => strtolower($name),
                 'link' => $contenttype->get_view_url($content),
-                'icon' => $contenttype->get_icon($content)
+                'icon' => $contenttype->get_icon($content),
+                'timemodified' => $content->get_timemodified(),
+                'bytes' => $filesize,
+                'size' => display_size($filesize),
+                'type' => $mimetype
             );
         }
         $data->contents = $contentdata;
index 8c43626..0826a65 100644 (file)
     {
         "contents": [
             {
-                "name": "accordion.h5p",
+                "name": "Accordion.h5p",
+                "title": "accordion.h5p",
+                "timemodified": 1589792272,
+                "size": "699.3KB",
+                "bytes": 716126,
+                "type": "Archive (H5P)",
                 "link": "http://something/contentbank/contenttype/h5p/view.php?url=http://something/pluginfile.php/1/contentbank/public/accordion.h5p",
                 "icon" : "http://something/theme/image.php/boost/core/1581597850/f/h5p-64"
             },
     }
 
 }}
-<div class="d-flex justify-content-between flex-column flex-sm-row">
-    <div class="cb-search-container mb-2">
-        {{>core_contentbank/bankcontent/search}}
-    </div>
-    <div class="cb-toolbar-container mb-2">
-        {{>core_contentbank/bankcontent/toolbar}}
+<div class="content-bank-container view-grid" data-region="contentbank">
+    <div class="d-flex justify-content-between flex-column flex-sm-row">
+        <div class="cb-search-container mb-2">
+            {{>core_contentbank/bankcontent/search}}
+        </div>
+        <div class="cb-toolbar-container mb-2 d-flex">
+            {{>core_contentbank/bankcontent/toolbar}}
+        </div>
     </div>
-</div>
-<div class="content-bank-container pb-3 border">
-    <div class="content-bank">
-        <div class="cb-navbar bg-light p-2 border-bottom">
-            <div class="cb-navbar-breadbrumb">
-                {{#pix}} i/folder {{/pix}}
-            </div>
-            <div class="cb-navbar-totalsearch d-none">
+    <div class="pb-3 border">
+        <div class="content-bank">
+            <div class="cb-navbar bg-light p-2 border-bottom">
+                <div class="cb-navbar-breadbrumb">
+                    {{#pix}} i/folder {{/pix}}
+                </div>
+                <div class="cb-navbar-totalsearch d-none">
+                </div>
             </div>
-        </div>
-        <div class="cb-content-wrapper d-flex flex-wrap p-2">
-        {{#contents}}
-            <div class="cb-file position-relative mb-2" data-file="{{{name}}}">
-                <div class="p-2">
-                    <div class="cb-thumbnail mb-1 text-center">
-                        <img class="icon iconsize-big" alt="{{{name}}}" title="{{{name}}}" src="{{{ icon }}}">
+            <div class="cb-content-wrapper d-flex px-2" data-region="filearea">
+                <div class="cb-heading bg-white">
+                    <div class="cb-file cb-column d-flex">
+                        <div class="title">{{#str}} contentname, contentbank {{/str}}</div>
+                        <button class="btn btn-sm cb-btnsort dir-none ml-auto" data-string="contentname" data-action="sortname"
+                            title="{{#str}} sortbyx, core, {{#str}} contentname, 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>
+                        </button>
                     </div>
-
-                    {{#link}}
-                        <a href="{{{ link }}}" class="stretched-link" title="{{{name}}}">
-                    {{/link}}
-                            <span class="cb-name word-break-all clamp-2 text-center" data-region="cb-content-name">
+                    <div class="cb-date cb-column d-flex">
+                        <div class="title">{{#str}} lastmodified, contentbank {{/str}}</div>
+                        <button class="btn btn-sm cb-btnsort dir-none ml-auto" data-string="lastmodified" data-action="sortdate"
+                        title="{{#str}} sortbyx, core, {{#str}} lastmodified, 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>
+                        </button>
+                    </div>
+                    <div class="cb-size cb-column d-flex">
+                        <div class="title">{{#str}} size, contentbank {{/str}}</div>
+                        <button class="btn btn-sm cb-btnsort dir-none ml-auto" data-string="size" data-action="sortsize"
+                        title="{{#str}} sortbyx, core, {{#str}} size, 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>
+                        </button>
+                    </div>
+                    <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}}">
+                            <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>
+                        </button>
+                    </div>
+                </div>
+            {{#contents}}
+                <div class="cb-listitem"
+                    data-file="{{{ title }}}"
+                    data-name="{{{ name }}}"
+                    data-bytes="{{ bytes }}"
+                    data-timemodified="{{ timemodified }}"
+                    data-type="{{{ type }}}">
+                    <div class="cb-file cb-column position-relative">
+                        <div class="cb-thumbnail" role="img" aria-label="{{{ name }}}"
+                        style="background-image: url('{{{ icon }}}');">
+                        </div>
+                        <a href="{{{ link }}}" class="cb-link stretched-link">
+                            <span class="cb-name word-break-all clamp-2" data-region="cb-content-name">
                                 {{{ name }}}
                             </span>
-                    {{#link}}
                         </a>
-                    {{/link}}
+                    </div>
+                    <div class="cb-date cb-column small">
+                        {{#userdate}} {{ timemodified }}, {{#str}} strftimedatetimeshort, core_langconfig {{/str}} {{/userdate}}
+                    </div>
+                    <div class="cb-size cb-column small">
+                        {{ size }}
+                    </div>
+                    <div class="cb-type cb-column last small">
+                        {{{ type }}}
+                    </div>
                 </div>
+            {{/contents}}
             </div>
-        {{/contents}}
         </div>
     </div>
 </div>
index 88f4a4c..04c762a 100644 (file)
     }
 
 }}
-<div class="content-bank-toolbar card border-0 mb-3">
-    <div class="content-bank">
-        <div class="cb-toolbar float-sm-right">
-        {{#tools}}
-            {{#link}}<a href="{{{ link }}}" title="{{{ name }}}">{{/link}}
-                <div class="cb-tool icon-no-margin btn btn-secondary btn-lg">
-                    {{#pix}} {{{ icon }}} {{/pix}} <span class="sr-only">{{{ name }}}</span>
-                </div>
-            {{#link}}</a>{{/link}}
-        {{/tools}}
-        </div>
-    </div>
-</div>
+
+{{#tools}}
+    <a href="{{{ link }}}" class="icon-no-margin btn btn-secondary" title="{{{ name }}}">
+        {{#pix}} {{{ icon }}} {{/pix}} {{{ name }}}
+    </a>
+{{/tools}}
+<button class="icon-no-margin btn btn-secondary active ml-2"
+title="{{#str}}  displayicons, contentbank  {{/str}}"
+data-action="viewgrid">
+    {{#pix}}a/view_icon_active, core, {{#str}} displayicons, contentbank {{/str}} {{/pix}}
+</button>
+<button class="icon-no-margin btn btn-secondary"
+title="{{#str}} displaydetails, contentbank {{/str}}"
+data-action="viewlist">
+    {{#pix}}t/viewdetails, core, {{#str}} displaydetails, contentbank {{/str}} {{/pix}}
+</button>
\ No newline at end of file
diff --git a/contentbank/tests/behat/sort_content.feature b/contentbank/tests/behat/sort_content.feature
new file mode 100644 (file)
index 0000000..b4ca7ad
--- /dev/null
@@ -0,0 +1,32 @@
+@core @core_contentbank @contentbank_h5p @javascript
+Feature: Sort content in the content bank
+  In order to temporarily organise the content of the content bank
+  As an admin
+  I need to be able to sort the content bank in various ways
+
+  Background:
+    Given the following "contentbank content" exist:
+        | contextlevel | reference | contenttype       | user  | contentname          |
+        | System       |           | contenttype_h5p   | admin | Dragon_santjordi.h5p |
+        | System       |           | contenttype_h5p   | admin | mathsbook.h5p        |
+        | System       |           | contenttype_h5p   | admin | historybook.h5p      |
+        | System       |           | contenttype_h5p   | admin | santjordi.h5p        |
+        | System       |           | contenttype_h5p   | admin | santjordi_rose.h5p   |
+        | System       |           | contenttype_h5p   | admin | SantJordi_book       |
+
+  Scenario: Admins can order content in the content bank
+    Given I log in as "admin"
+    And I am on site homepage
+    And I turn editing mode on
+    And I add the "Navigation" block if not present
+    And I expand "Site pages" node
+    And I click on "Content bank" "link"
+    When I click on "Display contentbank with file details" "button"
+    And I click on "Sort by Content name ascending" "button"
+    And "Dragon_santjordi.h5p" "text" should appear before "historybook.h5p" "text"
+    And "historybook.h5p" "text" should appear before "mathsbook.h5p" "text"
+    And "SantJordi_book" "text" should appear before "santjordi_rose.h5p" "text"
+    And I click on "Sort by Content name descending" "button"
+    And "historybook.h5p" "text" should appear before "Dragon_santjordi.h5p" "text"
+    And "mathsbook.h5p" "text" should appear before "historybook.h5p" "text"
+    Then "santjordi_rose.h5p" "text" should appear before "SantJordi_book" "text"
index 578e19c..b71e1e5 100644 (file)
@@ -18,13 +18,13 @@ Feature: Display and choose from the available activities in course
     And I am on "Course" course homepage with editing mode on
 
   Scenario: The available activities are displayed to the teacher in the activity chooser
-    Given I click on "Add an activity" "button" in the "Topic 1" "section"
-    Then I should see "Add an activity" in the ".modal-title" "css_element"
+    Given I click on "Add an activity or resource" "button" in the "Topic 1" "section"
+    Then I should see "Add an activity or resource" in the ".modal-title" "css_element"
     And I should see "Assignment" in the ".modal-body" "css_element"
 
   Scenario: The teacher can choose to add an activity from the activity items in the activity chooser
-    Given I click on "Add an activity" "button" in the "Topic 3" "section"
-    When I click on "Add a new Assignment" "link" in the "Add an activity" "dialogue"
+    Given I click on "Add an activity or resource" "button" in the "Topic 3" "section"
+    When I click on "Add a new Assignment" "link" in the "Add an activity or resource" "dialogue"
     Then I should see "Adding a new Assignment"
     And I set the following fields to these values:
       | Assignment name | Test Assignment Topic 3 |
@@ -32,19 +32,19 @@ Feature: Display and choose from the available activities in course
     Then I should see "Test Assignment Topic 3" in the "Topic 3" "section"
 
   Scenario: The teacher can choose to add an activity from the activity summary in the activity chooser
-    Given I click on "Add an activity" "button" in the "Topic 1" "section"
-    When I click on "Information about the Assignment activity" "button" in the "Add an activity" "dialogue"
+    Given I click on "Add an activity or resource" "button" in the "Topic 1" "section"
+    When I click on "Information about the Assignment activity" "button" in the "Add an activity or resource" "dialogue"
     When I click on "Add a new Assignment" "link" in the "help" "core_course > Activity chooser screen"
     Then I should see "Adding a new Assignment"
 
   Scenario: Show summary
-    Given I click on "Add an activity" "button" in the "Topic 1" "section"
-    When I click on "Information about the Assignment activity" "button" in the "Add an activity" "dialogue"
+    Given I click on "Add an activity or resource" "button" in the "Topic 1" "section"
+    When I click on "Information about the Assignment activity" "button" in the "Add an activity or resource" "dialogue"
     Then I should see "Assignment" in the "help" "core_course > Activity chooser screen"
     And I should see "The assignment activity module enables a teacher to communicate tasks, collect work and provide grades and feedback."
 
   Scenario: Hide summary
-    Given I click on "Add an activity" "button" in the "Topic 1" "section"
+    Given I click on "Add an activity or resource" "button" in the "Topic 1" "section"
     When I click on "Information about the Assignment activity" "button" in the "modules" "core_course > Activity chooser screen"
     And I should see "The assignment activity module enables a teacher to communicate tasks, collect work and provide grades and feedback." in the "help" "core_course > Activity chooser screen"
     And I should see "Back" in the "help" "core_course > Activity chooser screen"
@@ -52,7 +52,7 @@ Feature: Display and choose from the available activities in course
     Then "modules" "core_course > Activity chooser screen" should exist
     And "help" "core_course > Activity chooser screen" should not exist
     And "Back" "button" should not exist in the "modules" "core_course > Activity chooser screen"
-    And I should not see "The assignment activity module enables a teacher to communicate tasks, collect work and provide grades and feedback." in the "Add an activity" "dialogue"
+    And I should not see "The assignment activity module enables a teacher to communicate tasks, collect work and provide grades and feedback." in the "Add an activity or resource" "dialogue"
 
   Scenario: View recommended activities
     When I log out
@@ -65,96 +65,96 @@ Feature: Display and choose from the available activities in course
     And I log in as "teacher"
     And I am on "Course" course homepage with editing mode on
     And I open the activity chooser
-    Then I should see "Recommended" in the "Add an activity" "dialogue"
-    And I click on "Recommended" "link" in the "Add an activity" "dialogue"
+    Then I should see "Recommended" in the "Add an activity or resource" "dialogue"
+    And I click on "Recommended" "link" in the "Add an activity or resource" "dialogue"
     And I should see "Book" in the "recommended" "core_course > Activity chooser tab"
 
   Scenario: Favourite a module in the activity chooser
     Given I open the activity chooser
-    And I should not see "Starred" in the "Add an activity" "dialogue"
-    And I click on "Star Assignment activity" "button" in the "Add an activity" "dialogue"
-    And I should see "Starred" in the "Add an activity" "dialogue"
-    When I click on "Starred" "link" in the "Add an activity" "dialogue"
+    And I should not see "Starred" in the "Add an activity or resource" "dialogue"
+    And I click on "Star Assignment activity" "button" in the "Add an activity or resource" "dialogue"
+    And I should see "Starred" in the "Add an activity or resource" "dialogue"
+    When I click on "Starred" "link" in the "Add an activity or resource" "dialogue"
     Then I should see "Assignment" in the "favourites" "core_course > Activity chooser tab"
     And I click on "Information about the Assignment activity" "button" in the "favourites" "core_course > Activity chooser tab"
     And I should see "The assignment activity module enables a teacher to communicate tasks, collect work and provide grades and feedback."
 
   Scenario: Add a favourite module and check it exists when reopening the chooser
     Given I open the activity chooser
-    And I click on "Star Assignment activity" "button" in the "Add an activity" "dialogue"
-    And I click on "Star Forum activity" "button" in the "Add an activity" "dialogue"
-    And I should see "Starred" in the "Add an activity" "dialogue"
-    And I click on "Close" "button" in the "Add an activity" "dialogue"
-    When I click on "Add an activity" "button" in the "Topic 3" "section"
-    And I click on "Starred" "link" in the "Add an activity" "dialogue"
+    And I click on "Star Assignment activity" "button" in the "Add an activity or resource" "dialogue"
+    And I click on "Star Forum activity" "button" in the "Add an activity or resource" "dialogue"
+    And I should see "Starred" in the "Add an activity or resource" "dialogue"
+    And I click on "Close" "button" in the "Add an activity or resource" "dialogue"
+    When I click on "Add an activity or resource" "button" in the "Topic 3" "section"
+    And I click on "Starred" "link" in the "Add an activity or resource" "dialogue"
     Then I should see "Forum" in the "favourites" "core_course > Activity chooser tab"
 
   Scenario: Add a favourite and then remove it whilst checking the tabs work as expected
     Given I open the activity chooser
-    And I click on "Star Assignment activity" "button" in the "Add an activity" "dialogue"
-    And I click on "Starred" "link" in the "Add an activity" "dialogue"
-    And I click on "Star Assignment activity" "button" in the "Add an activity" "dialogue"
-    Then I should not see "Starred" in the "Add an activity" "dialogue"
+    And I click on "Star Assignment activity" "button" in the "Add an activity or resource" "dialogue"
+    And I click on "Starred" "link" in the "Add an activity or resource" "dialogue"
+    And I click on "Star Assignment activity" "button" in the "Add an activity or resource" "dialogue"
+    Then I should not see "Starred" in the "Add an activity or resource" "dialogue"
 
   Scenario: The teacher can search for an activity by it's name
-    Given I click on "Add an activity" "button" in the "Topic 1" "section"
+    Given I click on "Add an activity or resource" "button" in the "Topic 1" "section"
     When I set the field "search" to "Lesson"
-    Then I should see "1 results found" in the "Add an activity" "dialogue"
-    And I should see "Lesson" in the "Add an activity" "dialogue"
+    Then I should see "1 results found" in the "Add an activity or resource" "dialogue"
+    And I should see "Lesson" in the "Add an activity or resource" "dialogue"
 
   Scenario: The teacher can search for an activity by it's description
     Given I open the activity chooser
     When I set the field "search" to "The lesson activity module enables a teacher to deliver content"
-    Then I should see "1 results found" in the "Add an activity" "dialogue"
-    And I should see "Lesson" in the "Add an activity" "dialogue"
+    Then I should see "1 results found" in the "Add an activity or resource" "dialogue"
+    And I should see "Lesson" in the "Add an activity or resource" "dialogue"
 
   Scenario: Search results are not returned if the search query does not match any activity name or description
-    Given I click on "Add an activity" "button" in the "Topic 1" "section"
+    Given I click on "Add an activity or resource" "button" in the "Topic 1" "section"
     When I set the field "search" to "Random search query"
-    Then I should see "0 results found" in the "Add an activity" "dialogue"
+    Then I should see "0 results found" in the "Add an activity or resource" "dialogue"
     And ".option" "css_element" should not exist in the ".searchresultitemscontainer" "css_element"
 
   Scenario: Teacher can return to the default activity chooser state by manually removing the search query
-    Given I click on "Add an activity" "button" in the "Topic 1" "section"
+    Given I click on "Add an activity or resource" "button" in the "Topic 1" "section"
     And I set the field "search" to "Lesson"
-    And I should see "1 results found" in the "Add an activity" "dialogue"
-    And I should see "Lesson" in the "Add an activity" "dialogue"
+    And I should see "1 results found" in the "Add an activity or resource" "dialogue"
+    And I should see "Lesson" in the "Add an activity or resource" "dialogue"
     When I set the field "search" to ""
-    And I should not see "1 results found" in the "Add an activity" "dialogue"
+    And I should not see "1 results found" in the "Add an activity or resource" "dialogue"
     Then ".searchresultscontainer" "css_element" should not exist
     And ".optionscontainer" "css_element" should exist
 
   Scenario: Teacher can not see a "clear" button if a search query is not entered in the activity chooser search bar
-    When I click on "Add an activity" "button" in the "Topic 1" "section"
+    When I click on "Add an activity or resource" "button" in the "Topic 1" "section"
     Then "Clear search input" "button" should not exist
 
   Scenario: Teacher can see a "clear" button after entering a search query in the activity chooser search bar
-    Given I click on "Add an activity" "button" in the "Topic 1" "section"
+    Given I click on "Add an activity or resource" "button" in the "Topic 1" "section"
     When I set the field "search" to "Search query"
     Then "Clear search input" "button" should not exist
 
   Scenario: Teacher can not see a "clear" button if the search query is removed in the activity chooser search bar
-    Given I click on "Add an activity" "button" in the "Topic 1" "section"
+    Given I click on "Add an activity or resource" "button" in the "Topic 1" "section"
     And I set the field "search" to "Search query"
     And "Clear search input" "button" should exist
     When I set the field "search" to ""
     Then "Clear search input" "button" should not exist
 
   Scenario: Teacher can instantly remove the search query from the activity search bar by clicking on the "clear" button
-    Given I click on "Add an activity" "button" in the "Topic 1" "section"
+    Given I click on "Add an activity or resource" "button" in the "Topic 1" "section"
     And I set the field "search" to "Search query"
-    And I should see "results found" in the "Add an activity" "dialogue"
+    And I should see "results found" in the "Add an activity or resource" "dialogue"
     When I click on "Clear search input" "button"
     Then I should not see "Search query"
     And ".searchresultscontainer" "css_element" should not exist
     And ".optionscontainer" "css_element" should exist
 
   Scenario: Teacher gets the base case for the Activity Chooser tab mode
-    Given I click on "Add an activity" "button" in the "Topic 1" "section"
-    And I should see "Activities" in the "Add an activity" "dialogue"
-    When I click on "Activities" "link" in the "Add an activity" "dialogue"
+    Given I click on "Add an activity or resource" "button" in the "Topic 1" "section"
+    And I should see "Activities" in the "Add an activity or resource" "dialogue"
+    When I click on "Activities" "link" in the "Add an activity or resource" "dialogue"
     Then I should not see "Book" in the "activity" "core_course > Activity chooser tab"
-    And I click on "Resources" "link" in the "Add an activity" "dialogue"
+    And I click on "Resources" "link" in the "Add an activity or resource" "dialogue"
     And I should not see "Assignment" in the "resources" "core_course > Activity chooser tab"
 
   Scenario: Teacher gets the simple case for the Activity Chooser tab mode
@@ -167,9 +167,9 @@ Feature: Display and choose from the available activities in course
     And I log out
     And I log in as "teacher"
     And I am on "Course" course homepage with editing mode on
-    And I click on "Add an activity" "button" in the "Topic 1" "section"
-    Then I should not see "Activities" in the "Add an activity" "dialogue"
-    And I should not see "Resources" in the "Add an activity" "dialogue"
+    And I click on "Add an activity or resource" "button" in the "Topic 1" "section"
+    Then I should not see "Activities" in the "Add an activity or resource" "dialogue"
+    And I should not see "Resources" in the "Add an activity or resource" "dialogue"
 
   Scenario: Teacher gets the final case for the Activity Chooser tab mode
     Given I log out
@@ -181,7 +181,7 @@ Feature: Display and choose from the available activities in course
     And I log out
     And I log in as "teacher"
     And I am on "Course" course homepage with editing mode on
-    And I click on "Add an activity" "button" in the "Topic 1" "section"
-    Then I should not see "All" in the "Add an activity" "dialogue"
-    And I should see "Activities" in the "Add an activity" "dialogue"
-    And I should see "Resources" in the "Add an activity" "dialogue"
+    And I click on "Add an activity or resource" "button" in the "Topic 1" "section"
+    Then I should not see "All" in the "Add an activity or resource" "dialogue"
+    And I should see "Activities" in the "Add an activity or resource" "dialogue"
+    And I should see "Resources" in the "Add an activity or resource" "dialogue"
index b31933f..d3616b6 100644 (file)
@@ -39,5 +39,5 @@ Feature: Restrict activities availability
     And I log out
     And I log in as "teacher1"
     When I am on "Course 1" course homepage with editing mode on
-    Then the "Add an activity to section 'Topic 1'" select box should not contain "Chat"
+    Then the "Add a resource to section 'Topic 1'" select box should not contain "Chat"
     Then the "Add an activity to section 'Topic 1'" select box should not contain "Glossary"
index baf056a..082cef6 100644 (file)
         if (has_capability('moodle/grade:manageoutcomes', $context)) {
         ?>
         <td class="p-l-1 p-r-1">
-            <p class="arrow_button">
-                <input name="add" class="btn btn_secondary" id="add" type="submit" value="<?php echo '&nbsp; ' . $OUTPUT->larrow() . ' &nbsp; &nbsp; ' .
+            <div class="m-y-1">
+                <input name="add" class="btn btn-secondary" id="add" type="submit" value="<?php echo $OUTPUT->larrow() . ' ' .
                     get_string('add'); ?>" title="<?php print_string('add'); ?>" />
-                <br />
-                <input name="remove" class="btn btn_secondary" id="remove" type="submit" value="<?php echo '&nbsp;' . get_string('remove') . ' &nbsp; &nbsp; ' .
-                    $OUTPUT->rarrow(); ?>" title="<?php print_string('remove'); ?>" />
-            </p>
+            </div>
+            <div class="m-y-1">
+                <input name="remove" class="btn btn-secondary" id="remove" type="submit" value="<?php echo get_string('remove') .
+                    ' ' . $OUTPUT->rarrow(); ?>" title="<?php print_string('remove'); ?>" />
+            </div>
         </td>
         <?php } ?>
         <td>
index 91dda3f..9d1cae0 100644 (file)
@@ -48,6 +48,6 @@ $string['invalidmd5'] = 'Kontrolerako aldagaia gaizki zegoen - saiatu berriz ere
 $string['missingrequiredfield'] = 'Beharrezko eremuren bat falta da.';
 $string['remotedownloaderror'] = '<p>Errorea osagaia zure zerbitzarian jaistean, mesedez egiaztatu proxy-ezarpenak, PHP cURL hedapena erabat gomendatzen da.</p>
 <p> <a href="{$a->url}">{$a->url}</a> fitxategia eskuz jaitsi beharko zenuke, zure zerbitzariko "{$a->dest}"-ra kopiatu eta bertan deskonprimatu.</p>';
-$string['wrongdestpath'] = 'Bide desegokia';
+$string['wrongdestpath'] = 'Helmuga-bide desegokia';
 $string['wrongsourcebase'] = 'URL iturriaren oinarri akastuna';
 $string['wrongzipfilename'] = 'ZIP fitxategiaren izen desegokia';
index a6c57f4..ee988e9 100644 (file)
@@ -45,7 +45,7 @@ $string['datarootpermission'] = 'Datu-direktorioen baimena';
 $string['dbprefix'] = 'Taulen aurrizkia';
 $string['dirroot'] = 'Moodle direktorioa';
 $string['environmenthead'] = 'Zure ingurunea egiaztatzen...';
-$string['environmentsub2'] = 'Moodleko bertsio bakoitzak PHPko gutxieneko bertsioa eta derrigorrez instalatu beharreko PHP hedapen batzuk ditu. Ingurunearen azterketa oso bat egiten da instalazioa eta eguneraketa bakoitza egin aurretik. Mesedez, jarri harremanetan zerbitzariaren kudeatzailearekin ez badakizu bertsio berria edo PHP hedapenak nola instalatu.';
+$string['environmentsub2'] = 'Moodleko bertsio bakoitzak PHPko gutxieneko bertsioa eta derrigorrez instalatu beharreko PHP hedapen batzuk ditu. Ingurunearen azterketa oso bat egiten da instalazioa eta eguneratze bakoitza egin aurretik. Mesedez, jarri harremanetan zerbitzariaren kudeatzailearekin ez badakizu bertsio berria edo PHP hedapenak nola instalatu.';
 $string['errorsinenvironment'] = 'Huts egin du ingurunearen egiaztatzeak!';
 $string['installation'] = 'Instalazioa';
 $string['langdownloaderror'] = 'Zoritxarrez "{$a}" hizkuntza ezin izan da jaitsi. Instalazio-prozesuak ingelesez jarraituko du.';
index 14b1c98..55108d3 100644 (file)
@@ -38,9 +38,12 @@ $string['eventcontentviewed'] = 'Content viewed';
 $string['errordeletingcontentfromcategory'] = 'Error deleting content from category {$a}.';
 $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 contentbank with file details';
+$string['displayicons'] = 'Display contentbank with icons';
 $string['file'] = 'Upload content';
 $string['file_help'] = 'Files may be stored in the content bank for use in courses. Only files used by content types enabled on the site may be uploaded.';
 $string['itemsfound'] = '{$a} items found';
+$string['lastmodified'] = 'Last modified';
 $string['name'] = 'Content';
 $string['nopermissiontodelete'] = 'You do not have permission to delete content.';
 $string['nopermissiontomanage'] = 'You do not have permission to manage content.';
@@ -55,6 +58,8 @@ $string['privacy:metadata:userid'] = 'The ID of the user creating or modifying c
 $string['rename'] = 'Rename';
 $string['renamecontent'] = 'Rename content';
 $string['searchcontentbankbyname'] = 'Search for content by name';
+$string['size'] = 'Size';
 $string['timecreated'] = 'Time created';
+$string['type'] = 'Type';
 $string['unsupported'] = 'This content type is not supported.';
 $string['upload'] = 'Upload';
index 5563995..0164c28 100644 (file)
@@ -13,6 +13,8 @@ sitemessage,core
 coursemessage,core
 addedrecip,core
 addedrecips,core
+messagecontactrequestsnotification,core_message
+messagecontactrequestsnotificationsubject,core_message
 messagingdisabled,core_message
 messagedselectedcountusersfailed,core
 backtoparticipants,core
index 86b8085..61ed582 100644 (file)
@@ -101,8 +101,8 @@ $string['managemessageoutputs'] = 'Default notification preferences';
 $string['messageoutputs'] = 'Notification plugins';
 $string['messagepreferences'] = 'Message preferences';
 $string['message'] = 'Message';
-$string['messagecontactrequestsnotification'] = '{$a} is requesting to be added as a contact.';
-$string['messagecontactrequestsnotificationsubject'] = 'Contact request from {$a}';
+$string['messagecontactrequest'] = '{$a->user} is requesting to be added as a contact. Visit your <a href="{$a->url}">contact requests</a> page to respond to the request';
+$string['messagecontactrequestsubject'] = '{$a->sitename}: Contact request from {$a->user}';
 $string['messagecontentaudio'] = 'Audio';
 $string['messagecontentimage'] = 'Image';
 $string['messagecontentmultimediageneral'] = 'Other media';
@@ -289,3 +289,7 @@ $string['outputdoesnotexist'] = 'Message output does not exist';
 $string['outputenabled'] = 'Output enabled';
 $string['outputnotconfigured'] = 'Not configured';
 $string['canceledit'] = 'Cancel editing messages';
+
+// Deprecated since Moodle 3.9.
+$string['messagecontactrequestsnotification'] = '{$a} is requesting to be added as a contact.';
+$string['messagecontactrequestsnotificationsubject'] = 'Contact request from {$a}';
index 81fc120..75250a4 100644 (file)
@@ -70,7 +70,7 @@ $string['addnewuser'] = 'Add a new user';
 $string['addnousersrecip'] = 'Add users who haven\'t accessed this {$a} to recipient list';
 $string['addpagehere'] = 'Add text here';
 $string['addresource'] = 'Add a resource...';
-$string['addresourceoractivity'] = 'Add an activity';
+$string['addresourceoractivity'] = 'Add an activity or resource';
 $string['addresourcetosection'] = 'Add a resource to section \'{$a}\'';
 $string['address'] = 'Address';
 $string['addsections'] = 'Add sections';
index 93b736a..46af6d3 100644 (file)
@@ -185,7 +185,7 @@ $capabilities = array(
 
         'riskbitmask' => RISK_SPAM | RISK_PERSONAL | RISK_XSS,
 
-        'captype' => 'write',
+        'captype' => 'read',
         'contextlevel' => CONTEXT_COURSE,
         'archetypes' => array(
             'editingteacher' => CAP_ALLOW,
index 8d9ab81..77768fe 100644 (file)
@@ -152,7 +152,8 @@ abstract class file_archive implements Iterator {
             }
         }
 
-        $result = preg_replace('/\.\.+/', '', $result);
+        $result = preg_replace('/\.\.+\//', '', $result); // Cleanup any potential ../ transversal (any number of dots).
+        $result = preg_replace('/\.\.+/', '.', $result); // Join together any number of consecutive dots.
         $result = ltrim($result); // no leading /
 
         if ($result === '.') {
index 2131314..61b7d33 100644 (file)
@@ -145,7 +145,8 @@ class zip_archive extends file_archive {
      */
     protected function mangle_pathname($localname) {
         $result = str_replace('\\', '/', $localname);   // no MS \ separators
-        $result = preg_replace('/\.\.+/', '', $result); // prevent /.../
+        $result = preg_replace('/\.\.+\//', '', $result); // Cleanup any potential ../ transversal (any number of dots).
+        $result = preg_replace('/\.\.+/', '.', $result); // Join together any number of consecutive dots.
         $result = ltrim($result, '/');                  // no leading slash
 
         if ($result === '.') {
index 504ff4b..2d3022e 100644 (file)
@@ -1,8 +1,10 @@
 {{< core_form/element-template-inline }}
     {{$element}}
+    <div class="d-flex flex-wrap">
         {{#element.elements}}
             {{{separator}}}
             {{{html}}}
         {{/element.elements}}
+    </div>
     {{/element}}
 {{/ core_form/element-template-inline }}
index b8a5c03..f4b2bdc 100644 (file)
Binary files a/lib/table/amd/build/dynamic.min.js and b/lib/table/amd/build/dynamic.min.js differ
index 5c5e815..822e013 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 ec438f1..ff5ca26 100644 (file)
Binary files a/lib/table/amd/build/local/dynamic/repository.min.js and b/lib/table/amd/build/local/dynamic/repository.min.js differ
index af07af3..a8cb000 100644 (file)
Binary files a/lib/table/amd/build/local/dynamic/repository.min.js.map and b/lib/table/amd/build/local/dynamic/repository.min.js.map differ
index a53dc6f..235dd5e 100644 (file)
@@ -74,8 +74,7 @@ export const refreshTableContent = (tableRoot, resetContent = false) => {
         tableRoot.dataset.tableHandler,
         tableRoot.dataset.tableUniqueid,
         {
-            sortBy: tableRoot.dataset.tableSortBy,
-            sortOrder: tableRoot.dataset.tableSortOrder,
+            sortData: JSON.parse(tableRoot.dataset.tableSortData),
             joinType: filterset.jointype,
             filters: filterset.filters,
             firstinitial: tableRoot.dataset.tableFirstInitial,
@@ -116,8 +115,12 @@ export const updateTable = (tableRoot, {
 
     // Update sort fields.
     if (sortBy && sortOrder) {
-        tableRoot.dataset.tableSortBy = sortBy;
-        tableRoot.dataset.tableSortOrder = sortOrder;
+        const sortData = JSON.parse(tableRoot.dataset.tableSortData);
+        sortData.unshift({
+            sortby: sortBy,
+            sortorder: parseInt(sortOrder, 10),
+        });
+        tableRoot.dataset.tableSortData = JSON.stringify(sortData);
     }
 
     // Update initials.
index a8e6141..3e43ac9 100644 (file)
@@ -31,18 +31,20 @@ import {call as fetchMany} from 'core/ajax';
  * @param {String} component The component
  * @param {String} handler The name of the handler
  * @param {String} uniqueid The unique id of the table
- * @param {Object} filters The filters to apply when searching
- * @param {String} firstinitial The first name initial to filter on
- * @param {String} lastinitial The last name initial to filter on
- * @param {String} pageNumber The page number
- * @param {Number} pageSize The page size
- * @param {Number} params parameters to request table
+ * @param {Object} options The options to use when updating the table
+ * @param {Array} options.sortData The list of columns to sort by
+ * @param {Number} options.joinType The filterset join type
+ * @param {Object} options.filters The filters to apply when searching
+ * @param {String} options.firstinitial The first name initial to filter on
+ * @param {String} options.lastinitial The last name initial to filter on
+ * @param {String} options.pageNumber The page number
+ * @param {Number} options.pageSize The page size
+ * @param {Object} options.hiddenColumns The columns to hide
  * @param {Bool} resetPreferences
  * @return {Promise} Resolved with requested table view
  */
 export const fetch = (component, handler, uniqueid, {
-        sortBy = null,
-        sortOrder = null,
+        sortData = [],
         joinType = null,
         filters = {},
         firstinitial = null,
@@ -57,8 +59,7 @@ export const fetch = (component, handler, uniqueid, {
             component,
             handler,
             uniqueid,
-            sortby: sortBy,
-            sortorder: sortOrder,
+            sortdata: sortData,
             jointype: joinType,
             filters,
             firstinitial,
index 15b9583..b63c6c2 100644 (file)
@@ -67,15 +67,22 @@ class fetch extends external_api {
                 'Unique ID for the container',
                 VALUE_REQUIRED
             ),
-            'sortby' => new external_value(
-                PARAM_ALPHANUMEXT,
-                'The name of a sortable column',
-                VALUE_REQUIRED
-            ),
-            'sortorder' => new external_value(
-                PARAM_ALPHANUMEXT,
-                'The sort order',
-                VALUE_REQUIRED
+            'sortdata' => new external_multiple_structure(
+                new external_single_structure([
+                    'sortby' => new external_value(
+                        PARAM_ALPHANUMEXT,
+                        'The name of a sortable column',
+                        VALUE_REQUIRED
+                    ),
+                    'sortorder' => new external_value(
+                        PARAM_ALPHANUMEXT,
+                        'The direction that this column should be sorted by',
+                        VALUE_REQUIRED
+                    ),
+                ]),
+                'The combined sort order of the table. Multiple fields can be specified.',
+                VALUE_OPTIONAL,
+                []
             ),
             'filters' => new external_multiple_structure(
                 new external_single_structure([
@@ -138,8 +145,7 @@ class fetch extends external_api {
      * @param string $component The component.
      * @param string $handler Dynamic table class name.
      * @param string $uniqueid Unique ID for the container.
-     * @param string $sortby The name of a sortable column.
-     * @param string $sortorder The sort order.
+     * @param array $sortdata The columns and order to sort by
      * @param array $filters The filters that will be applied in the request.
      * @param string $jointype The join type.
      * @param string $firstinitial The first name initial to filter on
@@ -155,8 +161,7 @@ class fetch extends external_api {
         string $component,
         string $handler,
         string $uniqueid,
-        string $sortby,
-        string $sortorder,
+        array $sortdata,
         ?array $filters = null,
         ?string $jointype = null,
         ?string $firstinitial = null,
@@ -166,15 +171,13 @@ class fetch extends external_api {
         ?array $hiddencolumns = null,
         ?bool $resetpreferences = null
     ) {
-
         global $PAGE;
 
         [
             'component' => $component,
             'handler' => $handler,
             'uniqueid' => $uniqueid,
-            'sortby' => $sortby,
-            'sortorder' => $sortorder,
+            'sortdata' => $sortdata,
             'filters' => $filters,
             'jointype' => $jointype,
             'firstinitial' => $firstinitial,
@@ -187,8 +190,7 @@ class fetch extends external_api {
             'component' => $component,
             'handler' => $handler,
             'uniqueid' => $uniqueid,
-            'sortby' => $sortby,
-            'sortorder' => $sortorder,
+            'sortdata' => $sortdata,
             'filters' => $filters,
             'jointype' => $jointype,
             'firstinitial' => $firstinitial,
@@ -227,7 +229,7 @@ class fetch extends external_api {
         $instance->set_filterset($filterset);
         self::validate_context($instance->get_context());
 
-        $instance->set_sorting($sortby, $sortorder);
+        $instance->set_sortdata($sortdata);
 
         if ($firstinitial !== null) {
             $instance->set_first_initial($firstinitial);
index 111b221..c825079 100644 (file)
@@ -55,7 +55,21 @@ class fetch_test extends advanced_testcase {
         $this->resetAfterTest();
 
         $this->expectException(\invalid_parameter_exception::class);
-        fetch::execute("core-user", "participants", "", "email", "4", [], "1");
+        fetch::execute(
+            "core-user",
+            "participants",
+            "",
+            $this->get_sort_array(['email' => SORT_ASC]),
+            [],
+            (string) filter::JOINTYPE_ANY,
+            null,
+            null,
+            null,
+            null,
+            [],
+            null
+
+        );
     }
 
     /**
@@ -65,8 +79,20 @@ class fetch_test extends advanced_testcase {
         $this->resetAfterTest();
 
         $this->expectException(\UnexpectedValueException::class);
-        fetch::execute("core_users", "participants", "", "email", "4", [], (string)filter::JOINTYPE_ANY,
-            null, null, null, null, []);
+        fetch::execute(
+            "core_users",
+            "participants",
+            "",
+            $this->get_sort_array(['email' => SORT_ASC]),
+            [],
+            (string) filter::JOINTYPE_ANY,
+            null,
+            null,
+            null,
+            null,
+            [],
+            null
+        );
     }
 
     /**
@@ -80,8 +106,21 @@ class fetch_test extends advanced_testcase {
         $this->expectExceptionMessage("Table handler class {$handler} not found. Please make sure that your table handler class is under the \\core_user\\table namespace.");
 
         // Tests that invalid users_participants_table class gets an exception.
-        fetch::execute("core_user", "users_participants_table", "", "email", "4", [], (string)filter::JOINTYPE_ANY,
-            null, null, null, null, []);
+        fetch::execute(
+            "core_user",
+            "users_participants_table",
+            "",
+            $this->get_sort_array(['email' => SORT_ASC]),
+            [],
+            (string) filter::JOINTYPE_ANY,
+            null,
+            null,
+            null,
+            null,
+            [],
+            null
+
+        );
     }
 
     /**
@@ -97,15 +136,20 @@ class fetch_test extends advanced_testcase {
             [
                 'fullname' => 'courseid',
                 'jointype' => filter::JOINTYPE_ANY,
-                'values' => [(int)$course->id]
+                'values' => [(int) $course->id]
             ]
         ];
         $this->expectException(\invalid_parameter_exception::class);
         $this->expectExceptionMessage("Invalid parameter value detected (filters => Invalid parameter value detected " .
         "(Missing required key in single structure: name): Missing required key in single structure: name");
 
-        fetch::execute("core_user", "participants", "user-index-participants-{$course->id}",
-        "firstname", "4", $filter, (string)filter::JOINTYPE_ANY);
+        fetch::execute(
+            "core_user",
+            "participants", "user-index-participants-{$course->id}",
+            $this->get_sort_array(['firstname' => SORT_ASC]),
+            $filter,
+            (string) filter::JOINTYPE_ANY
+        );
     }
 
     /**
@@ -122,18 +166,30 @@ class fetch_test extends advanced_testcase {
 
         $this->setUser($teacher);
 
+        $this->get_sort_array(['email' => SORT_ASC]);
+
         $filter = [
             [
                 'name' => 'courseid',
                 'jointype' => filter::JOINTYPE_ANY,
-                'values' => [(int)$course->id]
+                'values' => [(int) $course->id]
             ]
         ];
 
-        $participantstable = fetch::execute("core_user", "participants",
-            "user-index-participants-{$course->id}", "firstname", "4", $filter, (string)filter::JOINTYPE_ANY,
-            null, null, null, null, []);
-
+        $participantstable = fetch::execute(
+            "core_user",
+            "participants",
+            "user-index-participants-{$course->id}",
+            $this->get_sort_array(['firstname' => SORT_ASC]),
+            $filter,
+            (string) filter::JOINTYPE_ANY,
+            null,
+            null,
+            null,
+            null,
+            [],
+            null
+        );
         $html = $participantstable['html'];
 
         $this->assertStringContainsString($user1->email, $html);
@@ -141,4 +197,23 @@ class fetch_test extends advanced_testcase {
         $this->assertStringContainsString($teacher->email, $html);
         $this->assertStringNotContainsString($user3->email, $html);
     }
+
+
+    /**
+     * Convert a traditional sort order into a sortorder for the web service.
+     *
+     * @param array $sortdata
+     * @return array
+     */
+    protected function get_sort_array(array $sortdata): array {
+        $newsortorder = [];
+        foreach ($sortdata as $sortby => $sortorder) {
+            $newsortorder[] = [
+                'sortby' => $sortby,
+                'sortorder' => $sortorder,
+            ];
+        }
+
+        return $newsortorder;
+    }
 }
index 997a965..73db35a 100644 (file)
@@ -87,14 +87,9 @@ class flexible_table {
     var $is_sortable    = false;
 
     /**
-     * @var string The field name to sort by.
+     * @var array The fields to sort.
      */
-    protected $sortby;
-
-    /**
-     * @var string $sortorder The direction for sorting.
-     */
-    protected $sortorder;
+    protected $sortdata;
 
     /** @var string The manually set first name initial preference */
     protected $ifirst;
@@ -1301,40 +1296,43 @@ class flexible_table {
      * Calculate the preferences for sort order based on user-supplied values and get params.
      */
     protected function set_sorting_preferences(): void {
-        $sortorder = $this->sortorder;
-        $sortby = $this->sortby;
+        $sortdata = $this->sortdata;
+
+        if ($sortdata === null) {
+            $sortdata = $this->prefs['sortby'];
 
-        if ($sortorder === null || $sortby === null) {
             $sortorder = optional_param($this->request[TABLE_VAR_DIR], $this->sort_default_order, PARAM_INT);
             $sortby = optional_param($this->request[TABLE_VAR_SORT], '', PARAM_ALPHANUMEXT);
-        }
-
-        $isvalidsort = $sortby && $this->is_sortable($sortby);
-        $isvalidsort = $isvalidsort && empty($this->prefs['collapse'][$sortby]);
-        $isrealcolumn = isset($this->columns[$sortby]);
-        $isfullnamefield = isset($this->columns['fullname']) && in_array($sortby, get_all_user_name_fields());
 
-        if ($isvalidsort && ($isrealcolumn || $isfullnamefield)) {
-            if (array_key_exists($sortby, $this->prefs['sortby'])) {
+            if (array_key_exists($sortby, $sortdata)) {
                 // This key already exists somewhere. Change its sortorder and bring it to the top.
-                $sortorder = $this->prefs['sortby'][$sortby] = $sortorder;
-                unset($this->prefs['sortby'][$sortby]);
-                $this->prefs['sortby'] = array_merge(array($sortby => $sortorder), $this->prefs['sortby']);
-            } else {
-                // Key doesn't exist, so just add it to the beginning of the array, ascending order.
-                $this->prefs['sortby'] = array_merge(array($sortby => $sortorder), $this->prefs['sortby']);
+                //$sortorder = $sortdata[$sortby] = $sortorder;
+                unset($sortdata['sortby'][$sortby]);
             }
-
-            // Finally, make sure that no more than $this->maxsortkeys are present into the array.
-            $this->prefs['sortby'] = array_slice($this->prefs['sortby'], 0, $this->maxsortkeys);
+            $sortdata = array_merge([$sortby => $sortorder], $sortdata);
         }
 
+        $usernamefields = get_all_user_name_fields();
+        $sortdata = array_filter($sortdata, function($sortby) use ($usernamefields) {
+            $isvalidsort = $sortby && $this->is_sortable($sortby);
+            $isvalidsort = $isvalidsort && empty($this->prefs['collapse'][$sortby]);
+            $isrealcolumn = isset($this->columns[$sortby]);
+            $isfullnamefield = isset($this->columns['fullname']) && in_array($sortby, $usernamefields);
+
+            return $isvalidsort && ($isrealcolumn || $isfullnamefield);
+        }, ARRAY_FILTER_USE_KEY);
+
+        // Finally, make sure that no more than $this->maxsortkeys are present into the array.
+        $sortdata = array_slice($sortdata, 0, $this->maxsortkeys);
+
         // If a default order is defined and it is not in the current list of order by columns, add it at the end.
         // This prevents results from being returned in a random order if the only order by column contains equal values.
-        if (!empty($this->sort_default_column) && !array_key_exists($this->sort_default_column, $this->prefs['sortby'])) {
-            $defaultsort = array($this->sort_default_column => $this->sort_default_order);
-            $this->prefs['sortby'] = array_merge($this->prefs['sortby'], $defaultsort);
+        if (!empty($this->sort_default_column) && !array_key_exists($this->sort_default_column, $sortdata)) {
+            $sortdata = array_merge($sortdata, [$this->sort_default_column => $this->sort_default_order]);
         }
+
+        // Apply the sortdata to the preference.
+        $this->prefs['sortby'] = $sortdata;
     }
 
     /**
@@ -1431,8 +1429,7 @@ class flexible_table {
 
         // Save user preferences if they have changed.
         if ($this->is_resetting_preferences()) {
-            $this->sortorder = null;
-            $this->sortby = null;
+            $this->sortdata = null;
             $this->ifirst = null;
             $this->ilast = null;
         }
@@ -1502,9 +1499,13 @@ class flexible_table {
      * @param string $sortby The field to sort by.
      * @param int $sortorder The sort order.
      */
-    public function set_sorting(string $sortby, int $sortorder): void {
-        $this->sortby = $sortby;
-        $this->sortorder = $sortorder;
+    public function set_sortdata(array $sortdata): void {
+        $this->sortdata = [];
+        foreach ($sortdata as $sortitem) {
+            if (!array_key_exists($sortitem['sortby'], $this->sortdata)) {
+                $this->sortdata[$sortitem['sortby']] = $sortitem['sortorder'];
+            }
+        }
     }
 
     /**
@@ -1643,7 +1644,13 @@ class flexible_table {
      */
     protected function get_dynamic_table_html_start(): string {
         if (is_a($this, \core_table\dynamic::class)) {
-            $sortdata = $this->get_sort_order();
+            $sortdata = array_map(function($sortby, $sortorder) {
+                return [
+                    'sortby' => $sortby,
+                    'sortorder' => $sortorder,
+                ];
+            }, array_keys($this->prefs['sortby']), array_values($this->prefs['sortby']));;
+
             return html_writer::start_tag('div', [
                 'class' => 'table-dynamic position-relative',
                 'data-region' => 'core_table/dynamic',
@@ -1651,8 +1658,7 @@ class flexible_table {
                 'data-table-component' => $this->get_component(),
                 'data-table-uniqueid' => $this->uniqueid,
                 'data-table-filters' => json_encode($this->get_filterset()),
-                'data-table-sort-by' => $sortdata['sortby'],
-                'data-table-sort-order' => $sortdata['sortorder'],
+                'data-table-sort-data' => json_encode($sortdata),
                 'data-table-first-initial' => $this->prefs['i_first'],
                 'data-table-last-initial' => $this->prefs['i_last'],
                 'data-table-page-number' => $this->currpage + 1,
diff --git a/lib/tests/filestorage_zip_archive_test.php b/lib/tests/filestorage_zip_archive_test.php
new file mode 100644 (file)
index 0000000..339919f
--- /dev/null
@@ -0,0 +1,86 @@
+<?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 /lib/filestorage/zip_archive.php.
+ *
+ * @package   core_files
+ * @copyright 2020 Universit√© Rennes 2 {@link https://www.univ-rennes2.fr}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+
+require_once($CFG->libdir . '/filestorage/zip_archive.php');
+
+/**
+ * Unit tests for /lib/filestorage/zip_archive.php.
+ *
+ * @package   core_files
+ * @copyright 2020 Universit√© Rennes 2 {@link https://www.univ-rennes2.fr}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class filestorage_zip_archive_testcase extends advanced_testcase {
+    /**
+     * Test mangle_pathname() method.
+     *
+     * @dataProvider pathname_provider
+     *
+     * @param string $string   Parameter sent to mangle_pathname method.
+     * @param string $expected Expected return value.
+     */
+    public function test_mangle_pathname($string, $expected) {
+        $ziparchive = new zip_archive();
+
+        $method = new ReflectionMethod('zip_archive', 'mangle_pathname');
+        $method->setAccessible(true);
+
+        $result = $method->invoke($ziparchive, $string);
+        $this->assertSame($expected, $result);
+    }
+
+    /**
+     * Provide some tested pathnames and expected results.
+     *
+     * @return array Array of tested pathnames and expected results.
+     */
+    public function pathname_provider() {
+        return [
+            // Test a string.
+            ['my file.pdf', 'my file.pdf'],
+
+            // Test a string with MS separator.
+            ['c:\temp\my file.pdf', 'c:/temp/my file.pdf'],
+
+            // Test a string with 2 consecutive dots.
+            ['my file..pdf', 'my file.pdf'],
+
+            // Test a string with 3 consecutive dots.
+            ['my file...pdf', 'my file.pdf'],
+
+            // Test a string beginning with leading slash.
+            ['/tmp/my file.pdf', 'tmp/my file.pdf'],
+
+            // Test some path traversal attacks.
+            ['../../../../../etc/passwd', 'etc/passwd'],
+            ['../', ''],
+            ['.../...//', ''],
+            ['.', ''],
+        ];
+    }
+}
index 29fbeeb..53c1f62 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 b1c6bcc..dac3fc9 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 7b911ce..03854ff 100644 (file)
@@ -506,6 +506,18 @@ function(
                 newestFirst,
                 timeFrom
             )
+            .then(function(result) {
+                // Prevent older requests from contaminating the current view.
+                if (result.id != viewState.id) {
+                    result.messages = [];
+                    // Purge old conversation cache to prevent messages lose.
+                    if (result.id in stateCache) {
+                        delete stateCache[result.id];
+                    }
+                }
+
+                return result;
+            })
             .then(function(result) {
                 if (result.messages.length && ignoreList.length) {
                     result.messages = result.messages.filter(function(message) {
@@ -1865,6 +1877,9 @@ function(
     var resetState = function(body, conversationId, loggedInUserProfile) {
         // Reset all of the states back to the beginning if we're loading a new
         // conversation.
+        if (newMessagesPollTimer) {
+            newMessagesPollTimer.stop();
+        }
         loadedAllMessages = false;
         messagesOffset = 0;
         newMessagesPollTimer = null;
@@ -1893,10 +1908,6 @@ function(
             viewState = initialState;
         }
 
-        if (newMessagesPollTimer) {
-            newMessagesPollTimer.stop();
-        }
-
         render(initialState);
     };
 
index c26fe8b..0055855 100644 (file)
@@ -2614,7 +2614,7 @@ class api {
      * @return \stdClass the request
      */
     public static function create_contact_request(int $userid, int $requesteduserid) : \stdClass {
-        global $DB, $PAGE;
+        global $DB, $PAGE, $SITE;
 
         $request = new \stdClass();
         $request->userid = $userid;
@@ -2627,10 +2627,16 @@ class api {
         $userfrom = \core_user::get_user($userid);
         $userfromfullname = fullname($userfrom);
         $userto = \core_user::get_user($requesteduserid);
-        $url = new \moodle_url('/message/pendingcontactrequests.php');
-
-        $subject = get_string('messagecontactrequestsnotificationsubject', 'core_message', $userfromfullname);
-        $fullmessage = get_string('messagecontactrequestsnotification', 'core_message', $userfromfullname);
+        $url = new \moodle_url('/message/index.php', ['view' => 'contactrequests']);
+
+        $subject = get_string('messagecontactrequestsubject', 'core_message', (object) [
+            'sitename' => format_string($SITE->fullname, true, ['context' => \context_system::instance()]),
+            'user' => $userfromfullname,
+        ]);
+        $fullmessage = get_string('messagecontactrequest', 'core_message', (object) [
+            'url' => $url->out(),
+            'user' => $userfromfullname,
+        ]);
 
         $message = new \core\message\message();
         $message->courseid = SITEID;
index 34f23e9..4b50121 100644 (file)
@@ -7422,6 +7422,7 @@ class assign {
         }
 
         $this->update_submission($submission, $userid, true, $instance->teamsubmission);
+        $users = [$userid];
 
         if ($instance->teamsubmission && !$instance->requireallteammemberssubmit) {
             $team = $this->get_submission_group_members($submission->groupid, true);
@@ -7430,22 +7431,26 @@ class assign {
                 if ($member->id != $userid) {
                     $membersubmission = clone($submission);
                     $this->update_submission($membersubmission, $member->id, true, $instance->teamsubmission);
+                    $users[] = $member->id;
                 }
             }
         }
 
-        // Logging.
-        if (isset($data->submissionstatement) && ($userid == $USER->id)) {
-            \mod_assign\event\statement_accepted::create_from_submission($this, $submission)->trigger();
-        }
-
         $complete = COMPLETION_INCOMPLETE;
         if ($submission->status == ASSIGN_SUBMISSION_STATUS_SUBMITTED) {
             $complete = COMPLETION_COMPLETE;
         }
+
         $completion = new completion_info($this->get_course());
         if ($completion->is_enabled($this->get_course_module()) && $instance->completionsubmit) {
-            $completion->update_state($this->get_course_module(), $complete, $userid);
+            foreach ($users as $id) {
+                $completion->update_state($this->get_course_module(), $complete, $id);
+            }
+        }
+
+        // Logging.
+        if (isset($data->submissionstatement) && ($userid == $USER->id)) {
+            \mod_assign\event\statement_accepted::create_from_submission($this, $submission)->trigger();
         }
 
         if (!$instance->submissiondrafts) {
index 33d9751..31c65ac 100644 (file)
@@ -3785,6 +3785,45 @@ Anchor link 2:<a title=\"bananas\" href=\"../logo-240x60.gif\">Link text</a>
         $this->assertEquals(1, $completiondata->completionstate);
     }
 
+    /**
+     * Test updating activity completion when submitting an assessment for MDL-67126.
+     */
+    public function test_update_activity_completion_records_team_submission_new() {
+        $this->resetAfterTest();
+
+        $course = $this->getDataGenerator()->create_course(['enablecompletion' => 1]);
+        $teacher = $this->getDataGenerator()->create_and_enrol($course, 'teacher');
+        $student = $this->getDataGenerator()->create_and_enrol($course, 'student');
+        $otherstudent = $this->getDataGenerator()->create_and_enrol($course, 'student');
+
+        $grouping = $this->getDataGenerator()->create_grouping(array('courseid' => $course->id));
+        $group1 = $this->getDataGenerator()->create_group(['courseid' => $course->id]);
+
+        groups_add_member($group1, $student);
+        groups_add_member($group1, $otherstudent);
+
+        $assign = $this->create_instance($course, [
+                'submissiondrafts' => 0,
+                'completion' => COMPLETION_TRACKING_AUTOMATIC,
+                'completionsubmit' => 1,
+                'teamsubmission' => 1,
+                'assignsubmission_onlinetext_enabled' => 1
+        ]);
+
+        $cm = $assign->get_course_module();
+
+        $this->add_submission($student, $assign);
+
+        $completion = new completion_info($course);
+
+        // Completion should now be met.
+        $completiondata = $completion->get_data($cm, false, $student->id);
+        $this->assertEquals(1, $completiondata->completionstate);
+
+        $completiondata = $completion->get_data($cm, false, $otherstudent->id);
+        $this->assertEquals(1, $completiondata->completionstate);
+    }
+
     /**
      * Data provider for test_fix_null_grades
      * @return array[] Test data for test_fix_null_grades. Each element should contain grade, expectedcount and gradebookvalue
diff --git a/mod/h5pactivity/classes/external/get_results.php b/mod/h5pactivity/classes/external/get_results.php
new file mode 100644 (file)
index 0000000..a37ab62
--- /dev/null
@@ -0,0 +1,303 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * This is the external method for getting the information needed to present a results report.
+ *
+ * @package    mod_h5pactivity
+ * @since      Moodle 3.9
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\external;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->libdir . '/externallib.php');
+
+use mod_h5pactivity\local\manager;
+use mod_h5pactivity\local\report\results as report_results;
+use external_api;
+use external_function_parameters;
+use external_value;
+use external_multiple_structure;
+use external_single_structure;
+use external_warnings;
+use moodle_exception;
+use context_module;
+use stdClass;
+
+/**
+ * This is the external method for getting the information needed to present a results report.
+ *
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class get_results extends external_api {
+
+    /**
+     * Webservice parameters.
+     *
+     * @return external_function_parameters
+     */
+    public static function execute_parameters(): external_function_parameters {
+        return new external_function_parameters(
+            [
+                'h5pactivityid' => new external_value(PARAM_INT, 'h5p activity instance id'),
+                'attemptids' => new external_multiple_structure(
+                    new external_value(PARAM_INT, 'The attempt id'),
+                    'Attempt ids', VALUE_DEFAULT, []
+                ),
+            ]
+        );
+    }
+
+    /**
+     * Return user attempts results information in a h5p activity.
+     *
+     * In case an empty array of attempt ids is passed, the method will load all
+     * activity attempts from the current user.
+     *
+     * @throws  moodle_exception if the user cannot see the report
+     * @param  int $h5pactivityid The h5p activity id
+     * @param  int[] $attemptids The attempt ids
+     * @return stdClass report data
+     */
+    public static function execute(int $h5pactivityid, array $attemptids = []): stdClass {
+        global $USER;
+
+        $params = external_api::validate_parameters(self::execute_parameters(), [
+            'h5pactivityid' => $h5pactivityid,
+            'attemptids' => $attemptids,
+        ]);
+        $h5pactivityid = $params['h5pactivityid'];
+        $attemptids = $params['attemptids'];
+
+        $warnings = [];
+
+        // Request and permission validation.
+        list ($course, $cm) = get_course_and_cm_from_instance($h5pactivityid, 'h5pactivity');
+
+        $context = context_module::instance($cm->id);
+        self::validate_context($context);
+
+        $manager = manager::create_from_coursemodule($cm);
+
+        if (empty($attemptids)) {
+            $attemptids = [];
+            foreach ($manager->get_user_attempts($USER->id) as $attempt) {
+                $attemptids[] = $attempt->get_id();
+            }
+        }
+
+        $attempts = [];
+        foreach ($attemptids as $attemptid) {
+            $report = $manager->get_report(null, $attemptid);
+
+            if ($report && $report instanceof report_results) {
+                $attempts[] = self::export_attempt($report);
+            } else {
+                $warnings[] = [
+                    'item' => 'h5pactivity_attempts',
+                    'itemid' => $attemptid,
+                    'warningcode' => '1',
+                    'message' => "Cannot access attempt",
+                ];
+            }
+        }
+
+        $result = (object)[
+            'activityid' => $h5pactivityid,
+            'attempts' => $attempts,
+            'warnings' => $warnings,
+        ];
+
+        return $result;
+    }
+
+    /**
+     * Return a data object from an attempt.
+     *
+     * @param report_results $report the attempt data
+     * @return stdClass a WS compatible version of the attempt
+     */
+    private static function export_attempt(report_results $report): stdClass {
+
+        $data = $report->export_data_for_external();
+
+        $attemptdata = $data->attempt;
+
+        $attempt = (object)[
+            'id' => $attemptdata->id,
+            'h5pactivityid' => $attemptdata->h5pactivityid,
+            'userid' => $attemptdata->userid,
+            'timecreated' => $attemptdata->timecreated,
+            'timemodified' => $attemptdata->timemodified,
+            'attempt' => $attemptdata->attempt,
+            'rawscore' => $attemptdata->rawscore,
+            'maxscore' => $attemptdata->maxscore,
+            'duration' => (empty($attemptdata->duration)) ? 0 : $attemptdata->duration,
+            'scaled' => (empty($attemptdata->scaled)) ? 0 : $attemptdata->scaled,
+            'results' => [],
+        ];
+        if (isset($attemptdata->completion) && $attemptdata->completion !== null) {
+            $attempt->completion = $attemptdata->completion;
+        }
+        if (isset($attemptdata->success) && $attemptdata->success !== null) {
+            $attempt->success = $attemptdata->success;
+        }
+        foreach ($data->results as $result) {
+            $attempt->results[] = self::export_result($result);
+        }
+        return $attempt;
+    }
+
+    /**
+     * Return a data object from a result.
+     *
+     * @param stdClass $data the result data
+     * @return stdClass a WS compatible version of the result
+     */
+    private static function export_result(stdClass $data): stdClass {
+        $result = (object)[
+            'id' => $data->id,
+            'attemptid' => $data->attemptid,
+            'subcontent' => $data->subcontent,
+            'timecreated' => $data->timecreated,
+            'interactiontype' => $data->interactiontype,
+            'description' => $data->description,
+            'rawscore' => $data->rawscore,
+            'maxscore' => $data->maxscore,
+            'duration' => $data->duration,
+            'optionslabel' => $data->optionslabel ?? get_string('choice', 'mod_h5pactivity'),
+            'correctlabel' => $data->correctlabel ?? get_string('correct_answer', 'mod_h5pactivity'),
+            'answerlabel' => $data->answerlabel ?? get_string('attempt_answer', 'mod_h5pactivity'),
+            'track' => $data->track ?? false,
+        ];
+        if (isset($data->completion) && $data->completion !== null) {
+            $result->completion = $data->completion;
+        }
+        if (isset($data->success) && $data->success !== null) {
+            $result->success = $data->success;
+        }
+        if (isset($data->options)) {
+            $result->options = $data->options;
+        }
+        if (isset($data->content)) {
+            $result->content = $data->content;
+        }
+        return $result;
+    }
+
+    /**
+     * Describes the get_h5pactivity_access_information return value.
+     *
+     * @return external_single_structure
+     */
+    public static function execute_returns(): external_single_structure {
+        return new external_single_structure([
+            'activityid' => new external_value(PARAM_INT, 'Activity course module ID'),
+            'attempts' => new external_multiple_structure(
+                self::get_attempt_returns(), 'The complete attempts list'
+            ),
+            'warnings' => new external_warnings(),
+        ], 'Activity attempts results data');
+    }
+
+    /**
+     * Return the external structure of an attempt
+     * @return external_single_structure
+     */
+    private static function get_attempt_returns(): external_single_structure {
+
+        $result = new external_single_structure([
+            'id'    => new external_value(PARAM_INT, 'ID of the context'),
+            'h5pactivityid' => new external_value(PARAM_INT, 'ID of the H5P activity'),
+            'userid' => new external_value(PARAM_INT, 'ID of the user'),
+            'timecreated' => new external_value(PARAM_INT, 'Attempt creation'),
+            'timemodified' => new external_value(PARAM_INT, 'Attempt modified'),
+            'attempt' => new external_value(PARAM_INT, 'Attempt number'),
+            'rawscore' => new external_value(PARAM_INT, 'Attempt score value'),
+            'maxscore' => new external_value(PARAM_INT, 'Attempt max score'),
+            'duration' => new external_value(PARAM_INT, 'Attempt duration in seconds'),
+            'completion' => new external_value(PARAM_INT, 'Attempt completion', VALUE_OPTIONAL),
+            'success' => new external_value(PARAM_INT, 'Attempt success', VALUE_OPTIONAL),
+            'scaled' => new external_value(PARAM_FLOAT, 'Attempt scaled'),
+            'results' => new external_multiple_structure(
+                self::get_result_returns(),
+                'The results of the attempt', VALUE_OPTIONAL
+            ),
+        ], 'The attempt general information');
+        return $result;
+    }
+
+    /**
+     * Return the external structure of a result
+     * @return external_single_structure
+     */
+    private static function get_result_returns(): external_single_structure {
+
+        $result = new external_single_structure([
+            'id'    => new external_value(PARAM_INT, 'ID of the context'),
+            'attemptid' => new external_value(PARAM_INT, 'ID of the H5P attempt'),
+            'subcontent' => new external_value(PARAM_NOTAGS, 'Subcontent identifier'),
+            'timecreated' => new external_value(PARAM_INT, 'Result creation'),
+            'interactiontype' => new external_value(PARAM_NOTAGS, 'Interaction type'),
+            'description' => new external_value(PARAM_TEXT, 'Result description'),
+            'rawscore' => new external_value(PARAM_INT, 'Result score value'),
+            'maxscore' => new external_value(PARAM_INT, 'Result max score'),
+            'duration' => new external_value(PARAM_INT, 'Result duration in seconds', VALUE_OPTIONAL, 0),
+            'completion' => new external_value(PARAM_INT, 'Result completion', VALUE_OPTIONAL),
+            'success' => new external_value(PARAM_INT, 'Result success', VALUE_OPTIONAL),
+            'optionslabel' => new external_value(PARAM_NOTAGS, 'Label used for result options', VALUE_OPTIONAL),
+            'correctlabel' => new external_value(PARAM_NOTAGS, 'Label used for correct answers', VALUE_OPTIONAL),
+            'answerlabel' => new external_value(PARAM_NOTAGS, 'Label used for user answers', VALUE_OPTIONAL),
+            'track' => new external_value(PARAM_BOOL, 'If the result has valid track information', VALUE_OPTIONAL),
+            'options' => new external_multiple_structure(
+                new external_single_structure([
+                    'description'    => new external_value(PARAM_TEXT, 'Option description'),
+                    'id' => new external_value(PARAM_INT, 'Option identifier'),
+                    'correctanswer' => self::get_answer_returns('The option correct answer'),
+                    'useranswer' => self::get_answer_returns('The option user answer'),
+                ]),
+                'The statement options', VALUE_OPTIONAL
+            ),
+        ], 'A single result statement tracking information');
+        return $result;
+    }
+
+    /**
+     * Return the external structure of an answer or correctanswer
+     *
+     * @param string $description the return description
+     * @return external_single_structure
+     */
+    private static function get_answer_returns(string $description): external_single_structure {
+
+        $result = new external_single_structure([
+            'answer' => new external_value(PARAM_NOTAGS, 'Option text value', VALUE_OPTIONAL),
+            'correct' => new external_value(PARAM_BOOL, 'If has to be displayed as correct', VALUE_OPTIONAL),
+            'incorrect' => new external_value(PARAM_BOOL, 'If has to be displayed as incorrect', VALUE_OPTIONAL),
+            'text' => new external_value(PARAM_BOOL, 'If has to be displayed as simple text', VALUE_OPTIONAL),
+            'checked' => new external_value(PARAM_BOOL, 'If has to be displayed as a checked option', VALUE_OPTIONAL),
+            'unchecked' => new external_value(PARAM_BOOL, 'If has to be displayed as a unchecked option', VALUE_OPTIONAL),
+            'pass' => new external_value(PARAM_BOOL, 'If has to be displayed as passed', VALUE_OPTIONAL),
+            'fail' => new external_value(PARAM_BOOL, 'If has to be displayed as failed', VALUE_OPTIONAL),
+        ], $description);
+        return $result;
+    }
+}
index 4c471bf..840de03 100644 (file)
@@ -417,7 +417,10 @@ class manager {
      */
     public function get_attempt(int $attemptid): ?attempt {
         global $DB;
-        $record = $DB->get_record('h5pactivity_attempts', ['id' => $attemptid]);
+        $record = $DB->get_record('h5pactivity_attempts', [
+            'id' => $attemptid,
+            'h5pactivityid' => $this->instance->id,
+        ]);
         if (!$record) {
             return null;
         }
index c5690f9..61fbb8b 100644 (file)
@@ -95,4 +95,20 @@ class results implements report {
         $widget = new reportresults($attempt, $this->user, $cm->course);
         echo $OUTPUT->render($widget);
     }
+
+    /**
+     * Get the export data form this report.
+     *
+     * This method is used to render the report in mobile.
+     */
+    public function export_data_for_external(): stdClass {
+        global $PAGE;
+
+        $manager = $this->manager;
+        $attempt = $this->attempt;
+        $cm = $manager->get_coursemodule();
+
+        $widget = new reportresults($attempt, $this->user, $cm->course);
+        return $widget->export_for_template($PAGE->get_renderer('core'));
+    }
 }
index db62e5e..904df63 100644 (file)
@@ -53,4 +53,13 @@ $functions = [
         'capabilities'  => 'mod/h5pactivity:view',
         'services'      => [MOODLE_OFFICIAL_MOBILE_SERVICE],
     ],
+    'mod_h5pactivity_get_results' => [
+        'classname'     => 'mod_h5pactivity\external\get_results',
+        'methodname'    => 'execute',
+        'classpath'     => '',
+        'description'   => 'Return the information needed to list a user attempt results.',
+        'type'          => 'read',
+        'capabilities'  => 'mod/h5pactivity:view',
+        'services'      => [MOODLE_OFFICIAL_MOBILE_SERVICE],
+    ],
 ];
diff --git a/mod/h5pactivity/tests/external/get_results_test.php b/mod/h5pactivity/tests/external/get_results_test.php
new file mode 100644 (file)
index 0000000..0a76597
--- /dev/null
@@ -0,0 +1,428 @@
+<?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/>.
+
+/**
+ * External function test for get_results.
+ *
+ * @package    mod_h5pactivity
+ * @category   external
+ * @since      Moodle 3.9
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\external;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/webservice/tests/helpers.php');
+
+use mod_h5pactivity\local\manager;
+use external_api;
+use externallib_advanced_testcase;
+use dml_missing_record_exception;
+
+/**
+ * External function test for get_results.
+ *
+ * @package    mod_h5pactivity
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class get_results_testcase extends externallib_advanced_testcase {
+
+    /**
+     * Test the behaviour of get_results.
+     *
+     * @dataProvider execute_data
+     * @param int $enabletracking the activity tracking enable
+     * @param int $reviewmode the activity review mode
+     * @param string $loginuser the user which calls the webservice
+     * @param string|null $participant the user to get the data
+     * @param bool $createattempts if the student user has attempts created
+     * @param int|null $count the expected number of attempts returned (null for exception)
+     */
+    public function test_execute(int $enabletracking, int $reviewmode, string $loginuser,
+            ?string $participant, bool $createattempts, ?int $count): void {
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $course = $this->getDataGenerator()->create_course();
+        $activity = $this->getDataGenerator()->create_module('h5pactivity',
+                ['course' => $course, 'enabletracking' => $enabletracking, 'reviewmode' => $reviewmode]);
+
+        $manager = manager::create_from_instance($activity);
+        $cm = $manager->get_coursemodule();
+
+        // Prepare users: 1 teacher, 1 student and 1 unenroled user.
+        $users = [
+            'editingteacher' => $this->getDataGenerator()->create_and_enrol($course, 'editingteacher'),
+            'student' => $this->getDataGenerator()->create_and_enrol($course, 'student'),
+            'other' => $this->getDataGenerator()->create_and_enrol($course, 'student'),
+        ];
+
+        $attempts = [];
+
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_h5pactivity');
+
+        if ($createattempts) {
+            $user = $users['student'];
+            $params = ['cmid' => $cm->id, 'userid' => $user->id];
+            $attempts['student'] = $generator->create_content($activity, $params);
+        }
+
+        // Create another 2 attempts for the user "other" to validate no cross attempts are returned.
+        $user = $users['other'];
+        $params = ['cmid' => $cm->id, 'userid' => $user->id];
+        $attempts['other'] = $generator->create_content($activity, $params);
+
+        // Execute external method.
+        $this->setUser($users[$loginuser]);
+
+        $attemptid = $attempts[$participant]->id ?? 0;
+
+        $result = get_results::execute($activity->id, [$attemptid]);
+        $result = external_api::clean_returnvalue(
+            get_results::execute_returns(),
+            $result
+        );
+
+        // Validate general structure.
+        $this->assertArrayHasKey('activityid', $result);
+        $this->assertArrayHasKey('attempts', $result);
+        $this->assertArrayHasKey('warnings', $result);
+
+        $this->assertEquals($activity->id, $result['activityid']);
+
+        if ($count === null) {
+            $this->assertCount(1, $result['warnings']);
+            $this->assertCount(0, $result['attempts']);
+            return;
+        }
+
+        $this->assertCount(0, $result['warnings']);
+        $this->assertCount(1, $result['attempts']);
+
+        // Validate attempt.
+        $attempt = $result['attempts'][0];
+        $this->assertEquals($attemptid, $attempt['id']);
+
+        // Validate results.
+        $this->assertArrayHasKey('results', $attempt);
+        $this->assertCount($count, $attempt['results']);
+        foreach ($attempt['results'] as $value) {
+            $this->assertEquals($attemptid, $value['attemptid']);
+            $this->assertArrayHasKey('subcontent', $value);
+            $this->assertArrayHasKey('rawscore', $value);
+            $this->assertArrayHasKey('maxscore', $value);
+            $this->assertArrayHasKey('duration', $value);
+            $this->assertArrayHasKey('track', $value);
+            if (isset($value['options'])) {
+                foreach ($value['options'] as $option) {
+                    $this->assertArrayHasKey('description', $option);
+                    $this->assertArrayHasKey('id', $option);
+                }
+            }
+        }
+    }
+
+    /**
+     * Data provider for the test_execute tests.
+     *
+     * @return  array
+     */
+    public function execute_data(): array {
+        return [
+            'Teacher reviewing an attempt' => [
+                1, manager::REVIEWCOMPLETION, 'editingteacher', 'student', true, 1
+            ],
+            'Teacher try to review an inexistent attempt' => [
+                1, manager::REVIEWCOMPLETION, 'editingteacher', 'student', false, null
+            ],
+            'Teacher reviewing attempt with student review mode off' => [
+                1, manager::REVIEWNONE, 'editingteacher', 'student', true, 1
+            ],
+            'Student reviewing own attempt' => [
+                1, manager::REVIEWCOMPLETION, 'student', 'student', true, 1
+            ],
+            'Student reviewing an inexistent attempt' => [
+                1, manager::REVIEWCOMPLETION, 'student', 'student', false, null
+            ],
+            'Student reviewing own attempt with review mode off' => [
+                1, manager::REVIEWNONE, 'student', 'student', true, null
+            ],
+            'Student try to stalk other student attempt' => [
+                1, manager::REVIEWCOMPLETION, 'student', 'other', false, null
+            ],
+            'Teacher trying to review an attempt without tracking enabled' => [
+                0, manager::REVIEWNONE, 'editingteacher', 'student', true, null
+            ],
+            'Student trying to review an attempt without tracking enabled' => [
+                0, manager::REVIEWNONE, 'editingteacher', 'student', true, null
+            ],
+            'Student trying to stalk another student attempt without tracking enabled' => [
+                0, manager::REVIEWNONE, 'editingteacher', 'student', true, null
+            ],
+        ];
+    }
+
+    /**
+     * Test the behaviour of get_results.
+     *
+     * @dataProvider execute_multipleattempts_data
+     * @param string $loginuser the user which calls the webservice
+     * @param array $getattempts the attempts to get the data
+     * @param array $warnings warnigns expected
+     * @param array $reports data expected
+     *
+     */
+    public function test_execute_multipleattempts(string $loginuser,
+            array $getattempts, array $warnings, array $reports): void {
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $course = $this->getDataGenerator()->create_course();
+        $activity = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course]);
+
+        $manager = manager::create_from_instance($activity);
+        $cm = $manager->get_coursemodule();
+
+        // Prepare users: 1 teacher, 2 student.
+        $users = [
+            'editingteacher' => $this->getDataGenerator()->create_and_enrol($course, 'editingteacher'),
+            'student1' => $this->getDataGenerator()->create_and_enrol($course, 'student'),
+            'student2' => $this->getDataGenerator()->create_and_enrol($course, 'student'),
+        ];
+
+        $attempts = [];
+
+        // Generate attempts for student 1 and 2.
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_h5pactivity');
+
+        $user = $users['student1'];
+        $params = ['cmid' => $cm->id, 'userid' => $user->id];
+        $attempts['student1_1'] = $generator->create_content($activity, $params);
+        $attempts['student1_2'] = $generator->create_content($activity, $params);
+
+        $user = $users['student2'];
+        $params = ['cmid' => $cm->id, 'userid' => $user->id];
+        $attempts['student2_1'] = $generator->create_content($activity, $params);
+        $attempts['student2_2'] = $generator->create_content($activity, $params);
+
+        // Execute external method.
+        $this->setUser($users[$loginuser]);
+
+        $attemptids = [];
+        foreach ($getattempts as $getattempt) {
+            $attemptids[] = $attempts[$getattempt]->id ?? 0;
+        }
+
+        $result = get_results::execute($activity->id, $attemptids);
+        $result = external_api::clean_returnvalue(
+            get_results::execute_returns(),
+            $result
+        );
+
+        // Validate general structure.
+        $this->assertArrayHasKey('activityid', $result);
+        $this->assertArrayHasKey('attempts', $result);
+        $this->assertArrayHasKey('warnings', $result);
+
+        $this->assertEquals($activity->id, $result['activityid']);
+
+        $this->assertCount(count($warnings), $result['warnings']);
+        $this->assertCount(count($reports), $result['attempts']);
+
+        // Validate warnings.
+        $expectedwarnings = [];
+        foreach ($warnings as $warningattempt) {
+            $id = $attempts[$warningattempt]->id ?? 0;
+            $expectedwarnings[$id] = $warningattempt;
+        }
+        foreach ($result['warnings'] as $warning) {
+            $this->assertEquals('h5pactivity_attempts', $warning['item']);
+            $this->assertEquals(1, $warning['warningcode']);
+            $this->assertArrayHasKey($warning['itemid'], $expectedwarnings);
+        }
+
+        // Validate attempts.
+        $expectedattempts = [];
+        foreach ($reports as $expectedattempt) {
+            $id = $attempts[$expectedattempt]->id;
+            $expectedattempts[$id] = $expectedattempt;
+        }
+        foreach ($result['attempts'] as $value) {
+            $this->assertArrayHasKey($value['id'], $expectedattempts);
+        }
+    }
+
+    /**
+     * Data provider for the test_execute_multipleattempts tests.
+     *
+     * @return  array
+     */
+    public function execute_multipleattempts_data(): array {
+        return [
+            // Teacher cases.
+            'Teacher reviewing students attempts' => [
+                'editingteacher', ['student1_1', 'student2_1'], [], ['student1_1', 'student2_1']
+            ],
+            'Teacher reviewing invalid attempt' => [
+                'editingteacher', ['student1_1', 'invalid'], ['invalid'], ['student1_1']
+            ],
+            'Teacher reviewing empty attempts list' => [
+                'editingteacher', [], [], []
+            ],
+            // Student cases.
+            'Student reviewing own students attempts' => [
+                'student1', ['student1_1', 'student1_2'], [], ['student1_1', 'student1_2']
+            ],
+            'Student reviewing invalid attempt' => [
+                'student1', ['student1_1', 'invalid'], ['invalid'], ['student1_1']
+            ],
+            'Student reviewing trying to access another user attempts' => [
+                'student1', ['student1_1', 'student2_1'], ['student2_1'], ['student1_1']
+            ],
+            'Student reviewing empty attempts list' => [
+                'student1', [], [], ['student1_1', 'student1_2']
+            ],
+        ];
+    }
+
+    /**
+     * Test the behaviour of get_results using mixed activityid.
+     *
+     * @dataProvider execute_mixactivities_data
+     * @param string $activityname the activity name to use
+     * @param string $attemptname the attempt name to use
+     * @param string $expectedwarnings expected warning attempt
+     * @param string $expectedattempt expected result attempt
+     *
+     */
+    public function test_execute_mixactivities(string $activityname, string $attemptname,
+            string $expectedwarnings, string $expectedattempt): void {
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        // Create 2 courses.
+        $course1 = $this->getDataGenerator()->create_course();
+        $course2 = $this->getDataGenerator()->create_course();
+
+        // Prepare users: 1 teacher, 1 student.
+        $user = $this->getDataGenerator()->create_and_enrol($course1, 'student');
+        $this->getDataGenerator()->enrol_user($user->id, $course2->id, 'student');
+
+        // Create our base activity.
+        $activity11 = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course1]);
+        $manager11 = manager::create_from_instance($activity11);
+        $cm11 = $manager11->get_coursemodule();
+
+        // Create a second activity in the same course to check if the retuned attempt is the correct one.
+        $activity12 = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course1]);
+        $manager12 = manager::create_from_instance($activity12);
+        $cm12 = $manager12->get_coursemodule();
+
+        // Create a second activity on a different course.
+        $activity21 = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course2]);
+        $manager21 = manager::create_from_instance($activity21);
+        $cm21 = $manager21->get_coursemodule();
+
+        $activities = [
+            '11' => $activity11->id,
+            '12' => $activity12->id,
+            '21' => $activity21->id,
+            'inexistent' => 0,
+        ];
+
+        // Generate attempts.
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_h5pactivity');
+
+        $params = ['cmid' => $cm11->id, 'userid' => $user->id];
+        $attempt11 = $generator->create_content($activity11, $params);
+        $params = ['cmid' => $cm12->id, 'userid' => $user->id];
+        $attempt12 = $generator->create_content($activity12, $params);
+        $params = ['cmid' => $cm21->id, 'userid' => $user->id];
+        $attempt21 = $generator->create_content($activity21, $params);
+
+        $attempts = [
+            '11' => $attempt11->id,
+            '12' => $attempt12->id,
+            '21' => $attempt21->id,
+            'inexistent' => 0,
+        ];
+
+        if ($activityname == 'inexistent') {
+            $this->expectException(dml_missing_record_exception::class);
+        }
+
+        // Execute external method.
+        $this->setUser($user);
+
+        $attemptid = $attempts[$attemptname];
+
+        $result = get_results::execute($activities[$activityname], [$attemptid]);
+        $result = external_api::clean_returnvalue(
+            get_results::execute_returns(),
+            $result
+        );
+
+        // Validate general structure.
+        $this->assertArrayHasKey('activityid', $result);
+        $this->assertArrayHasKey('attempts', $result);
+        $this->assertArrayHasKey('warnings', $result);
+
+        if (empty($expectedwarnings)) {
+            $this->assertEmpty($result['warnings']);
+        } else {
+            $this->assertEquals('h5pactivity_attempts', $result['warnings'][0]['item']);
+            $this->assertEquals(1, $result['warnings'][0]['warningcode']);
+            $this->assertEquals($attempts[$expectedwarnings], $result['warnings'][0]['itemid']);
+        }
+
+        if (empty($expectedattempt)) {
+            $this->assertEmpty($result['attempts']);
+        } else {
+            $this->assertEquals($attempts[$expectedattempt], $result['attempts'][0]['id']);
+        }
+    }
+
+    /**
+     * Data provider for the test_execute_multipleattempts tests.
+     *
+     * @return  array
+     */
+    public function execute_mixactivities_data(): array {
+        return [
+            // Teacher cases.
+            'Correct activity id' => [
+                '11', '11', '', '11'
+            ],
+            'Wrong activity id' => [
+                '21', '11', '11', ''
+            ],
+            'Inexistent activity id' => [
+                'inexistent', '11', '', ''
+            ],
+            'Inexistent attempt id' => [
+                '11', 'inexistent', 'inexistent', ''
+            ],
+        ];
+    }
+}
index cabec2f..c551632 100644 (file)
@@ -165,6 +165,7 @@ class mod_h5pactivity_generator extends testing_module_generator {
 
         $result->subcontent = '14fcc986-728b-47f3-915b-'.$userid;
         $result->interactiontype = 'matching';
+        $result->correctpattern = '["0[.]1[,]1[.]0[,]2[.]2"]';
         $result->response = '1[.]0[,]0[.]1[,]2[.]2';
         $result->additionals = '{"source":[{"id":"0","description":{"en-US":"A berry"}}'.
                 ',{"id":"1","description":{"en-US":"An orange berry"}},'.
index ff2e8b6..bbbf9ea 100644 (file)
@@ -738,6 +738,70 @@ class manager_testcase extends \advanced_testcase {
         ];
     }
 
+    /**
+     * Test get_attempt method.
+     *
+     * @dataProvider get_attempt_data
+     * @param string $attemptname the attempt to use
+     * @param string|null $result the expected attempt ID or null for none
+     */
+    public function test_get_attempt(string $attemptname, ?string $result): void {
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $course = $this->getDataGenerator()->create_course();
+
+        $activity = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course]);
+        $cm = get_coursemodule_from_id('h5pactivity', $activity->cmid, 0, false, MUST_EXIST);
+
+        $otheractivity = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course]);
+        $othercm = get_coursemodule_from_id('h5pactivity', $otheractivity->cmid, 0, false, MUST_EXIST);
+
+        $manager = manager::create_from_instance($activity);
+
+        $user = $this->getDataGenerator()->create_and_enrol($course, 'student');
+
+        $attempts = ['inexistent' => 0];
+
+        $this->generate_fake_attempts($activity, $user, 1);
+        $attempt = attempt::last_attempt($user, $cm);
+        $attempts['current'] = $attempt->get_id();
+
+        $this->generate_fake_attempts($otheractivity, $user, 1);
+        $attempt = attempt::last_attempt($user, $othercm);
+        $attempts['other'] = $attempt->get_id();
+
+        $attempt = $manager->get_attempt($attempts[$attemptname]);
+        if ($result === null) {
+            $this->assertNull($attempt);
+        } else {
+            $this->assertEquals($attempts[$attemptname], $attempt->get_id());
+            $this->assertEquals($activity->id, $attempt->get_h5pactivityid());
+            $this->assertEquals($user->id, $attempt->get_userid());
+            $this->assertEquals(4, $attempt->get_attempt());
+        }
+    }
+
+    /**
+     * Data provider for test_get_attempt.
+     *
+     * @return array
+     */
+    public function get_attempt_data(): array {
+        return [
+            'Get the current activity attempt' => [
+                'current', 'current'
+            ],
+            'Try to get another activity attempt' => [
+                'other', null
+            ],
+            'Try to get an inexistent attempt' => [
+                'inexistent', null
+            ],
+        ];
+    }
+
     /**
      * Insert fake attempt data into h5pactiviyt_attempts.
      *
index 91081ce..6bfe3b6 100644 (file)
@@ -25,5 +25,5 @@
 defined('MOODLE_INTERNAL') || die();
 
 $plugin->component = 'mod_h5pactivity';
-$plugin->version = 2020052000;
+$plugin->version = 2020052100;
 $plugin->requires = 2020013000;
index e54c2ca..607b518 100644 (file)
Binary files a/mod/lti/amd/build/contentitem.min.js and b/mod/lti/amd/build/contentitem.min.js differ
index 69fb814..32c354f 100644 (file)
Binary files a/mod/lti/amd/build/contentitem.min.js.map and b/mod/lti/amd/build/contentitem.min.js.map differ
index ee9106a..bc18ae2 100644 (file)
@@ -105,7 +105,8 @@ define(
             new FormField('secureicon', FormField.TYPES.TEXT, true, ''),
             new FormField('launchcontainer', FormField.TYPES.SELECT, true, 0),
             new FormField('grade_modgrade_point', FormField.TYPES.TEXT, false, ''),
-            new FormField('cmidnumber', FormField.TYPES.TEXT, true, '')
+            new FormField('lineitemresourceid', FormField.TYPES.TEXT, true, ''),
+            new FormField('lineitemtag', FormField.TYPES.TEXT, true, '')
         ];
 
         /**
index 5ad7a57..d95aa53 100644 (file)
@@ -226,6 +226,42 @@ abstract class service_base {
 
     }
 
+    /**
+     * Called when a new LTI Instance is added.
+     *
+     * @param object $lti LTI Instance.
+     */
+    public function instance_added(object $lti): void {
+
+    }
+
+    /**
+     * Called when a new LTI Instance is updated.
+     *
+     * @param object $lti LTI Instance.
+     */
+    public function instance_updated(object $lti): void {
+
+    }
+
+    /**
+     * Called when a new LTI Instance is deleted.
+     *
+     * @param int $id LTI Instance.
+     */
+    public function instance_deleted(int $id): void {
+
+    }
+
+    /**
+     * Set the form data when displaying the LTI Instance form.
+     *
+     * @param object $defaultvalues Default form values.
+     */
+    public function set_instance_form_values(object $defaultvalues): void {
+
+    }
+
     /**
      * Return an array with the names of the parameters that the service will be saving in the configuration
      *
@@ -471,5 +507,4 @@ abstract class service_base {
         return $ok;
 
     }
-
-}
+}
\ No newline at end of file
index 833be56..9c629a2 100644 (file)
@@ -118,6 +118,11 @@ function lti_add_instance($lti, $mform) {
         lti_grade_item_update($lti);
     }
 
+    $services = lti_get_services();
+    foreach ($services as $service) {
+        $service->instance_added( $lti );
+    }
+
     $completiontimeexpected = !empty($lti->completionexpected) ? $lti->completionexpected : null;
     \core_completion\api::update_completion_date_event($lti->coursemodule, 'lti', $lti->id, $completiontimeexpected);
 
@@ -165,6 +170,11 @@ function lti_update_instance($lti, $mform) {
         $lti->typeid = $lti->urlmatchedtypeid;
     }
 
+    $services = lti_get_services();
+    foreach ($services as $service) {
+        $service->instance_updated( $lti );
+    }
+
     $completiontimeexpected = !empty($lti->completionexpected) ? $lti->completionexpected : null;
     \core_completion\api::update_completion_date_event($lti->coursemodule, 'lti', $lti->id, $completiontimeexpected);
 
@@ -180,7 +190,8 @@ function lti_update_instance($lti, $mform) {
  * @return boolean Success/Failure
  **/
 function lti_delete_instance($id) {
-    global $DB;
+    global $DB, $CFG;
+    require_once($CFG->dirroot.'/mod/lti/locallib.php');
 
     if (! $basiclti = $DB->get_record("lti", array("id" => $id))) {
         return false;
@@ -201,7 +212,15 @@ function lti_delete_instance($id) {
     \core_completion\api::update_completion_date_event($cm->id, 'lti', $id, null);
 
     // We must delete the module record after we delete the grade item.
-    return $DB->delete_records("lti", array("id" => $basiclti->id));
+    if ($DB->delete_records("lti", array("id" => $basiclti->id)) ) {
+        $services = lti_get_services();
+        foreach ($services as $service) {
+            $service->instance_deleted( $id );
+        }
+        return true;
+    }
+    return false;
+
 }
 
 /**
index da61f6e..95f5059 100644 (file)
@@ -497,6 +497,23 @@ function lti_get_jwt_claim_mapping() {
     );
 }
 
+/**
+ * Return the type of the instance, using domain matching if no explicit type is set.
+ *
+ * @param  object $instance the external tool activity settings
+ * @return object|null
+ * @since  Moodle 3.9
+ */
+function lti_get_instance_type(object $instance) : ?object {
+    if (empty($instance->typeid)) {
+        if (!$tool = lti_get_tool_by_url_match($instance->toolurl, $instance->course)) {
+            $tool = lti_get_tool_by_url_match($instance->securetoolurl,  $instance->course);
+        }
+        return $tool;
+    }
+    return lti_get_type($instance->typeid);
+}
+
 /**
  * Return the launch data required for opening the external tool.
  *
@@ -508,25 +525,13 @@ function lti_get_jwt_claim_mapping() {
 function lti_get_launch_data($instance, $nonce = '') {
     global $PAGE, $CFG, $USER;
 
-    if (empty($instance->typeid)) {
-        $tool = lti_get_tool_by_url_match($instance->toolurl, $instance->course);
-        if ($tool) {
-            $typeid = $tool->id;
-            $ltiversion = $tool->ltiversion;
-        } else {
-            $tool = lti_get_tool_by_url_match($instance->securetoolurl,  $instance->course);
-            if ($tool) {
-                $typeid = $tool->id;
-                $ltiversion = $tool->ltiversion;
-            } else {
-                $typeid = null;
-                $ltiversion = LTI_VERSION_1;
-            }
-        }
-    } else {
-        $typeid = $instance->typeid;
-        $tool = lti_get_type($typeid);
+    $tool = lti_get_instance_type($instance);
+    if ($tool) {
+        $typeid = $tool->id;
         $ltiversion = $tool->ltiversion;
+    } else {
+        $typeid = null;
+        $ltiversion = LTI_VERSION_1;
     }
 
     if ($typeid) {
@@ -1515,8 +1520,13 @@ function lti_tool_configuration_from_content_item($typeid, $messagetype, $ltiver
                         }
                     }
                     $config->grade_modgrade_point = $maxscore;
+                    $config->lineitemresourceid = '';
+                    $config->lineitemtag = '';
                     if (isset($lineitem->assignedActivity) && isset($lineitem->assignedActivity->activityId)) {
-                        $config->cmidnumber = $lineitem->assignedActivity->activityId;
+                        $config->lineitemresourceid = $lineitem->assignedActivity->activityId ? : '';
+                    }
+                    if (isset($lineitem->tag)) {
+                        $config->lineitemtag = $lineitem->tag ? : '';
                     }
                 }
             }
@@ -1613,6 +1623,9 @@ function lti_convert_content_items($param) {
                         $newitem->lineItem->assignedActivity = new stdClass();
                         $newitem->lineItem->assignedActivity->activityId = $item->lineItem->resourceId;
                     }
+                    if (isset($item->lineItem->tag)) {
+                        $newitem->lineItem->tag = $item->lineItem->tag;
+                    }
                     if (isset($item->lineItem->scoreMaximum)) {
                         $newitem->lineItem->scoreConstraints = new stdClass();
                         $newitem->lineItem->scoreConstraints->{'@type'} = 'NumericLimits';
index a6033cc..70caa9c 100644 (file)
@@ -212,6 +212,12 @@ class mod_lti_mod_form extends moodleform_mod {
         $mform->addElement('hidden', 'urlmatchedtypeid', '', array('id' => 'id_urlmatchedtypeid'));
         $mform->setType('urlmatchedtypeid', PARAM_INT);
 
+        $mform->addElement('hidden', 'lineitemresourceid', '', array( 'id' => 'id_lineitemresourceid' ));
+        $mform->setType('lineitemresourceid', PARAM_TEXT);
+
+        $mform->addElement('hidden', 'lineitemtag', '', array( 'id' => 'id_lineitemtag'));
+        $mform->setType('lineitemtag', PARAM_TEXT);
+
         $launchoptions = array();
         $launchoptions[LTI_LAUNCH_CONTAINER_DEFAULT] = get_string('default', 'lti');
         $launchoptions[LTI_LAUNCH_CONTAINER_EMBED] = get_string('embed', 'lti');
@@ -348,4 +354,18 @@ class mod_lti_mod_form extends moodleform_mod {
         $PAGE->requires->js_init_call('M.mod_lti.editor.init', array(json_encode($jsinfo)), true, $module);
     }
 
+    /**
+     * Sets the current values handled by services in case of update.
+     *
+     * @param object $defaultvalues default values to populate the form with.
+     */
+    public function set_data($defaultvalues) {
+        $services = lti_get_services();
+        if (is_object($defaultvalues)) {
+            foreach ($services as $service) {
+                $service->set_instance_form_values( $defaultvalues );
+            }
+        }
+        parent::set_data($defaultvalues);
+    }
 }
index b53168f..2727374 100644 (file)
@@ -61,6 +61,7 @@ class backup_ltiservice_gradebookservices_subplugin extends backup_subplugin {
                 'typeid',
                 'baseurl',
                 'ltilinkid',
+                'resourceid',
                 'tag',
                 'vendorcode',
                 'guid'
index 35a16f6..7d39b59 100644 (file)
@@ -97,6 +97,10 @@ class restore_ltiservice_gradebookservices_subplugin extends restore_subplugin {
         } else {
             $ltilinkid = null;
         }
+        $resourceid = null;
+        if (property_exists( $data, 'resourceid' )) {
+            $resourceid = $data->resourceid;
+        }
         // If this has not been restored before.
         if ($this->get_mappingid('gbsgradeitemrestored',  $data->id, 0) == 0) {
             $newgbsid = $DB->insert_record('ltiservice_gradebookservices', (object) array(
@@ -106,6 +110,7 @@ class restore_ltiservice_gradebookservices_subplugin extends restore_subplugin {
                     'ltilinkid' => $ltilinkid,
                     'typeid' => $newtypeid,
                     'baseurl' => $data->baseurl,
+                    'resourceid' => $resourceid,
                     'tag' => $data->tag
             ));
             $this->set_mapping('gbsgradeitemoldid', $newgbsid, $data->gradeitemid);
@@ -201,9 +206,36 @@ class restore_ltiservice_gradebookservices_subplugin extends restore_subplugin {
             $newgradeitemid = $this->get_mappingid('grade_item', $oldgradeitemid, 0);
             if ($newgradeitemid > 0) {
                 $gbs->gradeitemid = $newgradeitemid;
+                if (!isset($gbs->resourceid)) {
+                    // Before 3.9 resourceid was stored in grade_item->idnumber.
+                    $gbs->resourceid = $DB->get_field_select('grade_items', 'idnumber', "id=:id", ['id' => $newgradeitemid]);
+                }
                 $DB->update_record('ltiservice_gradebookservices', $gbs);
             }
         }
+        // Pre 3.9 backups did not include a gradebookservices record. We create one here if idnumber is set.
+        $gradeitems = $DB->get_records('grade_items', array('itemtype' => 'mod', 'itemmodule' => 'lti', 'courseid' => $courseid));
+        foreach ($gradeitems as $gi) {
+            if (isset($gi->idnumber) && !empty(trim($gi->idnumber))) {
+                $gbs = $DB->get_records('ltiservice_gradebookservices', ['gradeitemid' => $gi->id]);
+                if (empty($gbs)  && !empty($gi->iteminstance)) {
+                    // We did not find an entry for an LTI grade item with an idnumber, so let's create a gbs entry.
+                    if ($instance = $DB->get_record('lti', array('id' => $gi->iteminstance))) {
+                        if ($tool = lti_get_instance_type($instance)) {
+                            $DB->insert_record('ltiservice_gradebookservices', (object) array(
+                                'gradeitemid' => $gi->id,
+                                'courseid' => $courseid,
+                                'toolproxyid' => $tool->toolproxyid,
+                                'ltilinkid' => $gi->iteminstance,
+                                'typeid' => $tool->id,
+                                'baseurl' => $tool->baseurl,
+                                'resourceid' => $gi->idnumber
+                            ));
+                        }
+                    }
+                }
+            }
+        }
     }
 
 }
index a713173..8595853 100644 (file)
@@ -170,17 +170,14 @@ class lineitem extends resource_base {
             }
             $item->grademax = grade_floatval($json->scoreMaximum);
         }
-        $resourceid = (isset($json->resourceId)) ? $json->resourceId : '';
-        if ($item->idnumber !== $resourceid) {
-            $updategradeitem = true;
-        }
-        $item->idnumber = $resourceid;
         if ($gbs) {
-            $tag = (isset($json->tag)) ? $json->tag : null;
-            if ($gbs->tag !== $tag) {
+            $resourceid = (isset($json->resourceId)) ? $json->resourceId : '';
+            $tag = (isset($json->tag)) ? $json->tag : '';
+            if ($gbs->tag !== $tag || $gbs->resourceid !== $resourceid) {
                 $upgradegradebookservices = true;
             }
             $gbs->tag = $tag;
+            $gbs->resourceid = $resourceid;
         }
         $ltilinkid = null;
         if (isset($json->resourceLinkId)) {
@@ -259,6 +256,7 @@ class lineitem extends resource_base {
                     'typeid' => $typeid,
                     'baseurl' => $baseurl,
                     'ltilinkid' => $ltilinkid,
+                    'resourceid' => $resourceid,
                     'tag' => $gbs->tag
             ));
         }
index 2efc688..86deee9 100644 (file)
@@ -266,33 +266,23 @@ class lineitems extends resource_base {
             $toolproxyid = null;
             $baseurl = lti_get_type_type_config($typeid)->lti_toolurl;
         }
-        $params = array();
-        $params['itemname'] = $json->label;
-        $params['gradetype'] = GRADE_TYPE_VALUE;
-        $params['grademax']  = $max;
-        $params['grademin']  = 0;
-        $item = new \grade_item(array('id' => 0, 'courseid' => $contextid));
-        \grade_item::set_properties($item, $params);
-        $item->itemtype = 'manual';
-        $item->idnumber = $resourceid;
-        $item->grademax = $max;
-        $id = $item->insert('mod/ltiservice_gradebookservices');
-        $DB->insert_record('ltiservice_gradebookservices', (object)array(
-                'gradeitemid' => $id,
-                'courseid' => $contextid,
-                'toolproxyid' => $toolproxyid,
-                'typeid' => $typeid,
-                'baseurl' => $baseurl,
-                'ltilinkid' => $ltilinkid,
-                'tag' => $tag
-        ));
+        $gradebookservices = new gradebookservices();
+        $id = $gradebookservices->add_standalone_lineitem($contextid,
+                                                         $json->label,
+                                                         $max,
+                                                         $baseurl,
+                                                         $ltilinkid,
+                                                         $resourceid,
+                                                         $tag,
+                                                         $typeid,
+                                                         $toolproxyid);
+
         if (is_null($typeid)) {
             $json->id = parent::get_endpoint() . "/{$id}/lineitem";
         } else {
             $json->id = parent::get_endpoint() . "/{$id}/lineitem?type_id={$typeid}";
         }
         return json_encode($json, JSON_UNESCAPED_SLASHES);
-
     }
 
     /**
index c73df89..43039f4 100644 (file)
@@ -158,25 +158,25 @@ class gradebookservices extends service_base {
                 $this->get_typeconfig()['ltiservice_gradesynchronization'] == self::GRADEBOOKSERVICES_FULL) {
                 // Check for used in context is only needed because there is no explicit site tool - course relation.
                 if ($this->is_allowed_in_context($typeid, $courseid)) {
-                    if (is_null($modlti)) {
-                        $id = null;
-                    } else {
+                    $id = null;
+                    if (!is_null($modlti)) {
                         $conditions = array('courseid' => $courseid, 'itemtype' => 'mod',
                                 'itemmodule' => 'lti', 'iteminstance' => $modlti);
 
-                        $lineitems = $DB->get_records('grade_items', $conditions);
+                        $coupledlineitems = $DB->get_records('grade_items', $conditions);
                         $conditionsgbs = array('courseid' => $courseid, 'ltilinkid' => $modlti);
                         $lineitemsgbs = $DB->get_records('ltiservice_gradebookservices', $conditionsgbs);
-                        if (count($lineitems) + count($lineitemsgbs) == 1) {
-                            if ($lineitems) {
-                                $lineitem = reset($lineitems);
-                                $id = $lineitem->id;
+                        // If a link has more that one attached grade items, per spec we do not populate line item url.
+                        if (count($lineitemsgbs) == 1) {
+                            $id = reset($lineitemsgbs)->gradeitemid;
+                        }
+                        if (count($lineitemsgbs) < 2 && count($coupledlineitems) == 1) {
+                            $coupledid = reset($coupledlineitems)->id;
+                            if (!is_null($id) && $id != $coupledid) {
+                                $id = null;
                             } else {
-                                $lineitemsgb = reset($lineitemsgbs);
-                                $id = $lineitemsgb->gradeitemid;
+                                $id = $coupledid;
                             }
-                        } else {
-                            $id = null;
                         }
                     }
                     $launchparameters['gradebookservices_scope'] = implode(',', $this->get_permitted_scopes());
@@ -210,15 +210,9 @@ class gradebookservices extends service_base {
         // Select all lti potential linetiems in site.
         $params = array('courseid' => $courseid);
 
-        $optionalfilters = "";
-        if (isset($resourceid)) {
-            $optionalfilters .= " AND (i.idnumber = :resourceid)";
-            $params['resourceid'] = $resourceid;
-        }
         $sql = "SELECT i.*
                   FROM {grade_items} i
                  WHERE (i.courseid = :courseid)
-                      {$optionalfilters}
                ORDER BY i.id";
         $lineitems = $DB->get_records_sql($sql, $params);
 
@@ -230,7 +224,8 @@ class gradebookservices extends service_base {
             foreach ($lineitems as $lineitem) {
                 $gbs = $this->find_ltiservice_gradebookservice_for_lineitem($lineitem->id);
                 if ($gbs && (!isset($tag) || (isset($tag) && $gbs->tag == $tag))
-                        && (!isset($ltilinkid) || (isset($ltilinkid) && $gbs->ltilinkid == $ltilinkid))) {
+                        && (!isset($ltilinkid) || (isset($ltilinkid) && $gbs->ltilinkid == $ltilinkid))
+                        && (!isset($resourceid) || (isset($resourceid) && $gbs->resourceid == $resourceid))) {
                     if (is_null($typeid)) {
                         if ($this->get_tool_proxy()->id == $gbs->toolproxyid) {
                             array_push($lineitemstoreturn, $lineitem);
@@ -240,8 +235,12 @@ class gradebookservices extends service_base {
                             array_push($lineitemstoreturn, $lineitem);
                         }
                     }
-                } else if (($lineitem->itemtype == 'mod') && ($lineitem->itemmodule == 'lti') && (!isset($tag) &&
-                        (!isset($ltilinkid) || (isset($ltilinkid) && $lineitem->iteminstance == $ltilinkid)))) {
+                } else if (($lineitem->itemtype == 'mod'
+                             && $lineitem->itemmodule == 'lti'
+                             && !isset($resourceid)
+                             && !isset($tag)
+                             && (!isset($ltilinkid) || (isset($ltilinkid)
+                             && $lineitem->iteminstance == $ltilinkid)))) {
                     // We will need to check if the activity related belongs to our tool proxy.
                     $ltiactivity = $DB->get_record('lti', array('id' => $lineitem->iteminstance));
                     if (($ltiactivity) && (isset($ltiactivity->typeid))) {
@@ -323,6 +322,59 @@ class gradebookservices extends service_base {
         return $lineitem;
     }
 
+    /**
+     * Adds a decoupled (standalone) line item.
+     * Decoupled line items are not directly attached to
+     * an lti instance activity. They are recorded in
+     * the gradebook as manual activities and the
+     * gradebookservices is used to associate that manual column
+     * with the tool in addition to storing the LTI related
+     * metadata (resource id, tag).
+     *
+     * @param string $courseid ID of course
+     * @param string $label label of lineitem
+     * @param float $maximumscore maximum score of lineitem
+     * @param string $baseurl
+     * @param int|null $ltilinkid id of lti instance this line item is associated with
+     * @param string|null $resourceid resource id of lineitem
+     * @param string|null $tag tag of lineitem
+     * @param int $typeid lti type to which this line item is associated with
+     * @param int|null $toolproxyid lti2 tool proxy to which this lineitem is associated to
+     *
+     * @return int id of the created gradeitem
+     */
+    public function add_standalone_lineitem(string $courseid,
+                                            string $label,
+                                            float $maximumscore,
+                                            string $baseurl,
+                                            ?int $ltilinkid,
+                                            ?string $resourceid,
+                                            ?string $tag,
+                                            int $typeid,
+                                            int $toolproxyid = null) : int {
+        global $DB;
+        $params = array();
+        $params['itemname'] = $label;
+        $params['gradetype'] = GRADE_TYPE_VALUE;
+        $params['grademax']  = $maximumscore;
+        $params['grademin']  = 0;
+        $item = new \grade_item(array('id' => 0, 'courseid' => $courseid));
+        \grade_item::set_properties($item, $params);
+        $item->itemtype = 'manual';
+        $item->grademax = $maximumscore;
+        $id = $item->insert('mod/ltiservice_gradebookservices');
+        $DB->insert_record('ltiservice_gradebookservices', (object)array(
+                'gradeitemid' => $id,
+                'courseid' => $courseid,
+                'toolproxyid' => $toolproxyid,
+                'typeid' => $typeid,
+                'baseurl' => $baseurl,
+                'ltilinkid' => $ltilinkid,
+                'resourceid' => $resourceid,
+                'tag' => $tag
+        ));
+        return $id;
+    }
 
     /**
      * Set a grade item.
@@ -341,7 +393,7 @@ class gradebookservices extends service_base {
     }
 
     /**
-     * Set a grade item.
+     * Saves a score received from the LTI tool.
      *
      * @param object $gradeitem Grade Item record
      * @param object $score Result object
@@ -428,9 +480,9 @@ class gradebookservices extends service_base {
         $lineitem->id = "{$endpoint}/{$item->id}/lineitem" . $typeidstring;
         $lineitem->label = $item->itemname;
         $lineitem->scoreMaximum = floatval($item->grademax);
-        $lineitem->resourceId = (!empty($item->idnumber)) ? $item->idnumber : '';
         $gbs = self::find_ltiservice_gradebookservice_for_lineitem($item->id);
         if ($gbs) {
+            $lineitem->resourceId = (!empty($gbs->resourceid)) ? $gbs->resourceid : '';
             $lineitem->tag = (!empty($gbs->tag)) ? $gbs->tag : '';
             if (isset($gbs->ltilinkid)) {
                 $lineitem->resourceLinkId = strval($gbs->ltilinkid);
@@ -561,6 +613,80 @@ class gradebookservices extends service_base {
         }
     }
 
+    /**
+     * Updates the tag and resourceid values for a grade item coupled to an lti link instance.
+     *
+     * @param object $ltiinstance The lti instance to which the grade item is coupled to
+     * @param string|null $resourceid The resourceid to apply to the lineitem. If empty string which will be stored as null.
+     * @param string|null $tag The tag to apply to the lineitem. If empty string which will be stored as null.
+     *
+     */
+    public static function update_coupled_gradebookservices(object $ltiinstance,
+                                                            ?string $resourceid,
+                                                            ?string $tag) : void {
+        global $DB;
+
+        if ($ltiinstance && $ltiinstance->typeid) {
+            $gradeitem = $DB->get_record('grade_items', array('itemmodule' => 'lti', 'iteminstance' => $ltiinstance->id));
+            if ($gradeitem) {
+                $resourceid = (isset($resourceid) && empty(trim($resourceid))) ? null : $resourceid;
+                $tag = (isset($tag) && empty(trim($tag))) ? null : $tag;
+                $gbs = self::find_ltiservice_gradebookservice_for_lineitem($gradeitem->id);
+                if ($gbs) {
+                    $gbs->resourceid = $resourceid;
+                    $gbs->tag = $tag;
+                    $DB->update_record('ltiservice_gradebookservices', $gbs);
+                } else {
+                    $baseurl = lti_get_type_type_config($ltiinstance->typeid)->lti_toolurl;
+                    $DB->insert_record('ltiservice_gradebookservices', (object)array(
+                        'gradeitemid' => $gradeitem->id,
+                        'courseid' => $gradeitem->courseid,
+                        'typeid' => $ltiinstance->typeid,
+                        'baseurl' => $baseurl,
+                        'ltilinkid' => $ltiinstance->id,
+                        'resourceid' => $resourceid,
+                        'tag' => $tag
+                    ));
+                }
+            }
+        }
+    }
+
+    /**
+     * Called when a new LTI Instance is added.
+     *
+     * @param object $lti LTI Instance.
+     */
+    public function instance_added(object $lti): void {
+        self::update_coupled_gradebookservices($lti, $lti->lineitemresourceid ?? null, $lti->lineitemtag ?? null);
+    }
+
+    /**
+     * Called when a new LTI Instance is updated.
+     *
+     * @param object $lti LTI Instance.
+     */
+    public function instance_updated(object $lti): void {
+        self::update_coupled_gradebookservices($lti, $lti->lineitemresourceid ?? null, $lti->lineitemtag ?? null);
+    }
+
+    /**
+     * Set the form data when displaying the LTI Instance form.
+     *
+     * @param object $defaultvalues Default form values.
+     */
+    public function set_instance_form_values(object $defaultvalues): void {
+        $defaultvalues->lineitemresourceid = '';
+        $defaultvalues->lineitemtag = '';
+        if (is_object($defaultvalues) && $defaultvalues->instance) {
+            $gbs = self::find_ltiservice_gradebookservice_for_lti($defaultvalues->instance);
+            if ($gbs) {
+                $defaultvalues->lineitemresourceid = $gbs->resourceid;
+                $defaultvalues->lineitemtag = $gbs->tag;
+            }
+        }
+    }
+
     /**
      * Deletes orphaned rows from the 'ltiservice_gradebookservices' table.
      *
@@ -607,28 +733,33 @@ class gradebookservices extends service_base {
     }
 
     /**
-     * Find the right element in the ltiservice_gradebookservice table for a lineitem
+     * Find the right element in the ltiservice_gradebookservice table for an lti instance
      *
-     * @param string $lineitemid            The lineitem
-     * @return object|bool gradebookservice id or false if none
+     * @param string $instanceid The LTI module instance id
+     * @return object gradebookservice for this line item
      */
-    public static function find_ltiservice_gradebookservice_for_lineitem($lineitemid) {
+    public static function find_ltiservice_gradebookservice_for_lti($instanceid) {
         global $DB;
 
-        if (!$lineitemid) {
-            return false;
-        }
-        $gradeitem = $DB->get_record('grade_items', array('id' => $lineitemid));
-        if ($gradeitem) {
-            $gbs = $DB->get_record('ltiservice_gradebookservices',
-                    array('gradeitemid' => $gradeitem->id, 'courseid' => $gradeitem->courseid));
-            if ($gbs) {
-                return $gbs;
-            } else {
-                return false;
+        if ($instanceid) {
+            $gradeitem = $DB->get_record('grade_items', array('itemmodule' => 'lti', 'iteminstance' => $instanceid));
+            if ($gradeitem) {
+                return self::find_ltiservice_gradebookservice_for_lineitem($gradeitem->id);
             }
-        } else {
-            return false;
+        }
+    }
+
+    /**
+     * Find the right element in the ltiservice_gradebookservice table for a lineitem
+     *
+     * @param string $lineitemid The lineitem (gradeitem) id
+     * @return object gradebookservice if it exists
+     */
+    public static function find_ltiservice_gradebookservice_for_lineitem($lineitemid) {
+        global $DB;
+        if ($lineitemid) {
+            return $DB->get_record('ltiservice_gradebookservices',
+                    array('gradeitemid' => $lineitemid));
         }
     }
 
index 73e8431..2bd03b7 100644 (file)
@@ -13,6 +13,7 @@
         <FIELD NAME="typeid" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false" COMMENT="ID of the LTI Type if not Proxy."/>
         <FIELD NAME="baseurl" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="Lineitem URL that will be returned to the Tool provider"/>
         <FIELD NAME="ltilinkid" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false" COMMENT="ID of the LTI element related with this lineitem."/>
+        <FIELD NAME="resourceid" TYPE="char" LENGTH="512" NOTNULL="false" SEQUENCE="false" COMMENT="Resource id for the line item"/>
         <FIELD NAME="tag" TYPE="char" LENGTH="255" NOTNULL="false" SEQUENCE="false" COMMENT="Tag type specified for the line item"/>
       </FIELDS>
       <KEYS>
diff --git a/mod/lti/service/gradebookservices/db/upgrade.php b/mod/lti/service/gradebookservices/db/upgrade.php
new file mode 100644 (file)
index 0000000..20d5443
--- /dev/null
@@ -0,0 +1,111 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+//
+// This file is part of BasicLTI4Moodle
+//
+// BasicLTI4Moodle is an IMS BasicLTI (Basic Learning Tools for Interoperability)
+// consumer for Moodle 1.9 and Moodle 2.0. BasicLTI is a IMS Standard that allows web
+// based learning tools to be easily integrated in LMS as native ones. The IMS BasicLTI
+// specification is part of the IMS standard Common Cartridge 1.1 Sakai and other main LMS
+// are already supporting or going to support BasicLTI. This project Implements the consumer
+// for Moodle. Moodle is a Free Open source Learning Management System by Martin Dougiamas.
+// BasicLTI4Moodle is a project iniciated and leaded by Ludo(Marc Alier) and Jordi Piguillem
+// at the GESSI research group at UPC.
+// SimpleLTI consumer for Moodle is an implementation of the early specification of LTI
+// by Charles Severance (Dr Chuck) htp://dr-chuck.com , developed by Jordi Piguillem in a
+// Google Summer of Code 2008 project co-mentored by Charles Severance and Marc Alier.
+//
+// BasicLTI4Moodle is copyright 2009 by Marc Alier Forment, Jordi Piguillem and Nikolas Galanis
+// of the Universitat Politecnica de Catalunya http://www.upc.edu
+// Contact info: Marc Alier Forment granludo @ gmail.com or marc.alier @ upc.edu.
+
+/**
+ * This file defines tasks performed by the plugin.
+ *
+ * @package    ltiservice_gradebookservices
+ * @copyright  2020 Cengage Learning http://www.cengage.com
+ * @author     Claude Vervoort
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+ defined('MOODLE_INTERNAL') || die;
+
+/**
+ * xmldb_ltiservice_gradebookservices_upgrade is the function that upgrades
+ * the gradebook lti service subplugin database when is needed.
+ *
+ * This function is automatically called when version number in
+ * version.php changes.
+ *
+ * @param int $oldversion New old version number.
+ *
+ * @return boolean
+ */
+function xmldb_ltiservice_gradebookservices_upgrade($oldversion) {
+    global $CFG, $DB, $OUTPUT;
+
+    $dbman = $DB->get_manager();
+
+    if ($oldversion < 2020042401) {
+        // Define field typeid to be added to lti_tool_settings.
+        $table = new xmldb_table('ltiservice_gradebookservices');
+        $field = new xmldb_field('resourceid', XMLDB_TYPE_CHAR, "512", null, null, null, null);
+
+        // Conditionally launch add field typeid.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Lti savepoint reached.
+        upgrade_plugin_savepoint(true, 2020042401, 'ltiservice', 'gradebookservices');
+    }
+
+    if ($oldversion < 2020042402) {
+        // Now that we have added the new column let's migrate it'
+        // Prior implementation was storing the resourceid under the grade item idnumber, so moving it to lti_gradebookservices.
+        // We only care for mod/lti grade items as manual columns would already have a matching gradebookservices record.
+
+        $DB->execute("INSERT INTO {ltiservice_gradebookservices}
+                (gradeitemid, courseid, typeid, ltilinkid, resourceid, baseurl, toolproxyid)
+         SELECT gi.id, courseid, lti.typeid, lti.id, gi.idnumber, t.baseurl, t.toolproxyid
+           FROM {grade_items} gi
+           JOIN {lti} lti ON lti.id=gi.iteminstance AND gi.itemtype='mod' AND gi.itemmodule='lti'
+           JOIN {lti_types} t ON t.id = lti.typeid
+          WHERE gi.id NOT IN ( SELECT gradeitemid
+                                 FROM {ltiservice_gradebookservices} )
+             AND gi.idnumber IS NOT NULL
+             AND gi.idnumber <> ''");
+
+        // Lti savepoint reached.
+        upgrade_plugin_savepoint(true, 2020042402, 'ltiservice', 'gradebookservices');
+    }
+
+    if ($oldversion < 2020042403) {
+        // Here updating the resourceid of pre-existing lti_gradebookservices.
+        $DB->execute("UPDATE {ltiservice_gradebookservices}
+                         SET resourceid = (SELECT idnumber FROM {grade_items} WHERE id=gradeitemid)
+                       WHERE gradeitemid in (SELECT id FROM {grade_items}
+                                             WHERE ((itemtype='mod' AND itemmodule='lti') OR itemtype='manual')
+                                               AND idnumber IS NOT NULL
+                                               AND idnumber <> '')
+                         AND (resourceid is null OR resourceid = '')");
+
+        // Lti savepoint reached.
+        upgrade_plugin_savepoint(true, 2020042403, 'ltiservice', 'gradebookservices');
+    }
+
+    return true;
+}
diff --git a/mod/lti/service/gradebookservices/tests/gradebookservices_test.php b/mod/lti/service/gradebookservices/tests/gradebookservices_test.php
new file mode 100644 (file)
index 0000000..d97c233
--- /dev/null
@@ -0,0 +1,282 @@
+<?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 mod_lti gradebookservices
+ * @package    ltiservice_gradebookservices
+ * @category   external
+ * @copyright  2020 Claude Vervoort <claude.vervoort@cengage.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+use ltiservice_gradebookservices\local\service\gradebookservices;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Unit tests for lti gradebookservices.
+ */
+class mod_lti_gradebookservices_testcase extends advanced_testcase {
+
+    /**
+     * Test saving a graded LTI with resource and tag info (as a result of
+     * content item selection) creates a gradebookservices record
+     * that can be retrieved using the gradebook service API.
+     */
+    public function test_lti_add_coupled_lineitem() {
+        global $CFG;
+        require_once($CFG->dirroot . '/mod/lti/locallib.php');
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        // Create a tool type, associated with that proxy.
+
+        $typeid = $this->create_type();
+        $course = $this->getDataGenerator()->create_course();
+        $resourceid = 'test-resource-id';
+        $tag = 'tag';
+
+        $ltiinstance = $this->create_graded_lti($typeid, $course, $resourceid, $tag);
+
+        $this->assertNotNull($ltiinstance);
+
+        $gbs = gradebookservices::find_ltiservice_gradebookservice_for_lti($ltiinstance->id);
+
+        $this->assertNotNull($gbs);
+        $this->assertEquals($resourceid, $gbs->resourceid);
+        $this->assertEquals($tag, $gbs->tag);
+
+        $this->assert_lineitems($course, $typeid, $ltiinstance->name, $ltiinstance, $resourceid, $tag);
+    }
+
+    /**
+     * Test saving a standalone LTI lineitem with resource and tag info
+     * that can be retrieved using the gradebook service API.
+     */
+    public function test_lti_add_standalone_lineitem() {
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $course = $this->getDataGenerator()->create_course();
+        $resourceid = "test-resource-standalone";
+        $tag = "test-tag-standalone";
+        $typeid = $this->create_type();
+
+        $this->create_standalone_lineitem($course->id, $typeid, $resourceid, $tag);
+
+        $this->assert_lineitems($course, $typeid, "manualtest", null, $resourceid, $tag);
+    }
+
+    /**
+     * Test line item URL is populated for coupled line item only
+     * if there is not another line item bound to the lti instance,
+     * since in that case there would be no rule to define which of
+     * the line items should be actually passed.
+     */
+    public function test_get_launch_parameters_coupled() {
+        global $CFG;
+        require_once($CFG->dirroot . '/mod/lti/locallib.php');
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        // Create a tool type, associated with that proxy.
+
+        $typeid = $this->create_type();
+        $course = $this->getDataGenerator()->create_course();
+
+        $ltiinstance = $this->create_graded_lti($typeid, $course, 'resource-id', 'tag');
+
+        $this->assertNotNull($ltiinstance);
+
+        $gbservice = new gradebookservices();
+        $params = $gbservice->get_launch_parameters('basic-lti-launch-request', $course->id, 111, $typeid, $ltiinstance->id);
+        $this->assertEquals('$LineItem.url', $params['lineitem_url']);
+        $this->assertEquals('$LineItem.url', $params['lineitem_url']);
+
+        $this->create_standalone_lineitem($course->id, $typeid, 'resource-id', 'tag', $ltiinstance->id);
+        $params = $gbservice->get_launch_parameters('basic-lti-launch-request', $course->id, 111, $typeid, $ltiinstance->id);
+        $this->assertEquals('$LineItems.url', $params['lineitems_url']);
+        // 2 line items for a single link, we cannot return a single line item url.
+        $this->assertFalse(array_key_exists('$LineItem.url', $params));
+    }
+
+    /**
+     * Test line item URL is populated for not coupled line item only
+     * if there is a single line item attached to that lti instance.
+     */
+    public function test_get_launch_parameters_decoupled() {
+        global $CFG;
+        require_once($CFG->dirroot . '/mod/lti/locallib.php');
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        // Create a tool type, associated with that proxy.
+
+        $typeid = $this->create_type();
+
+        $course = $this->getDataGenerator()->create_course();
+
+        $ltiinstance = $this->create_notgraded_lti($typeid, $course);
+
+        $this->assertNotNull($ltiinstance);
+
+        $gbservice = new gradebookservices();
+        $params = $gbservice->get_launch_parameters('basic-lti-launch-request', $course->id, 111, $typeid, $ltiinstance->id);
+        $this->assertEquals('$LineItems.url', $params['lineitems_url']);
+        $this->assertFalse(array_key_exists('$LineItem.url', $params));
+
+        $this->create_standalone_lineitem($course->id, $typeid, 'resource-id', 'tag', $ltiinstance->id);
+        $params = $gbservice->get_launch_parameters('basic-lti-launch-request', $course->id, 111, $typeid, $ltiinstance->id);
+        $this->assertEquals('$LineItems.url', $params['lineitems_url']);
+        $this->assertEquals('$LineItem.url', $params['lineitem_url']);
+
+        // 2 line items for a single link, we cannot return a single line item url.
+        $this->create_standalone_lineitem($course->id, $typeid, 'resource-id', 'tag-2', $ltiinstance->id);
+        $this->assertFalse(array_key_exists('$LineItem.url', $params));
+    }
+
+    /**
+     * Asserts a matching gradebookservices record exist with the matching tag and resourceid.
+     *
+     * @param object $course current course
+     * @param int $typeid Type id of the tool
+     * @param string $label Label of the line item
+     * @param object|null $ltiinstance lti instance related to that line item
+     * @param string|null $resourceid resourceid the line item should have
+     * @param string|null $tag tag the line item should have
+     */
+    private function assert_lineitems(object $course,
+                                      int $typeid,
+                                      string $label,
+                                      ?object $ltiinstance,
+                                      ?string $resourceid,
+                                      ?string $tag) : void {
+        $gbservice = new gradebookservices();
+        $gradeitems = $gbservice->get_lineitems($course->id, null, null, null, null, null, $typeid);
+
+        // The 1st item in the array is the items count.
+        $this->assertEquals(1, $gradeitems[0]);
+
+        $lineitem = gradebookservices::item_for_json($gradeitems[1][0], '', $typeid);
+        $this->assertEquals(10, $lineitem->scoreMaximum);
+        $this->assertEquals($resourceid, $lineitem->resourceId);
+        $this->assertEquals($tag, $lineitem->tag);
+        $this->assertEquals($label, $lineitem->label);
+
+        $gradeitems = $gbservice->get_lineitems($course->id, $resourceid, null, null, null, null, $typeid);
+        $this->assertEquals(1, $gradeitems[0]);
+
+        if (isset($ltiinstance)) {
+            $gradeitems = $gbservice->get_lineitems($course->id, null, $ltiinstance->id, null, null, null, $typeid);
+            $this->assertEquals(1, $gradeitems[0]);
+            $gradeitems = $gbservice->get_lineitems($course->id, null, $ltiinstance->id + 1, null, null, null, $typeid);
+            $this->assertEquals(0, $gradeitems[0]);
+        }
+
+        $gradeitems = $gbservice->get_lineitems($course->id, null, null, $tag, null, null, $typeid);
+        $this->assertEquals(1, $gradeitems[0]);
+
+        $gradeitems = $gbservice->get_lineitems($course->id, 'an unknown resource id', null, null, null, null, $typeid);
+        $this->assertEquals(0, $gradeitems[0]);
+
+        $gradeitems = $gbservice->get_lineitems($course->id, null, null, 'an unknown tag', null, null, $typeid);
+        $this->assertEquals(0, $gradeitems[0]);
+    }
+
+    /**
+     * Inserts a graded lti instance, which should create a grade_item and gradebookservices record.
+     *
+     * @param int $typeid Type ID of the LTI Tool.
+     * @param object $course course where to add the lti instance.
+     * @param string|null $resourceid resource id
+     * @param string|null $tag tag
+     *
+     * @return object lti instance created
+     */
+    private function create_graded_lti(int $typeid, object $course, ?string $resourceid, ?string $tag) : object {
+
+        $lti = ['course' => $course->id,
+                'typeid' => $typeid,
+                'instructorchoiceacceptgrades' => LTI_SETTING_ALWAYS,
+                'grade' => 10,
+                'lineitemresourceid' => $resourceid,
+                'lineitemtag' => $tag];
+
+        return $this->getDataGenerator()->create_module('lti', $lti, array());
+    }
+
+     /**
+      * Inserts an lti instance that is not graded.
+      *
+      * @param int $typeid Type Id of the LTI Tool.
+      * @param object $course course where to add the lti instance.
+      *
+      * @return object lti instance created
+      */
+    private function create_notgraded_lti(int $typeid, object $course) : object {
+
+        $lti = ['course' => $course->id,
+                'typeid' => $typeid,
+                'instructorchoiceacceptgrades' => LTI_SETTING_NEVER];
+
+        return $this->getDataGenerator()->create_module('lti', $lti, array());
+    }
+
+    /**
+     * Inserts a standalone lineitem (gradeitem, gradebookservices entries).
+     *
+     * @param int $courseid Id of the course where the standalone line item will be added.
+     * @param int $typeid of the LTI Tool
+     * @param string|null $resourceid resource id
+     * @param string|null $tag tag
+     * @param int|null $ltiinstanceid Id of the LTI instance the standalone line item will be related to.
+     *
+     */
+    private function create_standalone_lineitem(int $courseid,
+                                                int $typeid,
+                                                ?string $resourceid,
+                                                ?string $tag,
+                                                int $ltiinstanceid = null) : void {
+        $gbservice = new gradebookservices();
+        $gbservice->add_standalone_lineitem($courseid,
+            "manualtest",
+            10,
+            "https://test.phpunit",
+            $ltiinstanceid,
+            $resourceid,
+            $tag,
+            $typeid,
+            null /*toolproxyid*/);
+    }
+
+    /**
+     * Creates a new LTI Tool Type.
+     */
+    private function create_type() {
+        $type = new stdClass();
+        $type->state = LTI_TOOL_STATE_CONFIGURED;
+        $type->name = "Test tool";
+        $type->description = "Example description";
+        $type->clientid = "Test client ID";
+        $type->baseurl = $this->getExternalTestFileUrl('/test.html');
+
+        $config = new stdClass();
+        $config->ltiservice_gradesynchronization = 2;
+        return lti_add_type($type, $config);
+    }
+}
\ No newline at end of file
index 8c02f9e..9af909d 100644 (file)
@@ -25,6 +25,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2019111800;
+$plugin->version   = 2020042403;
 $plugin->requires  = 2019111200;
 $plugin->component = 'ltiservice_gradebookservices';
index a570734..564b5cf 100644 (file)
@@ -26,7 +26,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-
 /**
  * Unit tests for mod_lti lib
  *
index e26af0d..de1dccb 100644 (file)
@@ -48,7 +48,7 @@
 
 defined('MOODLE_INTERNAL') || die;
 
-$plugin->version   = 2020022200;    // The current module version (Date: YYYYMMDDXX).
+$plugin->version   = 2020042403;    // The current module version (Date: YYYYMMDDXX).
 $plugin->requires  = 2019111200;    // Requires this Moodle version.
 $plugin->component = 'mod_lti';     // Full name of the plugin (used for diagnostics).
 $plugin->cron      = 0;
index e36f33d..e85f696 100644 (file)
@@ -1092,6 +1092,12 @@ class quiz_attempt {
      * @return bool true if the navigation should be allowed.
      */
     public function can_navigate_to($slot) {
+        if ($this->attempt->state == self::OVERDUE) {
+            // When the attempt is overdue, students can only see the
+            // attempt summary page and cannot navigate anywhere else.
+            return false;
+        }
+
         switch ($this->get_navigation_method()) {
             case QUIZ_NAVMETHOD_FREE:
                 return true;
@@ -2783,6 +2789,10 @@ class quiz_attempt_nav_panel extends quiz_nav_panel_base {
     }
 
     public function render_end_bits(mod_quiz_renderer $output) {
+        if ($this->page == -1) {
+            // Don't link from the summary page to itself.
+            return '';
+        }
         return html_writer::link($this->attemptobj->summary_url(),
                 get_string('endtest', 'quiz'), array('class' => 'endtestlink')) .
                 $output->countdown_timer($this->attemptobj, time()) .
index fc1545d..ad12c34 100644 (file)
@@ -46,6 +46,15 @@ class html_parser extends nwiki_parser {
         parent::before_parsing();
 
         $this->minheaderlevel = $this->find_min_header_level($this->string);
+
+        // Protect all explicit links from further wiki parsing. The link text may contain another URL which would get
+        // converted into another link via {@see nwiki_parser::$tagrules} 'url' element.
+        if (preg_match_all('/<a\s[^>]+?>(.*?)<\/a>/is', $this->string, $matches)) {
+            foreach (array_unique($matches[0]) as $match) {
+                $this->string = str_replace($match, $this->protect($match), $this->string);
+            }
+        }
+
         $this->rules($this->string);
     }
 
index 909c2c1..003eff0 100644 (file)
@@ -39,6 +39,92 @@ require_once($CFG->dirroot . '/mod/wiki/parser/parser.php');
 
 class mod_wiki_wikiparser_test extends basic_testcase {
 
+    /**
+     * URL inside the clickable text of some link should not be turned into a new link via the url_tag_rule.
+     *
+     * @dataProvider urls_inside_link_text_provider
+     * @param string $markup Markup of the Wiki page the text is part of.
+     * @param string $input The input text.
+     * @param string $output The expected output HTML as a result of the parsed input text.
+     */
+    public function test_urls_inside_link_text(string $markup, string $input, string $output) {
+
+        $parsingresult = wiki_parser_proxy::parse($input, $markup, [
+            'link_callback' => '/mod/wiki/locallib.php:wiki_parser_link',
+            'link_callback_args' => ['swid' => 1],
+        ]);
+
+        $this->assertContains($output, $parsingresult['parsed_text']);
+    }
+
+    /**
+     * Provides data sets for {@see self::test_urls_inside_link_text()}.
+     *
+     * @return array
+     */
+    public function urls_inside_link_text_provider() {
+        return [
+            'creole implicit link' => [
+                'markup' => 'creole',
+                'input' => 'Visit https://site.url for more information.',
+                'output' => 'Visit <a href="https://site.url">https://site.url</a> for more information.',
+            ],
+            'creole explicit link' => [
+                'markup' => 'creole',
+                'input' => 'Visit [[https://site.url]] for more information.',
+                'output' => 'Visit <a href="https://site.url">https://site.url</a> for more information.',
+            ],
+            'creole explicit link with text' => [
+                'markup' => 'creole',
+                'input' => 'Visit [[https://site.url|http://www.site.url]] for more information.',
+                'output' => 'Visit <a href="https://site.url">http://www.site.url</a> for more information.',
+            ],
+            'nwiki implicit link' => [
+                'markup' => 'nwiki',
+                'input' => 'Visit https://site.url for more information.',
+                'output' => 'Visit <a href="https://site.url">https://site.url</a> for more information.',
+            ],
+            'nwiki explicit link' => [
+                'markup' => 'nwiki',
+                'input' => 'Visit [https://site.url] for more information.',
+                'output' => 'Visit <a href="https://site.url">https://site.url</a> for more information.',
+            ],
+            'nwiki explicit link with space separated text' => [
+                'markup' => 'nwiki',
+                'input' => 'Visit [https://site.url http://www.site.url] for more information.',
+                'output' => 'Visit <a href="https://site.url">http://www.site.url</a> for more information.',
+            ],
+            'nwiki explicit link with pipe separated text' => [
+                'markup' => 'nwiki',
+                'input' => 'Visit [https://site.url|http://www.site.url] for more information.',
+                'output' => 'Visit <a href="https://site.url">http://www.site.url</a> for more information.',
+            ],
+            'html implicit link' => [
+                'markup' => 'html',
+                'input' => 'Visit https://site.url for more information.',
+                'output' => 'Visit <a href="https://site.url">https://site.url</a> for more information.',
+            ],
+            'html explicit link with text' => [
+                'markup' => 'html',
+                'input' => 'Visit <a href="https://site.url">http://www.site.url</a> for more information.',
+                'output' => 'Visit <a href="https://site.url">http://www.site.url</a> for more information.',
+            ],
+            'html wiki link to non-existing page' => [
+                'markup' => 'html',
+                'input' => 'Visit [[Another page]] for more information.',
+                'output' => 'Visit <a class="wiki_newentry" ' .
+                    'href="https://www.example.com/moodle/mod/wiki/create.php?swid=1&amp;title=Another+page&amp;action=new">' .
+                    'Another page</a> for more information.',
+            ],
+            'html wiki link inside an explicit link' => [
+                // The explicit href URL takes precedence here, the [[...]] is not turned into a wiki link.
+                'markup' => 'html',
+                'input' => 'Visit <a href="https://site.url">[[Another page]]</a> for more information.',
+                'output' => 'Visit <a href="https://site.url">[[Another page]]</a> for more information.',
+            ],
+        ];
+    }
+
     function testCreoleMarkup() {
         $this->assertTestFiles('creole');
     }
index d989642..2bf0639 100644 (file)
@@ -166,7 +166,8 @@ echo $output->render($workshop->prepare_submission($submission, has_capability('
 if (trim($workshop->instructreviewers)) {
     $instructions = file_rewrite_pluginfile_urls($workshop->instructreviewers, 'pluginfile.php', $PAGE->context->id,
         'mod_workshop', 'instructreviewers', null, workshop::instruction_editors_options($PAGE->context));
-    print_collapsible_region_start('', 'workshop-viewlet-instructreviewers', get_string('instructreviewers', 'workshop'));
+    print_collapsible_region_start('', 'workshop-viewlet-instructreviewers', get_string('instructreviewers', 'workshop'),
+            'workshop-viewlet-instructreviewers-collapsed');
     echo $output->box(format_text($instructions, $workshop->instructreviewersformat, array('overflowdiv'=>true)), array('generalbox', 'instructions'));
     print_collapsible_region_end();
 }
index 1a93a3c..828461d 100644 (file)
@@ -110,7 +110,15 @@ class provider implements
         $collection->add_subsystem_link('core_files', [], 'privacy:metadata:subsystem:corefiles');
         $collection->add_subsystem_link('core_plagiarism', [], 'privacy:metadata:subsystem:coreplagiarism');
 
-        $collection->add_user_preference('workshop_perpage', 'privacy:metadata:preference:perpage');
+        $userprefs = self::get_user_prefs();
+        foreach ($userprefs as $userpref) {
+            if ($userpref === 'workshop_perpage') {
+                $collection->add_user_preference('workshop_perpage', 'privacy:metadata:preference:perpage');
+            } else {
+                $summary = str_replace('workshop-', '', $userpref);
+                $collection->add_user_preference($userpref, "privacy:metadata:preference:$summary");
+            }
+        }
 
         return $collection;
     }
@@ -259,12 +267,22 @@ class provider implements
      * @param int $userid ID of the user we are exporting data for
      */
     public static function export_user_preferences(int $userid) {
-
-        $perpage = get_user_preferences('workshop_perpage', null, $userid);
-
-        if ($perpage !== null) {
-            writer::export_user_preference('mod_workshop', 'workshop_perpage', $perpage,
-                get_string('privacy:metadata:preference:perpage', 'mod_workshop'));
+        $userprefs = self::get_user_prefs();
+        $expandstr = get_string('expand');
+        $collapsestr = get_string('collapse');
+        foreach ($userprefs as $userpref) {
+            $userprefval = get_user_preferences($userpref, null, $userid);
+            if ($userprefval !== null) {
+                $langid = str_replace('workshop-', '', $userpref);
+                $description = get_string("privacy:metadata:preference:$langid", 'mod_workshop');
+                if ($userpref === 'workshop_perpage') {
+                    writer::export_user_preference('mod_workshop', $userpref, $userprefval,
+                            get_string('privacy:metadata:preference:perpage', 'mod_workshop'));
+                } else {
+                    writer::export_user_preference('mod_workshop', $userpref,
+                        $userprefval == 1 ? $collapsestr : $expandstr, $description);
+                }
+            }
         }
     }
 
@@ -837,4 +855,31 @@ class provider implements
             \core_plagiarism\privacy\provider::delete_plagiarism_for_user($userid, $context);
         }
     }
+
+    /**
+     * Get the user preferences.
+     *
+     * @return array List of user preferences
+     */
+    protected static function get_user_prefs(): array {
+        return [
+            'workshop_perpage',
+            'workshop-viewlet-allexamples-collapsed',
+            'workshop-viewlet-allsubmissions-collapsed',
+            'workshop-viewlet-assessmentform-collapsed',
+            'workshop-viewlet-assignedassessments-collapsed',
+            'workshop-viewlet-cleargrades-collapsed',
+            'workshop-viewlet-conclusion-collapsed',
+            'workshop-viewlet-examples-collapsed',
+            'workshop-viewlet-examplesfail-collapsed',
+            'workshop-viewlet-gradereport-collapsed',
+            'workshop-viewlet-instructauthors-collapsed',
+            'workshop-viewlet-instructreviewers-collapsed',
+            'workshop-viewlet-intro-collapsed',
+            'workshop-viewlet-overallfeedback-collapsed',
+            'workshop-viewlet-ownsubmission-collapsed',
+            'workshop-viewlet-publicsubmissions-collapsed',
+            'workshop-viewlet-yourgrades-collapsed'
+        ];
+    }
 }
index 9320238..5d47e45 100644 (file)
@@ -144,7 +144,8 @@ echo $output->render($workshop->prepare_example_submission(($example)));
 if (trim($workshop->instructreviewers)) {
     $instructions = file_rewrite_pluginfile_urls($workshop->instructreviewers, 'pluginfile.php', $PAGE->context->id,
         'mod_workshop', 'instructreviewers', null, workshop::instruction_editors_options($PAGE->context));
-    print_collapsible_region_start('', 'workshop-viewlet-instructreviewers', get_string('instructreviewers', 'workshop'));
+    print_collapsible_region_start('', 'workshop-viewlet-instructreviewers', get_string('instructreviewers', 'workshop'),
+            'workshop-viewlet-instructreviewers-collapsed');
     echo $output->box(format_text($instructions, $workshop->instructreviewersformat, array('overflowdiv'=>true)), array('generalbox', 'instructions'));
     print_collapsible_region_end();
 }
index f2065db..f8f3be0 100644 (file)
@@ -180,7 +180,8 @@ echo $output->heading(format_string($workshop->name), 2);
 if (trim($workshop->instructauthors)) {
     $instructions = file_rewrite_pluginfile_urls($workshop->instructauthors, 'pluginfile.php', $PAGE->context->id,
         'mod_workshop', 'instructauthors', null, workshop::instruction_editors_options($PAGE->context));
-    print_collapsible_region_start('', 'workshop-viewlet-instructauthors', get_string('instructauthors', 'workshop'));
+    print_collapsible_region_start('', 'workshop-viewlet-instructauthors', get_string('instructauthors', 'workshop'),
+            'workshop-viewlet-instructauthors-collapsed');
     echo $output->box(format_text($instructions, $workshop->instructauthorsformat, array('overflowdiv'=>true)), array('generalbox', 'instructions'));
     print_collapsible_region_end();
 }
index ecfb93d..653dd74 100644 (file)
@@ -263,6 +263,22 @@ $string['privacy:metadata:feedbackreviewerformat'] = 'Text format of the feedbac
 $string['privacy:metadata:late'] = 'Whether the submission been submitted after the deadline';
 $string['privacy:metadata:peercomment'] = 'Comment on the given grade by the user providing the assessment';
 $string['privacy:metadata:peercommentformat'] = 'Text format of the comment on the given grade';
+$string['privacy:metadata:preference:viewlet-allexamples-collapsed'] = 'The collapsed/expanded state for the \'Example submissions\' viewlet.';
+$string['privacy:metadata:preference:viewlet-allsubmissions-collapsed'] = 'The collapsed/expanded state for the \'Workshop submissions report\' viewlet.';
+$string['privacy:metadata:preference:viewlet-assessmentform-collapsed'] = 'The collapsed/expanded state for the \'Assessment form\' viewlet.';
+$string['privacy:metadata:preference:viewlet-assignedassessments-collapsed'] = 'The collapsed/expanded state for the \'Assigned submissions to assess\' viewlet.';
+$string['privacy:metadata:preference:viewlet-cleargrades-collapsed'] = 'The collapsed/expanded state for the \'Workshop toolbox\' viewlet.';
+$string['privacy:metadata:preference:viewlet-conclusion-collapsed'] = 'The collapsed/expanded state for the \'Conclusion\' viewlet.';
+$string['privacy:metadata:preference:viewlet-examples-collapsed'] = 'The collapsed/expanded state for the \'Example submissions to assess\' viewlet.';
+$string['privacy:metadata:preference:viewlet-examplesfail-collapsed'] = 'The collapsed/expanded state for the \'Example submissions to assess\' viewlet.';
+$string['privacy:metadata:preference:viewlet-gradereport-collapsed'] = 'The collapsed/expanded state for the \'Workshop grades report\' viewlet.';
+$string['privacy:metadata:preference:viewlet-instructauthors-collapsed'] = 'The collapsed/expanded state for the \'Instructions for submission\' viewlet.';
+$string['privacy:metadata:preference:viewlet-instructreviewers-collapsed'] = 'The collapsed/expanded state for the \'Instructions for assessment\' viewlet.';
+$string['privacy:metadata:preference:viewlet-intro-collapsed'] = 'The collapsed/expanded state for the \'Intro\' viewlet.';
+$string['privacy:metadata:preference:viewlet-overallfeedback-collapsed'] = 'The collapsed/expanded state for the \'Overall feedback\' viewlet.';
+$string['privacy:metadata:preference:viewlet-ownsubmission-collapsed'] = 'The collapsed/expanded state for the \'Your submission\' viewlet.';
+$string['privacy:metadata:preference:viewlet-publicsubmissions-collapsed'] = 'The collapsed/expanded state for the \'Published submissions\' viewlet.';
+$string['privacy:metadata:preference:viewlet-yourgrades-collapsed'] = 'The collapsed/expanded state for the \'Your grades\' viewlet.';
 $string['privacy:metadata:preference:perpage'] = 'Number of submissions the user prefers to see on one page';
 $string['privacy:metadata:published'] = 'Whether the submission should be published to all participants once the workshop is closed';
 $string['privacy:metadata:reviewerid'] = 'ID of the user providing the assessment';
index 94d5258..5b4bf75 100644 (file)
@@ -692,7 +692,7 @@ class mod_workshop_renderer extends plugin_renderer_base {
 
         if (!is_null($assessment->form)) {
             $o .= print_collapsible_region_start('assessment-form-wrapper', uniqid('workshop-assessment'),
-                    get_string('assessmentform', 'workshop'), '', false, true);
+                    get_string('assessmentform', 'workshop'), 'workshop-viewlet-assessmentform-collapsed', false, true);
             $o .= $this->output->container(self::moodleform($assessment->form), 'assessment-form');
             $o .= print_collapsible_region_end(true);
 
@@ -782,7 +782,7 @@ class mod_workshop_renderer extends plugin_renderer_base {
 
         $o = $this->output->box($o, 'overallfeedback');
         $o = print_collapsible_region($o, 'overall-feedback-wrapper', uniqid('workshop-overall-feedback'),
-            get_string('overallfeedback', 'workshop'), '', false, true);
+                get_string('overallfeedback', 'workshop'), 'workshop-viewlet-overallfeedback-collapsed', false, true);
 
         return $o;
     }
index 195fc92..a9d2072 100644 (file)
@@ -193,7 +193,8 @@ echo $output->heading(get_string('mysubmission', 'workshop'), 3);
 if (trim($workshop->instructauthors)) {
     $instructions = file_rewrite_pluginfile_urls($workshop->instructauthors, 'pluginfile.php', $PAGE->context->id,
         'mod_workshop', 'instructauthors', null, workshop::instruction_editors_options($PAGE->context));
-    print_collapsible_region_start('', 'workshop-viewlet-instructauthors', get_string('instructauthors', 'workshop'));
+    print_collapsible_region_start('', 'workshop-viewlet-instructauthors', get_string('instructauthors', 'workshop'),
+            'workshop-viewlet-instructauthors-collapsed');
     echo $output->box(format_text($instructions, $workshop->instructauthorsformat, array('overflowdiv'=>true)), array('generalbox', 'instructions'));
     print_collapsible_region_end();
 }
diff --git a/mod/workshop/tests/behat/workshop_section_remembered.feature b/mod/workshop/tests/behat/workshop_section_remembered.feature
new file mode 100644 (file)
index 0000000..d4e4b32
--- /dev/null
@@ -0,0 +1,39 @@
+@mod @mod_workshop
+Feature: Workshop should remember collapsed/expanded sections in view page.
+  In order to keep the last state of collapsed/expanded sections in view page
+  As an user
+  I need to be able to choose collapsed/expanded, and after refresh the page it will display collapsed/expanded I chose before.
+
+  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 |
+      | Course1  | c1        |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | teacher1 | c1     | editingteacher |
+      | student1 | c1     | student        |
+    And the following "activities" exist:
+      | activity | name       | intro                  | course | idnumber  |
+      | workshop | Workshop 1 | Workshop 1 description | c1     | workshop1 |
+
+  @javascript
+  Scenario: Check section in view page can be remembered.
+    Given I log in as "teacher1"
+    And I am on "Course1" course homepage
+    And I follow "Workshop 1"
+    When I change phase in workshop "Workshop 1" to "Submission phase"
+    And I wait until the page is ready
+    And I log out
+    And I log in as "student1"
+    And I am on "Course1" course homepage
+    And I follow "Workshop 1"
+    Then I should see "You have not submitted your work yet"
+    And I click on "Your submission" "link"
+    And I should not see "You have not submitted your work yet"
+    And I reload the page
+    And I wait until the page is ready
+    And I should not see "You have not submitted your work yet"
index e016597..22d91bf 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2019111800;        // The current module version (YYYYMMDDXX)
+$plugin->version   = 2020032900;        // The current module version (YYYYMMDDXX)
 $plugin->requires  = 2019111200;        // Requires this Moodle version.
 $plugin->component = 'mod_workshop';
index 19e0769..31e3f44 100644 (file)
@@ -109,12 +109,14 @@ echo $output->render($userplan);
 switch ($workshop->phase) {
 case workshop::PHASE_SETUP:
     if (trim($workshop->intro)) {
-        print_collapsible_region_start('', 'workshop-viewlet-intro', get_string('introduction', 'workshop'));
+        print_collapsible_region_start('', 'workshop-viewlet-intro', get_string('introduction', 'workshop'),
+                'workshop-viewlet-intro-collapsed');
         echo $output->box(format_module_intro('workshop', $workshop, $workshop->cm->id), 'generalbox');
         print_collapsible_region_end();
     }
     if ($workshop->useexamples and has_capability('mod/workshop:manageexamples', $PAGE->context)) {
-        print_collapsible_region_start('', 'workshop-viewlet-allexamples', get_string('examplesubmissions', 'workshop'));
+        print_collapsible_region_start('', 'workshop-viewlet-allexamples', get_string('examplesubmissions', 'workshop'),
+                'workshop-viewlet-allexamples-collapsed');
         echo $output->box_start('generalbox examples');
         if ($workshop->grading_strategy_instance()->form_ready()) {
             if (! $examples = $workshop->get_examples_for_manager()) {
@@ -138,7 +140,8 @@ case workshop::PHASE_SUBMISSION:
     if (trim($workshop->instructauthors)) {
         $instructions = file_rewrite_pluginfile_urls($workshop->instructauthors, 'pluginfile.php', $PAGE->context->id,
             'mod_workshop', 'instructauthors', null, workshop::instruction_editors_options($PAGE->context));
-        print_collapsible_region_start('', 'workshop-viewlet-instructauthors', get_string('instructauthors', 'workshop'));
+        print_collapsible_region_start('', 'workshop-viewlet-instructauthors', get_string('instructauthors', 'workshop'),
+                'workshop-viewlet-instructauthors-collapsed');
         echo $output->box(format_text($instructions, $workshop->instructauthorsformat, array('overflowdiv'=>true)), array('generalbox', 'instructions'));
         print_collapsible_region_end();
     }
@@ -168,7 +171,8 @@ case workshop::PHASE_SUBMISSION:
         } else {
             $examplesdone = true;
         }
-        print_collapsible_region_start('', 'workshop-viewlet-examples', get_string('exampleassessments', 'workshop'), false, $examplesdone);
+        print_collapsible_region_start('', 'workshop-viewlet-examples', get_string('exampleassessments', 'workshop'),
+                'workshop-viewlet-examples-collapsed', $examplesdone);
         echo $output->box_start('generalbox exampleassessments');
         if ($total == 0) {
             echo $output->heading(get_string('noexamples', 'workshop'), 3);
@@ -183,7 +187,8 @@ case workshop::PHASE_SUBMISSION:
     }
 
     if (has_capability('mod/workshop:submit', $PAGE->context) and (!$examplesmust or $examplesdone)) {
-        print_collapsible_region_start('', 'workshop-viewlet-ownsubmission', get_string('yoursubmission', 'workshop'));
+        print_collapsible_region_start('', 'workshop-viewlet-ownsubmission', get_string('yoursubmission', 'workshop'),
+                'workshop-viewlet-ownsubmission-collapsed');
         echo $output->box_start('generalbox ownsubmission');
         if ($submission = $workshop->get_submission_by_author($USER->id)) {
             echo $output->render($workshop->prepare_submission_summary($submission, true));
@@ -221,7 +226,8 @@ case workshop::PHASE_SUBMISSION:
             }
         }
 
-        print_collapsible_region_start('', 'workshop-viewlet-allsubmissions', get_string('submissionsreport', 'workshop'));
+        print_collapsible_region_start('', 'workshop-viewlet-allsubmissions', get_string('submissionsreport', 'workshop'),
+                'workshop-viewlet-allsubmissions-collapsed');
 
         $perpage = get_user_preferences('workshop_perpage', 10);
         $data = $workshop->prepare_grading_report_data($USER->id, $groupid, $page, $perpage, $sortby, $sorthow);
@@ -266,12 +272,14 @@ case workshop::PHASE_ASSESSMENT:
     $ownsubmissionexists = null;
     if (has_capability('mod/workshop:submit', $PAGE->context)) {
         if ($ownsubmission = $workshop->get_submission_by_author($USER->id)) {
-            print_collapsible_region_start('', 'workshop-viewlet-ownsubmission', get_string('yoursubmission', 'workshop'), false, true);
+            print_collapsible_region_start('', 'workshop-viewlet-ownsubmission', get_string('yoursubmission', 'workshop'),
+                    'workshop-viewlet-ownsubmission-collapsed', true);
             echo $output->box_start('generalbox ownsubmission');
             echo $output->render($workshop->prepare_submission_summary($ownsubmission, true));
             $ownsubmissionexists = true;
         } else {
-            print_collapsible_region_start('', 'workshop-viewlet-ownsubmission', get_string('yoursubmission', 'workshop'));
+            print_collapsible_region_start('', 'workshop-viewlet-ownsubmission', get_string('yoursubmission', 'workshop'),
+                    'workshop-viewlet-ownsubmission-collapsed');
             echo $output->box_start('generalbox ownsubmission');
             echo $output->container(get_string('noyoursubmission', 'workshop'));
             $ownsubmissionexists = false;
@@ -309,7 +317,8 @@ case workshop::PHASE_ASSESSMENT:
             $reportopts->showgradinggrade       = false;
             $reportopts->workshopphase          = $workshop->phase;
 
-            print_collapsible_region_start('', 'workshop-viewlet-gradereport', get_string('gradesreport', 'workshop'));
+            print_collapsible_region_start('', 'workshop-viewlet-gradereport', get_string('gradesreport', 'workshop'),
+                    'workshop-viewlet-gradereport-collapsed');
             echo $output->box_start('generalbox gradesreport');
             echo $output->container(groups_print_activity_menu($workshop->cm, $PAGE->url, true), 'groupwidget');
             echo $output->render($pagingbar);
@@ -323,7 +332,8 @@ case workshop::PHASE_ASSESSMENT:
     if (trim($workshop->instructreviewers)) {
         $instructions = file_rewrite_pluginfile_urls($workshop->instructreviewers, 'pluginfile.php', $PAGE->context->id,
             'mod_workshop', 'instructreviewers', null, workshop::instruction_editors_options($PAGE->context));
-        print_collapsible_region_start('', 'workshop-viewlet-instructreviewers', get_string('instructreviewers', 'workshop'));
+        print_collapsible_region_start('', 'workshop-viewlet-instructreviewers', get_string('instructreviewers', 'workshop'),
+                'workshop-viewlet-instructreviewers-collapsed');
         echo $output->box(format_text($instructions, $workshop->instructreviewersformat, array('overflowdiv'=>true)), array('generalbox', 'instructions'));
         print_collapsible_region_end();
     }
@@ -338,7 +348,8 @@ case workshop::PHASE_ASSESSMENT:
     $examplesavailable = true;
 
     if (!$examplesdone and $examplesmust and ($ownsubmissionexists === false)) {
-        print_collapsible_region_start('', 'workshop-viewlet-examplesfail', get_string('exampleassessments', 'workshop'));
+        print_collapsible_region_start('', 'workshop-viewlet-examplesfail', get_string('exampleassessments', 'workshop'),
+                'workshop-viewlet-examplesfail-collapsed');
         echo $output->box(get_string('exampleneedsubmission', 'workshop'));
         print_collapsible_region_end();
         $examplesavailable = false;
@@ -365,7 +376,8 @@ case workshop::PHASE_ASSESSMENT:
         } else {
             $examplesdone = true;
         }
-        print_collapsible_region_start('', 'workshop-viewlet-examples', get_string('exampleassessments', 'workshop'), false, $examplesdone);
+        print_collapsible_region_start('', 'workshop-viewlet-examples', get_string('exampleassessments', 'workshop'),
+                'workshop-viewlet-examples-collapsed', $examplesdone);
         echo $output->box_start('generalbox exampleassessments');
         if ($total == 0) {
             echo $output->heading(get_string('noexamples', 'workshop'), 3);
@@ -379,7 +391,8 @@ case workshop::PHASE_ASSESSMENT:
         print_collapsible_region_end();
     }
     if (!$examplesmust or $examplesdone) {
-        print_collapsible_region_start('', 'workshop-viewlet-assignedassessments', get_string('assignedassessments', 'workshop'));
+        print_collapsible_region_start('', 'workshop-viewlet-assignedassessments', get_string('assignedassessments', 'workshop'),
+                'workshop-viewlet-assignedassessments-collapsed');
         if (! $assessments = $workshop->get_assessments_by_reviewer($USER->id)) {
             echo $output->box_start('generalbox assessment-none');
             echo $output->notification(get_string('assignedassessmentsnone', 'workshop'));
@@ -459,7 +472,8 @@ case workshop::PHASE_EVALUATION:
             $reportopts->showgradinggrade       = true;
             $reportopts->workshopphase          = $workshop->phase;
 
-            print_collapsible_region_start('', 'workshop-viewlet-gradereport', get_string('gradesreport', 'workshop'));
+            print_collapsible_region_start('', 'workshop-viewlet-gradereport', get_string('gradesreport', 'workshop'),
+                    'workshop-viewlet-gradereport-collapsed');
             echo $output->box_start('generalbox gradesreport');
             echo $output->container(groups_print_activity_menu($workshop->cm, $PAGE->url, true), 'groupwidget');
             echo $output->render($pagingbar);
@@ -471,7 +485,8 @@ case workshop::PHASE_EVALUATION:
         }
     }
     if (has_capability('mod/workshop:overridegrades', $workshop->context)) {
-        print_collapsible_region_start('', 'workshop-viewlet-cleargrades', get_string('toolbox', 'workshop'), false, true);
+        print_collapsible_region_start('', 'workshop-viewlet-cleargrades', get_string('toolbox', 'workshop'),
+                'workshop-viewlet-cleargrades-collapsed', true);
         echo $output->box_start('generalbox toolbox');
 
         // Clear aggregated grades
@@ -497,7 +512,8 @@ case workshop::PHASE_EVALUATION:
         print_collapsible_region_end();
     }
     if (has_capability('mod/workshop:submit', $PAGE->context)) {
-        print_collapsible_region_start('', 'workshop-viewlet-ownsubmission', get_string('yoursubmission', 'workshop'));
+        print_collapsible_region_start('', 'workshop-viewlet-ownsubmission', get_string('yoursubmission', 'workshop'),
+                'workshop-viewlet-ownsubmission-collapsed');
         echo $output->box_start('generalbox ownsubmission');
         if ($submission = $workshop->get_submission_by_author($USER->id)) {
             echo $output->render($workshop->prepare_submission_summary($submission, true));
@@ -508,7 +524,8 @@ case workshop::PHASE_EVALUATION:
         print_collapsible_region_end();
     }
     if ($assessments = $workshop->get_assessments_by_reviewer($USER->id)) {
-        print_collapsible_region_start('', 'workshop-viewlet-assignedassessments', get_string('assignedassessments', 'workshop'));
+        print_collapsible_region_start('', 'workshop-viewlet-assignedassessments', get_string('assignedassessments', 'workshop'),
+                'workshop-viewlet-assignedassessments-collapsed');
         $shownames = has_capability('mod/workshop:viewauthornames', $PAGE->context);
         foreach ($assessments as $assessment) {
             $submission                     = new stdclass();
@@ -542,13 +559,15 @@ case workshop::PHASE_CLOSED:
     if (trim($workshop->conclusion)) {
         $conclusion = file_rewrite_pluginfile_urls($workshop->conclusion, 'pluginfile.php', $workshop->context->id,
             'mod_workshop', 'conclusion', null, workshop::instruction_editors_options($workshop->context));
-        print_collapsible_region_start('', 'workshop-viewlet-conclusion', get_string('conclusion', 'workshop'));
+        print_collapsible_region_start('', 'workshop-viewlet-conclusion', get_string('conclusion', 'workshop'),
+                'workshop-viewlet-conclusion-collapsed');
         echo $output->box(format_text($conclusion, $workshop->conclusionformat, array('overflowdiv'=>true)), array('generalbox', 'conclusion'));
         print_collapsible_region_end();
     }
     $finalgrades = $workshop->get_gradebook_grades($USER->id);
     if (!empty($finalgrades)) {
-        print_collapsible_region_start('', 'workshop-viewlet-yourgrades', get_string('yourgrades', 'workshop'));
+        print_collapsible_region_start('', 'workshop-viewlet-yourgrades', get_string('yourgrades', 'workshop'),
+                'workshop-viewlet-yourgrades-collapsed');
         echo $output->box_start('generalbox grades-yourgrades');
         echo $output->render($finalgrades);
         echo $output->box_end();
@@ -576,7 +595,8 @@ case workshop::PHASE_CLOSED:
             $reportopts->showgradinggrade       = true;
             $reportopts->workshopphase          = $workshop->phase;
 
-            print_collapsible_region_start('', 'workshop-viewlet-gradereport', get_string('gradesreport', 'workshop'));
+            print_collapsible_region_start('', 'workshop-viewlet-gradereport', get_string('gradesreport', 'workshop'),
+                    'workshop-viewlet-gradereport-collapsed');
             echo $output->box_start('generalbox gradesreport');
             echo $output->container(groups_print_activity_menu($workshop->cm, $PAGE->url, true), 'groupwidget');
             echo $output->render($pagingbar);
@@ -588,7 +608,8 @@ case workshop::PHASE_CLOSED:
         }
     }
     if (has_capability('mod/workshop:submit', $PAGE->context)) {
-        print_collapsible_region_start('', 'workshop-viewlet-ownsubmission', get_string('yoursubmission', 'workshop'));
+        print_collapsible_region_start('', 'workshop-viewlet-ownsubmission', get_string('yoursubmission', 'workshop'),
+                'workshop-viewlet-ownsubmission-collapsed');
         echo $output->box_start('generalbox ownsubmission');
         if ($submission = $workshop->get_submission_by_author($USER->id)) {
             echo $output->render($workshop->prepare_submission_summary($submission, true));
@@ -606,7 +627,8 @@ case workshop::PHASE_CLOSED:
     if (has_capability('mod/workshop:viewpublishedsubmissions', $workshop->context)) {
         $shownames = has_capability('mod/workshop:viewauthorpublished', $workshop->context);
         if ($submissions = $workshop->get_published_submissions()) {
-            print_collapsible_region_start('', 'workshop-viewlet-publicsubmissions', get_string('publishedsubmissions', 'workshop'));
+            print_collapsible_region_start('', 'workshop-viewlet-publicsubmissions', get_string('publishedsubmissions', 'workshop'),
+                    'workshop-viewlet-publicsubmissions-collapsed');
             foreach ($submissions as $submission) {
                 echo $output->box_start('generalbox submission-summary');
                 echo $output->render($workshop->prepare_submission_summary($submission, $shownames));
@@ -616,7 +638,8 @@ case workshop::PHASE_CLOSED:
         }
     }
     if ($assessments = $workshop->get_assessments_by_reviewer($USER->id)) {
-        print_collapsible_region_start('', 'workshop-viewlet-assignedassessments', get_string('assignedassessments', 'workshop'));
+        print_collapsible_region_start('', 'workshop-viewlet-assignedassessments', get_string('assignedassessments', 'workshop'),
+                'workshop-viewlet-assignedassessments-collapsed');
         $shownames = has_capability('mod/workshop:viewauthornames', $PAGE->context);
         foreach ($assessments as $assessment) {
             $submission                     = new stdclass();
index 5f370f9..2f182c5 100644 (file)
@@ -183,6 +183,7 @@ class qbehaviour_interactive_walkthrough_test extends qbehaviour_walkthrough_tes
 
         // Create a multichoice single question.
         $mc = test_question_maker::make_a_multichoice_single_question();
+        $mc->showstandardinstruction = true;
         $mc->hints = array(
             new question_hint_with_parts(0, 'This is the first hint.', FORMAT_HTML, false, false),
         );
@@ -338,6 +339,7 @@ class qbehaviour_interactive_walkthrough_test extends qbehaviour_walkthrough_tes
 
         // Create a multichoice multiple question.
         $mc = test_question_maker::make_a_multichoice_multi_question();
+        $mc->showstandardinstruction = true;
         $mc->hints = array(
             new question_hint_with_parts(0, 'This is the first hint.', FORMAT_HTML, true, true),
             new question_hint_with_parts(0, 'This is the second hint.', FORMAT_HTML, true, true),
index 8443178..13d48b9 100644 (file)
@@ -329,6 +329,7 @@ class test_question_maker {
 
         $mc->shuffleanswers = 1;
         $mc->answernumbering = 'abc';
+        $mc->showstandardinstruction = 0;
 
         $mc->answers = array(
             13 => new question_answer(13, 'A', 1, 'A is right', FORMAT_HTML),
@@ -355,6 +356,7 @@ class test_question_maker {
 
         $mc->shuffleanswers = 1;
         $mc->answernumbering = 'abc';
+        $mc->showstandardinstruction = 0;
 
         self::set_standard_combined_feedback_fields($mc);
 
index d2323d0..b2b71ff 100644 (file)
@@ -439,6 +439,8 @@ class qformat_xml extends qformat_default {
         $qo->answernumbering = $this->getpath($question,
                 array('#', 'answernumbering', 0, '#'), 'abc');
         $qo->shuffleanswers = $this->trans_single($shuffleanswers);
+        $qo->showstandardinstruction = $this->getpath($question,
+            array('#', 'showstandardinstruction', 0, '#'), '1');
 
         // There was a time on the 1.8 branch when it could output an empty
         // answernumbering tag, so fix up any found.
@@ -1255,7 +1257,9 @@ class qformat_xml extends qformat_default {
                         $this->get_single($question->options->shuffleanswers) .
                         "</shuffleanswers>\n";
                 $expout .= "    <answernumbering>" . $question->options->answernumbering .
-                        "</answernumbering>\n";
+                    "</answernumbering>\n";
+                $expout .= "    <showstandardinstruction>" . $question->options->showstandardinstruction .
+                    "</showstandardinstruction>\n";
                 $expout .= $this->write_combined_feedback($question->options, $question->id, $question->contextid);
                 $expout .= $this->write_answers($question->options->answers);
                 break;
index c8cd52c..0889ad5 100644 (file)
@@ -893,6 +893,7 @@ END;
         $qdata->options->single = 0;
         $qdata->options->shuffleanswers = 0;
         $qdata->options->answernumbering = 'abc';
+        $qdata->options->showstandardinstruction = 0;
         $qdata->options->correctfeedback = '<p>Your answer is correct.</p>';
         $qdata->options->correctfeedbackformat = FORMAT_HTML;
         $qdata->options->partiallycorrectfeedback = '<p>Your answer is partially correct.</p>';
@@ -934,6 +935,7 @@ END;
     <single>false</single>
     <shuffleanswers>false</shuffleanswers>
     <answernumbering>abc</answernumbering>
+    <showstandardinstruction>0</showstandardinstruction>
     <correctfeedback format="html">
       <text><![CDATA[<p>Your answer is correct.</p>]]></text>
     </correctfeedback>
index c603b2e..1753414 100644 (file)
Binary files a/question/type/ddimageortext/amd/build/form.min.js and b/question/type/ddimageortext/amd/build/form.min.js differ
index 0d032ee..1e6304f 100644 (file)
Binary files a/question/type/ddimageortext/amd/build/form.min.js.map and b/question/type/ddimageortext/amd/build/form.min.js.map differ
index 6e931eb..00c2afd 100644 (file)
Binary files a/question/type/ddimageortext/amd/build/question.min.js and b/question/type/ddimageortext/amd/build/question.min.js differ
index 1d991ea..675e499 100644 (file)
Binary files a/question/type/ddimageortext/amd/build/question.min.js.map and b/question/type/ddimageortext/amd/build/question.min.js.map differ
index da384b7..6147e6b 100644 (file)
@@ -48,17 +48,12 @@ define(['jquery', 'core/dragdrop'], function($, dragDrop) {
 
         /**
          * Initialise the form javascript features.
-         *
-         * @param {Object} maxBgImageSize object with two properties: width and height.
-         * @param {Object} maxDragImageSize object with two properties: width and height.
          */
-        init: function(maxBgImageSize, maxDragImageSize) {
-            dragDropToImageForm.maxBgImageSize = maxBgImageSize;
-            dragDropToImageForm.maxDragImageSize = maxDragImageSize;
+        init: function() {
             dragDropToImageForm.fp = dragDropToImageForm.filePickers();
 
             $('#id_previewareaheader').append(
-                '<div class="ddarea">' +
+                '<div class="ddarea que ddimageortext">' +
                 '  <div class="droparea">' +
                 '    <img class="dropbackground" />' +
                 '    <div class="dropzones"></div>' +
@@ -108,27 +103,10 @@ define(['jquery', 'core/dragdrop'], function($, dragDrop) {
          * After the background image is loaded, continue setting up the preview.
          */
         afterPreviewImageLoaded: function() {
-            var bgImg = $('fieldset#id_previewareaheader .dropbackground');
-            dragDropToImageForm.constrainImageSize(bgImg, dragDropToImageForm.maxBgImageSize);
             dragDropToImageForm.createDropZones();
             M.util.js_complete('dragDropToImageForm');
         },
 
-        /**
-         * Limits an image display size to the given maximums.
-         *
-         * @param {jQuery} img the image.
-         * @param {Object} maxSize with width and height properties.
-         */
-        constrainImageSize: function(img, maxSize) {
-            var reduceby = Math.max(img.width() / maxSize.width,
-                img.height() / maxSize.height);
-            if (reduceby > 1) {
-                img.css('width', Math.floor(img.width() / reduceby));
-            }
-            img.addClass('constrained');
-        },
-
         /**
          * Create, or recreate all the drop zones.
          */
@@ -532,9 +510,6 @@ define(['jquery', 'core/dragdrop'], function($, dragDrop) {
     return {
         /**
          * Initialise the form JavaScript features.
-         *
-         * @param {Object} maxBgImageSize object with two properties: width and height.
-         * @param {Object} maxDragImageSize object with two properties: width and height.
          */
         init: dragDropToImageForm.init
     };
index a2327e9..9c7fd7b 100644 (file)
@@ -39,6 +39,7 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
         this.places = places;
         this.allImagesLoaded = false;
         this.imageLoadingTimeoutId = null;
+        this.isPrinting = false;
         if (readOnly) {
             this.getRoot().addClass('qtype_ddimageortext-readonly');
         }
@@ -175,7 +176,7 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
             if (label === '') {
                 label = M.util.get_string('blank', 'qtype_ddimageortext');
             }
-            root.find('.dropzones').append('<div class="dropzone group' + place.group +
+            root.find('.dropzones').append('<div class="dropzone active group' + place.group +
                             ' place' + i + '" tabindex="0">' +
                     '<span class="accesshide">' + label + '</span>&nbsp;</div>');
             root.find('.dropzone.place' + i).width(maxWidth - 2).height(maxHeight - 2);
@@ -189,8 +190,14 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
      */
     DragDropOntoImageQuestion.prototype.cloneDrags = function() {
         var thisQ = this;
-        this.getRoot().find('.ddarea .draghome').each(function(index, dragHome) {
-            thisQ.cloneDragsForOneChoice($(dragHome));
+        thisQ.getRoot().find('.draghome').each(function(index, dragHome) {
+            var drag = $(dragHome);
+            var placeHolder = drag.clone();
+            placeHolder.removeClass();
+            placeHolder.addClass('draghome choice' +
+                thisQ.getChoice(drag) + ' group' +
+                thisQ.getGroup(drag) + ' dragplaceholder');
+            drag.before(placeHolder);
         });
     };
 
@@ -229,25 +236,27 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
     DragDropOntoImageQuestion.prototype.positionDragsAndDrops = function() {
         var thisQ = this,
             root = this.getRoot(),
-            bgPosition = this.bgImage().offset();
+            bgRatio = this.bgRatio();
 
         // Move the drops into position.
         root.find('.ddarea .dropzone').each(function(i, dropNode) {
             var drop = $(dropNode),
                 place = thisQ.places[thisQ.getPlace(drop)];
             // The xy values come from PHP as strings, so we need parseInt to stop JS doing string concatenation.
-            drop.offset({
-                left: bgPosition.left + parseInt(place.xy[0]),
-                top: bgPosition.top + parseInt(place.xy[1])});
+            drop.css('left', parseInt(place.xy[0]) * bgRatio)
+                .css('top', parseInt(place.xy[1]) * bgRatio);
+            drop.data('originX', parseInt(place.xy[0]))
+                .data('originY', parseInt(place.xy[1]));
+            thisQ.handleElementScale(drop, 'left top');
         });
 
         // First move all items back home.
-        root.find('.ddarea .drag').each(function(i, dragNode) {
+        root.find('.draghome').not('.dragplaceholder').each(function(i, dragNode) {
             var drag = $(dragNode),
                 currentPlace = thisQ.getClassnameNumericSuffix(drag, 'inplace');
             drag.addClass('unplaced')
-                .removeClass('placed')
-                .offset(thisQ.getDragHome(thisQ.getGroup(drag), thisQ.getChoice(drag)).offset());
+                .removeClass('placed');
+            drag.removeAttr('tabindex');
             if (currentPlace !== null) {
                 drag.removeClass('inplace' + currentPlace);
             }
@@ -257,39 +266,37 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
         root.find('input.placeinput').each(function(i, inputNode) {
             var input = $(inputNode),
                 choice = input.val();
-            if (choice === '0') {
+            if (choice.length === 0 || (choice.length > 0 && choice === '0')) {
                 // No item in this place.
                 return;
             }
 
             var place = thisQ.getPlace(input);
-            thisQ.getUnplacedChoice(thisQ.getGroup(input), choice)
-                .removeClass('unplaced')
-                .addClass('placed inplace' + place)
-                .offset(root.find('.dropzone.place' + place).offset());
-        });
-
-        this.bgImage().data('prev-top', bgPosition.top).data('prev-left', bgPosition.left);
-    };
+            // Get the unplaced drag.
+            var unplacedDrag = thisQ.getUnplacedChoice(thisQ.getGroup(input), choice);
+            // Get the clone of the drag.
+            var hiddenDrag = thisQ.getDragClone(unplacedDrag);
+            if (hiddenDrag.length) {
+                if (unplacedDrag.hasClass('infinite')) {
+                    var noOfDrags = thisQ.noOfDropsInGroup(thisQ.getGroup(unplacedDrag));
+                    var cloneDrags = thisQ.getInfiniteDragClones(unplacedDrag, false);
+                    if (cloneDrags.length < noOfDrags) {
+                        var cloneDrag = unplacedDrag.clone();
+                        cloneDrag.removeClass('beingdragged');
+                        cloneDrag.removeAttr('tabindex');
+                        hiddenDrag.after(cloneDrag);
+                    } else {
+                        hiddenDrag.addClass('active');
+                    }
+                } else {
+                    hiddenDrag.addClass('active');
+                }
+            }
 
-    /**
-     * Check to see if the background image has moved. If so, refresh the layout.
-     */
-    DragDropOntoImageQuestion.prototype.fixLayoutIfBackgroundMoved = function() {
-        var bgImage = this.bgImage(),
-            bgPosition = bgImage.offset(),
-            prevTop = bgImage.data('prev-top'),
-            prevLeft = bgImage.data('prev-left');
-        if (prevLeft === undefined || prevTop === undefined) {
-            // Question is not set up yet. Nothing to do.
-            return;
-        }
-        if (prevTop === bgPosition.top && prevLeft === bgPosition.left) {
-            // Things have not moved.
-            return;
-        }
-        // We need to reposition things.
-        this.positionDragsAndDrops();
+            // Send the drag to drop.
+            var drop = root.find('.dropzone.place' + place);
+            thisQ.sendDragToDrop(unplacedDrag, drop);
+        });
     };
 
     /**
@@ -299,20 +306,48 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
      */
     DragDropOntoImageQuestion.prototype.handleDragStart = function(e) {
         var thisQ = this,
-            drag = $(e.target).closest('.drag');
+            drag = $(e.target).closest('.draghome'),
+            currentIndex = this.calculateZIndex(),
+            newIndex = currentIndex + 2;
 
         var info = dragDrop.prepare(e);
         if (!info.start) {
             return;
         }
 
+        drag.addClass('beingdragged').css('transform', '').css('z-index', newIndex);
         var currentPlace = this.getClassnameNumericSuffix(drag, 'inplace');
         if (currentPlace !== null) {
             this.setInputValue(currentPlace, 0);
             drag.removeClass('inplace' + currentPlace);
+            var hiddenDrop = thisQ.getDrop(drag, currentPlace);
+            if (hiddenDrop.length) {
+                hiddenDrop.addClass('active');
+                drag.offset(hiddenDrop.offset());
+            }
+        } else {
+            var hiddenDrag = thisQ.getDragClone(drag);
+            if (hiddenDrag.length) {
+                if (drag.hasClass('infinite')) {
+                    var noOfDrags = this.noOfDropsInGroup(thisQ.getGroup(drag));
+                    var cloneDrags = this.getInfiniteDragClones(drag, false);
+                    if (cloneDrags.length < noOfDrags) {
+                        var cloneDrag = drag.clone();
+                        cloneDrag.removeClass('beingdragged');
+                        cloneDrag.removeAttr('tabindex');
+                        hiddenDrag.after(cloneDrag);
+                        drag.offset(cloneDrag.offset());
+                    } else {
+                        hiddenDrag.addClass('active');
+                        drag.offset(hiddenDrag.offset());
+                    }
+                } else {
+                    hiddenDrag.addClass('active');
+                    drag.offset(hiddenDrag.offset());
+                }
+            }
         }
 
-        drag.addClass('beingdragged');
         dragDrop.start(e, drag, function(x, y, drag) {
             thisQ.dragMove(x, y, drag);
         }, function(x, y, drag) {
@@ -337,6 +372,14 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
                 drop.removeClass('valid-drag-over-drop');
             }
         });
+        this.getRoot().find('.draghome.placed.group' + this.getGroup(drag)).not('.beingdragged').each(function(i, dropNode) {
+            var drop = $(dropNode);
+            if (thisQ.isPointInDrop(pageX, pageY, drop)) {
+                drop.addClass('valid-drag-over-drop');
+            } else {
+                drop.removeClass('valid-drag-over-drop');
+            }
+        });
     };
 
     /**
@@ -364,6 +407,22 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
             return false; // Stop the each() here.
         });
 
+        root.find('.draghome.placed.group' + this.getGroup(drag)).not('.beingdragged').each(function(i, placedNode) {
+            var placedDrag = $(placedNode);
+            if (!thisQ.isPointInDrop(pageX, pageY, placedDrag)) {
+                // Not this placed drag.
+                return true;
+            }
+
+            // Now put this drag into the drop.
+            placedDrag.removeClass('valid-drag-over-drop');
+            var currentPlace = thisQ.getClassnameNumericSuffix(placedDrag, 'inplace');
+            var drop = thisQ.getDrop(drag, currentPlace);
+            thisQ.sendDragToDrop(drag, drop);
+            placed = true;
+            return false; // Stop the each() here.
+        });
+
         if (!placed) {
             this.sendDragHome(drag);
         }
@@ -379,15 +438,24 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
         // Is there already a drag in this drop? if so, evict it.
         var oldDrag = this.getCurrentDragInPlace(this.getPlace(drop));
         if (oldDrag.length !== 0) {
+            oldDrag.addClass('beingdragged');
+            oldDrag.offset(oldDrag.offset());
+            var currentPlace = this.getClassnameNumericSuffix(oldDrag, 'inplace');
+            var hiddenDrop = this.getDrop(oldDrag, currentPlace);
+            hiddenDrop.addClass('active');
             this.sendDragHome(oldDrag);
         }
 
         if (drag.length === 0) {
             this.setInputValue(this.getPlace(drop), 0);
+            if (drop.data('isfocus')) {
+                drop.focus();
+            }
         } else {
             this.setInputValue(this.getPlace(drop), this.getChoice(drag));
             drag.removeClass('unplaced')
                 .addClass('placed inplace' + this.getPlace(drop));
+            drag.attr('tabindex', 0);
             this.animateTo(drag, drop);
         }
     };
@@ -398,11 +466,11 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
      * @param {jQuery} drag the item being moved.
      */
     DragDropOntoImageQuestion.prototype.sendDragHome = function(drag) {
-        drag.removeClass('placed').addClass('unplaced');
         var currentPlace = this.getClassnameNumericSuffix(drag, 'inplace');
         if (currentPlace !== null) {
             drag.removeClass('inplace' + currentPlace);
         }
+        drag.data('unplaced', true);
 
         this.animateTo(drag, this.getDragHome(this.getGroup(drag), this.getChoice(drag)));
     };
@@ -416,8 +484,15 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
      * @param {KeyboardEvent} e
      */
     DragDropOntoImageQuestion.prototype.handleKeyPress = function(e) {
-        var drop = $(e.target).closest('.dropzone'),
-            currentDrag = this.getCurrentDragInPlace(this.getPlace(drop)),
+        var drop = $(e.target).closest('.dropzone');
+        if (drop.length === 0) {
+            var placedDrag = $(e.target);
+            var currentPlace = this.getClassnameNumericSuffix(placedDrag, 'inplace');
+            if (currentPlace !== null) {
+                drop = this.getDrop(placedDrag, currentPlace);
+            }
+        }
+        var currentDrag = this.getCurrentDragInPlace(this.getPlace(drop)),
             nextDrag = $();
 
         switch (e.keyCode) {
@@ -436,9 +511,37 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
                 break;
 
             default:
+                questionManager.isKeyboardNavigation = false;
                 return; // To avoid the preventDefault below.
         }
 
+        if (nextDrag.length) {
+            nextDrag.data('isfocus', true);
+            nextDrag.addClass('beingdragged');
+            var hiddenDrag = this.getDragClone(nextDrag);
+            if (hiddenDrag.length) {
+                if (nextDrag.hasClass('infinite')) {
+                    var noOfDrags = this.noOfDropsInGroup(this.getGroup(nextDrag));
+                    var cloneDrags = this.getInfiniteDragClones(nextDrag, false);
+                    if (cloneDrags.length < noOfDrags) {
+                        var cloneDrag = nextDrag.clone();
+                        cloneDrag.removeClass('beingdragged');
+                        cloneDrag.removeAttr('tabindex');
+                        hiddenDrag.after(cloneDrag);
+                        nextDrag.offset(cloneDrag.offset());
+                    } else {
+                        hiddenDrag.addClass('active');
+                        nextDrag.offset(hiddenDrag.offset());
+                    }
+                } else {
+                    hiddenDrag.addClass('active');
+                    nextDrag.offset(hiddenDrag.offset());
+                }
+            }
+        } else {
+            drop.data('isfocus', true);
+        }
+
         e.preventDefault();
         this.sendDragToDrop(nextDrag, drop);
     };
@@ -503,9 +606,10 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
      */
     DragDropOntoImageQuestion.prototype.animateTo = function(drag, target) {
         var currentPos = drag.offset(),
-            targetPos = target.offset();
-        drag.addClass('beingdragged');
+            targetPos = target.offset(),
+            thisQ = this;
 
+        M.util.js_pending('qtype_ddimageortext-animate-' + thisQ.containerId);
         // Animate works in terms of CSS position, whereas locating an object
         // on the page works best with jQuery offset() function. So, to get
         // the right target position, we work out the required change in
@@ -518,10 +622,8 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
             {
                 duration: 'fast',
                 done: function() {
-                    drag.removeClass('beingdragged');
-                    // It seems that the animation sometimes leaves the drag
-                    // one pixel out of position. Put it in exactly the right place.
-                    drag.offset(targetPos);
+                    $('body').trigger('dragmoved', [drag, target, thisQ]);
+                    M.util.js_complete('qtype_ddimageortext-animate-' + thisQ.containerId);
                 }
             }
         );
@@ -537,6 +639,10 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
      */
     DragDropOntoImageQuestion.prototype.isPointInDrop = function(pageX, pageY, drop) {
         var position = drop.offset();
+        if (drop.hasClass('draghome')) {
+            return pageX >= position.left && pageX < position.left + drop.outerWidth()
+                && pageY >= position.top && pageY < position.top + drop.outerHeight();
+        }
         return pageX >= position.left && pageX < position.left + drop.width()
             && pageY >= position.top && pageY < position.top + drop.height();
     };
@@ -576,7 +682,13 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
      * @returns {jQuery} containing that div.
      */
     DragDropOntoImageQuestion.prototype.getDragHome = function(group, choice) {
-        return this.getRoot().find('.ddarea .draghome.group' + group + '.choice' + choice);
+        if (!this.getRoot().find('.draghome.dragplaceholder.group' + group + '.choice' + choice).is(':visible')) {
+            return this.getRoot().find('.dragitemgroup' + group +
+                ' .draghome.infinite' +
+                '.choice' + choice +
+                '.group' + group);
+        }
+        return this.getRoot().find('.draghome.dragplaceholder.group' + group + '.choice' + choice);
     };
 
     /**
@@ -587,7 +699,7 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
      * @returns {jQuery} jQuery wrapping the unplaced choice. If there isn't one, the jQuery will be empty.
      */
     DragDropOntoImageQuestion.prototype.getUnplacedChoice = function(group, choice) {
-        return this.getRoot().find('.ddarea .drag.group' + group + '.choice' + choice + '.unplaced').slice(0, 1);
+        return this.getRoot().find('.ddarea .draghome.group' + group + '.choice' + choice + '.unplaced').slice(0, 1);
     };
 
     /**
@@ -597,7 +709,7 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
      * @return {jQuery} the current drag (or an empty jQuery if none).
      */
     DragDropOntoImageQuestion.prototype.getCurrentDragInPlace = function(place) {
-        return this.getRoot().find('.ddarea .drag.inplace' + place);
+        return this.getRoot().find('.ddarea .draghome.inplace' + place);
     };
 
     /**
@@ -674,6 +786,134 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
         return this.getClassnameNumericSuffix(node, 'place');
     };
 
+    /**
+     * Get drag clone for a given drag.
+     *
+     * @param {jQuery} drag the drag.
+     * @returns {jQuery} the drag's clone.
+     */
+    DragDropOntoImageQuestion.prototype.getDragClone = function(drag) {
+        return this.getRoot().find('.dragitemgroup' +
+            this.getGroup(drag) +
+            ' .draghome' +
+            '.choice' + this.getChoice(drag) +
+            '.group' + this.getGroup(drag) +
+            '.dragplaceholder');
+    };
+
+    /**
+     * Get infinite drag clones for given drag.
+     *
+     * @param {jQuery} drag the drag.
+     * @param {Boolean} inHome in the home area or not.
+     * @returns {jQuery} the drag's clones.
+     */
+    DragDropOntoImageQuestion.prototype.getInfiniteDragClones = function(drag, inHome) {
+        if (inHome) {
+            return this.getRoot().find('.dragitemgroup' +
+                this.getGroup(drag) +
+                ' .draghome' +
+                '.choice' + this.getChoice(drag) +
+                '.group' + this.getGroup(drag) +
+                '.infinite').not('.dragplaceholder');
+        }
+        return this.getRoot().find('.draghome' +
+            '.choice' + this.getChoice(drag) +
+            '.group' + this.getGroup(drag) +
+            '.infinite').not('.dragplaceholder');
+    };
+
+    /**
+     * Get drop for a given drag and place.
+     *
+     * @param {jQuery} drag the drag.
+     * @param {Integer} currentPlace the current place of drag.
+     * @returns {jQuery} the drop's clone.
+     */
+    DragDropOntoImageQuestion.prototype.getDrop = function(drag, currentPlace) {
+        return this.getRoot().find('.dropzone.group' + this.getGroup(drag) + '.place' + currentPlace);
+    };
+
+    /**
+     * Handle when the window is resized.
+     */
+    DragDropOntoImageQuestion.prototype.handleResize = function() {
+        var thisQ = this,
+            bgRatio = this.bgRatio();
+        if (this.isPrinting) {
+            bgRatio = 1;
+        }
+
+        this.getRoot().find('.ddarea .dropzone').each(function(i, dropNode) {
+            $(dropNode)
+                .css('left', parseInt($(dropNode).data('originX')) * parseFloat(bgRatio))
+                .css('top', parseInt($(dropNode).data('originY')) * parseFloat(bgRatio));
+            thisQ.handleElementScale(dropNode, 'left top');
+        });
+
+        this.getRoot().find('div.droparea .draghome').not('.beingdragged').each(function(key, drag) {
+            $(drag)
+                .css('left', parseFloat($(drag).data('originX')) * parseFloat(bgRatio))
+                .css('top', parseFloat($(drag).data('originY')) * parseFloat(bgRatio));
+            thisQ.handleElementScale(drag, 'left top');
+        });
+    };
+
+    /**
+     * Return the background ratio.
+     *
+     * @returns {number} Background ratio.
+     */
+    DragDropOntoImageQuestion.prototype.bgRatio = function() {
+        var bgImg = this.bgImage();
+        var bgImgNaturalWidth = bgImg.get(0).naturalWidth;
+        var bgImgClientWidth = bgImg.width();
+
+        return bgImgClientWidth / bgImgNaturalWidth;
+    };
+
+    /**
+     * Scale the drag if needed.
+     *
+     * @param {jQuery} element the item to place.
+     * @param {String} type scaling type
+     */
+    DragDropOntoImageQuestion.prototype.handleElementScale = function(element, type) {
+        var bgRatio = parseFloat(this.bgRatio());
+        if (this.isPrinting) {
+            bgRatio = 1;
+        }
+        $(element).css({
+            '-webkit-transform': 'scale(' + bgRatio + ')',
+            '-moz-transform': 'scale(' + bgRatio + ')',
+            '-ms-transform': 'scale(' + bgRatio + ')',
+            '-o-transform': 'scale(' + bgRatio + ')',
+            'transform': 'scale(' + bgRatio + ')',
+            'transform-origin': type
+        });
+    };
+
+    /**
+     * Calculate z-index value.
+     *
+     * @returns {number} z-index value
+     */
+    DragDropOntoImageQuestion.prototype.calculateZIndex = function() {
+        var zIndex = 0;
+        this.getRoot().find('.ddarea .dropzone, div.droparea .draghome').each(function(i, dropNode) {
+            dropNode = $(dropNode);
+            // Note that webkit browsers won't return the z-index value from the CSS stylesheet
+            // if the element doesn't have a position specified. Instead it'll return "auto".
+            var itemZIndex = dropNode.css('z-index') ? parseInt(dropNode.css('z-index')) : 0;
+
+            if (itemZIndex > zIndex) {
+                zIndex = itemZIndex;
+            }
+        });
+
+        return zIndex;
+    };
+
     /**
      * Singleton object that handles all the DragDropOntoImageQuestions
      * on the page, and deals with event dispatching.
@@ -686,6 +926,16 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
          */
         eventHandlersInitialised: false,
 
+        /**
+         * {boolean} is printing or not.
+         */
+        isPrinting: false,
+
+        /**
+         * {boolean} is keyboard navigation or not.
+         */
+        isKeyboardNavigation: false,
+
         /**
          * {Object} all the questions on this page, indexed by containerId (id on the .que div).
          */
@@ -713,13 +963,29 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
         setupEventHandlers: function() {
             $('body')
                 .on('mousedown touchstart',
-                    '.que.ddimageortext:not(.qtype_ddimageortext-readonly) .dragitems .drag',
+                    '.que.ddimageortext:not(.qtype_ddimageortext-readonly) .draghome',
                     questionManager.handleDragStart)
                 .on('keydown',
                     '.que.ddimageortext:not(.qtype_ddimageortext-readonly) .dropzones .dropzone',
-                    questionManager.handleKeyPress);
-            $(window).on('resize', questionManager.handleWindowResize);
-            setTimeout(questionManager.fixLayoutIfThingsMoved, 100);
+                    questionManager.handleKeyPress)
+                .on('keydown',
+                    '.que.ddimageortext:not(.qtype_ddimageortext-readonly) .draghome.placed:not(.beingdragged)',
+                    questionManager.handleKeyPress)
+                .on('dragmoved', questionManager.handleDragMoved);
+            $(window).on('resize', function() {
+                questionManager.handleWindowResize(false);
+            });
+            window.addEventListener('beforeprint', function() {
+                questionManager.isPrinting = true;
+                questionManager.handleWindowResize(questionManager.isPrinting);
+            });
+            window.addEventListener('afterprint', function() {
+                questionManager.isPrinting = false;
+                questionManager.handleWindowResize(questionManager.isPrinting);
+            });
+            setTimeout(function() {
+                questionManager.fixLayoutIfThingsMoved();
+            }, 100);
         },
 
         /**
@@ -739,6 +1005,10 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
          * @param {KeyboardEvent} e
          */
         handleKeyPress: function(e) {
+            if (questionManager.isKeyboardNavigation) {
+                return;
+            }
+            questionManager.isKeyboardNavigation = true;
             var question = questionManager.getQuestionForEvent(e);
             if (question) {
                 question.handleKeyPress(e);
@@ -747,11 +1017,13 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
 
         /**
          * Handle when the window is resized.
+         * @param {boolean} isPrinting
          */
-        handleWindowResize: function() {
+        handleWindowResize: function(isPrinting) {
             for (var containerId in questionManager.questions) {
                 if (questionManager.questions.hasOwnProperty(containerId)) {
-                    questionManager.questions[containerId].positionDragsAndDrops();
+                    questionManager.questions[containerId].isPrinting = isPrinting;
+                    questionManager.questions[containerId].handleResize();
                 }
             }
         },
@@ -762,16 +1034,52 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
          * Therefore, we need to periodically check everything is in the right position.
          */
         fixLayoutIfThingsMoved: function() {
-            for (var containerId in questionManager.questions) {
-                if (questionManager.questions.hasOwnProperty(containerId)) {
-                    questionManager.questions[containerId].fixLayoutIfBackgroundMoved();
-                }
-            }
-
+            this.handleWindowResize(questionManager.isPrinting);
             // We use setTimeout after finishing work, rather than setInterval,
             // in case positioning things is slow. We want 100 ms gap
             // between executions, not what setInterval does.
-            setTimeout(questionManager.fixLayoutIfThingsMoved, 100);
+            setTimeout(function() {
+                questionManager.fixLayoutIfThingsMoved(questionManager.isPrinting);
+            }, 100);
+        },
+
+        /**
+         * Handle when drag moved.
+         *
+         * @param {Event} e the event.
+         * @param {jQuery} drag the drag
+         * @param {jQuery} target the target
+         * @param {DragDropOntoImageQuestion} thisQ the question.
+         */
+        handleDragMoved: function(e, drag, target, thisQ) {
+            drag.removeClass('beingdragged').css('z-index', '');
+            drag.css('top', target.position().top).css('left', target.position().left);
+            target.after(drag);
+            target.removeClass('active');
+            if (typeof drag.data('unplaced') !== 'undefined' && drag.data('unplaced') === true) {
+                drag.removeClass('placed').addClass('unplaced');
+                drag.removeAttr('tabindex');
+                drag.removeData('unplaced');
+                drag.css('top', '')
+                    .css('left', '')
+                    .css('transform', '');
+                if (drag.hasClass('infinite') && thisQ.getInfiniteDragClones(drag, true).length > 1) {
+                    thisQ.getInfiniteDragClones(drag, true).first().remove();
+                }
+            } else {
+                drag.data('originX', target.data('originX')).data('originY', target.data('originY'));
+                thisQ.handleElementScale(drag, 'left top');
+            }
+            if (typeof drag.data('isfocus') !== 'undefined' && drag.data('isfocus') === true) {
+                drag.focus();
+                drag.removeData('isfocus');
+            }
+            if (typeof target.data('isfocus') !== 'undefined' && target.data('isfocus') === true) {
+                target.removeData('isfocus');
+            }
+            if (questionManager.isKeyboardNavigation) {
+                questionManager.isKeyboardNavigation = false;
+            }
         },
 
         /**
index b564f44..9b9f1d8 100644 (file)
@@ -108,17 +108,7 @@ class qtype_ddimageortext_edit_form extends qtype_ddtoimage_edit_form_base {
 
     public function js_call() {
         global $PAGE;
-
-        $maxbgimagesize = [
-                'width' => QTYPE_DDIMAGEORTEXT_BGIMAGE_MAXWIDTH,
-                'height' => QTYPE_DDIMAGEORTEXT_BGIMAGE_MAXHEIGHT
-        ];
-        $maxdragimagesize = [
-                'width' => QTYPE_DDIMAGEORTEXT_DRAGIMAGE_MAXWIDTH,
-                'height' => QTYPE_DDIMAGEORTEXT_DRAGIMAGE_MAXHEIGHT
-        ];
-        $PAGE->requires->js_call_amd('qtype_ddimageortext/form', 'init',
-                [$maxbgimagesize, $maxdragimagesize]);
+        $PAGE->requires->js_call_amd('qtype_ddimageortext/form', 'init');
     }
 
     // Drag items.
index 05fd85d..7be493f 100644 (file)
@@ -27,11 +27,6 @@ defined('MOODLE_INTERNAL') || die();
 
 require_once($CFG->dirroot . '/question/type/ddimageortext/questiontypebase.php');
 
-define('QTYPE_DDIMAGEORTEXT_BGIMAGE_MAXWIDTH', 600);
-define('QTYPE_DDIMAGEORTEXT_BGIMAGE_MAXHEIGHT', 400);
-define('QTYPE_DDIMAGEORTEXT_DRAGIMAGE_MAXWIDTH', 150);
-define('QTYPE_DDIMAGEORTEXT_DRAGIMAGE_MAXHEIGHT', 100);
-
 /**
  * The drag-and-drop onto image question type class.
  *
@@ -114,9 +109,6 @@ class qtype_ddimageortext extends qtype_ddtoimage_base {
                 }
 
                 if ($formdata->drags[$dragno]['dragitemtype'] == 'image') {
-                    self::constrain_image_size_in_draft_area($draftitemid,
-                                        QTYPE_DDIMAGEORTEXT_DRAGIMAGE_MAXWIDTH,
-                                        QTYPE_DDIMAGEORTEXT_DRAGIMAGE_MAXHEIGHT);
                     file_save_draft_area_files($draftitemid, $formdata->context->id,
                                         'qtype_ddimageortext', 'dragimage', $drag->id,
                                         array('subdirs' => 0, 'maxbytes' => 0, 'maxfiles' => 1));
@@ -135,10 +127,6 @@ class qtype_ddimageortext extends qtype_ddtoimage_base {
             list($sql, $params) = $DB->get_in_or_equal(array_values($olddragids));
             $DB->delete_records_select('qtype_ddimageortext_drags', "id $sql", $params);
         }
-
-        self::constrain_image_size_in_draft_area($formdata->bgimage,
-                                                    QTYPE_DDIMAGEORTEXT_BGIMAGE_MAXWIDTH,
-                                                    QTYPE_DDIMAGEORTEXT_BGIMAGE_MAXHEIGHT);
         file_save_draft_area_files($formdata->bgimage, $formdata->context->id,
                                     'qtype_ddimageortext', 'bgimage', $formdata->id,
                                     array('subdirs' => 0, 'maxbytes' => 0, 'maxfiles' => 1));
index b9e1a6b..947e239 100644 (file)
@@ -102,50 +102,6 @@ class qtype_ddtoimage_base extends question_type {
         }
     }
 
-    public static function constrain_image_size_in_draft_area($draftitemid, $maxwidth, $maxheight) {
-        global $USER;
-        $usercontext = context_user::instance($USER->id);
-        $fs = get_file_storage();
-        $draftfiles = $fs->get_area_files($usercontext->id, 'user', 'draft', $draftitemid, 'id');
-        if ($draftfiles) {
-            foreach ($draftfiles as $file) {
-                if ($file->is_directory()) {
-                    continue;
-                }
-                $imageinfo = $file->get_imageinfo();
-                $width    = $imageinfo['width'];
-                $height   = $imageinfo['height'];
-                $mimetype = $imageinfo['mimetype'];
-                switch ($mimetype) {
-                    case 'image/jpeg' :
-                        $quality = 80;
-                        break;
-                    case 'image/png' :
-                        $quality = 8;
-                        break;
-                    default :
-                        $quality = null;
-                }
-                $newwidth = min($maxwidth, $width);
-                $newheight = min($maxheight, $height);
-                if ($newwidth != $width || $newheight != $height) {
-                    $newimagefilename = $file->get_filename();
-                    $newimagefilename =
-                        preg_replace('!\.!', "_{$newwidth}x{$newheight}.", $newimagefilename, 1);
-                    $newrecord = new stdClass();
-                    $newrecord->contextid = $usercontext->id;
-                    $newrecord->component = 'user';
-                    $newrecord->filearea  = 'draft';
-                    $newrecord->itemid    = $draftitemid;
-                    $newrecord->filepath  = '/';
-                    $newrecord->filename  = $newimagefilename;
-                    $fs->convert_image($newrecord, $file, $newwidth, $newheight, true, $quality);
-                    $file->delete();
-                }
-            }
-        }
-    }
-
     /**
      * Convert files into text output in the given format.
      * This method is copied from qformat_default as a quick fix, as the method there is
index bdc15b4..c7d348d 100644 (file)
@@ -58,16 +58,23 @@ class qtype_ddtoimage_renderer_base extends qtype_with_combined_feedback_rendere
 
         $questiontext = $question->format_questiontext($qa);
 
-        $output = html_writer::tag('div', $questiontext, array('class' => 'qtext'));
+        $dropareaclass = 'droparea';
+        $draghomesclass = 'draghomes';
+        if ($options->readonly) {
+            $dropareaclass .= ' readonly';
+            $draghomesclass .= ' readonly';
+        }
 
-        $bgimage = self::get_url_for_image($qa, 'bgimage');
+        $output = html_writer::div($questiontext, 'qtext');
 
-        $img = html_writer::empty_tag('img', array(
-                'src' => $bgimage, 'class' => 'dropbackground',
-                'alt' => get_string('dropbackground', 'qtype_ddimageortext')));
-        $dropzones = html_writer::tag('div', '', array('class' => 'dropzones'));
+        $output .= html_writer::start_div('ddarea');
+        $output .= html_writer::start_div($dropareaclass);
+        $output .= html_writer::img(self::get_url_for_image($qa, 'bgimage'), get_string('dropbackground', 'qtype_ddmarker'),
+                ['class' => 'dropbackground img-responsive img-fluid']);
 
-        $droparea = html_writer::tag('div', $img . $dropzones, array('class' => 'droparea'));
+        $output .= html_writer::div('', 'dropzones');
+        $output .= html_writer::end_div();
+        $output .= html_writer::start_div($draghomesclass);
 
         $dragimagehomes = '';
         foreach ($question->choices as $groupno => $group) {
@@ -75,51 +82,42 @@ class qtype_ddtoimage_renderer_base extends qtype_with_combined_feedback_rendere
             $orderedgroup = $question->get_ordered_choices($groupno);
             foreach ($orderedgroup as $choiceno => $dragimage) {
                 $dragimageurl = self::get_url_for_image($qa, 'dragimage', $dragimage->id);
-                $classes = array("group{$groupno}",
-                                 'draghome',
-                                 "choice{$choiceno}");
+                $classes = [
+                        'group' . $groupno,
+                        'draghome',
+                        'choice' . $choiceno
+                ];
                 if ($dragimage->infinite) {
                     $classes[] = 'infinite';
                 }
                 if ($dragimageurl === null) {
-                    $dragimagehomesgroup .= html_writer::tag('div', $dragimage->text,
-                            array('src' => $dragimageurl, 'class' => join(' ', $classes)));
+                    $dragimagehomesgroup .= html_writer::div($dragimage->text, join(' ', $classes), ['src' => $dragimageurl]);
                 } else {
-                    $dragimagehomesgroup .= html_writer::empty_tag('img',
-                            array('src' => $dragimageurl, 'alt' => $dragimage->text,
-                                    'class' => join(' ', $classes)));
+                    $dragimagehomesgroup .= html_writer::img($dragimageurl, $dragimage->text, ['class' => join(' ', $classes)]);
                 }
             }
-            $dragimagehomes .= html_writer::tag('div', $dragimagehomesgroup,
-                    array('class' => 'dragitemgroup' . $groupno));
+            $dragimagehomes .= html_writer::div($dragimagehomesgroup, 'dragitemgroup' . $groupno);
         }
 
-        $draghomes = html_writer::tag('div', $dragimagehomes, array('class' => 'draghomes'));
-        $dragitemsclass = 'dragitems';
-        if ($options->readonly) {
-            $dragitemsclass .= ' readonly';
-        }
-        $dragitems = html_writer::tag('div', '', array('class' => $dragitemsclass));
+        $output .= $dragimagehomes;
+        $output .= html_writer::end_div();
 
-        $hiddens = '';
         foreach ($question->places as $placeno => $place) {
             $varname = $question->field($placeno);
             list($fieldname, $html) = $this->hidden_field_for_qt_var($qa, $varname, null,
                     ['placeinput', 'place' . $placeno, 'group' . $place->group]);
-            $hiddens .= $html;
+            $output .= $html;
             $question->places[$placeno]->fieldname = $fieldname;
         }
-        $output .= html_writer::tag('div',
-                $droparea . $draghomes. $dragitems . $hiddens, array('class' => 'ddarea'));
+
+        $output .= html_writer::end_div();
 
         $this->page->requires->string_for_js('blank', 'qtype_ddimageortext');
         $this->page->requires->js_call_amd('qtype_ddimageortext/question', 'init',
                 [$qa->get_outer_question_div_unique_id(), $options->readonly, $question->places]);
 
         if ($qa->get_state() == question_state::$invalid) {
-            $output .= html_writer::nonempty_tag('div',
-                                        $question->get_validation_error($qa->get_last_qt_data()),
-                                        array('class' => 'validationerror'));
+            $output .= html_writer::div($question->get_validation_error($qa->get_last_qt_data()), 'validationerror');
         }
         return $output;
     }
index 8f5b95f..3b93673 100644 (file)
@@ -13,22 +13,51 @@ form.mform fieldset#id_previewareaheader div.ddarea {
     position: relative;
 }
 
+.que.ddimageortext div.droparea {
+    display: inline-block;
+}
+
+.que.ddimageortext div.droparea .draghome {
+    position: absolute;
+    cursor: move;
+    white-space: nowrap;
+}
+
+.que.ddimageortext div.droparea .dropzones {
+    position: absolute;
+    top: 0;
+    left: 0;
+}
+
 .que.ddimageortext .dropbackground,
 form.mform fieldset#id_previewareaheader .dropbackground {
     border: 1px solid #000;
-    max-width: none;
     margin: 0 auto;
 }
 
+form.mform fieldset#id_previewareaheader .dropbackground {
+    max-width: none;
+}
+
+.que.ddimageortext .dropbackground.img-responsive.img-fluid {
+    width: 100%;
+}
+
 .que.ddimageortext .dropzone {
+    display: none;
     position: absolute;
     opacity: 0.5;
     border: 1px solid black;
-    z-index: 1;
+}
+
+.que.ddimageortext .dropzone.active {
+    display: block;
 }
 
 .que.ddimageortext .dropzone:focus,
-.que.ddimageortext .dropzone.valid-drag-over-drop {
+.que.ddimageortext .droparea .draghome:focus,
+.que.ddimageortext .dropzone.valid-drag-over-drop,
+.que.ddimageortext .draghome.placed.valid-drag-over-drop {
     border-color: #0a0;
     box-shadow: 0 0 5px 5px rgba(255, 255, 150, 1);
     outline: 0;
@@ -42,12 +71,26 @@ form.mform fieldset#id_previewareaheader .droppreview {
     font: 13px/1.231 arial, helvetica, clean, sans-serif;
 }
 
-.que.ddimageortext .draghome {
+.que.ddimageortext .draghomes .draghome {
     vertical-align: top;
     margin: 5px;
-    visibility: hidden;
     height: auto;
     width: auto;
+    cursor: move;
+}
+
+.que.ddimageortext .draghomes.readonly .draghome,
+.que.ddimageortext .droparea.readonly .draghome {
+    cursor: auto;
+}
+
+.que.ddimageortext .draghomes .draghome.dragplaceholder {
+    display: none;
+}
+
+.que.ddimageortext .draghomes .draghome.dragplaceholder.active {
+    visibility: hidden;
+    display: inline-block;
 }
 
 .que.ddimageortext .dragitems,
@@ -59,7 +102,6 @@ form.mform fieldset#id_previewareaheader .dragitems {
 form.mform fieldset#id_previewareaheader .droppreview {
     position: absolute;
     cursor: move;
-    z-index: 2;
 }
 
 .que.ddimageortext .dragitems.readonly .drag {
@@ -67,11 +109,17 @@ form.mform fieldset#id_previewareaheader .droppreview {
 }