Merge branch 'MDL-69520-master' of git://github.com/sarjona/moodle
authorJun Pataleta <jun@moodle.com>
Thu, 15 Oct 2020 05:53:26 +0000 (13:53 +0800)
committerJun Pataleta <jun@moodle.com>
Thu, 15 Oct 2020 05:53:26 +0000 (13:53 +0800)
43 files changed:
admin/tool/usertours/amd/build/filter_cssselector.min.js [new file with mode: 0644]
admin/tool/usertours/amd/build/filter_cssselector.min.js.map [new file with mode: 0644]
admin/tool/usertours/amd/build/usertours.min.js
admin/tool/usertours/amd/build/usertours.min.js.map
admin/tool/usertours/amd/src/filter_cssselector.js [new file with mode: 0644]
admin/tool/usertours/amd/src/usertours.js
admin/tool/usertours/classes/external/tour.php
admin/tool/usertours/classes/helper.php
admin/tool/usertours/classes/local/clientside_filter/clientside_filter.php [new file with mode: 0644]
admin/tool/usertours/classes/local/clientside_filter/cssselector.php [new file with mode: 0644]
admin/tool/usertours/classes/manager.php
admin/tool/usertours/classes/tour.php
admin/tool/usertours/lang/en/tool_usertours.php
admin/tool/usertours/tests/behat/tour_filter.feature
admin/tool/usertours/tests/manager_test.php
admin/tool/usertours/version.php
cache/classes/administration_helper.php
cache/tests/administration_helper_test.php
h5p/tests/framework_test.php
lang/en/error.php
lib/upgrade.txt
lib/weblib.php
message/classes/api.php
message/externallib.php
message/output/popup/db/upgrade.php
mod/forum/externallib.php
mod/forum/tests/externallib_test.php
mod/forum/upgrade.txt
mod/glossary/classes/external.php
mod/glossary/classes/external/delete_entry.php [new file with mode: 0644]
mod/glossary/classes/external/prepare_entry.php [new file with mode: 0644]
mod/glossary/classes/external/update_entry.php [new file with mode: 0644]
mod/glossary/db/services.php
mod/glossary/deleteentry.php
mod/glossary/edit.php
mod/glossary/lib.php
mod/glossary/tests/external/delete_entry.php [new file with mode: 0644]
mod/glossary/tests/external/prepare_entry.php [new file with mode: 0644]
mod/glossary/tests/external/update_entry.php [new file with mode: 0644]
mod/glossary/tests/external_test.php
mod/glossary/tests/lib_test.php
mod/glossary/upgrade.txt
mod/glossary/version.php

diff --git a/admin/tool/usertours/amd/build/filter_cssselector.min.js b/admin/tool/usertours/amd/build/filter_cssselector.min.js
new file mode 100644 (file)
index 0000000..6dbb873
Binary files /dev/null and b/admin/tool/usertours/amd/build/filter_cssselector.min.js differ
diff --git a/admin/tool/usertours/amd/build/filter_cssselector.min.js.map b/admin/tool/usertours/amd/build/filter_cssselector.min.js.map
new file mode 100644 (file)
index 0000000..9ea27ca
Binary files /dev/null and b/admin/tool/usertours/amd/build/filter_cssselector.min.js.map differ
index 6b16a51..8ebe59f 100644 (file)
Binary files a/admin/tool/usertours/amd/build/usertours.min.js and b/admin/tool/usertours/amd/build/usertours.min.js differ
index 9f614ad..c5cca21 100644 (file)
Binary files a/admin/tool/usertours/amd/build/usertours.min.js.map and b/admin/tool/usertours/amd/build/usertours.min.js.map differ
diff --git a/admin/tool/usertours/amd/src/filter_cssselector.js b/admin/tool/usertours/amd/src/filter_cssselector.js
new file mode 100644 (file)
index 0000000..06e825c
--- /dev/null
@@ -0,0 +1,39 @@
+// 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/>.
+
+/**
+ * CSS selector client side filter.
+ *
+ * @module     tool_usertours/filter_cssselector
+ * @class      filter_cssselector
+ * @package    tool_usertours
+ * @copyright 2020 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/**
+ * Checks whether the configured CSS selector exists on this page.
+ *
+ * @param {array} tourConfig  The tour configuration.
+ * @returns {boolean}
+ */
+export const filterMatches = function(tourConfig) {
+    let filterValues = tourConfig.filtervalues.cssselector;
+    if (filterValues[0]) {
+        return !!document.querySelector(filterValues[0]);
+    }
+    // If there is no CSS selector configured, this page matches.
+    return true;
+};
index 4bb1050..a79f7f2 100644 (file)
@@ -14,36 +14,62 @@ function(ajax, BootstrapTour, $, templates, str, log, notification) {
 
         currentTour: null,
 
-        context: null,
-
         /**
          * Initialise the user tour for the current page.
          *
          * @method  init
-         * @param   {Number}    tourId      The ID of the tour to start.
-         * @param   {Bool}      startTour   Attempt to start the tour now.
-         * @param   {Number}    context     The context of the current page.
+         * @param   {Array}    tourDetails      The matching tours for this page.
+         * @param   {Array}    filters          The names of all client side filters.
          */
-        init: function(tourId, startTour, context) {
-            // Only one tour per page is allowed.
-            usertours.tourId = tourId;
+        init: function(tourDetails, filters) {
+            let requirements = [];
+            for (var req = 0; req < filters.length; req++) {
+                requirements[req] = 'tool_usertours/filter_' + filters[req];
+            }
+            require(requirements, function() {
+                // Run the client side filters to find the first matching tour.
+                let matchingTour = null;
+                for (let key in tourDetails) {
+                    let tour = tourDetails[key];
+                    for (let i = 0; i < filters.length; i++) {
+                        let filter = arguments[i];
+                        if (filter.filterMatches(tour)) {
+                            matchingTour = tour;
+                        } else {
+                            // If any filter doesn't match, move on to the next tour.
+                            matchingTour = null;
+                            break;
+                        }
+                    }
+                    // If all filters matched then use this tour.
+                    if (matchingTour) {
+                        break;
+                    }
+                }
 
-            usertours.context = context;
+                if (matchingTour === null) {
+                    return;
+                }
 
-            if (typeof startTour === 'undefined') {
-                startTour = true;
-            }
+                // Only one tour per page is allowed.
+                usertours.tourId = matchingTour.tourId;
 
-            if (startTour) {
-                // Fetch the tour configuration.
-                usertours.fetchTour(tourId);
-            }
+                let startTour = matchingTour.startTour;
+                if (typeof startTour === 'undefined') {
+                    startTour = true;
+                }
+
+                if (startTour) {
+                    // Fetch the tour configuration.
+                    usertours.fetchTour(usertours.tourId);
+                }
 
-            usertours.addResetLink();
-            // Watch for the reset link.
-            $('body').on('click', '[data-action="tool_usertours/resetpagetour"]', function(e) {
-                e.preventDefault();
-                usertours.resetTourState(usertours.tourId);
+                usertours.addResetLink();
+                // Watch for the reset link.
+                $('body').on('click', '[data-action="tool_usertours/resetpagetour"]', function(e) {
+                    e.preventDefault();
+                    usertours.resetTourState(usertours.tourId);
+                });
             });
         },
 
@@ -61,7 +87,7 @@ function(ajax, BootstrapTour, $, templates, str, log, notification) {
                         methodname: 'tool_usertours_fetch_and_start_tour',
                         args: {
                             tourid:     tourId,
-                            context:    usertours.context,
+                            context:    M.cfg.contextid,
                             pageurl:    window.location.href,
                         }
                     }
@@ -186,7 +212,7 @@ function(ajax, BootstrapTour, $, templates, str, log, notification) {
                         methodname: 'tool_usertours_step_shown',
                         args: {
                             tourid:     usertours.tourId,
-                            context:    usertours.context,
+                            context:    M.cfg.contextid,
                             pageurl:    window.location.href,
                             stepid:     stepConfig.stepid,
                             stepindex:  this.getCurrentStepNumber(),
@@ -209,7 +235,7 @@ function(ajax, BootstrapTour, $, templates, str, log, notification) {
                         methodname: 'tool_usertours_complete_tour',
                         args: {
                             tourid:     usertours.tourId,
-                            context:    usertours.context,
+                            context:    M.cfg.contextid,
                             pageurl:    window.location.href,
                             stepid:     stepConfig.stepid,
                             stepindex:  this.getCurrentStepNumber(),
@@ -232,7 +258,7 @@ function(ajax, BootstrapTour, $, templates, str, log, notification) {
                         methodname: 'tool_usertours_reset_tour',
                         args: {
                             tourid:     tourId,
-                            context:    usertours.context,
+                            context:    M.cfg.contextid,
                             pageurl:    window.location.href,
                         }
                     }
index c16c5a0..7ec9cb8 100644 (file)
@@ -131,8 +131,9 @@ class tour extends external_api {
 
         $result = [];
 
-        if ($tourinstance = \tool_usertours\manager::get_matching_tours(new \moodle_url($params['pageurl']))) {
-            if ($tour->get_id() === $tourinstance->get_id()) {
+        $matchingtours = \tool_usertours\manager::get_matching_tours(new \moodle_url($params['pageurl']));
+        foreach ($matchingtours as $match) {
+            if ($tour->get_id() === $match->get_id()) {
                 $result['startTour'] = $tour->get_id();
 
                 \tool_usertours\event\tour_reset::create([
@@ -142,7 +143,7 @@ class tour extends external_api {
                         'pageurl'   => $params['pageurl'],
                     ],
                 ])->trigger();
-
+                break;
             }
         }
 
index df04ed9..f1f8e4b 100644 (file)
@@ -24,6 +24,8 @@
 
 namespace tool_usertours;
 
+use tool_usertours\local\clientside_filter\clientside_filter;
+
 defined('MOODLE_INTERNAL') || die();
 
 /**
@@ -523,33 +525,57 @@ class helper {
         }
         self::$bootstrapped = true;
 
-        if ($tour = manager::get_current_tour()) {
+        $tours = manager::get_current_tours();
+
+        if ($tours) {
+            $filters = static::get_all_clientside_filters();
+
+            $tourdetails = array_map(function($tour) use ($filters) {
+                return [
+                        'tourId' => $tour->get_id(),
+                        'startTour' => $tour->should_show_for_user(),
+                        'filtervalues' => $tour->get_client_filter_values($filters),
+                ];
+            }, $tours);
+
+            $filternames = [];
+            foreach ($filters as $filter) {
+                    $filternames[] = $filter::get_filter_name();
+            }
+
             $PAGE->requires->js_call_amd('tool_usertours/usertours', 'init', [
-                    $tour->get_id(),
-                    $tour->should_show_for_user(),
-                    $PAGE->context->id,
-                ]);
+                    $tourdetails,
+                    $filternames,
+            ]);
         }
     }
 
     /**
-     * Add the reset link to the current page.
+     * Get a list of all possible filters.
+     *
+     * @return  array
      */
-    public static function bootstrap_reset() {
-        if (manager::get_current_tour()) {
-            echo \html_writer::link('', get_string('resettouronpage', 'tool_usertours'), [
-                    'data-action'   => 'tool_usertours/resetpagetour',
-                ]);
-        }
+    public static function get_all_filters() {
+        $filters = \core_component::get_component_classes_in_namespace('tool_usertours', 'local\filter');
+        $filters = array_keys($filters);
+
+        $filters = array_filter($filters, function($filterclass) {
+            $rc = new \ReflectionClass($filterclass);
+            return $rc->isInstantiable();
+        });
+
+        $filters = array_merge($filters, static::get_all_clientside_filters());
+
+        return $filters;
     }
 
     /**
-     * Get a list of all possible filters.
+     * Get a list of all clientside filters.
      *
      * @return  array
      */
-    public static function get_all_filters() {
-        $filters = \core_component::get_component_classes_in_namespace('tool_usertours', 'local\filter');
+    public static function get_all_clientside_filters() {
+        $filters = \core_component::get_component_classes_in_namespace('tool_usertours', 'local\clientside_filter');
         $filters = array_keys($filters);
 
         $filters = array_filter($filters, function($filterclass) {
diff --git a/admin/tool/usertours/classes/local/clientside_filter/clientside_filter.php b/admin/tool/usertours/classes/local/clientside_filter/clientside_filter.php
new file mode 100644 (file)
index 0000000..6fa403a
--- /dev/null
@@ -0,0 +1,55 @@
+<?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/>.
+
+/**
+ * Clientside filter base.
+ *
+ * @package    tool_usertours
+ * @copyright  2020 The Open University
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_usertours\local\clientside_filter;
+
+defined('MOODLE_INTERNAL') || die();
+
+use stdClass;
+use tool_usertours\local\filter\base;
+use tool_usertours\tour;
+
+/**
+ * Clientside filter base.
+ *
+ * @copyright  2020 The Open University
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+abstract class clientside_filter extends base {
+    /**
+     * Returns the filter values needed for client side filtering.
+     *
+     * @param   tour            $tour       The tour to find the filter values for
+     * @return  stdClass
+     */
+    public static function get_client_side_values(tour $tour): stdClass {
+        $data = (object) [];
+
+        if (is_a(static::class, clientside_filter::class, true)) {
+            $data->filterdata = $tour->get_filter_values(static::get_filter_name());
+        }
+
+        return $data;
+    }
+}
\ No newline at end of file
diff --git a/admin/tool/usertours/classes/local/clientside_filter/cssselector.php b/admin/tool/usertours/classes/local/clientside_filter/cssselector.php
new file mode 100644 (file)
index 0000000..e6d6c2d
--- /dev/null
@@ -0,0 +1,115 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Selector filter.
+ *
+ * @package    tool_usertours
+ * @copyright  2020 The Open University
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace tool_usertours\local\clientside_filter;
+
+use stdClass;
+use tool_usertours\tour;
+
+/**
+ * Course filter.
+ *
+ * @copyright  2020 The Open University
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class cssselector extends clientside_filter {
+    /**
+     * The name of the filter.
+     *
+     * @return  string
+     */
+    public static function get_filter_name() {
+        return 'cssselector';
+    }
+
+    /**
+     * Overrides the base add form element with a selector text box.
+     *
+     * @param \MoodleQuickForm $mform
+     */
+    public static function add_filter_to_form(\MoodleQuickForm &$mform) {
+        $filtername = self::get_filter_name();
+        $key = "filter_{$filtername}";
+
+        $mform->addElement('text', $key, get_string($key, 'tool_usertours'));
+        $mform->setType($key, PARAM_RAW);
+        $mform->addHelpButton($key, $key, 'tool_usertours');
+    }
+
+    /**
+     * Prepare the filter values for the form.
+     *
+     * @param   tour            $tour       The tour to prepare values from
+     * @param   stdClass        $data       The data value
+     * @return  stdClass
+     */
+    public static function prepare_filter_values_for_form(tour $tour, \stdClass $data) {
+        $filtername = static::get_filter_name();
+
+        $key = "filter_{$filtername}";
+        $values = $tour->get_filter_values($filtername);
+        if (empty($values)) {
+            $values = [""];
+        }
+        $data->$key = $values[0];
+
+        return $data;
+    }
+
+    /**
+     * Save the filter values from the form to the tour.
+     *
+     * @param   tour            $tour       The tour to save values to
+     * @param   stdClass        $data       The data submitted in the form
+     */
+    public static function save_filter_values_from_form(tour $tour, \stdClass $data) {
+        $filtername = static::get_filter_name();
+
+        $key = "filter_{$filtername}";
+
+        $newvalue = [$data->$key];
+        if (empty($data->$key)) {
+            $newvalue = [];
+        }
+
+        $tour->set_filter_values($filtername, $newvalue);
+    }
+
+    /**
+     * Returns the filter values needed for client side filtering.
+     *
+     * @param   tour            $tour       The tour to find the filter values for
+     * @return  stdClass
+     */
+    public static function get_client_side_values(tour $tour): stdClass {
+        $filtername = static::get_filter_name();
+        $filtervalues = $tour->get_filter_values($filtername);
+
+        // Filter values might not exist for tours that were created before this filter existed.
+        if (!$filtervalues) {
+            return new stdClass;
+        }
+
+        return (object) $filtervalues;
+    }
+}
index 6741568..437b416 100644 (file)
@@ -608,42 +608,44 @@ class manager {
     }
 
     /**
-     * Get the first tour matching the current page URL.
+     * Get all tours for the current page URL.
      *
-     * @param   bool        $reset      Forcibly update the current tour
-     * @return  tour
+     * @param   bool        $reset      Forcibly update the current tours
+     * @return  array
      */
-    public static function get_current_tour($reset = false) {
+    public static function get_current_tours($reset = false): array {
         global $PAGE;
 
-        static $tour = false;
+        static $tours = false;
 
-        if ($tour === false || $reset) {
-            $tour = self::get_matching_tours($PAGE->url);
+        if ($tours === false || $reset) {
+            $tours = self::get_matching_tours($PAGE->url);
         }
 
-        return $tour;
+        return $tours;
     }
 
     /**
-     * Get the first tour matching the specified URL.
+     * Get all tours matching the specified URL.
      *
      * @param   moodle_url  $pageurl        The URL to match.
-     * @return  tour
+     * @return  array
      */
-    public static function get_matching_tours(\moodle_url $pageurl) {
+    public static function get_matching_tours(\moodle_url $pageurl): array {
         global $PAGE;
 
         $tours = cache::get_matching_tourdata($pageurl);
 
+        $matches = [];
+        $filters = helper::get_all_filters();
         foreach ($tours as $record) {
             $tour = tour::load_from_record($record);
-            if ($tour->is_enabled() && $tour->matches_all_filters($PAGE->context)) {
-                return $tour;
+            if ($tour->is_enabled() && $tour->matches_all_filters($PAGE->context, $filters)) {
+                $matches[] = $tour;
             }
         }
 
-        return null;
+        return $matches;
     }
 
     /**
index 0765ee3..08e8279 100644 (file)
@@ -24,6 +24,8 @@
 
 namespace tool_usertours;
 
+use tool_usertours\local\clientside_filter\clientside_filter;
+
 defined('MOODLE_INTERNAL') || die();
 
 /**
@@ -769,11 +771,14 @@ class tour {
     /**
      * Check whether this tour matches all filters.
      *
-     * @param   context     $context    The context to check
+     * @param   \context     $context    The context to check.
+     * @param   array|null   $filters    Optional array of filters.
      * @return  bool
      */
-    public function matches_all_filters(\context $context) {
-        $filters = helper::get_all_filters();
+    public function matches_all_filters(\context $context, array $filters = null): bool {
+        if (!$filters) {
+            $filters = helper::get_all_filters();
+        }
 
         // All filters must match.
         // If any one filter fails to match, we return false.
@@ -785,4 +790,20 @@ class tour {
 
         return true;
     }
+
+    /**
+     * Gets all filter values for use in client side filters.
+     *
+     * @param   array     $filters    Array of clientside filters.
+     * @return  array
+     */
+    public function get_client_filter_values(array $filters): array {
+        $results = [];
+
+        foreach ($filters as $filter) {
+            $results[$filter::get_filter_name()] = $filter::get_client_side_values($this);
+        }
+
+        return $results;
+    }
 }
index 3d86e56..f142b46 100644 (file)
@@ -63,6 +63,8 @@ $string['filter_course'] = 'Courses';
 $string['filter_course_help'] = 'Show the tour on a page that is associated with the selected course.';
 $string['filter_courseformat'] = 'Course format';
 $string['filter_courseformat_help'] = 'Show the tour on a page that is associated with a course using the selected course format.';
+$string['filter_cssselector'] = 'CSS selector';
+$string['filter_cssselector_help'] = 'Only show the tour when the specified CSS selector is found on the page.';
 $string['filter_header'] = 'Tour filters';
 $string['filter_help'] = 'Select the conditions under which the tour will be shown. All of the filters must match for a tour to be shown to a user.';
 $string['filter_date_account_creation'] = 'User account creation date within';
index ac3a164..0fe72f4 100644 (file)
@@ -142,3 +142,88 @@ Feature: Apply tour filters to a tour
     When I am on "Course 2" course homepage
     And I wait until the page is ready
     Then I should not see "Welcome to your course tour."
+
+  @javascript
+  Scenario: Add tours with CSS selectors
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | student1 | Student   | 1        | student1@example.com |
+    Given the following "courses" exist:
+      | fullname | shortname | format | enablecompletion |
+      | Course 1 | C1        | topics | 1                |
+      | Course 2 | C2        | topics | 1                |
+    And I log in as "admin"
+    And I am on "Course 1" course homepage with editing mode on
+    And I add a "Wiki" to section "1" and I fill the form with:
+      | Wiki name       | Test wiki name        |
+      | Description     | Test wiki description |
+      | First page name | First page            |
+      | Wiki mode       | Collaborative wiki    |
+    And I am on "Course 2" course homepage
+    And I add a "Forum" to section "1" and I fill the form with:
+      | Forum name  | Test forum name                |
+      | Forum type  | Standard forum for general use |
+      | Description | Test forum description         |
+    And I add a new user tour with:
+      | Name               | Wiki tour                |
+      | Description        | A tour with both matches |
+      | Apply to URL match | /course/view.php%        |
+      | Tour is enabled    | 1                        |
+      | CSS selector       | .modtype_wiki            |
+    And I add steps to the "Wiki tour" tour:
+      | targettype                | Title   | Content                  |
+      | Display in middle of page | Welcome | Welcome to the Wiki tour |
+    And I add a new user tour with:
+      | Name               | Forum tour               |
+      | Description        | A tour with both matches |
+      | Apply to URL match | /course/view.php%        |
+      | Tour is enabled    | 1                        |
+      | CSS selector       | .modtype_forum           |
+    And I add steps to the "Forum tour" tour:
+      | targettype                | Title   | Content                   |
+      | Display in middle of page | Welcome | Welcome to the Forum tour |
+    And I am on "Course 1" course homepage
+    Then I should see "Welcome to the Wiki tour"
+    And I am on "Course 2" course homepage
+    Then I should see "Welcome to the Forum tour"
+
+  @javascript
+  Scenario: Check filtering respects the sort order
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | student1 | Student   | 1        | student1@example.com |
+    And I log in as "admin"
+    And I add a new user tour with:
+      | Name               | First tour     |
+      | Description        | The first tour |
+      | Apply to URL match | /my/%          |
+      | Tour is enabled    | 1              |
+      | CSS selector       | #page-my-index |
+    And I add steps to the "First tour" tour:
+      | targettype                | Title   | Content                   |
+      | Display in middle of page | Welcome | Welcome to the First tour |
+    And I add a new user tour with:
+      | Name               | Second tour     |
+      | Description        | The second tour |
+      | Apply to URL match | /my/%           |
+      | Tour is enabled    | 0               |
+      | CSS selector       | #page-my-index  |
+    And I add steps to the "Second tour" tour:
+      | targettype                | Title   | Content                    |
+      | Display in middle of page | Welcome | Welcome to the Second tour |
+    And I add a new user tour with:
+      | Name               | Third tour     |
+      | Description        | The third tour |
+      | Apply to URL match | /my/%           |
+      | Tour is enabled    | 1               |
+      | CSS selector       | #page-my-index  |
+    And I add steps to the "Third tour" tour:
+      | targettype                | Title   | Content                   |
+      | Display in middle of page | Welcome | Welcome to the Third tour |
+    And I am on homepage
+    Then I should see "Welcome to the First tour"
+    And I open the User tour settings page
+    And I click on "Move tour down" "link" in the "The first tour" "table_row"
+    And I click on "Move tour down" "link" in the "The first tour" "table_row"
+    And I am on homepage
+    Then I should see "Welcome to the Third tour"
index 913c3e1..d5e4893 100644 (file)
@@ -222,6 +222,13 @@ class tool_usertours_manager_testcase extends advanced_testcase {
                     'description'   => '',
                     'configdata'    => '',
                 ],
+            [
+                    'pathmatch'     => '/my/%',
+                    'enabled'       => true,
+                    'name'          => 'My tour enabled 2',
+                    'description'   => '',
+                    'configdata'    => '',
+                ],
             [
                     'pathmatch'     => '/my/%',
                     'enabled'       => false,
@@ -277,32 +284,32 @@ class tool_usertours_manager_testcase extends advanced_testcase {
                 'No matches found' => [
                         $alltours,
                         $CFG->wwwroot . '/some/invalid/value',
-                        null,
+                        [],
                     ],
                 'Never return a disabled tour' => [
                         $alltours,
                         $CFG->wwwroot . '/my/index.php',
-                        'My tour enabled',
+                        ['My tour enabled', 'My tour enabled 2'],
                     ],
                 'My not course' => [
                         $alltours,
                         $CFG->wwwroot . '/my/index.php',
-                        'My tour enabled',
+                        ['My tour enabled', 'My tour enabled 2'],
                     ],
                 'My with params' => [
                         $alltours,
                         $CFG->wwwroot . '/my/index.php?id=42',
-                        'My tour enabled',
+                        ['My tour enabled', 'My tour enabled 2'],
                     ],
                 'Course with params' => [
                         $alltours,
                         $CFG->wwwroot . '/course/?id=42',
-                        'course tour enabled',
+                        ['course tour enabled'],
                     ],
                 'Course with params and trailing content' => [
                         $alltours,
                         $CFG->wwwroot . '/course/?id=42&foo=bar',
-                        'course tour with additional params enabled',
+                        ['course tour with additional params enabled', 'course tour enabled'],
                     ],
             ];
     }
@@ -311,11 +318,11 @@ class tool_usertours_manager_testcase extends advanced_testcase {
      * Tests for the get_matching_tours function.
      *
      * @dataProvider get_matching_tours_provider
-     * @param   array   $alltours   The list of tours to insert
-     * @param   string  $url        The URL to test
-     * @param   string  $expected   The name of the expected matching tour
+     * @param   array   $alltours   The list of tours to insert.
+     * @param   string  $url        The URL to test.
+     * @param   array   $expected   List of names of the expected matching tours.
      */
-    public function test_get_matching_tours($alltours, $url, $expected) {
+    public function test_get_matching_tours(array $alltours, string $url, array $expected) {
         $this->resetAfterTest();
 
         foreach ($alltours as $tourconfig) {
@@ -323,12 +330,10 @@ class tool_usertours_manager_testcase extends advanced_testcase {
             $this->helper_create_step((object) ['tourid' => $tour->get_id()]);
         }
 
-        $match = \tool_usertours\manager::get_matching_tours(new moodle_url($url));
-        if ($expected === null) {
-            $this->assertNull($match);
-        } else {
-            $this->assertNotNull($match);
-            $this->assertEquals($expected, $match->get_name());
+        $matches = \tool_usertours\manager::get_matching_tours(new moodle_url($url));
+        $this->assertEquals(count($expected), count($matches));
+        for ($i = 0; $i < count($matches); $i++) {
+            $this->assertEquals($expected[$i], $matches[$i]->get_name());
         }
     }
 }
index ead833e..ed8e6ee 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2021052501;            // The current module version (Date: YYYYMMDDXX).
+$plugin->version   = 2021052502;            // The current module version (Date: YYYYMMDDXX).
 $plugin->requires  = 2021052500;            // Requires this Moodle version.
 $plugin->component = 'tool_usertours';      // Full name of the plugin (used for diagnostics).
index 551e62c..620505c 100644 (file)
@@ -101,13 +101,31 @@ abstract class administration_helper extends cache_helper {
         ksort($default);
         $return = $return + $default;
 
-        foreach ($instance->get_definition_mappings() as $mapping) {
+        $mappings = $instance->get_definition_mappings();
+        foreach ($mappings as $mapping) {
             if (!array_key_exists($mapping['store'], $return)) {
                 continue;
             }
             $return[$mapping['store']]['mappings']++;
         }
 
+        // Now get all definitions, and if not mapped, increment the defaults for the mode.
+        $modemappings = $instance->get_mode_mappings();
+        foreach ($instance->get_definitions() as $definition) {
+            // Construct the definition name to search for.
+            $defname = $definition['component'] . '/' . $definition['area'];
+            // Skip if definition is already mapped.
+            if (array_search($defname, array_column($mappings, 'definition')) !== false) {
+                continue;
+            }
+
+            $mode = $definition['mode'];
+            // Get the store name of the default mapping from the mode.
+            $index = array_search($mode, array_column($modemappings, 'mode'));
+            $store = $modemappings[$index]['store'];
+            $return[$store]['mappings']++;
+        }
+
         return $return;
     }
 
index 865539f..146efe3 100644 (file)
@@ -92,7 +92,12 @@ class core_cache_administration_helper_testcase extends advanced_testcase {
         $this->assertEquals(0, $summary['default']);
         $this->assertEquals(1, $summary['isready']);
         $this->assertEquals(1, $summary['requirementsmet']);
-        $this->assertEquals(1, $summary['mappings']);
+
+        // Find the number of mappings to sessionstore.
+        $mappingcount = count(array_filter($config->get_definitions(), function($element) {
+            return $element['mode'] === cache_store::MODE_APPLICATION;
+        }));
+        $this->assertEquals($mappingcount, $summary['mappings']);
 
         $definitionsummaries = core_cache\administration_helper::get_definition_summaries();
         $this->assertInternalType('array', $definitionsummaries);
index ce12f57..13883c1 100644 (file)
@@ -25,7 +25,7 @@
 
 namespace core_h5p;
 
-defined('MOODLE_INTERNAL') || die();
+use core_collator;
 
 /**
  *
@@ -521,15 +521,19 @@ class framework_testcase extends \advanced_testcase {
         // The addons array should return 2 results (Library and Library1 addon).
         $this->assertCount(2, $addons);
 
+        // Ensure the addons array is consistently ordered before asserting their contents.
+        core_collator::asort_array_of_arrays_by_key($addons, 'machineName');
+        [$addonone, $addontwo] = array_values($addons);
+
         // Make sure the version 1.3 is the latest 'Library' addon version.
-        $this->assertEquals('Library', $addons[0]['machineName']);
-        $this->assertEquals(1, $addons[0]['majorVersion']);
-        $this->assertEquals(3, $addons[0]['minorVersion']);
+        $this->assertEquals('Library', $addonone['machineName']);
+        $this->assertEquals(1, $addonone['majorVersion']);
+        $this->assertEquals(3, $addonone['minorVersion']);
 
         // Make sure the version 1.2 is the latest 'Library1' addon version.
-        $this->assertEquals('Library1', $addons[1]['machineName']);
-        $this->assertEquals(1, $addons[1]['majorVersion']);
-        $this->assertEquals(2, $addons[1]['minorVersion']);
+        $this->assertEquals('Library1', $addontwo['machineName']);
+        $this->assertEquals(1, $addontwo['majorVersion']);
+        $this->assertEquals(2, $addontwo['minorVersion']);
     }
 
     /**
@@ -553,7 +557,6 @@ class framework_testcase extends \advanced_testcase {
         $this->assertEquals('1', $libraries['MainLibrary'][0]->major_version);
         $this->assertEquals('0', $libraries['MainLibrary'][0]->minor_version);
         $this->assertEquals('1', $libraries['MainLibrary'][0]->patch_version);
-        $this->assertEquals('MainLibrary', $libraries['MainLibrary'][0]->machine_name);
     }
 
     /**
index 7530ded..f8d5b9b 100644 (file)
@@ -390,6 +390,7 @@ $string['loginasnoenrol'] = 'You cannot use enrol or unenrol when in course "Log
 $string['loginasonecourse'] = 'You cannot enter this course.<br /> You have to terminate the "Login as" session before entering any other course.';
 $string['maxbytesfile'] = 'The file {$a->file} is too large. The maximum size you can upload is {$a->size}.';
 $string['maxareabytes'] = 'The file is larger than the space remaining in this area.';
+$string['messageundeliveredbynotificationsettings'] = 'The message could not be sent because personal messages between users (in Notification settings) has been disabled by a site administrator.';
 $string['messagingdisable'] = 'Messaging is disabled on this site';
 $string['mimetexisnotexist'] = 'Your system is not configured to run mimeTeX. You need to obtain the C source from <a href="https://www.forkosh.com/mimetex.zip">https://www.forkosh.com/mimetex.zip</a>, compile it and put the executable into your moodle/filter/tex/ directory.';
 $string['mimetexnotexecutable'] = 'Custom mimetex is not executable!';
index d507537..2d2536e 100644 (file)
@@ -55,6 +55,7 @@ information provided here is intended especially for developers.
 * A new admin externalpage type `\core_admin\local\externalpage\accesscallback` for use in plugin settings is available that allows
   a callback to be provided to determine whether page can be accessed.
 * New setting $CFG->localtempdir overrides which defaults to sys_get_temp_dir()
+* Function redirect() now emits a line of backtrace into the X-Redirect-By header when debugging is on
 
 === 3.9 ===
 * Following function has been deprecated, please use \core\task\manager::run_from_cli().
index 29ef0e7..4251846 100644 (file)
@@ -2954,9 +2954,17 @@ function redirect($url, $message='', $delay=null, $messagetype = \core\output\no
     \core\session\manager::write_close();
 
     if ($delay == 0 && !$debugdisableredirect && !headers_sent()) {
+
         // This helps when debugging redirect issues like loops and it is not clear
-        // which layer in the stack sent the redirect header.
-        @header('X-Redirect-By: Moodle');
+        // which layer in the stack sent the redirect header. If debugging is on
+        // then the file and line is also shown.
+        $redirectby = 'Moodle';
+        if (debugging('', DEBUG_DEVELOPER)) {
+            $origin = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 1)[0];
+            $redirectby .= ' /' . str_replace($CFG->dirroot . '/', '', $origin['file']) . ':' . $origin['line'];
+        }
+        @header("X-Redirect-By: $redirectby");
+
         // 302 might not work for POST requests, 303 is ignored by obsolete clients.
         @header($_SERVER['SERVER_PROTOCOL'] . ' 303 See Other');
         @header('Location: '.$url);
index 4d758b1..dd6bea6 100644 (file)
@@ -1699,6 +1699,10 @@ class api {
 
         $messageid = message_send($eventdata);
 
+        if (!$messageid) {
+            throw new \moodle_exception('messageundeliveredbynotificationsettings', 'moodle');
+        }
+
         $messagerecord = $DB->get_record('messages', ['id' => $messageid], 'id, useridfrom, fullmessage,
                 timecreated, fullmessagetrust');
         $message = (object) [
index 32504c6..e809173 100644 (file)
@@ -215,6 +215,9 @@ class core_message_external extends external_api {
                 //          We should have thrown exceptions as these errors prevent results to be returned.
                 // See http://docs.moodle.org/dev/Errors_handling_in_web_services#When_to_send_a_warning_on_the_server_side .
                 $resultmsg['msgid'] = -1;
+                if (!isset($errormessage)) { // Nobody has set a message error or thrown an exception, let's set it.
+                    $errormessage = get_string('messageundeliveredbynotificationsettings', 'error');
+                }
                 $resultmsg['errormessage'] = $errormessage;
             }
 
index 9152c48..69daf6f 100644 (file)
@@ -46,7 +46,22 @@ function xmldb_message_popup_upgrade($oldversion) {
 
     if ($oldversion < 2020020600) {
         // Clean up orphaned popup notification records.
-        $DB->delete_records_select('message_popup_notifications', 'notificationid NOT IN (SELECT id FROM {notifications})');
+        $fromsql = "FROM {message_popup_notifications} mpn
+               LEFT JOIN {notifications} n
+                      ON mpn.notificationid = n.id
+                   WHERE n.id IS NULL";
+        $total = $DB->count_records_sql("SELECT COUNT(mpn.id) " . $fromsql);
+        $i = 0;
+        $pbar = new progress_bar('deletepopupnotification', 500, true);
+        do {
+            if ($popupnotifications = $DB->get_records_sql("SELECT mpn.id " . $fromsql, null, 0, 1000)) {
+                list($insql, $inparams) = $DB->get_in_or_equal(array_keys($popupnotifications));
+                $DB->delete_records_select('message_popup_notifications', "id $insql", $inparams);
+                // Update progress.
+                $i += count($inparams);
+                $pbar->update($i, $total, "Cleaning up orphaned popup notification records - $i/$total.");
+            }
+        } while ($popupnotifications);
 
         // Reportbuilder savepoint reached.
         upgrade_plugin_savepoint(true, 2020020600, 'message', 'popup');
index 94b10b9..2956639 100644 (file)
@@ -2245,7 +2245,7 @@ class mod_forum_external extends external_api {
             $parentposts = [];
             if ($parentids) {
                 $parentposts = $postbuilder->build(
-                    $user,
+                    $USER,
                     [$forum],
                     [$discussion],
                     $postvault->get_from_ids(array_values($parentids))
@@ -2261,7 +2261,7 @@ class mod_forum_external extends external_api {
                 'timecreated' => $firstpost->get_time_created(),
                 'authorfullname' => $discussionauthor->get_full_name(),
                 'posts' => [
-                    'userposts' => $postbuilder->build($user, [$forum], [$discussion], $posts),
+                    'userposts' => $postbuilder->build($USER, [$forum], [$discussion], $posts),
                     'parentposts' => $parentposts,
                 ],
             ];
index 0ce852f..2430f27 100644 (file)
@@ -2611,6 +2611,7 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
      * Test get forum posts by user id.
      */
     public function test_mod_forum_get_discussion_posts_by_userid() {
+        global $DB;
         $this->resetAfterTest(true);
 
         $urlfactory = mod_forum\local\container::get_url_factory();
@@ -2722,9 +2723,20 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
 
         // Following line enrol and assign default role id to the user.
         // So the user automatically gets mod/forum:viewdiscussion on all forums of the course.
-        $this->getDataGenerator()->enrol_user($user1->id, $course1->id);
+        $this->getDataGenerator()->enrol_user($user1->id, $course1->id, 'teacher');
         $this->getDataGenerator()->enrol_user($user2->id, $course1->id);
-
+        // Changed display period for the discussions in past.
+        $time = time();
+        $discussion = new \stdClass();
+        $discussion->id = $discussion1->id;
+        $discussion->timestart = $time - 200;
+        $discussion->timeend = $time - 100;
+        $DB->update_record('forum_discussions', $discussion);
+        $discussion = new \stdClass();
+        $discussion->id = $discussion2->id;
+        $discussion->timestart = $time - 200;
+        $discussion->timeend = $time - 100;
+        $DB->update_record('forum_discussions', $discussion);
         // Create what we expect to be returned when querying the discussion.
         $expectedposts = array(
             'discussions' => array(),
@@ -2773,34 +2785,36 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
                             'view' => true,
                             'edit' => true,
                             'delete' => true,
-                            'split' => false,
+                            'split' => true,
                             'reply' => true,
                             'export' => false,
                             'controlreadstatus' => false,
-                            'canreplyprivately' => false,
+                            'canreplyprivately' => true,
                             'selfenrol' => false
                         ],
                         'urls' => [
                             'view' => $urlfactory->get_view_post_url_from_post_id(
-                                    $discussion1reply1->discussion, $discussion1reply1->id)->out(false),
+                                $discussion1reply1->discussion, $discussion1reply1->id)->out(false),
                             'viewisolated' => $isolatedurluser->out(false),
                             'viewparent' => $urlfactory->get_view_post_url_from_post_id(
-                                    $discussion1reply1->discussion, $discussion1reply1->parent)->out(false),
+                                $discussion1reply1->discussion, $discussion1reply1->parent)->out(false),
                             'edit' => (new moodle_url('/mod/forum/post.php', [
-                                    'edit' => $discussion1reply1->id
+                                'edit' => $discussion1reply1->id
                             ]))->out(false),
                             'delete' => (new moodle_url('/mod/forum/post.php', [
-                                    'delete' => $discussion1reply1->id
+                                'delete' => $discussion1reply1->id
+                            ]))->out(false),
+                            'split' => (new moodle_url('/mod/forum/post.php', [
+                                'prune' => $discussion1reply1->id
                             ]))->out(false),
-                            'split' => null,
                             'reply' => (new moodle_url('/mod/forum/post.php#mformforum', [
-                                    'reply' => $discussion1reply1->id
+                                'reply' => $discussion1reply1->id
                             ]))->out(false),
                             'export' => null,
                             'markasread' => null,
                             'markasunread' => null,
                             'discuss' => $urlfactory->get_discussion_view_url_from_discussion_id(
-                                    $discussion1reply1->discussion)->out(false),
+                                $discussion1reply1->discussion)->out(false),
                         ],
                     ]
                 ],
@@ -2833,13 +2847,13 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
                         'charcount' => null,
                         'capabilities' => [
                             'view' => true,
-                            'edit' => false,
-                            'delete' => false,
+                            'edit' => true,
+                            'delete' => true,
                             'split' => false,
                             'reply' => true,
                             'export' => false,
                             'controlreadstatus' => false,
-                            'canreplyprivately' => false,
+                            'canreplyprivately' => true,
                             'selfenrol' => false
                         ],
                         'urls' => [
@@ -2847,8 +2861,12 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
                                 $discussion1firstpostobject->discussion, $discussion1firstpostobject->id)->out(false),
                             'viewisolated' => $isolatedurlparent->out(false),
                             'viewparent' => null,
-                            'edit' => null,
-                            'delete' => null,
+                            'edit' => (new moodle_url('/mod/forum/post.php', [
+                                'edit' => $discussion1firstpostobject->id
+                            ]))->out(false),
+                            'delete' => (new moodle_url('/mod/forum/post.php', [
+                                'delete' => $discussion1firstpostobject->id
+                            ]))->out(false),
                             'split' => null,
                             'reply' => (new moodle_url('/mod/forum/post.php#mformforum', [
                                 'reply' => $discussion1firstpostobject->id
@@ -2906,11 +2924,11 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
                             'view' => true,
                             'edit' => true,
                             'delete' => true,
-                            'split' => false,
+                            'split' => true,
                             'reply' => true,
                             'export' => false,
                             'controlreadstatus' => false,
-                            'canreplyprivately' => false,
+                            'canreplyprivately' => true,
                             'selfenrol' => false
                         ],
                         'urls' => [
@@ -2925,7 +2943,9 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
                             'delete' => (new moodle_url('/mod/forum/post.php', [
                                 'delete' => $discussion2reply1->id
                             ]))->out(false),
-                            'split' => null,
+                            'split' => (new moodle_url('/mod/forum/post.php', [
+                                'prune' => $discussion2reply1->id
+                            ]))->out(false),
                             'reply' => (new moodle_url('/mod/forum/post.php#mformforum', [
                                 'reply' => $discussion2reply1->id
                             ]))->out(false),
@@ -2966,13 +2986,13 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
                         'charcount' => null,
                         'capabilities' => [
                             'view' => true,
-                            'edit' => false,
-                            'delete' => false,
+                            'edit' => true,
+                            'delete' => true,
                             'split' => false,
                             'reply' => true,
                             'export' => false,
                             'controlreadstatus' => false,
-                            'canreplyprivately' => false,
+                            'canreplyprivately' => true,
                             'selfenrol' => false
                         ],
                         'urls' => [
@@ -2980,8 +3000,12 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
                                 $discussion2firstpostobject->discussion, $discussion2firstpostobject->id)->out(false),
                             'viewisolated' => $isolatedurlparent->out(false),
                             'viewparent' => null,
-                            'edit' => null,
-                            'delete' => null,
+                            'edit' => (new moodle_url('/mod/forum/post.php', [
+                                'edit' => $discussion2firstpostobject->id
+                            ]))->out(false),
+                            'delete' => (new moodle_url('/mod/forum/post.php', [
+                                'delete' => $discussion2firstpostobject->id
+                            ]))->out(false),
                             'split' => null,
                             'reply' => (new moodle_url('/mod/forum/post.php#mformforum', [
                                 'reply' => $discussion2firstpostobject->id
index dc259c0..154c1f1 100644 (file)
@@ -1,6 +1,12 @@
 This files describes API changes in /mod/forum/*,
 information provided here is intended especially for developers.
 
+=== 3.10 ===
+
+* Changes in external function mod_forum_external::get_discussion_posts_by_userid
+  Now returns the posts of a given user checking the current user capabilities ($USER, the user who is requesting the posts).
+  Previously, it returned the posts checking the capabilities of the user that created the posts.
+
 === 3.8 ===
 
 * The following functions have been finally deprecated and can not be used anymore:
index a06d115..98acf59 100644 (file)
@@ -162,7 +162,7 @@ class mod_glossary_external extends external_api {
      * @param  int $id The glossary ID.
      * @return array Contains glossary, context, course and cm.
      */
-    protected static function validate_glossary($id) {
+    public static function validate_glossary($id) {
         global $DB;
         $glossary = $DB->get_record('glossary', array('id' => $id), '*', MUST_EXIST);
         list($course, $cm) = get_course_and_cm_from_instance($glossary, 'glossary');
@@ -1397,7 +1397,7 @@ class mod_glossary_external extends external_api {
 
         // Get and validate the glossary.
         $entry = $DB->get_record('glossary_entries', array('id' => $id), '*', MUST_EXIST);
-        list($glossary, $context) = self::validate_glossary($entry->glossaryid);
+        list($glossary, $context, $course, $cm) = self::validate_glossary($entry->glossaryid);
 
         if (empty($entry->approved) && $entry->userid != $USER->id && !has_capability('mod/glossary:approve', $context)) {
             throw new invalid_parameter_exception('invalidentry');
@@ -1406,10 +1406,17 @@ class mod_glossary_external extends external_api {
         $entry = glossary_get_entry_by_id($id);
         self::fill_entry_details($entry, $context);
 
+        // Permissions (for entry edition).
+        $permissions = [
+            'candelete' => mod_glossary_can_delete_entry($entry, $glossary, $context),
+            'canupdate' => mod_glossary_can_update_entry($entry, $glossary, $context, $cm),
+        ];
+
         return array(
             'entry' => $entry,
             'ratinginfo' => \core_rating\external\util::get_rating_info($glossary, $context, 'mod_glossary', 'entry',
                 array($entry)),
+            'permissions' => $permissions,
             'warnings' => $warnings
         );
     }
@@ -1424,6 +1431,13 @@ class mod_glossary_external extends external_api {
         return new external_single_structure(array(
             'entry' => self::get_entry_return_structure(),
             'ratinginfo' => \core_rating\external\util::external_ratings_structure(),
+            'permissions' => new external_single_structure(
+                [
+                    'candelete' => new external_value(PARAM_BOOL, 'Whether the user can delete the entry.'),
+                    'canupdate' => new external_value(PARAM_BOOL, 'Whether the user can update the entry.'),
+                ],
+                'User permissions for the managing the entry.', VALUE_OPTIONAL
+            ),
             'warnings' => new external_warnings()
         ));
     }
diff --git a/mod/glossary/classes/external/delete_entry.php b/mod/glossary/classes/external/delete_entry.php
new file mode 100644 (file)
index 0000000..7551c43
--- /dev/null
@@ -0,0 +1,97 @@
+<?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 deleting a content.
+ *
+ * @package    mod_glossary
+ * @since      Moodle 3.10
+ * @copyright  2020 Juan Leyva <juan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_glossary\external;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->libdir . '/externallib.php');
+require_once($CFG->dirroot . '/mod/glossary/lib.php');
+
+use external_api;
+use external_function_parameters;
+use external_multiple_structure;
+use external_single_structure;
+use external_value;
+use external_warnings;
+
+/**
+ * This is the external method for deleting a content.
+ *
+ * @copyright  2020 Juan Leyva <juan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class delete_entry extends external_api {
+    /**
+     * Parameters.
+     *
+     * @return external_function_parameters
+     */
+    public static function execute_parameters(): external_function_parameters {
+        return new external_function_parameters([
+            'entryid' => new external_value(PARAM_INT, 'Glossary entry id to delete'),
+        ]);
+    }
+
+    /**
+     * Delete the indicated entry from the glossary.
+     *
+     * @param  int $entryid The entry to delete
+     * @return array with result and warnings
+     * @throws moodle_exception
+     */
+    public static function execute(int $entryid): array {
+        global $DB;
+
+        $params = self::validate_parameters(self::execute_parameters(), compact('entryid'));
+        $id = $params['entryid'];
+
+        // Get and validate the glossary.
+        $entry = $DB->get_record('glossary_entries', ['id' => $id], '*', MUST_EXIST);
+        list($glossary, $context, $course, $cm) = \mod_glossary_external::validate_glossary($entry->glossaryid);
+
+        // Check and delete.
+        mod_glossary_can_delete_entry($entry, $glossary, $context, false);
+        mod_glossary_delete_entry($entry, $glossary, $cm, $context, $course);
+
+        return [
+            'result' => true,
+            'warnings' => [],
+        ];
+    }
+
+    /**
+     * Return.
+     *
+     * @return external_single_structure
+     */
+    public static function execute_returns(): external_single_structure {
+        return new external_single_structure([
+            'result' => new external_value(PARAM_BOOL, 'The processing result'),
+            'warnings' => new external_warnings()
+        ]);
+    }
+}
diff --git a/mod/glossary/classes/external/prepare_entry.php b/mod/glossary/classes/external/prepare_entry.php
new file mode 100644 (file)
index 0000000..77134f4
--- /dev/null
@@ -0,0 +1,153 @@
+<?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 preparing a entry for edition.
+ *
+ * @package    mod_glossary
+ * @since      Moodle 3.10
+ * @copyright  2020 Juan Leyva <juan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_glossary\external;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->libdir . '/externallib.php');
+require_once($CFG->dirroot . '/mod/glossary/lib.php');
+
+use external_api;
+use external_function_parameters;
+use external_multiple_structure;
+use external_single_structure;
+use external_value;
+use external_warnings;
+
+/**
+ * This is the external method for preparing a entry for edition.
+ *
+ * @copyright  2020 Juan Leyva <juan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class prepare_entry extends external_api {
+    /**
+     * Parameters.
+     *
+     * @return external_function_parameters
+     */
+    public static function execute_parameters(): external_function_parameters {
+        return new external_function_parameters([
+            'entryid' => new external_value(PARAM_INT, 'Glossary entry id to update'),
+        ]);
+    }
+
+    /**
+     * Prepare for update the indicated entry from the glossary.
+     *
+     * @param  int $entryid The entry to update
+     * @return array with result and warnings
+     * @throws moodle_exception
+     */
+    public static function execute(int $entryid): array {
+        global $DB;
+
+        $params = self::validate_parameters(self::execute_parameters(), compact('entryid'));
+        $id = $params['entryid'];
+
+        // Get and validate the glossary.
+        $entry = $DB->get_record('glossary_entries', ['id' => $id], '*', MUST_EXIST);
+        list($glossary, $context, $course, $cm) = \mod_glossary_external::validate_glossary($entry->glossaryid);
+
+        // Check permissions.
+        mod_glossary_can_update_entry($entry, $glossary, $context, $cm, false);
+
+        list($definitionoptions, $attachmentoptions) = glossary_get_editor_and_attachment_options($course, $context, $entry);
+
+        $entry->aliases = '';
+        $entry->categories = [];
+        $entry = mod_glossary_prepare_entry_for_edition($entry);
+        $entry = file_prepare_standard_editor($entry, 'definition', $definitionoptions, $context, 'mod_glossary', 'entry',
+            $entry->id);
+        $entry = file_prepare_standard_filemanager($entry, 'attachment', $attachmentoptions, $context, 'mod_glossary', 'attachment',
+            $entry->id);
+
+        // Just get a structure compatible with external API.
+        array_walk($definitionoptions, function(&$item, $key) use (&$definitionoptions) {
+            if (!is_scalar($item)) {
+                unset($definitionoptions[$key]);
+                return;
+            }
+            $item = ['name' => $key, 'value' => $item];
+        });
+
+        array_walk($attachmentoptions, function(&$item, $key) use (&$attachmentoptions) {
+            if (!is_scalar($item)) {
+                unset($attachmentoptions[$key]);
+                return;
+            }
+            $item = ['name' => $key, 'value' => $item];
+        });
+
+        return [
+            'inlineattachmentsid' => $entry->definition_editor['itemid'],
+            'attachmentsid' => $entry->attachment_filemanager,
+            'areas' => [
+                [
+                    'area' => 'definition',
+                    'options' => $definitionoptions,
+                ],
+                [
+                    'area' => 'attachment',
+                    'options' => $attachmentoptions,
+                ],
+            ],
+            'aliases' => explode("\n", trim($entry->aliases)),
+            'categories' => $entry->categories,
+        ];
+    }
+
+    /**
+     * Return.
+     *
+     * @return external_single_structure
+     */
+    public static function execute_returns(): external_single_structure {
+        return new external_single_structure([
+            'inlineattachmentsid' => new external_value(PARAM_INT, 'Draft item id for the text editor.'),
+            'attachmentsid' => new external_value(PARAM_INT, 'Draft item id for the file manager.'),
+            'areas' => new external_multiple_structure(
+                new external_single_structure(
+                    [
+                        'area' => new external_value(PARAM_ALPHA, 'File area name.'),
+                        'options' => new external_multiple_structure(
+                            new external_single_structure(
+                                [
+                                    'name' => new external_value(PARAM_RAW, 'Name of option.'),
+                                    'value' => new external_value(PARAM_RAW, 'Value of option.'),
+                                ]
+                            ), 'Draft file area options.'
+                        )
+                    ]
+                ), 'File areas including options'
+            ),
+            'aliases' => new external_multiple_structure(new external_value(PARAM_RAW, 'Alias name.')),
+            'categories' => new external_multiple_structure(new external_value(PARAM_INT, 'Category id')),
+            'warnings' => new external_warnings(),
+        ]);
+    }
+}
diff --git a/mod/glossary/classes/external/update_entry.php b/mod/glossary/classes/external/update_entry.php
new file mode 100644 (file)
index 0000000..695fbb7
--- /dev/null
@@ -0,0 +1,176 @@
+<?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 updating a glossary entry.
+ *
+ * @package    mod_glossary
+ * @since      Moodle 3.10
+ * @copyright  2020 Juan Leyva <juan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_glossary\external;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->libdir . '/externallib.php');
+require_once($CFG->dirroot . '/mod/glossary/lib.php');
+
+use external_api;
+use external_function_parameters;
+use external_multiple_structure;
+use external_single_structure;
+use external_value;
+use external_format_value;
+use external_warnings;
+use core_text;
+use moodle_exception;
+
+/**
+ * This is the external method for updating a glossary entry.
+ *
+ * @copyright  2020 Juan Leyva <juan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class update_entry extends external_api {
+    /**
+     * Parameters.
+     *
+     * @return external_function_parameters
+     */
+    public static function execute_parameters(): external_function_parameters {
+        return new external_function_parameters([
+            'entryid' => new external_value(PARAM_INT, 'Glossary entry id to update'),
+            'concept' => new external_value(PARAM_TEXT, 'Glossary concept'),
+            'definition' => new external_value(PARAM_RAW, 'Glossary concept definition'),
+            'definitionformat' => new external_format_value('definition'),
+            'options' => new external_multiple_structure (
+                new external_single_structure(
+                    [
+                        'name' => new external_value(PARAM_ALPHANUM,
+                            'The allowed keys (value format) are:
+                            inlineattachmentsid (int); the draft file area id for inline attachments
+                            attachmentsid (int); the draft file area id for attachments
+                            categories (comma separated int); comma separated category ids
+                            aliases (comma separated str); comma separated aliases
+                            usedynalink (bool); whether the entry should be automatically linked.
+                            casesensitive (bool); whether the entry is case sensitive.
+                            fullmatch (bool); whether to match whole words only.'),
+                        'value' => new external_value(PARAM_RAW, 'the value of the option (validated inside the function)')
+                    ]
+                ), 'Optional settings', VALUE_DEFAULT, []
+            )
+        ]);
+    }
+
+    /**
+     * Update the indicated glossary entry.
+     *
+     * @param  int $entryid The entry to update
+     * @param string $concept    the glossary concept
+     * @param string $definition the concept definition
+     * @param int $definitionformat the concept definition format
+     * @param array  $options    additional settings
+     * @return array with result and warnings
+     * @throws moodle_exception
+     */
+    public static function execute(int $entryid, string $concept, string $definition, int $definitionformat,
+            array $options = []): array {
+
+        global $DB;
+
+        $params = self::validate_parameters(self::execute_parameters(), compact('entryid', 'concept', 'definition',
+            'definitionformat', 'options'));
+        $id = $params['entryid'];
+
+        // Get and validate the glossary entry.
+        $entry = $DB->get_record('glossary_entries', ['id' => $id], '*', MUST_EXIST);
+        list($glossary, $context, $course, $cm) = \mod_glossary_external::validate_glossary($entry->glossaryid);
+
+        // Check if the user can update the entry.
+        mod_glossary_can_update_entry($entry, $glossary, $context, $cm, false);
+
+        // Check for duplicates if the concept changes.
+        if (!$glossary->allowduplicatedentries &&
+                core_text::strtolower($entry->concept) != core_text::strtolower(trim($params['concept']))) {
+
+            if (glossary_concept_exists($glossary, $params['concept'])) {
+                throw new moodle_exception('errconceptalreadyexists', 'glossary');
+            }
+        }
+
+        // Prepare the entry object.
+        $entry->aliases = '';
+        $entry = mod_glossary_prepare_entry_for_edition($entry);
+        $entry->concept = $params['concept'];
+        $entry->definition_editor = [
+            'text' => $params['definition'],
+            'format' => $params['definitionformat'],
+        ];
+        // Options.
+        foreach ($params['options'] as $option) {
+            $name = trim($option['name']);
+            switch ($name) {
+                case 'inlineattachmentsid':
+                    $entry->definition_editor['itemid'] = clean_param($option['value'], PARAM_INT);
+                    break;
+                case 'attachmentsid':
+                    $entry->attachment_filemanager = clean_param($option['value'], PARAM_INT);
+                    break;
+                case 'categories':
+                    $entry->categories = clean_param($option['value'], PARAM_SEQUENCE);
+                    $entry->categories = explode(',', $entry->categories);
+                    break;
+                case 'aliases':
+                    $entry->aliases = clean_param($option['value'], PARAM_NOTAGS);
+                    // Convert to the expected format.
+                    $entry->aliases = str_replace(",", "\n", $entry->aliases);
+                    break;
+                case 'usedynalink':
+                case 'casesensitive':
+                case 'fullmatch':
+                    // Only allow if linking is enabled.
+                    if ($glossary->usedynalink) {
+                        $entry->{$name} = clean_param($option['value'], PARAM_BOOL);
+                    }
+                    break;
+                default:
+                    throw new moodle_exception('errorinvalidparam', 'webservice', '', $name);
+            }
+        }
+
+        $entry = glossary_edit_entry($entry, $course, $cm, $glossary, $context);
+
+        return [
+            'result' => true,
+            'warnings' => [],
+        ];
+    }
+
+    /**
+     * Return.
+     *
+     * @return external_single_structure
+     */
+    public static function execute_returns(): external_single_structure {
+        return new external_single_structure([
+            'result' => new external_value(PARAM_BOOL, 'The update result'),
+            'warnings' => new external_warnings()
+        ]);
+    }
+}
index 942434f..1584e01 100644 (file)
@@ -162,4 +162,30 @@ $functions = array(
         'services'      => array(MOODLE_OFFICIAL_MOBILE_SERVICE)
     ),
 
+    'mod_glossary_delete_entry' => [
+        'classname'     => 'mod_glossary\external\delete_entry',
+        'methodname'    => 'execute',
+        'classpath'     => '',
+        'description'   => 'Delete the given entry from the glossary.',
+        'type'          => 'write',
+        'services'      => [MOODLE_OFFICIAL_MOBILE_SERVICE]
+    ],
+
+    'mod_glossary_update_entry' => [
+        'classname'     => 'mod_glossary\external\update_entry',
+        'methodname'    => 'execute',
+        'classpath'     => '',
+        'description'   => 'Updates the given glossary entry.',
+        'type'          => 'write',
+        'services'      => [MOODLE_OFFICIAL_MOBILE_SERVICE]
+    ],
+
+    'mod_glossary_prepare_entry_for_edition' => [
+        'classname'     => 'mod_glossary\external\prepare_entry',
+        'methodname'    => 'execute',
+        'classpath'     => '',
+        'description'   => 'Prepares the given entry for edition returning draft item areas and file areas information.',
+        'type'          => 'read',
+        'services'      => [MOODLE_OFFICIAL_MOBILE_SERVICE]
+    ],
 );
index 624130d..ea174b7 100644 (file)
@@ -46,107 +46,23 @@ if ($cm->instance != $entry->glossaryid) {
 
 require_login($course, false, $cm);
 $context = context_module::instance($cm->id);
-$manageentries = has_capability('mod/glossary:manageentries', $context);
 
 if (! $glossary = $DB->get_record("glossary", array("id"=>$cm->instance))) {
     print_error('invalidid', 'glossary');
 }
 
-
-$strareyousuredelete = get_string("areyousuredelete","glossary");
-
-if (($entry->userid != $USER->id) and !$manageentries) { // guest id is never matched, no need for special check here
-    print_error('nopermissiontodelentry');
-}
-$ineditperiod = ((time() - $entry->timecreated <  $CFG->maxeditingtime) || $glossary->editalways);
-if (!$ineditperiod and !$manageentries) {
-    print_error('errdeltimeexpired', 'glossary');
-}
+// Throws an exception if the user cannot delete the entry.
+mod_glossary_can_delete_entry($entry, $glossary, $context, false);
 
 /// If data submitted, then process and store.
 
 if ($confirm and confirm_sesskey()) { // the operation was confirmed.
-    // if it is an imported entry, just delete the relation
-
-    $origentry = fullclone($entry);
-    if ($entry->sourceglossaryid) {
-        if (!$newcm = get_coursemodule_from_instance('glossary', $entry->sourceglossaryid)) {
-            print_error('invalidcoursemodule');
-        }
-        $newcontext = context_module::instance($newcm->id);
-
-        $entry->glossaryid       = $entry->sourceglossaryid;
-        $entry->sourceglossaryid = 0;
-        $DB->update_record('glossary_entries', $entry);
-
-        // move attachments too
-        $fs = get_file_storage();
-
-        if ($oldfiles = $fs->get_area_files($context->id, 'mod_glossary', 'attachment', $entry->id)) {
-            foreach ($oldfiles as $oldfile) {
-                $file_record = new stdClass();
-                $file_record->contextid = $newcontext->id;
-                $fs->create_file_from_storedfile($file_record, $oldfile);
-            }
-            $fs->delete_area_files($context->id, 'mod_glossary', 'attachment', $entry->id);
-            $entry->attachment = '1';
-        } else {
-            $entry->attachment = '0';
-        }
-        $DB->update_record('glossary_entries', $entry);
-
-    } else {
-        $fs = get_file_storage();
-        $fs->delete_area_files($context->id, 'mod_glossary', 'attachment', $entry->id);
-        $DB->delete_records("comments", array('itemid'=>$entry->id, 'commentarea'=>'glossary_entry', 'contextid'=>$context->id));
-        $DB->delete_records("glossary_alias", array("entryid"=>$entry->id));
-        $DB->delete_records("glossary_entries", array("id"=>$entry->id));
-
-        // Update completion state
-        $completion = new completion_info($course);
-        if ($completion->is_enabled($cm) == COMPLETION_TRACKING_AUTOMATIC && $glossary->completionentries) {
-            $completion->update_state($cm, COMPLETION_INCOMPLETE, $entry->userid);
-        }
-
-        //delete glossary entry ratings
-        require_once($CFG->dirroot.'/rating/lib.php');
-        $delopt = new stdClass;
-        $delopt->contextid = $context->id;
-        $delopt->component = 'mod_glossary';
-        $delopt->ratingarea = 'entry';
-        $delopt->itemid = $entry->id;
-        $rm = new rating_manager();
-        $rm->delete_ratings($delopt);
-    }
-
-    // Delete cached RSS feeds.
-    if (!empty($CFG->enablerssfeeds)) {
-        require_once($CFG->dirroot.'/mod/glossary/rsslib.php');
-        glossary_rss_delete_file($glossary);
-    }
-
-    core_tag_tag::remove_all_item_tags('mod_glossary', 'glossary_entries', $origentry->id);
-
-    $event = \mod_glossary\event\entry_deleted::create(array(
-        'context' => $context,
-        'objectid' => $origentry->id,
-        'other' => array(
-            'mode' => $prevmode,
-            'hook' => $hook,
-            'concept' => $origentry->concept
-        )
-    ));
-    $event->add_record_snapshot('glossary_entries', $origentry);
-    $event->trigger();
-
-    // Reset caches.
-    if ($entry->usedynalink and $entry->approved) {
-        \mod_glossary\local\concept_cache::reset_glossary($glossary);
-    }
 
+    mod_glossary_delete_entry($entry, $glossary, $cm, $context, $course, $hook, $prevmode);
     redirect("view.php?id=$cm->id&amp;mode=$prevmode&amp;hook=$hook");
 
 } else {        // the operation has not been confirmed yet so ask the user to do so
+    $strareyousuredelete = get_string("areyousuredelete", "glossary");
     $PAGE->navbar->add(get_string('delete'));
     $PAGE->set_title($glossary->name);
     $PAGE->set_heading($course->fullname);
index 1e5461f..15d06e5 100644 (file)
@@ -38,23 +38,10 @@ if ($id) { // if entry is specified
         print_error('invalidentry');
     }
 
-    $ineditperiod = ((time() - $entry->timecreated <  $CFG->maxeditingtime) || $glossary->editalways);
-    if (!has_capability('mod/glossary:manageentries', $context) and !($entry->userid == $USER->id and ($ineditperiod and has_capability('mod/glossary:write', $context)))) {
-        if ($USER->id != $entry->userid) {
-            print_error('errcannoteditothers', 'glossary', "view.php?id=$cm->id&amp;mode=entry&amp;hook=$id");
-        } elseif (!$ineditperiod) {
-            print_error('erredittimeexpired', 'glossary', "view.php?id=$cm->id&amp;mode=entry&amp;hook=$id");
-        }
-    }
-
-    //prepare extra data
-    if ($aliases = $DB->get_records_menu("glossary_alias", array("entryid"=>$id), '', 'id, alias')) {
-        $entry->aliases = implode("\n", $aliases) . "\n";
-    }
-    if ($categoriesarr = $DB->get_records_menu("glossary_entries_categories", array('entryid'=>$id), '', 'id, categoryid')) {
-        // TODO: this fetches cats from both main and secondary glossary :-(
-        $entry->categories = array_values($categoriesarr);
-    }
+    // Check if the user can update the entry (trigger exception if he can't).
+    mod_glossary_can_update_entry($entry, $glossary, $context, $cm, false);
+    // Prepare extra data.
+    $entry = mod_glossary_prepare_entry_for_edition($entry);
 
 } else { // new entry
     require_capability('mod/glossary:write', $context);
index 596660c..afae7fe 100644 (file)
@@ -4318,3 +4318,197 @@ function mod_glossary_get_completion_active_rule_descriptions($cm) {
     }
     return $descriptions;
 }
+
+/**
+ * Checks if the current user can delete the given glossary entry.
+ *
+ * @since Moodle 3.10
+ * @param stdClass $entry the entry database object
+ * @param stdClass $glossary the glossary database object
+ * @param stdClass $context the glossary context
+ * @param bool $return Whether to return a boolean value or stop the execution (exception)
+ * @return bool if the user can delete the entry
+ * @throws moodle_exception
+ */
+function mod_glossary_can_delete_entry($entry, $glossary, $context, $return = true) {
+    global $USER, $CFG;
+
+    $manageentries = has_capability('mod/glossary:manageentries', $context);
+
+    if ($manageentries) {   // Users with the capability will always be able to delete entries.
+        return true;
+    }
+
+    if ($entry->userid != $USER->id) { // Guest id is never matched, no need for special check here.
+        if ($return) {
+            return false;
+        }
+        throw new moodle_exception('nopermissiontodelentry');
+    }
+
+    $ineditperiod = ((time() - $entry->timecreated < $CFG->maxeditingtime) || $glossary->editalways);
+
+    if (!$ineditperiod) {
+        if ($return) {
+            return false;
+        }
+        throw new moodle_exception('errdeltimeexpired', 'glossary');
+    }
+
+    return true;
+}
+
+/**
+ * Deletes the given entry, this function does not perform capabilities/permission checks.
+ *
+ * @since Moodle 3.10
+ * @param stdClass $entry the entry database object
+ * @param stdClass $glossary the glossary database object
+ * @param stdClass $cm the glossary course moduule object
+ * @param stdClass $context the glossary context
+ * @param stdClass $course the glossary course
+ * @param string $hook the hook, usually type of filtering, value
+ * @param string $prevmode the previsualisation mode
+ * @throws moodle_exception
+ */
+function mod_glossary_delete_entry($entry, $glossary, $cm, $context, $course, $hook = '', $prevmode = '') {
+    global $CFG, $DB;
+
+    $origentry = fullclone($entry);
+
+    // If it is an imported entry, just delete the relation.
+    if ($entry->sourceglossaryid) {
+        if (!$newcm = get_coursemodule_from_instance('glossary', $entry->sourceglossaryid)) {
+            print_error('invalidcoursemodule');
+        }
+        $newcontext = context_module::instance($newcm->id);
+
+        $entry->glossaryid       = $entry->sourceglossaryid;
+        $entry->sourceglossaryid = 0;
+        $DB->update_record('glossary_entries', $entry);
+
+        // Move attachments too.
+        $fs = get_file_storage();
+
+        if ($oldfiles = $fs->get_area_files($context->id, 'mod_glossary', 'attachment', $entry->id)) {
+            foreach ($oldfiles as $oldfile) {
+                $filerecord = new stdClass();
+                $filerecord->contextid = $newcontext->id;
+                $fs->create_file_from_storedfile($filerecord, $oldfile);
+            }
+            $fs->delete_area_files($context->id, 'mod_glossary', 'attachment', $entry->id);
+            $entry->attachment = '1';
+        } else {
+            $entry->attachment = '0';
+        }
+        $DB->update_record('glossary_entries', $entry);
+
+    } else {
+        $fs = get_file_storage();
+        $fs->delete_area_files($context->id, 'mod_glossary', 'attachment', $entry->id);
+        $DB->delete_records("comments",
+            ['itemid' => $entry->id, 'commentarea' => 'glossary_entry', 'contextid' => $context->id]);
+        $DB->delete_records("glossary_alias", ["entryid" => $entry->id]);
+        $DB->delete_records("glossary_entries", ["id" => $entry->id]);
+
+        // Update completion state.
+        $completion = new completion_info($course);
+        if ($completion->is_enabled($cm) == COMPLETION_TRACKING_AUTOMATIC && $glossary->completionentries) {
+            $completion->update_state($cm, COMPLETION_INCOMPLETE, $entry->userid);
+        }
+
+        // Delete glossary entry ratings.
+        require_once($CFG->dirroot.'/rating/lib.php');
+        $delopt = new stdClass;
+        $delopt->contextid = $context->id;
+        $delopt->component = 'mod_glossary';
+        $delopt->ratingarea = 'entry';
+        $delopt->itemid = $entry->id;
+        $rm = new rating_manager();
+        $rm->delete_ratings($delopt);
+    }
+
+    // Delete cached RSS feeds.
+    if (!empty($CFG->enablerssfeeds)) {
+        require_once($CFG->dirroot . '/mod/glossary/rsslib.php');
+        glossary_rss_delete_file($glossary);
+    }
+
+    core_tag_tag::remove_all_item_tags('mod_glossary', 'glossary_entries', $origentry->id);
+
+    $event = \mod_glossary\event\entry_deleted::create(
+        [
+            'context' => $context,
+            'objectid' => $origentry->id,
+            'other' => [
+                'mode' => $prevmode,
+                'hook' => $hook,
+                'concept' => $origentry->concept
+            ]
+        ]
+    );
+    $event->add_record_snapshot('glossary_entries', $origentry);
+    $event->trigger();
+
+    // Reset caches.
+    if ($entry->usedynalink and $entry->approved) {
+        \mod_glossary\local\concept_cache::reset_glossary($glossary);
+    }
+}
+
+/**
+ * Checks if the current user can update the given glossary entry.
+ *
+ * @since Moodle 3.10
+ * @param stdClass $entry the entry database object
+ * @param stdClass $glossary the glossary database object
+ * @param stdClass $context the glossary context
+ * @param object $cm the course module object (cm record or cm_info instance)
+ * @param bool $return Whether to return a boolean value or stop the execution (exception)
+ * @return bool if the user can update the entry
+ * @throws moodle_exception
+ */
+function mod_glossary_can_update_entry(stdClass $entry, stdClass $glossary, stdClass $context, object $cm,
+        bool $return = true): bool {
+
+    global $USER, $CFG;
+
+    $ineditperiod = ((time() - $entry->timecreated < $CFG->maxeditingtime) || $glossary->editalways);
+    if (!has_capability('mod/glossary:manageentries', $context) and
+            !($entry->userid == $USER->id and ($ineditperiod and has_capability('mod/glossary:write', $context)))) {
+
+        if ($USER->id != $entry->userid) {
+            if ($return) {
+                return false;
+            }
+            throw new moodle_exception('errcannoteditothers', 'glossary', "view.php?id=$cm->id&amp;mode=entry&amp;hook=$entry->id");
+        } else if (!$ineditperiod) {
+            if ($return) {
+                return false;
+            }
+            throw new moodle_exception('erredittimeexpired', 'glossary', "view.php?id=$cm->id&amp;mode=entry&amp;hook=$entry->id");
+        }
+    }
+
+    return true;
+}
+
+/**
+ * Prepares an entry for editing, adding aliases and category information.
+ *
+ * @param  stdClass $entry the entry being edited
+ * @return stdClass the entry with the additional data
+ */
+function mod_glossary_prepare_entry_for_edition(stdClass $entry): stdClass {
+    global $DB;
+
+    if ($aliases = $DB->get_records_menu("glossary_alias", ["entryid" => $entry->id], '', 'id, alias')) {
+        $entry->aliases = implode("\n", $aliases) . "\n";
+    }
+    if ($categoriesarr = $DB->get_records_menu("glossary_entries_categories", ['entryid' => $entry->id], '', 'id, categoryid')) {
+        // TODO: this fetches cats from both main and secondary glossary :-(
+        $entry->categories = array_values($categoriesarr);
+    }
+
+    return $entry;
+}
diff --git a/mod/glossary/tests/external/delete_entry.php b/mod/glossary/tests/external/delete_entry.php
new file mode 100644 (file)
index 0000000..df56e07
--- /dev/null
@@ -0,0 +1,83 @@
+<?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 delete_entry.
+ *
+ * @package    mod_glossary
+ * @category   external
+ * @since      Moodle 3.10
+ * @copyright  2020 Juan Leyva <juan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_glossary\external;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/webservice/tests/helpers.php');
+
+use external_api;
+use externallib_advanced_testcase;
+
+/**
+ * External function test for delete_entry.
+ *
+ * @package    mod_glossary
+ * @copyright  2020 Juan Leyva <juan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class delete_entry_testcase extends externallib_advanced_testcase {
+
+    /**
+     * Test the behaviour of delete_entry().
+     */
+    public function test_delete_entry() {
+        global $DB;
+        $this->resetAfterTest();
+
+        // Create required data.
+        $course = $this->getDataGenerator()->create_course();
+        $student = $this->getDataGenerator()->create_and_enrol($course, 'student');
+        $anotherstudent = $this->getDataGenerator()->create_and_enrol($course, 'student');
+        $glossary = $this->getDataGenerator()->create_module('glossary', ['course' => $course->id]);
+        $gg = $this->getDataGenerator()->get_plugin_generator('mod_glossary');
+
+        $this->setUser($student);
+        $entry = $gg->create_content($glossary);
+
+        // Test entry creator can delete.
+        $result = delete_entry::execute($entry->id);
+        $result = external_api::clean_returnvalue(delete_entry::execute_returns(), $result);
+        $this->assertTrue($result['result']);
+        $this->assertEquals(0, $DB->count_records('glossary_entries', ['id' => $entry->id]));
+
+        // Test admin can delete.
+        $this->setAdminUser();
+        $entry = $gg->create_content($glossary);
+        $result = delete_entry::execute($entry->id);
+        $result = external_api::clean_returnvalue(delete_entry::execute_returns(), $result);
+        $this->assertTrue($result['result']);
+        $this->assertEquals(0, $DB->count_records('glossary_entries', ['id' => $entry->id]));
+
+        $entry = $gg->create_content($glossary);
+        // Test a different student is not able to delete.
+        $this->setUser($anotherstudent);
+        $this->expectExceptionMessage(get_string('nopermissiontodelentry', 'error'));
+        delete_entry::execute($entry->id);
+    }
+}
diff --git a/mod/glossary/tests/external/prepare_entry.php b/mod/glossary/tests/external/prepare_entry.php
new file mode 100644 (file)
index 0000000..b5870a5
--- /dev/null
@@ -0,0 +1,83 @@
+<?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 prepare_entry.
+ *
+ * @package    mod_glossary
+ * @category   external
+ * @since      Moodle 3.10
+ * @copyright  2020 Juan Leyva <juan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_glossary\external;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/webservice/tests/helpers.php');
+
+use external_api;
+use externallib_advanced_testcase;
+use mod_glossary_external;
+use context_module;
+use context_user;
+use external_util;
+
+/**
+ * External function test for prepare_entry.
+ *
+ * @package    mod_glossary
+ * @copyright  2020 Juan Leyva <juan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class prepare_entry_testcase extends externallib_advanced_testcase {
+
+    /**
+     * test_prepare_entry
+     */
+    public function test_prepare_entry() {
+        global $USER;
+        $this->resetAfterTest(true);
+
+        $course = $this->getDataGenerator()->create_course();
+        $glossary = $this->getDataGenerator()->create_module('glossary', ['course' => $course->id]);
+        $gg = $this->getDataGenerator()->get_plugin_generator('mod_glossary');
+
+        $this->setAdminUser();
+        $aliases = ['alias1', 'alias2'];
+        $entry = $gg->create_content(
+            $glossary,
+            ['approved' => 1, 'userid' => $USER->id],
+            $aliases
+        );
+
+        $cat1 = $gg->create_category($glossary, [], [$entry]);
+        $gg->create_category($glossary);
+
+        $return = prepare_entry::execute($entry->id);
+        $return = external_api::clean_returnvalue(prepare_entry::execute_returns(), $return);
+
+        $this->assertNotEmpty($return['inlineattachmentsid']);
+        $this->assertNotEmpty($return['attachmentsid']);
+        $this->assertEquals($aliases, $return['aliases']);
+        $this->assertEquals([$cat1->id], $return['categories']);
+        $this->assertCount(2, $return['areas']);
+        $this->assertNotEmpty($return['areas'][0]['options']);
+        $this->assertNotEmpty($return['areas'][1]['options']);
+    }
+}
diff --git a/mod/glossary/tests/external/update_entry.php b/mod/glossary/tests/external/update_entry.php
new file mode 100644 (file)
index 0000000..ed6ffc5
--- /dev/null
@@ -0,0 +1,297 @@
+<?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 update_entry.
+ *
+ * @package    mod_glossary
+ * @category   external
+ * @since      Moodle 3.10
+ * @copyright  2020 Juan Leyva <juan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_glossary\external;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/webservice/tests/helpers.php');
+
+use external_api;
+use externallib_advanced_testcase;
+use mod_glossary_external;
+use context_module;
+use context_user;
+use external_util;
+
+/**
+ * External function test for update_entry.
+ *
+ * @package    mod_glossary
+ * @copyright  2020 Juan Leyva <juan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class update_entry_testcase extends externallib_advanced_testcase {
+
+    /**
+     * test_update_entry_without_optional_settings
+     */
+    public function test_update_entry_without_optional_settings() {
+        global $CFG, $DB;
+        $this->resetAfterTest(true);
+
+        $course = $this->getDataGenerator()->create_course();
+        $glossary = $this->getDataGenerator()->create_module('glossary', ['course' => $course->id]);
+
+        $this->setAdminUser();
+        $concept = 'A concept';
+        $definition = '<p>A definition</p>';
+        $return = mod_glossary_external::add_entry($glossary->id, $concept, $definition, FORMAT_HTML);
+        $return = external_api::clean_returnvalue(mod_glossary_external::add_entry_returns(), $return);
+        $entryid = $return['entryid'];
+
+        // Updates the entry.
+        $concept .= ' Updated!';
+        $definition .= ' <p>Updated!</p>';
+        $return = update_entry::execute($entryid, $concept, $definition, FORMAT_HTML);
+        $return = external_api::clean_returnvalue(update_entry::execute_returns(), $return);
+
+        // Get entry from DB.
+        $entry = $DB->get_record('glossary_entries', ['id' => $entryid]);
+
+        $this->assertEquals($concept, $entry->concept);
+        $this->assertEquals($definition, $entry->definition);
+        $this->assertEquals($CFG->glossary_linkentries, $entry->usedynalink);
+        $this->assertEquals($CFG->glossary_casesensitive, $entry->casesensitive);
+        $this->assertEquals($CFG->glossary_fullmatch, $entry->fullmatch);
+        $this->assertEmpty($DB->get_records('glossary_alias', ['entryid' => $entryid]));
+        $this->assertEmpty($DB->get_records('glossary_entries_categories', ['entryid' => $entryid]));
+    }
+
+    /**
+     * test_update_entry_duplicated
+     */
+    public function test_update_entry_duplicated() {
+        global $CFG, $DB;
+        $this->resetAfterTest(true);
+
+        $course = $this->getDataGenerator()->create_course();
+        $glossary = $this->getDataGenerator()->create_module('glossary', ['course' => $course->id, 'allowduplicatedentries' => 1]);
+
+        // Create three entries.
+        $this->setAdminUser();
+        $concept = 'A concept';
+        $definition = '<p>A definition</p>';
+        mod_glossary_external::add_entry($glossary->id, $concept, $definition, FORMAT_HTML);
+
+        $concept = 'B concept';
+        $definition = '<p>B definition</p>';
+        mod_glossary_external::add_entry($glossary->id, $concept, $definition, FORMAT_HTML);
+
+        $concept = 'Another concept';
+        $definition = '<p>Another definition</p>';
+        $return = mod_glossary_external::add_entry($glossary->id, $concept, $definition, FORMAT_HTML);
+        $return = external_api::clean_returnvalue(mod_glossary_external::add_entry_returns(), $return);
+        $entryid = $return['entryid'];
+
+        // Updates the entry using an existing entry name when duplicateds are allowed.
+        $concept = 'A concept';
+        update_entry::execute($entryid, $concept, $definition, FORMAT_HTML);
+
+        // Updates the entry using an existing entry name when duplicateds are NOT allowed.
+        $DB->set_field('glossary', 'allowduplicatedentries', 0, ['id' => $glossary->id]);
+        $concept = 'B concept';
+        $this->expectExceptionMessage(get_string('errconceptalreadyexists', 'glossary'));
+        update_entry::execute($entryid, $concept, $definition, FORMAT_HTML);
+    }
+
+    /**
+     * test_update_entry_with_aliases
+     */
+    public function test_update_entry_with_aliases() {
+        global $DB;
+        $this->resetAfterTest(true);
+
+        $course = $this->getDataGenerator()->create_course();
+        $glossary = $this->getDataGenerator()->create_module('glossary', ['course' => $course->id]);
+
+        $this->setAdminUser();
+        $concept = 'A concept';
+        $definition = 'A definition';
+        $paramaliases = 'abc, def, gez';
+        $options = [
+            [
+                'name' => 'aliases',
+                'value' => $paramaliases,
+            ]
+        ];
+        $return = mod_glossary_external::add_entry($glossary->id, $concept, $definition, FORMAT_HTML, $options);
+        $return = external_api::clean_returnvalue(mod_glossary_external::add_entry_returns(), $return);
+        $entryid = $return['entryid'];
+
+        // Updates the entry.
+        $newaliases = 'abz, xyz';
+        $options[0]['value'] = $newaliases;
+        $return = update_entry::execute($entryid, $concept, $definition, FORMAT_HTML, $options);
+        $return = external_api::clean_returnvalue(update_entry::execute_returns(), $return);
+
+        $aliases = $DB->get_records('glossary_alias', ['entryid' => $entryid]);
+        $this->assertCount(2, $aliases);
+        foreach ($aliases as $alias) {
+            $this->assertContains($alias->alias, $newaliases);
+        }
+    }
+
+    /**
+     * test_update_entry_in_categories
+     */
+    public function test_update_entry_in_categories() {
+        global $DB;
+        $this->resetAfterTest(true);
+
+        $course = $this->getDataGenerator()->create_course();
+        $glossary = $this->getDataGenerator()->create_module('glossary', ['course' => $course->id]);
+        $gg = $this->getDataGenerator()->get_plugin_generator('mod_glossary');
+        $cat1 = $gg->create_category($glossary);
+        $cat2 = $gg->create_category($glossary);
+        $cat3 = $gg->create_category($glossary);
+
+        $this->setAdminUser();
+        $concept = 'A concept';
+        $definition = 'A definition';
+        $paramcategories = "$cat1->id, $cat2->id";
+        $options = [
+            [
+                'name' => 'categories',
+                'value' => $paramcategories,
+            ]
+        ];
+        $return = mod_glossary_external::add_entry($glossary->id, $concept, $definition, FORMAT_HTML, $options);
+        $return = external_api::clean_returnvalue(mod_glossary_external::add_entry_returns(), $return);
+        $entryid = $return['entryid'];
+
+        // Updates the entry.
+        $newcategories = "$cat1->id, $cat3->id";
+        $options[0]['value'] = $newcategories;
+        $return = update_entry::execute($entryid, $concept, $definition, FORMAT_HTML, $options);
+        $return = external_api::clean_returnvalue(update_entry::execute_returns(), $return);
+
+        $categories = $DB->get_records('glossary_entries_categories', ['entryid' => $entryid]);
+        $this->assertCount(2, $categories);
+        foreach ($categories as $category) {
+            $this->assertContains($category->categoryid, $newcategories);
+        }
+    }
+
+    /**
+     * test_update_entry_with_attachments
+     */
+    public function test_update_entry_with_attachments() {
+        global $DB, $USER;
+        $this->resetAfterTest(true);
+
+        $course = $this->getDataGenerator()->create_course();
+        $glossary = $this->getDataGenerator()->create_module('glossary', ['course' => $course->id]);
+        $context = context_module::instance($glossary->cmid);
+
+        $this->setAdminUser();
+        $concept = 'A concept';
+        $definition = 'A definition';
+
+        // Draft files.
+        $draftidinlineattach = file_get_unused_draft_itemid();
+        $draftidattach = file_get_unused_draft_itemid();
+        $usercontext = context_user::instance($USER->id);
+        $filerecordinline = [
+            'contextid' => $usercontext->id,
+            'component' => 'user',
+            'filearea'  => 'draft',
+            'itemid'    => $draftidinlineattach,
+            'filepath'  => '/',
+            'filename'  => 'shouldbeanimage.png',
+        ];
+        $fs = get_file_storage();
+
+        // Create a file in a draft area for regular attachments.
+        $filerecordattach = $filerecordinline;
+        $attachfilename = 'attachment.txt';
+        $filerecordattach['filename'] = $attachfilename;
+        $filerecordattach['itemid'] = $draftidattach;
+        $fs->create_file_from_string($filerecordinline, 'image contents (not really)');
+        $fs->create_file_from_string($filerecordattach, 'simple text attachment');
+
+        $options = [
+            [
+                'name' => 'inlineattachmentsid',
+                'value' => $draftidinlineattach,
+            ],
+            [
+                'name' => 'attachmentsid',
+                'value' => $draftidattach,
+            ]
+        ];
+        $return = mod_glossary_external::add_entry($glossary->id, $concept, $definition, FORMAT_HTML, $options);
+        $return = external_api::clean_returnvalue(mod_glossary_external::add_entry_returns(), $return);
+        $entryid = $return['entryid'];
+        $entry = $DB->get_record('glossary_entries', ['id' => $entryid]);
+
+        list($definitionoptions, $attachmentoptions) = glossary_get_editor_and_attachment_options($course, $context, $entry);
+
+        $entry = file_prepare_standard_editor($entry, 'definition', $definitionoptions, $context, 'mod_glossary', 'entry',
+            $entry->id);
+        $entry = file_prepare_standard_filemanager($entry, 'attachment', $attachmentoptions, $context, 'mod_glossary', 'attachment',
+            $entry->id);
+
+        $inlineattachmentsid = $entry->definition_editor['itemid'];
+        $attachmentsid = $entry->attachment_filemanager;
+
+        // Change the file areas.
+
+        // Delete one inline editor file.
+        $selectedfile = (object)[
+            'filename' => $filerecordinline['filename'],
+            'filepath' => $filerecordinline['filepath'],
+        ];
+        $return = repository_delete_selected_files($usercontext, 'user', 'draft', $inlineattachmentsid, [$selectedfile]);
+
+        // Add more files.
+        $filerecordinline['filename'] = 'newvideo.mp4';
+        $filerecordinline['itemid'] = $inlineattachmentsid;
+
+        $filerecordattach['filename'] = 'newattach.txt';
+        $filerecordattach['itemid'] = $attachmentsid;
+
+        $fs->create_file_from_string($filerecordinline, 'image contents (not really)');
+        $fs->create_file_from_string($filerecordattach, 'simple text attachment');
+
+        // Updates the entry.
+        $options[0]['value'] = $inlineattachmentsid;
+        $options[1]['value'] = $attachmentsid;
+        $return = update_entry::execute($entryid, $concept, $definition, FORMAT_HTML, $options);
+        $return = external_api::clean_returnvalue(update_entry::execute_returns(), $return);
+
+        $editorfiles = external_util::get_area_files($context->id, 'mod_glossary', 'entry', $entryid);
+        $attachmentfiles = external_util::get_area_files($context->id, 'mod_glossary', 'attachment', $entryid);
+
+        $this->assertCount(1, $editorfiles);
+        $this->assertCount(2, $attachmentfiles);
+
+        $this->assertEquals('newvideo.mp4', $editorfiles[0]['filename']);
+        $this->assertEquals('attachment.txt', $attachmentfiles[0]['filename']);
+        $this->assertEquals('newattach.txt', $attachmentfiles[1]['filename']);
+    }
+}
index 18c3a41..4f6a849 100644 (file)
@@ -1077,11 +1077,14 @@ class mod_glossary_external_testcase extends externallib_advanced_testcase {
         $c1 = $this->getDataGenerator()->create_course();
         $c2 = $this->getDataGenerator()->create_course();
         $g1 = $this->getDataGenerator()->create_module('glossary', array('course' => $c1->id));
-        $g2 = $this->getDataGenerator()->create_module('glossary', array('course' => $c1->id, 'visible' => 0));
+        $g2 = $this->getDataGenerator()->create_module('glossary', array('course' => $c2->id, 'visible' => 0));
         $u1 = $this->getDataGenerator()->create_user();
         $u2 = $this->getDataGenerator()->create_user();
+        $u3 = $this->getDataGenerator()->create_user();
         $ctx = context_module::instance($g1->cmid);
         $this->getDataGenerator()->enrol_user($u1->id, $c1->id);
+        $this->getDataGenerator()->enrol_user($u2->id, $c1->id);
+        $this->getDataGenerator()->enrol_user($u3->id, $c1->id);
 
         $e1 = $gg->create_content($g1, array('approved' => 1, 'userid' => $u1->id, 'tags' => array('Cats', 'Dogs')));
         // Add a fake inline image to the entry.
@@ -1108,10 +1111,12 @@ class mod_glossary_external_testcase extends externallib_advanced_testcase {
         $this->assertEquals('Cats', $return['entry']['tags'][0]['rawname']);
         $this->assertEquals('Dogs', $return['entry']['tags'][1]['rawname']);
         $this->assertEquals($filename, $return['entry']['definitioninlinefiles'][0]['filename']);
+        $this->assertTrue($return['permissions']['candelete']);
 
         $return = mod_glossary_external::get_entry_by_id($e2->id);
         $return = external_api::clean_returnvalue(mod_glossary_external::get_entry_by_id_returns(), $return);
         $this->assertEquals($e2->id, $return['entry']['id']);
+        $this->assertTrue($return['permissions']['candelete']);
 
         try {
             $return = mod_glossary_external::get_entry_by_id($e3->id);
@@ -1127,11 +1132,19 @@ class mod_glossary_external_testcase extends externallib_advanced_testcase {
             // All good.
         }
 
-        // An admin can be other's entries to be approved.
+        // An admin can see other's entries to be approved.
         $this->setAdminUser();
         $return = mod_glossary_external::get_entry_by_id($e3->id);
         $return = external_api::clean_returnvalue(mod_glossary_external::get_entry_by_id_returns(), $return);
         $this->assertEquals($e3->id, $return['entry']['id']);
+        $this->assertTrue($return['permissions']['candelete']);
+
+        // Students can see other students approved entries but they will not be able to delete them.
+        $this->setUser($u3);
+        $return = mod_glossary_external::get_entry_by_id($e1->id);
+        $return = external_api::clean_returnvalue(mod_glossary_external::get_entry_by_id_returns(), $return);
+        $this->assertEquals($e1->id, $return['entry']['id']);
+        $this->assertFalse($return['permissions']['candelete']);
     }
 
     public function test_add_entry_without_optional_settings() {
index 58de9ee..24b0811 100644 (file)
@@ -503,4 +503,267 @@ class mod_glossary_lib_testcase extends advanced_testcase {
         $search = glossary_get_entries_search($concept, $course->id);
         $this->assertCount(0, $search);
     }
+
+    public function test_mod_glossary_can_delete_entry_users() {
+        $this->resetAfterTest();
+
+        // Create required data.
+        $course = $this->getDataGenerator()->create_course();
+        $student = $this->getDataGenerator()->create_and_enrol($course, 'student');
+        $anotherstudent = $this->getDataGenerator()->create_and_enrol($course, 'student');
+        $teacher = $this->getDataGenerator()->create_and_enrol($course, 'editingteacher');
+        $glossary = $this->getDataGenerator()->create_module('glossary', ['course' => $course->id]);
+
+        $gg = $this->getDataGenerator()->get_plugin_generator('mod_glossary');
+        $this->setUser($student);
+        $entry = $gg->create_content($glossary);
+        $context = context_module::instance($glossary->cmid);
+
+        // Test student can delete.
+        $this->assertTrue(mod_glossary_can_delete_entry($entry, $glossary, $context));
+
+        // Test teacher can delete.
+        $this->setUser($teacher);
+        $this->assertTrue(mod_glossary_can_delete_entry($entry, $glossary, $context));
+
+        // Test admin can delete.
+        $this->setAdminUser();
+        $this->assertTrue(mod_glossary_can_delete_entry($entry, $glossary, $context));
+
+        // Test a different student is not able to delete.
+        $this->setUser($anotherstudent);
+        $this->assertFalse(mod_glossary_can_delete_entry($entry, $glossary, $context));
+
+        // Test exception.
+        $this->expectExceptionMessage(get_string('nopermissiontodelentry', 'error'));
+        mod_glossary_can_delete_entry($entry, $glossary, $context, false);
+    }
+
+    public function test_mod_glossary_can_delete_entry_edit_period() {
+        global $CFG;
+        $this->resetAfterTest();
+
+        // Create required data.
+        $course = $this->getDataGenerator()->create_course();
+        $student = $this->getDataGenerator()->create_and_enrol($course, 'student');
+        $glossary = $this->getDataGenerator()->create_module('glossary', ['course' => $course->id, 'editalways' => 1]);
+
+        $gg = $this->getDataGenerator()->get_plugin_generator('mod_glossary');
+        $this->setUser($student);
+        $entry = $gg->create_content($glossary);
+        $context = context_module::instance($glossary->cmid);
+
+        // Test student can always delete when edit always is set to 1.
+        $entry->timecreated = time() - 2 * $CFG->maxeditingtime;
+        $this->assertTrue(mod_glossary_can_delete_entry($entry, $glossary, $context));
+
+        // Test student cannot delete old entries when edit always is set to 0.
+        $glossary->editalways = 0;
+        $this->assertFalse(mod_glossary_can_delete_entry($entry, $glossary, $context));
+
+        // Test student can delete recent entries when edit always is set to 0.
+        $entry->timecreated = time();
+        $this->assertTrue(mod_glossary_can_delete_entry($entry, $glossary, $context));
+
+        // Check exception.
+        $entry->timecreated = time() - 2 * $CFG->maxeditingtime;
+        $this->expectExceptionMessage(get_string('errdeltimeexpired', 'glossary'));
+        mod_glossary_can_delete_entry($entry, $glossary, $context, false);
+    }
+
+    public function test_mod_glossary_delete_entry() {
+        global $DB, $CFG;
+        $this->resetAfterTest();
+        require_once($CFG->dirroot . '/rating/lib.php');
+
+        // Create required data.
+        $course = $this->getDataGenerator()->create_course();
+        $student1 = $this->getDataGenerator()->create_and_enrol($course, 'student');
+        $student2 = $this->getDataGenerator()->create_and_enrol($course, 'student');
+
+        $record = new stdClass();
+        $record->course = $course->id;
+        $record->assessed = RATING_AGGREGATE_AVERAGE;
+        $scale = $this->getDataGenerator()->create_scale(['scale' => 'A,B,C,D']);
+        $record->scale = "-$scale->id";
+        $glossary = $this->getDataGenerator()->create_module('glossary', $record);
+        $context = context_module::instance($glossary->cmid);
+        $cm = get_coursemodule_from_instance('glossary', $glossary->id);
+
+        $gg = $this->getDataGenerator()->get_plugin_generator('mod_glossary');
+        $this->setUser($student1);
+
+        // Create entry with tags and rating.
+        $entry = $gg->create_content(
+            $glossary,
+            ['approved' => 1, 'userid' => $student1->id, 'tags' => ['Cats', 'Dogs']],
+            ['alias1', 'alias2']
+        );
+
+        // Rate the entry as user2.
+        $rating1 = new stdClass();
+        $rating1->contextid = $context->id;
+        $rating1->component = 'mod_glossary';
+        $rating1->ratingarea = 'entry';
+        $rating1->itemid = $entry->id;
+        $rating1->rating = 1; // 1 is A.
+        $rating1->scaleid = "-$scale->id";
+        $rating1->userid = $student2->id;
+        $rating1->timecreated = time();
+        $rating1->timemodified = time();
+        $rating1->id = $DB->insert_record('rating', $rating1);
+
+        $sink = $this->redirectEvents();
+        mod_glossary_delete_entry(fullclone($entry), $glossary, $cm, $context, $course);
+        $events = $sink->get_events();
+        $event = array_pop($events);
+
+        // Check events.
+        $this->assertEquals('\mod_glossary\event\entry_deleted', $event->eventname);
+        $this->assertEquals($entry->id, $event->objectid);
+        $sink->close();
+
+        // No entry, no alias, no ratings, no tags.
+        $this->assertEquals(0, $DB->count_records('glossary_entries', ['id' => $entry->id]));
+        $this->assertEquals(0, $DB->count_records('glossary_alias', ['entryid' => $entry->id]));
+        $this->assertEquals(0, $DB->count_records('rating', ['component' => 'mod_glossary', 'itemid' => $entry->id]));
+        $this->assertEmpty(core_tag_tag::get_by_name(0, 'Cats'));
+    }
+
+    public function test_mod_glossary_delete_entry_imported() {
+        global $DB;
+        $this->resetAfterTest();
+
+        // Create required data.
+        $course = $this->getDataGenerator()->create_course();
+        $student = $this->getDataGenerator()->create_and_enrol($course, 'student');
+
+        $glossary1 = $this->getDataGenerator()->create_module('glossary', ['course' => $course->id]);
+        $glossary2 = $this->getDataGenerator()->create_module('glossary', ['course' => $course->id]);
+
+        $context = context_module::instance($glossary2->cmid);
+        $cm = get_coursemodule_from_instance('glossary', $glossary2->id);
+
+        $gg = $this->getDataGenerator()->get_plugin_generator('mod_glossary');
+        $this->setUser($student);
+
+        $entry1 = $gg->create_content($glossary1);
+        $entry2 = $gg->create_content(
+            $glossary2,
+            ['approved' => 1, 'userid' => $student->id, 'sourceglossaryid' => $glossary1->id, 'tags' => ['Cats', 'Dogs']]
+        );
+
+        $sink = $this->redirectEvents();
+        mod_glossary_delete_entry(fullclone($entry2), $glossary2, $cm, $context, $course);
+        $events = $sink->get_events();
+        $event = array_pop($events);
+
+        // Check events.
+        $this->assertEquals('\mod_glossary\event\entry_deleted', $event->eventname);
+        $this->assertEquals($entry2->id, $event->objectid);
+        $sink->close();
+
+        // Check source.
+        $this->assertEquals(0, $DB->get_field('glossary_entries', 'sourceglossaryid', ['id' => $entry2->id]));
+        $this->assertEquals($glossary1->id, $DB->get_field('glossary_entries', 'glossaryid', ['id' => $entry2->id]));
+
+        // Tags.
+        $this->assertEmpty(core_tag_tag::get_by_name(0, 'Cats'));
+    }
+
+    public function test_mod_glossary_can_update_entry_users() {
+        $this->resetAfterTest();
+
+        // Create required data.
+        $course = $this->getDataGenerator()->create_course();
+        $student = $this->getDataGenerator()->create_and_enrol($course, 'student');
+        $anotherstudent = $this->getDataGenerator()->create_and_enrol($course, 'student');
+        $teacher = $this->getDataGenerator()->create_and_enrol($course, 'editingteacher');
+        $glossary = $this->getDataGenerator()->create_module('glossary', array('course' => $course->id));
+
+        $gg = $this->getDataGenerator()->get_plugin_generator('mod_glossary');
+        $this->setUser($student);
+        $entry = $gg->create_content($glossary);
+        $context = context_module::instance($glossary->cmid);
+        $cm = get_coursemodule_from_instance('glossary', $glossary->id);
+
+        // Test student can update.
+        $this->assertTrue(mod_glossary_can_update_entry($entry, $glossary, $context, $cm));
+
+        // Test teacher can update.
+        $this->setUser($teacher);
+        $this->assertTrue(mod_glossary_can_update_entry($entry, $glossary, $context, $cm));
+
+        // Test admin can update.
+        $this->setAdminUser();
+        $this->assertTrue(mod_glossary_can_update_entry($entry, $glossary, $context, $cm));
+
+        // Test a different student is not able to update.
+        $this->setUser($anotherstudent);
+        $this->assertFalse(mod_glossary_can_update_entry($entry, $glossary, $context, $cm));
+
+        // Test exception.
+        $this->expectExceptionMessage(get_string('errcannoteditothers', 'glossary'));
+        mod_glossary_can_update_entry($entry, $glossary, $context, $cm, false);
+    }
+
+    public function test_mod_glossary_can_update_entry_edit_period() {
+        global $CFG;
+        $this->resetAfterTest();
+
+        // Create required data.
+        $course = $this->getDataGenerator()->create_course();
+        $student = $this->getDataGenerator()->create_and_enrol($course, 'student');
+        $glossary = $this->getDataGenerator()->create_module('glossary', array('course' => $course->id, 'editalways' => 1));
+
+        $gg = $this->getDataGenerator()->get_plugin_generator('mod_glossary');
+        $this->setUser($student);
+        $entry = $gg->create_content($glossary);
+        $context = context_module::instance($glossary->cmid);
+        $cm = get_coursemodule_from_instance('glossary', $glossary->id);
+
+        // Test student can always update when edit always is set to 1.
+        $entry->timecreated = time() - 2 * $CFG->maxeditingtime;
+        $this->assertTrue(mod_glossary_can_update_entry($entry, $glossary, $context, $cm));
+
+        // Test student cannot update old entries when edit always is set to 0.
+        $glossary->editalways = 0;
+        $this->assertFalse(mod_glossary_can_update_entry($entry, $glossary, $context, $cm));
+
+        // Test student can update recent entries when edit always is set to 0.
+        $entry->timecreated = time();
+        $this->assertTrue(mod_glossary_can_update_entry($entry, $glossary, $context, $cm));
+
+        // Check exception.
+        $entry->timecreated = time() - 2 * $CFG->maxeditingtime;
+        $this->expectExceptionMessage(get_string('erredittimeexpired', 'glossary'));
+        mod_glossary_can_update_entry($entry, $glossary, $context, $cm, false);
+    }
+
+    public function test_prepare_entry_for_edition() {
+        global $USER;
+        $this->resetAfterTest(true);
+
+        $course = $this->getDataGenerator()->create_course();
+        $glossary = $this->getDataGenerator()->create_module('glossary', ['course' => $course->id]);
+        $gg = $this->getDataGenerator()->get_plugin_generator('mod_glossary');
+
+        $this->setAdminUser();
+        $aliases = ['alias1', 'alias2'];
+        $entry = $gg->create_content(
+            $glossary,
+            ['approved' => 1, 'userid' => $USER->id],
+            $aliases
+        );
+
+        $cat1 = $gg->create_category($glossary, [], [$entry]);
+        $gg->create_category($glossary);
+
+        $entry = mod_glossary_prepare_entry_for_edition($entry);
+        $this->assertCount(1, $entry->categories);
+        $this->assertEquals($cat1->id, $entry->categories[0]);
+        $returnedaliases = array_values(explode("\n", trim($entry->aliases)));
+        sort($returnedaliases);
+        $this->assertEquals($aliases, $returnedaliases);
+    }
 }
index 7738fe5..07650ac 100644 (file)
@@ -1,6 +1,10 @@
 This files describes API changes in /mod/glossary/*,
 information provided here is intended especially for developers.
 
+=== 3.10 ===
+* External function get_entries_by_id now returns and additional "permissions" field indicating the user permissions for managing
+  the entry.
+
 === 3.8 ===
 * The following functions have been finally deprecated and can not be used anymore:
     * glossary_scale_used()
index 6a66d3e..666054c 100644 (file)
@@ -24,7 +24,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2021052500;       // The current module version (Date: YYYYMMDDXX)
+$plugin->version   = 2021052502;       // The current module version (Date: YYYYMMDDXX)
 $plugin->requires  = 2021052500;    // Requires this Moodle version
 $plugin->component = 'mod_glossary';   // Full name of the plugin (used for diagnostics)
 $plugin->cron      = 0;