Merge branch 'MDL-69069-master' of git://github.com/bmbrands/moodle
authorAndrew Nicols <andrew@nicols.co.uk>
Thu, 3 Sep 2020 01:32:36 +0000 (09:32 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Thu, 3 Sep 2020 01:32:36 +0000 (09:32 +0800)
120 files changed:
admin/settings/language.php
admin/tool/uploadcourse/classes/step2_form.php
admin/tool/usertours/classes/manager.php
auth/db/auth.php
auth/ldap/auth.php
auth/mnet/auth.php
backup/util/dbops/restore_dbops.class.php
backup/util/ui/classes/output/copy_form.php
badges/criteria/award_criteria_manual.php
calendar/classes/local/event/forms/eventtype.php
calendar/tests/behat/calendar.feature
cohort/edit_form.php
cohort/tests/behat/add_cohort.feature
comment/classes/external.php
comment/comment_ajax.php
comment/lib.php
comment/tests/context_freeze_test.php [new file with mode: 0644]
contentbank/classes/content.php
contentbank/classes/contentbank.php
contentbank/classes/contenttype.php
contentbank/contenttype/h5p/classes/contenttype.php
contentbank/contenttype/h5p/tests/behat/admin_replace_content.feature [new file with mode: 0644]
contentbank/contenttype/h5p/tests/behat/teacher_replace_content.feature [new file with mode: 0644]
contentbank/contenttype/h5p/tests/contenttype_h5p_test.php
contentbank/files_form.php
contentbank/tests/behat/download_content.feature [new file with mode: 0644]
contentbank/tests/content_test.php
contentbank/tests/contentbank_test.php
contentbank/tests/contenttype_test.php
contentbank/upload.php
contentbank/view.php
course/classes/deletecategory_form.php
course/classes/editcategory_form.php
course/edit_form.php
course/request_form.php
course/switchrole.php
course/tests/behat/category_management.feature
course/tests/behat/course_request.feature
course/tests/behat/coursetags.feature
course/tests/behat/rename_roles.feature
course/tests/behat/role_renaming.feature
customfield/field/select/classes/data_controller.php
customfield/field/select/classes/field_controller.php
enrol/cohort/lib.php
enrol/manual/classes/enrol_users_form.php
grade/edit/tree/category.php
grade/edit/tree/lib.php
grade/report/grader/lib.php
grade/report/singleview/classes/local/screen/grade.php
grade/report/singleview/classes/local/ui/finalgrade.php
grade/report/singleview/tests/behat/bulk_insert_grades.feature
grade/report/singleview/tests/behat/singleview.feature
group/autogroup.php
h5p/classes/player.php
lang/en/admin.php
lang/en/contentbank.php
lang/en/error.php
lang/en/role.php
lib/accesslib.php
lib/adminlib.php
lib/classes/dataformat.php
lib/classes/grades_external.php
lib/classes/user.php
lib/db/access.php
lib/db/services.php
lib/form/duration.php
lib/form/tests/duration_test.php
lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector-debug.js
lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector-min.js
lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector.js
lib/form/yui/src/dateselector/js/calendar.js
lib/grade/grade_grade.php
lib/grade/tests/grade_grade_test.php
lib/moodlelib.php
lib/outputlib.php
lib/tests/dataformat_test.php
lib/tests/grades_externallib_test.php
lib/tests/moodlelib_test.php
lib/upgrade.txt
mod/assign/feedback/file/importziplib.php
mod/assign/feedback/file/tests/importziplib_test.php [new file with mode: 0644]
mod/h5pactivity/mod_form.php
mod/h5pactivity/view.php
pix/i/completion-auto-enabled.png
pix/i/completion-auto-enabled.svg
pix/i/completion-auto-fail.png
pix/i/completion-auto-fail.svg
pix/i/completion-auto-n.png
pix/i/completion-auto-n.svg
pix/i/completion-auto-pass.png
pix/i/completion-auto-pass.svg
pix/i/completion-auto-y-override.png
pix/i/completion-auto-y-override.svg
pix/i/completion-auto-y.png
pix/i/completion-auto-y.svg
pix/i/completion-manual-enabled.png
pix/i/completion-manual-enabled.svg
pix/i/completion-manual-n.png
pix/i/completion-manual-n.svg
pix/i/completion-manual-y-override.png
pix/i/completion-manual-y-override.svg
pix/i/completion-manual-y.png
pix/i/completion-manual-y.svg
repository/equella/lib.php
theme/boost/amd/build/form-display-errors.min.js
theme/boost/amd/build/form-display-errors.min.js.map
theme/boost/amd/src/form-display-errors.js
theme/boost/classes/autoprefixer.php
theme/boost/config.php
theme/boost/lang/en/theme_boost.php
theme/boost/lib.php
theme/boost/scss/moodle.scss
theme/boost/scss/moodle/calendar.scss
theme/boost/scss/moodle/prefixes.scss [new file with mode: 0644]
theme/boost/style/moodle.css
theme/classic/style/moodle.css
user/classes/output/participants_filter.php
user/classes/output/user_roles_editable.php
user/classes/table/participants.php
version.php

index 1477082..c865dfb 100644 (file)
@@ -8,6 +8,8 @@ if ($hassiteconfig) {
     $temp = new admin_settingpage('langsettings', new lang_string('languagesettings', 'admin'));
     $temp->add(new admin_setting_configcheckbox('autolang', new lang_string('autolang', 'admin'), new lang_string('configautolang', 'admin'), 1));
     $temp->add(new admin_setting_configselect('lang', new lang_string('lang', 'admin'), new lang_string('configlang', 'admin'), current_language(), get_string_manager()->get_list_of_translations())); // $CFG->lang might be set in installer already, default en is in setup.php
+    $temp->add(new admin_setting_configcheckbox('autolangusercreation', new lang_string('autolangusercreation', 'admin'),
+        new lang_string('configautolangusercreation', 'admin'), 1));
     $temp->add(new admin_setting_configcheckbox('langmenu', new lang_string('langmenu', 'admin'), new lang_string('configlangmenu', 'admin'), 1));
     $temp->add(new admin_setting_langlist());
     $temp->add(new admin_setting_configcheckbox('langcache', new lang_string('langcache', 'admin'), new lang_string('langcache_desc', 'admin'), 1));
index 8854abb..9f1a799 100644 (file)
@@ -83,7 +83,7 @@ class tool_uploadcourse_step2_form extends tool_uploadcourse_base_form {
         $mform->setExpanded('defaultheader', true);
 
         $displaylist = core_course_category::make_categories_list('moodle/course:create');
-        $mform->addElement('select', 'defaults[category]', get_string('coursecategory'), $displaylist);
+        $mform->addElement('autocomplete', 'defaults[category]', get_string('coursecategory'), $displaylist);
         $mform->addHelpButton('defaults[category]', 'coursecategory');
 
         $choices = array();
index 4a9f5cf..6741568 100644 (file)
@@ -138,13 +138,28 @@ class manager {
      */
     const CONFIG_SHIPPED_VERSION = 'shipped_version';
 
+    /**
+     * Helper method to initialize admin page, setting appropriate extra URL parameters
+     *
+     * @param string $action
+     */
+    protected function setup_admin_externalpage(string $action): void {
+        admin_externalpage_setup('tool_usertours/tours', '', array_filter([
+            'action' => $action,
+            'id' => optional_param('id', 0, PARAM_INT),
+            'tourid' => optional_param('tourid', 0, PARAM_INT),
+            'direction' => optional_param('direction', 0, PARAM_INT),
+        ]));
+    }
+
     /**
      * This is the entry point for this controller class.
      *
      * @param   string  $action     The action to perform.
      */
     public function execute($action) {
-        admin_externalpage_setup('tool_usertours/tours');
+        $this->setup_admin_externalpage($action);
+
         // Add the main content.
         switch($action) {
             case self::ACTION_NEWTOUR:
index 13da0f4..5c0f74f 100644 (file)
@@ -454,9 +454,7 @@ class auth_plugin_db extends auth_plugin_base {
                 $user->confirmed  = 1;
                 $user->auth       = $this->authtype;
                 $user->mnethostid = $CFG->mnet_localhost_id;
-                if (empty($user->lang)) {
-                    $user->lang = $CFG->lang;
-                }
+
                 if ($collision = $DB->get_record_select('user', "username = :username AND mnethostid = :mnethostid AND auth <> :auth", array('username'=>$user->username, 'mnethostid'=>$CFG->mnet_localhost_id, 'auth'=>$this->authtype), 'id,username,auth')) {
                     $trace->output(get_string('auth_dbinsertuserduplicate', 'auth_db', array('username'=>$user->username, 'auth'=>$collision->auth)), 1);
                     continue;
index f03372f..aae5522 100644 (file)
@@ -949,9 +949,7 @@ class auth_plugin_ldap extends auth_plugin_base {
                 //
                 // The cast to int is a workaround for MDL-53959.
                 $user->suspended = (int)$this->is_user_suspended($user);
-                if (empty($user->lang)) {
-                    $user->lang = $CFG->lang;
-                }
+
                 if (empty($user->calendartype)) {
                     $user->calendartype = $CFG->calendartype;
                 }
index 2744dcd..27cf5d7 100644 (file)
@@ -278,13 +278,7 @@ class auth_plugin_mnet extends auth_plugin_base {
         if (isset($remoteuser->lang)) {
             $remoteuser->lang = clean_param(str_replace('_utf8', '', $remoteuser->lang), PARAM_LANG);
         }
-        if (empty($remoteuser->lang)) {
-            if (!empty($CFG->lang)) {
-                $remoteuser->lang = $CFG->lang;
-            } else {
-                $remoteuser->lang = 'en';
-            }
-        }
+
         $firsttime = false;
 
         // get the local record for the remote user
index a5ca451..e8f1382 100644 (file)
@@ -1165,7 +1165,7 @@ abstract class restore_dbops {
 
             // if user lang doesn't exist here, use site default
             if (!array_key_exists($user->lang, $languages)) {
-                $user->lang = $CFG->lang;
+                $user->lang = get_newuser_language();
             }
 
             // if user theme isn't available on target site or they are disabled, reset theme
index ff7a55d..34794a8 100644 (file)
@@ -106,7 +106,7 @@ class copy_form extends \moodleform {
             // Always keep current category.
             $displaylist[$course->category] = \core_course_category::get($course->category, MUST_EXIST, true)->get_formatted_name();
         }
-        $mform->addElement('select', 'category', get_string('coursecategory'), $displaylist);
+        $mform->addElement('autocomplete', 'category', get_string('coursecategory'), $displaylist);
         $mform->addHelpButton('category', 'coursecategory');
 
         // Course visibility.
index ff1e613..e7f65ca 100644 (file)
@@ -49,7 +49,7 @@ class award_criteria_manual extends award_criteria {
         $rec = $DB->get_record('role', array('id' => $rid));
 
         if ($rec) {
-            return role_get_name($rec, $PAGE->context, ROLENAME_ALIAS);
+            return role_get_name($rec, $PAGE->context, ROLENAME_BOTH);
         } else {
             return null;
         }
index 8475e29..6488acb 100644 (file)
@@ -94,7 +94,7 @@ trait eventtype {
                 $categoryoptions[$id] = $category;
             }
 
-            $mform->addElement('select', 'categoryid', get_string('category'), $categoryoptions);
+            $mform->addElement('autocomplete', 'categoryid', get_string('category'), $categoryoptions);
             $mform->hideIf('categoryid', 'eventtype', 'noteq', 'category');
         }
 
index 5f1ffaf..abdf3ed 100644 (file)
@@ -199,10 +199,10 @@ Feature: Perform basic calendar functionality
     And I am viewing site calendar
     And I click on "New event" "button"
     And I set the field "Type of event" to "Course"
-    When I open the autocomplete suggestions list
-    Then I should see "Course 1" in the ".form-autocomplete-suggestions" "css_element"
-    And I should not see "Course 2" in the ".form-autocomplete-suggestions" "css_element"
-    And I should not see "Course 3" in the ".form-autocomplete-suggestions" "css_element"
+    When I expand the "Course" autocomplete
+    Then "Course 1" "autocomplete_suggestions" should exist
+    And "Course 2" "autocomplete_suggestions" should not exist
+    And "Course 3" "autocomplete_suggestions" should not exist
     And I click on "Close" "button"
     And I am on site homepage
     And I navigate to "Appearance > Calendar" in site administration
@@ -211,10 +211,10 @@ Feature: Perform basic calendar functionality
     And I am viewing site calendar
     And I click on "New event" "button"
     And I set the field "Type of event" to "Course"
-    When I open the autocomplete suggestions list
-    Then I should see "Course 1" in the ".form-autocomplete-suggestions" "css_element"
-    And I should see "Course 2" in the ".form-autocomplete-suggestions" "css_element"
-    And I should see "Course 3" in the ".form-autocomplete-suggestions" "css_element"
+    When I expand the "Course" autocomplete
+    Then "Course 1" "autocomplete_suggestions" should exist
+    And "Course 2" "autocomplete_suggestions" should exist
+    And "Course 3" "autocomplete_suggestions" should exist
 
   @javascript
   Scenario: Students can only see user event type by default.
index 7dbe18b..77599fd 100644 (file)
@@ -43,7 +43,7 @@ class cohort_edit_form extends moodleform {
         $mform->setType('name', PARAM_TEXT);
 
         $options = $this->get_category_options($cohort->contextid);
-        $mform->addElement('select', 'contextid', get_string('context', 'role'), $options);
+        $mform->addElement('autocomplete', 'contextid', get_string('context', 'role'), $options);
 
         $mform->addElement('text', 'idnumber', get_string('idnumber', 'cohort'), 'maxlength="254" size="50"');
         $mform->setType('idnumber', PARAM_RAW); // Idnumbers are plain text, must not be changed.
index 1a5955d..b93d41f 100644 (file)
@@ -21,6 +21,7 @@ Feature: Add cohorts of users
       | Description | Test cohort description |
     And I press "Save changes"
 
+  @javascript
   Scenario: Add a cohort
     When I follow "Cohorts"
     Then I should see "Test cohort name"
@@ -28,6 +29,7 @@ Feature: Add cohorts of users
     And I should see "Test cohort description"
     And I should see "Created manually"
 
+  @javascript
   Scenario: Add users to a cohort selecting them from the system users list
     When I add "First User (first@example.com)" user to "333" cohort members
     And I add "Second User (second@example.com)" user to "333" cohort members
@@ -37,6 +39,7 @@ Feature: Add cohorts of users
     And the "Current users" select box should contain "Second User (second@example.com)"
     And the "Current users" select box should not contain "Forth User (forth@example.com)"
 
+  @javascript
   Scenario: Add users to a cohort using a bulk user action
     When I follow "Accounts"
     And I follow "Bulk user actions"
index 41a8b41..1eb3d8e 100644 (file)
@@ -337,7 +337,7 @@ class core_comment_external extends external_api {
             $args->area      = $commentrecord->commentarea;
             $manager = new comment($args);
 
-            if ($commentrecord->userid != $USER->id && !$manager->can_delete($commentrecord->id)) {
+            if (!$manager->can_delete($commentrecord)) {
                 throw new comment_exception('nopermissiontodelentry');
             }
 
index c1fe37c..50caa94 100644 (file)
@@ -91,8 +91,8 @@ switch ($action) {
         }
         break;
     case 'delete':
-        $comment_record = $DB->get_record('comments', array('id'=>$commentid));
-        if ($manager->can_delete($commentid) || $comment_record->userid == $USER->id) {
+        $comment = $DB->get_record('comments', ['id' => $commentid]);
+        if ($manager->can_delete($comment)) {
             if ($manager->delete($commentid)) {
                 $result = array(
                     'client_id' => $client_id,
index 58f8151..65d81e3 100644 (file)
@@ -589,8 +589,7 @@ class comment {
             $c->avatar = $OUTPUT->user_picture($u, array('size'=>18));
             $c->userid = $u->id;
 
-            $candelete = $this->can_delete($c->id);
-            if (($USER->id == $u->id) || !empty($candelete)) {
+            if ($this->can_delete($c)) {
                 $c->delete = true;
             }
             $comments[] = $c;
@@ -800,16 +799,22 @@ class comment {
     /**
      * Delete a comment
      *
-     * @param  int $commentid
+     * @param  int|stdClass $comment The id of a comment, or a comment record.
      * @return bool
      */
-    public function delete($commentid) {
-        global $DB, $USER;
-        $candelete = has_capability('moodle/comment:delete', $this->context);
-        if (!$comment = $DB->get_record('comments', array('id'=>$commentid))) {
+    public function delete($comment) {
+        global $DB;
+        if (is_object($comment)) {
+            $commentid = $comment->id;
+        } else {
+            $commentid = $comment;
+            $comment = $DB->get_record('comments', ['id' => $commentid]);
+        }
+
+        if (!$comment) {
             throw new comment_exception('dbupdatefailed');
         }
-        if (!($USER->id == $comment->userid || !empty($candelete))) {
+        if (!$this->can_delete($comment)) {
             throw new comment_exception('nopermissiontocomment');
         }
         $DB->delete_records('comments', array('id'=>$commentid));
@@ -976,13 +981,35 @@ class comment {
     }
 
     /**
-     * Returns true if the user can delete this comment
-     * @param int $commentid
+     * Returns true if the user can delete this comment.
+     *
+     * The user can delete comments if it is one they posted and they can still make posts,
+     * or they have the capability to delete comments.
+     *
+     * A database call is avoided if a comment record is passed.
+     *
+     * @param int|stdClass $comment The id of a comment, or a comment record.
      * @return bool
      */
-    public function can_delete($commentid) {
+    public function can_delete($comment) {
+        global $USER, $DB;
+        if (is_object($comment)) {
+            $commentid = $comment->id;
+        } else {
+            $commentid = $comment;
+        }
+
         $this->validate(array('commentid'=>$commentid));
-        return has_capability('moodle/comment:delete', $this->context);
+
+        if (!is_object($comment)) {
+            // Get the comment record from the database.
+            $comment = $DB->get_record('comments', array('id' => $commentid), 'id, userid', MUST_EXIST);
+        }
+
+        $hascapability = has_capability('moodle/comment:delete', $this->context);
+        $owncomment = $USER->id == $comment->userid;
+
+        return ($hascapability || ($owncomment && $this->can_post()));
     }
 
     /**
diff --git a/comment/tests/context_freeze_test.php b/comment/tests/context_freeze_test.php
new file mode 100644 (file)
index 0000000..36f1d60
--- /dev/null
@@ -0,0 +1,166 @@
+<?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/>.
+
+/**
+ * Tests for comments when the context is frozen.
+ *
+ * @package    core_comment
+ * @copyright  2019 University of Nottingham
+ * @author     Neill Magill <neill.magill@nottingham.ac.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Tests for comments when the context is frozen.
+ *
+ * @package    core_comment
+ * @copyright  2019 University of Nottingham
+ * @author     Neill Magill <neill.magill@nottingham.ac.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class comment_context_freeze_testcase extends advanced_testcase {
+    /**
+     * Creates a comment by a student.
+     *
+     * Returns:
+     * - The comment object
+     * - The sudent that wrote the comment
+     * - The arguments used to create the comment
+     *
+     * @param stdClass $course Moodle course from the datagenerator
+     * @return array
+     */
+    protected function create_student_comment_and_freeze_course($course): array {
+        set_config('contextlocking', 1);
+
+        $context = context_course::instance($course->id);
+        $student = $this->getDataGenerator()->create_and_enrol($course, 'student');
+
+        $args = new stdClass;
+        $args->context = $context;
+        $args->course = $course;
+        $args->area = 'page_comments';
+        $args->itemid = 0;
+        $args->component = 'block_comments';
+        $args->linktext = get_string('showcomments');
+        $args->notoggle = true;
+        $args->autostart = true;
+        $args->displaycancel = false;
+
+        // Create a comment by the student.
+        $this->setUser($student);
+        $comment = new comment($args);
+        $newcomment = $comment->add('New comment');
+
+        // Freeze the context.
+        $this->setAdminUser();
+        $context->set_locked(true);
+
+        return [$newcomment, $student, $args];
+    }
+
+    /**
+     * Test that a student cannot delete their own comments in frozen contexts via the external service.
+     */
+    public function test_delete_student_external() {
+        global $CFG;
+        require_once($CFG->dirroot . '/comment/lib.php');
+
+        $this->resetAfterTest();
+
+        $course = $this->getDataGenerator()->create_course();
+
+        list($newcomment, $student, $args) = $this->create_student_comment_and_freeze_course($course);
+
+        // Check that a student cannot delete their own comment.
+        $this->setUser($student);
+        $studentcomment = new comment($args);
+        $this->assertFalse($studentcomment->can_delete($newcomment->id));
+        $this->assertFalse($studentcomment->can_post());
+        $this->expectException(comment_exception::class);
+        $this->expectExceptionMessage(get_string('nopermissiontodelentry', 'error'));
+        core_comment_external::delete_comments([$newcomment->id]);
+    }
+
+    /**
+     * Test that a student cannot delete their own comments in frozen contexts.
+     */
+    public function test_delete_student() {
+        global $CFG;
+        require_once($CFG->dirroot . '/comment/lib.php');
+
+        $this->resetAfterTest();
+
+        $course = $this->getDataGenerator()->create_course();
+
+        list($newcomment, $student, $args) = $this->create_student_comment_and_freeze_course($course);
+
+        // Check that a student cannot delete their own comment.
+        $this->setUser($student);
+        $studentcomment = new comment($args);
+        $this->assertFalse($studentcomment->can_delete($newcomment->id));
+        $this->assertFalse($studentcomment->can_post());
+        $this->expectException(comment_exception::class);
+        $this->expectExceptionMessage(get_string('nopermissiontocomment', 'error'));
+        $studentcomment->delete($newcomment->id);
+    }
+
+    /**
+     * Test that an admin cannot delete comments in frozen contexts via the external service.
+     */
+    public function test_delete_admin_external() {
+        global $CFG;
+        require_once($CFG->dirroot . '/comment/lib.php');
+
+        $this->resetAfterTest();
+
+        $course = $this->getDataGenerator()->create_course();
+
+        list($newcomment, $student, $args) = $this->create_student_comment_and_freeze_course($course);
+
+        // Check that the admin user cannot delete the comment.
+        $admincomment = new comment($args);
+        $this->assertFalse($admincomment->can_delete($newcomment->id));
+        $this->assertFalse($admincomment->can_post());
+        $this->expectException(comment_exception::class);
+        $this->expectExceptionMessage(get_string('nopermissiontodelentry', 'error'));
+        core_comment_external::delete_comments([$newcomment->id]);
+    }
+
+    /**
+     * Test that an admin cannot delete comments in frozen contexts.
+     */
+    public function test_delete_admin() {
+        global $CFG;
+        require_once($CFG->dirroot . '/comment/lib.php');
+
+        $this->resetAfterTest();
+
+        $course = $this->getDataGenerator()->create_course();
+
+        list($newcomment, $student, $args) = $this->create_student_comment_and_freeze_course($course);
+
+        // Check that the admin user cannot delete the comment.
+        $admincomment = new comment($args);
+        $this->assertFalse($admincomment->can_delete($newcomment->id));
+        $this->assertFalse($admincomment->can_post());
+        $this->expectException(comment_exception::class);
+        $this->expectExceptionMessage(get_string('nopermissiontocomment', 'error'));
+        $admincomment->delete($newcomment->id);
+    }
+}
index 37e3385..227294b 100644 (file)
@@ -28,6 +28,7 @@ use core_text;
 use stored_file;
 use stdClass;
 use coding_exception;
+use context;
 use moodle_url;
 use core\event\contentbank_content_updated;
 
@@ -85,6 +86,17 @@ abstract class content {
         return $this->content->contenttype;
     }
 
+    /**
+     * Return the contenttype instance of this content.
+     *
+     * @return contenttype The content type instance
+     */
+    public function get_content_type_instance(): contenttype {
+        $context = context::instance_by_id($this->content->contextid);
+        $contenttypeclass = "\\{$this->content->contenttype}\\contenttype";
+        return new $contenttypeclass($context);
+    }
+
     /**
      * Returns $this->content->timemodified.
      *
index 307b949..1e5c934 100644 (file)
@@ -334,4 +334,18 @@ class contentbank {
 
         return $contenttypes;
     }
+
+    /**
+     * Return a content class form a content id.
+     *
+     * @throws coding_exception if the ID is not valid or some class does no exists
+     * @param int $id the content id
+     * @return content the content class instance
+     */
+    public function get_content_from_id(int $id): content {
+        global $DB;
+        $record = $DB->get_record('contentbank_content', ['id' => $id], '*', MUST_EXIST);
+        $contentclass = "\\$record->contenttype\\content";
+        return new $contentclass($record);
+    }
 }
index 699cee0..8f9fec3 100644 (file)
@@ -40,12 +40,18 @@ use moodle_url;
  */
 abstract class contenttype {
 
-    /** Plugin implements uploading feature */
+    /** @var string Constant representing whether the plugin implements uploading feature */
     const CAN_UPLOAD = 'upload';
 
-    /** Plugin implements edition feature */
+    /** @var string Constant representing whether the plugin implements edition feature */
     const CAN_EDIT = 'edit';
 
+    /**
+     * @var string Constant representing whether the plugin implements download feature
+     * @since  Moodle 3.10
+     */
+    const CAN_DOWNLOAD = 'download';
+
     /** @var \context This contenttype's context. **/
     protected $context = null;
 
@@ -115,6 +121,21 @@ abstract class contenttype {
         return $content;
     }
 
+    /**
+     * Replace a content using an uploaded file.
+     *
+     * @throws file_exception If file operations fail
+     * @throws dml_exception if the content creation fails
+     * @param stored_file $file the uploaded file
+     * @param content $content the original content record
+     * @return content Object with the updated content bank information.
+     */
+    public function replace_content(stored_file $file, content $content): content {
+        $content->import_file($file);
+        $content->update_content();
+        return $content;
+    }
+
     /**
      * Delete this content from the content_bank.
      * This method can be overwritten by the plugins if they need to delete specific information.
@@ -220,6 +241,31 @@ abstract class contenttype {
         return '';
     }
 
+    /**
+     * Returns the URL to download the content.
+     *
+     * @since  Moodle 3.10
+     * @param  content $content The content to be downloaded.
+     * @return string           URL with the content to download.
+     */
+    public function get_download_url(content $content): string {
+        $downloadurl = '';
+        $file = $content->get_file();
+        if (!empty($file)) {
+            $url = \moodle_url::make_pluginfile_url(
+                $file->get_contextid(),
+                $file->get_component(),
+                $file->get_filearea(),
+                $file->get_itemid(),
+                $file->get_filepath(),
+                $file->get_filename()
+            );
+            $downloadurl = $url->out(false);
+        }
+
+        return $downloadurl;
+    }
+
     /**
      * Returns the HTML code to render the icon for content bank contents.
      *
@@ -392,6 +438,38 @@ abstract class contenttype {
         return true;
     }
 
+    /**
+     * Returns whether or not the user has permission to download the content.
+     *
+     * @since  Moodle 3.10
+     * @param  content $content The content to be downloaded.
+     * @return bool    True if the user can download the content. False otherwise.
+     */
+    final public function can_download(content $content): bool {
+        if (!$this->is_feature_supported(self::CAN_DOWNLOAD)) {
+            return false;
+        }
+
+        if (!$this->can_access()) {
+            return false;
+        }
+
+        $hascapability = has_capability('moodle/contentbank:downloadcontent', $this->context);
+        return $hascapability && $this->is_download_allowed($content);
+    }
+
+    /**
+     * Returns plugin allows downloading.
+     *
+     * @since  Moodle 3.10
+     * @param  content $content The content to be downloaed.
+     * @return bool    True if plugin allows downloading. False otherwise.
+     */
+    protected function is_download_allowed(content $content): bool {
+        // Plugins can overwrite this function to add any check they need.
+        return true;
+    }
+
     /**
      * Returns the plugin supports the feature.
      *
index 1c1f2ea..e16a30e 100644 (file)
@@ -110,7 +110,7 @@ class contenttype extends \core_contentbank\contenttype {
      * @return array
      */
     protected function get_implemented_features(): array {
-        return [self::CAN_UPLOAD, self::CAN_EDIT];
+        return [self::CAN_UPLOAD, self::CAN_EDIT, self::CAN_DOWNLOAD];
     }
 
     /**
diff --git a/contentbank/contenttype/h5p/tests/behat/admin_replace_content.feature b/contentbank/contenttype/h5p/tests/behat/admin_replace_content.feature
new file mode 100644 (file)
index 0000000..bc5202c
--- /dev/null
@@ -0,0 +1,30 @@
+@core @core_contentbank @contenttype_h5p @_file_upload @_switch_iframe @javascript
+Feature: Replace H5P file from an existing content
+  In order to replace an H5P content from the content bank
+  As an admin
+  I need to be able to replace the content with a new .h5p file
+
+  Background:
+    Given the following "contentbank content" exist:
+      | contextlevel | reference | contenttype     | user  | contentname       | filepath                              |
+      | System       |           | contenttype_h5p | admin | filltheblanks.h5p | /h5p/tests/fixtures/filltheblanks.h5p |
+    And I log in as "admin"
+    And I press "Customise this page"
+    And I add the "Navigation" block if not present
+    And I expand "Site pages" node
+    And I click on "Content bank" "link"
+
+  Scenario: Admins can replace the original .h5p file with a new one
+    Given I click on "filltheblanks.h5p" "link"
+    And I switch to "h5p-player" class iframe
+    And I switch to "h5p-iframe" class iframe
+    And I should see "Of which countries"
+    And I switch to the main frame
+    When I open the action menu in "region-main-settings-menu" "region"
+    And I choose "Replace with file" in the open action menu
+    And I upload "h5p/tests/fixtures/ipsums.h5p" file to "Upload content" filemanager
+    And I click on "Save changes" "button"
+    Then I switch to "h5p-player" class iframe
+    And I switch to "h5p-iframe" class iframe
+    And I should see "Lorum ipsum"
+    And I switch to the main frame
diff --git a/contentbank/contenttype/h5p/tests/behat/teacher_replace_content.feature b/contentbank/contenttype/h5p/tests/behat/teacher_replace_content.feature
new file mode 100644 (file)
index 0000000..5a53f5c
--- /dev/null
@@ -0,0 +1,67 @@
+@core @core_contentbank @contenttype_h5p @_file_upload @_switch_iframe @javascript
+Feature: Replace H5P file from an existing content requires special capabilities
+  In order replace an H5P content from the content bank
+  As a teacher
+  I need to be able to replace the content only if certain capabilities are allowed
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | teacher1 | Teacher   | 1        | teacher1@example.com |
+    And the following "categories" exist:
+      | name  | category | idnumber |
+      | Cat 1 | 0        | CAT1     |
+    And the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1        | CAT1     |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | teacher1 | C1     | editingteacher |
+    And the following "contentbank content" exist:
+      | contextlevel | reference | contenttype     | user     | contentname       | filepath                              |
+      | Course       | C1        | contenttype_h5p | admin    | admincontent      | /h5p/tests/fixtures/ipsums.h5p        |
+      | Course       | C1        | contenttype_h5p | teacher1 | teachercontent    | /h5p/tests/fixtures/filltheblanks.h5p |
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+    And I add the "Navigation" block if not present
+    And I expand "Site pages" node
+    And I click on "Content bank" "link"
+    # Force the content deploy
+    And I click on "admincontent" "link"
+    And I click on "Content bank" "link"
+
+  Scenario: Teacher can replace its own H5P files
+    Given I click on "teachercontent" "link"
+    When I open the action menu in "region-main-settings-menu" "region"
+    And I choose "Replace with file" in the open action menu
+    And I upload "h5p/tests/fixtures/ipsums.h5p" file to "Upload content" filemanager
+    And I click on "Save changes" "button"
+    Then I switch to "h5p-player" class iframe
+    And I switch to "h5p-iframe" class iframe
+    And I should see "Lorum ipsum"
+    And I switch to the main frame
+
+  Scenario: Teacher cannot replace another user's H5P files
+    When I click on "admincontent" "link"
+    Then "region-main-settings-menu" "region" should not exist
+
+  Scenario: Teacher cannot replace a content without having upload capability
+    Given the following "permission overrides" exist:
+      | capability                | permission | role           | contextlevel | reference |
+      | moodle/contentbank:upload | Prevent    | editingteacher | Course       | C1        |
+    When I click on "teachercontent" "link"
+    Then "region-main-settings-menu" "region" should not exist
+
+  Scenario: Teacher cannot replace a content without having the H5P upload capability
+    Given the following "permission overrides" exist:
+      | capability             | permission | role           | contextlevel | reference |
+      | contenttype/h5p:upload | Prevent    | editingteacher | Course       | C1        |
+    When I click on "teachercontent" "link"
+    Then "region-main-settings-menu" "region" should not exist
+
+  Scenario: Teacher cannot replace a content without having the manage own content capability
+    Given the following "permission overrides" exist:
+      | capability                          | permission | role           | contextlevel | reference |
+      | moodle/contentbank:manageowncontent | Prevent    | editingteacher | Course       | C1        |
+    When I click on "teachercontent" "link"
+    Then "region-main-settings-menu" "region" should not exist
index c708516..c806134 100644 (file)
@@ -147,4 +147,40 @@ class contenttype_h5p_contenttype_plugin_testcase extends advanced_testcase {
         $this->assertNotEquals($defaulticon, $findicon);
         $this->assertContains('find', $findicon, '', true);
     }
+
+    /**
+     * Tests get_download_url result.
+     *
+     * @covers ::get_download_url
+     */
+    public function test_get_download_url() {
+        global $CFG;
+
+        $this->resetAfterTest();
+        $systemcontext = context_system::instance();
+        $this->setAdminUser();
+        $contenttype = new contenttype_h5p\contenttype($systemcontext);
+
+        // Add an H5P fill the blanks file to the content bank.
+        $filename = 'filltheblanks.h5p';
+        $filepath = $CFG->dirroot . '/h5p/tests/fixtures/' . $filename;
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_contentbank');
+        $contents = $generator->generate_contentbank_data('contenttype_h5p', 1, 0, $systemcontext, true, $filepath);
+        $filltheblanks = array_shift($contents);
+
+        // Check before deploying the URL is returned OK.
+        $url1 = $contenttype->get_download_url($filltheblanks);
+        $this->assertNotEmpty($url1);
+        $this->assertContains($filename, $url1);
+
+        // Deploy the contents though the player to create the H5P DB entries and know specific content type.
+        $h5pplayer = new \core_h5p\player($filltheblanks->get_file_url(), new \stdClass(), true);
+        $h5pplayer->add_assets_to_page();
+        $h5pplayer->output();
+
+        // Once the H5P has been deployed, the URL is still the same.
+        $url2 = $contenttype->get_download_url($filltheblanks);
+        $this->assertNotEmpty($url2);
+        $this->assertEquals($url1, $url2);
+    }
 }
index d94f61f..6a001f0 100644 (file)
@@ -44,6 +44,11 @@ class contentbank_files_form extends moodleform {
         $mform->addElement('hidden', 'contextid', $this->_customdata['contextid']);
         $mform->setType('contextid', PARAM_INT);
 
+        if (!empty($this->_customdata['id'])) {
+            $mform->addElement('hidden', 'id', $this->_customdata['id']);
+            $mform->setType('id', PARAM_INT);
+        }
+
         $options = $this->_customdata['options'];
         $mform->addElement('filepicker', 'file', get_string('file', 'core_contentbank'), null, $options);
         $mform->addHelpButton('file', 'file', 'core_contentbank');
diff --git a/contentbank/tests/behat/download_content.feature b/contentbank/tests/behat/download_content.feature
new file mode 100644 (file)
index 0000000..9fca557
--- /dev/null
@@ -0,0 +1,61 @@
+@core @core_contentbank @contentbank_h5p @_file_upload @javascript
+Feature: Download H5P content from the content bank
+  In order export H5P content from the content bank
+  As an admin
+  I need to be able to download any H5P content from the content bank
+
+  Background:
+    Given the following "users" exist:
+      | username    | firstname | lastname | email              |
+      | manager     | Max       | Manager  | man@example.com    |
+    And the following "role assigns" exist:
+      | user        | role      | contextlevel  | reference     |
+      | manager     | manager   | System        |               |
+    And the following "contentbank content" exist:
+      | contextlevel | reference | contenttype     | user    | contentname              | filepath                               |
+      | System       |           | contenttype_h5p | admin   | filltheblanksadmin.h5p   | /h5p/tests/fixtures/filltheblanks.h5p  |
+      | System       |           | contenttype_h5p | manager | filltheblanksmanager.h5p | /h5p/tests/fixtures/filltheblanks.h5p  |
+    And I log in as "admin"
+    And I am on site homepage
+    And I turn editing mode on
+    And I add the "Navigation" block if not present
+    And I configure the "Navigation" block
+    And I set the following fields to these values:
+      | Page contexts | Display throughout the entire site |
+    And I press "Save changes"
+
+  Scenario: Admins can download content from the content bank
+    Given I click on "Site pages" "list_item" in the "Navigation" "block"
+    And I click on "Content bank" "link" in the "Navigation" "block"
+    And I follow "filltheblanksmanager.h5p"
+    And I open the action menu in "region-main-settings-menu" "region"
+    And I should see "Download"
+    When I choose "Download" in the open action menu
+    Then I should see "filltheblanksmanager.h5p"
+
+  Scenario: Users can download content created by different users
+    Given the following "permission overrides" exist:
+      | capability                            | permission | role    | contextlevel | reference |
+      | moodle/contentbank:manageanycontent   | Prohibit   | manager | System       |           |
+    And I log out
+    And I log in as "manager"
+    When I click on "Site pages" "list_item" in the "Navigation" "block"
+    And I click on "Content bank" "link" in the "Navigation" "block"
+    And I should see "filltheblanksadmin.h5p"
+    And I follow "filltheblanksadmin.h5p"
+    And I open the action menu in "region-main-settings-menu" "region"
+    Then I should see "Download"
+    And I should not see "Rename"
+
+  Scenario: Users without the required capability cannot download content
+    Given the following "permission overrides" exist:
+      | capability                            | permission | role    | contextlevel | reference |
+      | moodle/contentbank:downloadcontent    | Prohibit   | manager | System       |           |
+    And I log out
+    And I log in as "manager"
+    When I click on "Site pages" "list_item" in the "Navigation" "block"
+    And I click on "Content bank" "link" in the "Navigation" "block"
+    And I should see "filltheblanksmanager.h5p"
+    And I follow "filltheblanksmanager.h5p"
+    And I open the action menu in "region-main-settings-menu" "region"
+    Then I should not see "Download"
index ddfff94..4c7ec2b 100644 (file)
@@ -275,4 +275,27 @@ class core_contenttype_content_testcase extends \advanced_testcase {
         $contentfile = $content->get_file($file);
         $this->assertEquals($importedfile->get_id(), $contentfile->get_id());
     }
+
+    /**
+     * Tests for 'get_content_type_instance'
+     *
+     * @covers ::get_content_type_instance
+     */
+    public function test_get_content_type_instance(): void {
+        global $USER;
+        $this->resetAfterTest();
+        $this->setAdminUser();
+        $context = context_system::instance();
+
+        $type = new contenttype($context);
+        $record = (object)[
+            'name' => 'content name',
+            'usercreated' => $USER->id,
+        ];
+        $content = $type->create_content($record);
+
+        $contenttype = $content->get_content_type_instance();
+
+        $this->assertInstanceOf(get_class($type), $contenttype);
+    }
 }
index cd22e80..3d6a703 100644 (file)
@@ -31,6 +31,7 @@ use advanced_testcase;
 use context_course;
 use context_coursecat;
 use context_system;
+use Exception;
 
 global $CFG;
 require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_contenttype.php');
@@ -603,4 +604,31 @@ class core_contentbank_testcase extends advanced_testcase {
         $actual = $cb->get_contenttypes_with_capability_feature('test2', null, $enabled);
         $this->assertEquals($contenttypescanfeature, array_values($actual));
     }
+
+    /**
+     * Test the behaviour of get_content_from_id()
+     *
+     * @covers  ::get_content_from_id
+     */
+    public function test_get_content_from_id() {
+
+        $this->resetAfterTest();
+        $cb = new \core_contentbank\contentbank();
+
+        // Create a category and two courses.
+        $systemcontext = context_system::instance();
+
+        // Add some content to the content bank.
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_contentbank');
+        $contents = $generator->generate_contentbank_data(null, 3, 0, $systemcontext);
+        $content = reset($contents);
+
+        // Get the content instance form id.
+        $newinstance = $cb->get_content_from_id($content->get_id());
+        $this->assertEquals($content->get_id(), $newinstance->get_id());
+
+        // Now produce and exception with an innexistent id.
+        $this->expectException(Exception::class);
+        $cb->get_content_from_id(0);
+    }
 }
index c8ae908..1995357 100644 (file)
@@ -290,6 +290,84 @@ class core_contenttype_contenttype_testcase extends \advanced_testcase {
         $this->assertEquals(1, $DB->count_records('files', ['contenthash' => $dummyfile->get_contenthash()]));
     }
 
+    /**
+     * Tests for behaviour of replace_content() using a dummy file.
+     *
+     * @covers ::replace_content
+     */
+    public function test_replace_content(): void {
+        global $USER;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+        $context = context_system::instance();
+
+        // Add some content to the content bank.
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_contentbank');
+        $contents = $generator->generate_contentbank_data('contenttype_testable', 3, 0, $context);
+        $content = reset($contents);
+
+        $dummy = [
+            'contextid' => context_user::instance($USER->id)->id,
+            'component' => 'user',
+            'filearea' => 'draft',
+            'itemid' => 1,
+            'filepath' => '/',
+            'filename' => 'file.h5p',
+            'userid' => $USER->id,
+        ];
+        $fs = get_file_storage();
+        $dummyfile = $fs->create_file_from_string($dummy, 'Dummy content');
+
+        $contenttype = new contenttype(context_system::instance());
+        $content = $contenttype->replace_content($dummyfile, $content);
+
+        $this->assertEquals('contenttype_testable', $content->get_content_type());
+        $this->assertInstanceOf('\\contenttype_testable\\content', $content);
+
+        $file = $content->get_file();
+        $this->assertEquals($dummyfile->get_userid(), $file->get_userid());
+        $this->assertEquals($dummyfile->get_contenthash(), $file->get_contenthash());
+        $this->assertEquals('contentbank', $file->get_component());
+        $this->assertEquals('public', $file->get_filearea());
+        $this->assertEquals('/', $file->get_filepath());
+    }
+
+    /**
+     * Tests for behaviour of replace_content() using an error file.
+     *
+     * @covers ::replace_content
+     */
+    public function test_replace_content_exception(): void {
+        global $USER;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+        $context = context_system::instance();
+
+        // Add some content to the content bank.
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_contentbank');
+        $contents = $generator->generate_contentbank_data('contenttype_testable', 3, 0, $context);
+        $content = reset($contents);
+
+        $dummy = [
+            'contextid' => context_user::instance($USER->id)->id,
+            'component' => 'user',
+            'filearea' => 'draft',
+            'itemid' => 1,
+            'filepath' => '/',
+            'filename' => 'error.txt',
+            'userid' => $USER->id,
+        ];
+        $fs = get_file_storage();
+        $dummyfile = $fs->create_file_from_string($dummy, 'Dummy content');
+
+        $contenttype = new contenttype(context_system::instance());
+
+        $this->expectException(Exception::class);
+        $content = $contenttype->replace_content($dummyfile, $content);
+    }
+
     /**
      * Test the behaviour of can_delete().
      */
@@ -348,7 +426,7 @@ class core_contenttype_contenttype_testcase extends \advanced_testcase {
     /**
      * Helper function to setup 3 users (manager1, manager2 and user) and 4 contents (3 created by manager1 and 1 by user).
      */
-    protected function contenttype_setup_scenario_data(): void {
+    protected function contenttype_setup_scenario_data(string $contenttype = 'contenttype_testable'): void {
         global $DB;
         $systemcontext = context_system::instance();
 
@@ -358,14 +436,17 @@ class core_contenttype_contenttype_testcase extends \advanced_testcase {
         $this->managerroleid = $DB->get_field('role', 'id', array('shortname' => 'manager'));
         $this->getDataGenerator()->role_assign($this->managerroleid, $this->manager1->id);
         $this->getDataGenerator()->role_assign($this->managerroleid, $this->manager2->id);
+        $editingteacherrolerid = $DB->get_field('role', 'id', ['shortname' => 'editingteacher']);
         $this->user = $this->getDataGenerator()->create_user();
+        $this->getDataGenerator()->role_assign($editingteacherrolerid, $this->user->id);
 
         // Add some content to the content bank.
         $generator = $this->getDataGenerator()->get_plugin_generator('core_contentbank');
-        $this->contents[$this->manager1->id] = $generator->generate_contentbank_data(null, 3, $this->manager1->id);
-        $this->contents[$this->user->id] = $generator->generate_contentbank_data(null, 1, $this->user->id);
+        $this->contents[$this->manager1->id] = $generator->generate_contentbank_data($contenttype, 3, $this->manager1->id);
+        $this->contents[$this->user->id] = $generator->generate_contentbank_data($contenttype, 1, $this->user->id);
 
-        $this->contenttype = new \contenttype_testable\contenttype($systemcontext);
+        $contenttypeclass = "\\$contenttype\\contenttype";
+        $this->contenttype = new $contenttypeclass($systemcontext);
     }
 
     /**
@@ -510,4 +591,70 @@ class core_contenttype_contenttype_testcase extends \advanced_testcase {
         $this->assertFalse($contenttype->can_manage($contentbyteacher));
         $this->assertFalse($contenttype->can_manage($contentbyadmin));
     }
+
+    /**
+     * Test the behaviour of can_download().
+     *
+     * @covers ::can_download
+     */
+    public function test_can_download() {
+        global $DB;
+
+        $this->resetAfterTest();
+        $this->contenttype_setup_scenario_data('contenttype_h5p');
+
+        $managercontent = array_shift($this->contents[$this->manager1->id]);
+        $usercontent = array_shift($this->contents[$this->user->id]);
+
+        // Check the content has been created as expected.
+        $records = $DB->count_records('contentbank_content');
+        $this->assertEquals(4, $records);
+
+        // Check user can download content created by anybody.
+        $this->setUser($this->user);
+        $this->assertTrue($this->contenttype->can_download($usercontent));
+        $this->assertTrue($this->contenttype->can_download($managercontent));
+
+        // Check manager can download all the content too.
+        $this->setUser($this->manager1);
+        $this->assertTrue($this->contenttype->can_download($managercontent));
+        $this->assertTrue($this->contenttype->can_download($usercontent));
+
+        // Unassign capability to manager role and check she cannot download content anymore.
+        unassign_capability('moodle/contentbank:downloadcontent', $this->managerroleid);
+        $this->assertFalse($this->contenttype->can_download($managercontent));
+        $this->assertFalse($this->contenttype->can_download($usercontent));
+    }
+
+    /**
+     * Tests get_download_url result.
+     *
+     * @covers ::get_download_url
+     */
+    public function test_get_download_url() {
+        global $CFG;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+        $systemcontext = context_system::instance();
+
+        // Add some content to the content bank.
+        $filename = 'filltheblanks.h5p';
+        $filepath = $CFG->dirroot . '/h5p/tests/fixtures/' . $filename;
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_contentbank');
+        $contents = $generator->generate_contentbank_data('contenttype_testable', 1, 0, $systemcontext, true, $filepath);
+        $content = array_shift($contents);
+
+        // Check the URL is returned OK for a content with file.
+        $contenttype = new contenttype($systemcontext);
+        $url = $contenttype->get_download_url($content);
+        $this->assertNotEmpty($url);
+        $this->assertContains($filename, $url);
+
+        // Check the URL is empty when the content hasn't any file.
+        $record = new stdClass();
+        $content = $contenttype->create_content($record);
+        $url = $contenttype->get_download_url($content);
+        $this->assertEmpty($url);
+    }
 }
index 00cc40c..4410de4 100644 (file)
@@ -34,6 +34,17 @@ $context = context::instance_by_id($contextid, MUST_EXIST);
 
 require_capability('moodle/contentbank:upload', $context);
 
+$cb = new \core_contentbank\contentbank();
+
+$id = optional_param('id', null, PARAM_INT);
+if ($id) {
+    $content = $cb->get_content_from_id($id);
+    $contenttype = $content->get_content_type_instance();
+    if (!$contenttype->can_manage($content) || !$contenttype->can_upload()) {
+        print_error('nopermissions', 'error', $returnurl, get_string('replacecontent', 'contentbank'));
+    }
+}
+
 $title = get_string('contentbank');
 \core_contentbank\helper::get_page_ready($context, $title, true);
 if ($PAGE->course) {
@@ -55,8 +66,12 @@ if (has_capability('moodle/user:ignoreuserquota', $context)) {
     $maxareabytes = FILE_AREA_MAX_BYTES_UNLIMITED;
 }
 
-$cb = new \core_contentbank\contentbank();
-$accepted = $cb->get_supported_extensions_as_string($context);
+if ($id) {
+    $extensions = $contenttype->get_manageable_extensions();
+    $accepted = implode(',', $extensions);
+} else {
+    $accepted = $cb->get_supported_extensions_as_string($context);
+}
 
 $data = new stdClass();
 $options = array(
@@ -68,7 +83,7 @@ $options = array(
 );
 file_prepare_standard_filemanager($data, 'files', $options, $context, 'contentbank', 'public', 0);
 
-$mform = new contentbank_files_form(null, ['contextid' => $contextid, 'data' => $data, 'options' => $options]);
+$mform = new contentbank_files_form(null, ['contextid' => $contextid, 'data' => $data, 'options' => $options, 'id' => $id]);
 
 $error = '';
 
@@ -82,7 +97,11 @@ if ($mform->is_cancelled()) {
     $files = $fs->get_area_files($usercontext->id, 'user', 'draft', $formdata->file, 'itemid, filepath, filename', false);
     if (!empty($files)) {
         $file = reset($files);
-        $content = $cb->create_content_from_file($context, $USER->id, $file);
+        if ($id) {
+            $content = $contenttype->replace_content($file, $content);
+        } else {
+            $content = $cb->create_content_from_file($context, $USER->id, $file);
+        }
         $viewurl = new \moodle_url('/contentbank/view.php', ['id' => $content->get_id(), 'contextid' => $contextid]);
         redirect($viewurl);
     } else {
index 5fd66d1..4c84b1b 100644 (file)
@@ -83,6 +83,24 @@ if ($contenttype->can_manage($content)) {
         false,
         $attributes
     ));
+
+    if ($contenttype->can_upload()) {
+        $actionmenu->add_secondary_action(new action_menu_link(
+            new moodle_url('/contentbank/upload.php', ['contextid' => $context->id, 'id' => $content->get_id()]),
+            new pix_icon('i/upload', get_string('upload')),
+            get_string('replacecontent', 'contentbank'),
+            false
+        ));
+    }
+}
+if ($contenttype->can_download($content)) {
+    // Add the download content item to the menu.
+    $actionmenu->add_secondary_action(new action_menu_link(
+        new moodle_url($contenttype->get_download_url($content)),
+        new pix_icon('t/download', get_string('download')),
+        get_string('download'),
+        false
+    ));
 }
 if ($contenttype->can_delete($content)) {
     // Add the delete content item to the menu.
index ddbd433..906837e 100644 (file)
@@ -111,7 +111,7 @@ class core_course_deletecategory_form extends moodleform {
         }
 
         if ($displaylist) {
-            $mform->addElement('select', 'newparent', get_string('movecategorycontentto'), $displaylist);
+            $mform->addElement('autocomplete', 'newparent', get_string('movecategorycontentto'), $displaylist);
             if (in_array($this->coursecat->parent, $displaylist)) {
                 $mform->setDefault('newparent', $this->coursecat->parent);
             }
index f11d28c..236fbad 100644 (file)
@@ -63,7 +63,7 @@ class core_course_editcategory_form extends moodleform {
             $strsubmit = get_string('createcategory');
         }
 
-        $mform->addElement('select', 'parent', get_string('parentcategory'), $options);
+        $mform->addElement('autocomplete', 'parent', get_string('parentcategory'), $options);
 
         $mform->addElement('text', 'name', get_string('categoryname'), array('size' => '30'));
         $mform->addRule('name', get_string('required'), 'required', null);
index 1b9cfc4..8a289cc 100644 (file)
@@ -77,7 +77,7 @@ class course_edit_form extends moodleform {
         if (empty($course->id)) {
             if (has_capability('moodle/course:create', $categorycontext)) {
                 $displaylist = core_course_category::make_categories_list('moodle/course:create');
-                $mform->addElement('select', 'category', get_string('coursecategory'), $displaylist);
+                $mform->addElement('autocomplete', 'category', get_string('coursecategory'), $displaylist);
                 $mform->addHelpButton('category', 'coursecategory');
                 $mform->setDefault('category', $category->id);
             } else {
@@ -93,7 +93,7 @@ class course_edit_form extends moodleform {
                     $displaylist[$course->category] = core_course_category::get($course->category, MUST_EXIST, true)
                         ->get_formatted_name();
                 }
-                $mform->addElement('select', 'category', get_string('coursecategory'), $displaylist);
+                $mform->addElement('autocomplete', 'category', get_string('coursecategory'), $displaylist);
                 $mform->addHelpButton('category', 'coursecategory');
             } else {
                 //keep current
index 3107584..d917970 100644 (file)
@@ -70,7 +70,7 @@ class course_request_form extends moodleform {
 
         if (empty($CFG->lockrequestcategory)) {
             $displaylist = core_course_category::make_categories_list('moodle/course:request');
-            $mform->addElement('select', 'category', get_string('coursecategory'), $displaylist);
+            $mform->addElement('autocomplete', 'category', get_string('coursecategory'), $displaylist);
             $mform->setDefault('category', $CFG->defaultrequestcategory);
             $mform->addHelpButton('category', 'coursecategory');
         }
index f03ae95..5e5ce0a 100644 (file)
@@ -88,7 +88,7 @@ if ($switchrole > 0 && has_capability('moodle/role:switchroles', $context)) {
         $roles[0] = get_string('switchrolereturn');
         $assumedrole = $USER->access['rsw'][$context->path];
     }
-    $availableroles = get_switchable_roles($context);
+    $availableroles = get_switchable_roles($context, ROLENAME_BOTH);
     if (is_array($availableroles)) {
         foreach ($availableroles as $key => $role) {
             if ($assumedrole == (int)$key) {
index eaeeeec..14ad0b2 100644 (file)
@@ -43,6 +43,7 @@ Feature: Test category management actions
     And I should see "Category 1 (edited)" in the "#category-listing" "css_element"
     And I should see "Category 1 (edited)" in the "#course-listing h3" "css_element"
 
+  @javascript
   Scenario: Test deleting a categories through the management interface.
     Given the following "categories" exist:
       | name | category | idnumber |
@@ -227,9 +228,9 @@ Feature: Test category management actions
     And I should see "Delete category: Cat 1"
     And I should see "Contents of Cat 1"
     And "What to do" "select" should exist
-    And "Move into" "select" should exist
-    And the "Move into" select box should not contain "Cat 2"
-    And the "Move into" select box should contain "Miscellaneous"
+    And I expand the "Move into" autocomplete
+    And "Cat 2" "autocomplete_suggestions" should not exist
+    And "Miscellaneous" "autocomplete_selection" should be visible
     And I set the field "What to do" to "Delete all - cannot be undone"
     And "Move into" "select" should not be visible
     And I press "Cancel"
index c50671f..9a9c6e8 100644 (file)
@@ -76,7 +76,7 @@ Feature: Users can request and approve courses
     And I am on course index
     And I follow "English category"
     And I press "Request a course"
-    And the field "Course category" matches value "English category"
+    And the "Course category" select box should contain "English category"
     And I set the following fields to these values:
       | Course full name  | My new course |
       | Course short name | Mynewcourse   |
index 0883b52..c2655fc 100644 (file)
@@ -36,7 +36,7 @@ Feature: Tagging courses
     And I am on "Course 1" course homepage
     And I navigate to "Edit settings" in current page administration
     And I expand all fieldsets
-    Then I should see "Mathematics" in the ".form-autocomplete-selection" "css_element"
+    Then "Mathematics" "autocomplete_suggestions" should exist
     And I set the following fields to these values:
       | Tags | Algebra |
     And I press "Save and display"
index bb5ab23..1b3c581 100644 (file)
@@ -32,9 +32,15 @@ Feature: Rename roles within a course
     And I navigate to course participants
     And I set the field "type" in the "Filter 1" "fieldset" to "Roles"
     And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 1" "fieldset"
-    And I should see "Tutor" in the ".form-autocomplete-suggestions" "css_element"
-    And I should see "Learner" in the ".form-autocomplete-suggestions" "css_element"
-    And I should not see "Student" in the ".form-autocomplete-suggestions" "css_element"
+    And I should see "Tutor (Non-editing teacher)" in the ".form-autocomplete-suggestions" "css_element"
+    And I should see "Learner (Student)" in the ".form-autocomplete-suggestions" "css_element"
+    And I click on "Student 1's role assignments" "link"
+    And I click on ".form-autocomplete-downarrow" "css_element" in the "Student 1" "table_row"
+    And "Tutor (Non-editing teacher)" "autocomplete_suggestions" should exist
+    And I click on "Cancel" "link"
+    And I press "Enrol users"
+    And the "Assign role" select box should contain "Learner (Student)"
+    And I click on "Cancel" "button" in the "Enrol users" "dialogue"
     And I am on "Course 1" course homepage
     And I navigate to "Edit settings" in current page administration
     And I set the following fields to these values:
index d9a389d..b3aac3d 100644 (file)
@@ -26,9 +26,17 @@ Feature: Rename roles in a course
       | Your word for 'Teacher' | Lecturer |
       | Your word for 'Student' | Learner  |
     And I press "Save and display"
-    And I navigate to "Users > Enrolled users" in current page administration
-    Then I should see "Lecturer" in the "Teacher 1" "table_row"
+    And I navigate to course participants
+    Then I should see "Lecturer (Teacher)" in the "Teacher 1" "table_row"
+    And I should see "Learner (Student)" in the "Student 1" "table_row"
+    And I log out
+    And I log in as "student1"
+    And I am on "Course 1" course homepage
+    And I navigate to course participants
+    And I should see "Lecturer" in the "Teacher 1" "table_row"
     And I should see "Learner" in the "Student 1" "table_row"
+    And I should not see "Lecturer (Teacher)" in the "Teacher 1" "table_row"
+    And I should not see "Learner (Student)" in the "Student 1" "table_row"
 
   Scenario: Ability to rename roles can be prevented
     Given I log in as "admin"
index 682441e..73116b7 100644 (file)
@@ -24,8 +24,6 @@
 
 namespace customfield_select;
 
-use core_customfield\api;
-
 defined('MOODLE_INTERNAL') || die;
 
 /**
@@ -53,8 +51,7 @@ class data_controller extends \core_customfield\data_controller {
     public function get_default_value() {
         $defaultvalue = $this->get_field()->get_configdata_property('defaultvalue');
         if ('' . $defaultvalue !== '') {
-            $options = field_controller::get_options_array($this->get_field());
-            $key = array_search($defaultvalue, $options);
+            $key = array_search($defaultvalue, $this->get_field()->get_options());
             if ($key !== false) {
                 return $key;
             }
@@ -70,7 +67,7 @@ class data_controller extends \core_customfield\data_controller {
     public function instance_form_definition(\MoodleQuickForm $mform) {
         $field = $this->get_field();
         $config = $field->get('configdata');
-        $options = field_controller::get_options_array($field);
+        $options = $field->get_options();
         $formattedoptions = array();
         $context = $this->get_field()->get_handler()->get_configuration_context();
         foreach ($options as $key => $option) {
@@ -120,7 +117,7 @@ class data_controller extends \core_customfield\data_controller {
             return null;
         }
 
-        $options = field_controller::get_options_array($this->get_field());
+        $options = $this->get_field()->get_options();
         if (array_key_exists($value, $options)) {
             return format_string($options[$value], true,
                 ['context' => $this->get_field()->get_handler()->get_configuration_context()]);
index c47f05c..6f198f7 100644 (file)
@@ -60,10 +60,24 @@ class field_controller extends \core_customfield\field_controller {
      *
      * @param \core_customfield\field_controller $field
      * @return array
+     *
+     * @deprecated since Moodle 3.10 - MDL-68569 please use $field->get_options
      */
     public static function get_options_array(\core_customfield\field_controller $field) : array {
-        if ($field->get_configdata_property('options')) {
-            $options = preg_split("/\s*\n\s*/", trim($field->get_configdata_property('options')));
+        debugging('get_options_array() is deprecated, please use $field->get_options() instead', DEBUG_DEVELOPER);
+
+        return $field->get_options();
+    }
+
+    /**
+     * Return configured field options
+     *
+     * @return array
+     */
+    public function get_options(): array {
+        $optionconfig = $this->get_configdata_property('options');
+        if ($optionconfig) {
+            $options = preg_split("/\s*\n\s*/", trim($optionconfig));
         } else {
             $options = array();
         }
@@ -108,7 +122,7 @@ class field_controller extends \core_customfield\field_controller {
      * @return array
      */
     public function course_grouping_format_values($values): array {
-        $options = self::get_options_array($this);
+        $options = $this->get_options();
         $ret = [];
         foreach ($values as $value) {
             if (isset($options[$value])) {
@@ -127,6 +141,6 @@ class field_controller extends \core_customfield\field_controller {
      * @return int
      */
     public function parse_value(string $value) {
-        return (int) array_search($value, self::get_options_array($this));
+        return (int) array_search($value, $this->get_options());
     }
 }
\ No newline at end of file
index aaca643..1f5214b 100644 (file)
@@ -68,7 +68,7 @@ class enrol_cohort_plugin extends enrol_plugin {
             }
             $cohortname = format_string($cohort->name, true, array('context'=>context::instance_by_id($cohort->contextid)));
             if ($role = $DB->get_record('role', array('id'=>$instance->roleid))) {
-                $role = role_get_name($role, context_course::instance($instance->courseid, IGNORE_MISSING));
+                $role = role_get_name($role, context_course::instance($instance->courseid, IGNORE_MISSING), ROLENAME_BOTH);
                 return get_string('pluginname', 'enrol_'.$enrol) . ' (' . $cohortname . ' - ' . $role .')';
             } else {
                 return get_string('pluginname', 'enrol_'.$enrol) . ' (' . $cohortname . ')';
@@ -364,13 +364,14 @@ class enrol_cohort_plugin extends enrol_plugin {
     protected function get_role_options($instance, $coursecontext) {
         global $DB;
 
-        $roles = get_assignable_roles($coursecontext);
+        $roles = get_assignable_roles($coursecontext, ROLENAME_BOTH);
         $roles[0] = get_string('none');
         $roles = array_reverse($roles, true); // Descending default sortorder.
+
+        // If the instance is already configured, but the configured role is no longer assignable in the course then add it back.
         if ($instance->id and !isset($roles[$instance->roleid])) {
             if ($role = $DB->get_record('role', array('id' => $instance->roleid))) {
-                $roles = role_fix_names($roles, $coursecontext, ROLENAME_ALIAS, true);
-                $roles[$instance->roleid] = role_get_name($role, $coursecontext);
+                $roles[$instance->roleid] = role_get_name($role, $coursecontext, ROLENAME_BOTH);
             } else {
                 $roles[$instance->roleid] = get_string('error');
             }
index 171aa26..257a5c2 100644 (file)
@@ -116,7 +116,7 @@ class enrol_manual_enrol_users_form extends moodleform {
             }
         }
 
-        $roles = get_assignable_roles($context);
+        $roles = get_assignable_roles($context, ROLENAME_BOTH);
         $mform->addElement('select', 'roletoassign', get_string('assignrole', 'enrol_manual'), $roles);
         $mform->setDefault('roletoassign', $instance->roleid);
 
index deb397b..699e02c 100644 (file)
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
-require_once '../../../config.php';
-require_once $CFG->dirroot.'/grade/lib.php';
-require_once $CFG->dirroot.'/grade/report/lib.php';
-require_once 'category_form.php';
+require_once('../../../config.php');
+require_once($CFG->dirroot.'/grade/lib.php');
+require_once($CFG->dirroot.'/grade/edit/tree/lib.php');
+require_once($CFG->dirroot.'/grade/report/lib.php');
+require_once('category_form.php');
 
 $courseid = required_param('courseid', PARAM_INT);
 $id       = optional_param('id', 0, PARAM_INT); // grade_category->id
@@ -133,132 +134,7 @@ if ($mform->is_cancelled()) {
     redirect($returnurl);
 
 } else if ($data = $mform->get_data(false)) {
-    // If no fullname is entered for a course category, put ? in the DB
-    if (!isset($data->fullname) || $data->fullname == '') {
-        $data->fullname = '?';
-    }
-
-    if (!isset($data->aggregateonlygraded)) {
-        $data->aggregateonlygraded = 0;
-    }
-    if (!isset($data->aggregateoutcomes)) {
-        $data->aggregateoutcomes = 0;
-    }
-    grade_category::set_properties($grade_category, $data);
-
-    /// CATEGORY
-    if (empty($grade_category->id)) {
-        $grade_category->insert();
-
-    } else {
-        $grade_category->update();
-    }
-
-    /// GRADE ITEM
-    // grade item data saved with prefix "grade_item_"
-    $itemdata = new stdClass();
-    foreach ($data as $k => $v) {
-        if (preg_match('/grade_item_(.*)/', $k, $matches)) {
-            $itemdata->{$matches[1]} = $v;
-        }
-    }
-
-    if (!isset($itemdata->aggregationcoef)) {
-        $itemdata->aggregationcoef = 0;
-    }
-
-    if (!isset($itemdata->gradepass) || $itemdata->gradepass == '') {
-        $itemdata->gradepass = 0;
-    }
-
-    if (!isset($itemdata->grademax) || $itemdata->grademax == '') {
-        $itemdata->grademax = 0;
-    }
-
-    if (!isset($itemdata->grademin) || $itemdata->grademin == '') {
-        $itemdata->grademin = 0;
-    }
-
-    $hidden      = empty($itemdata->hidden) ? 0: $itemdata->hidden;
-    $hiddenuntil = empty($itemdata->hiddenuntil) ? 0: $itemdata->hiddenuntil;
-    unset($itemdata->hidden);
-    unset($itemdata->hiddenuntil);
-
-    $locked   = empty($itemdata->locked) ? 0: $itemdata->locked;
-    $locktime = empty($itemdata->locktime) ? 0: $itemdata->locktime;
-    unset($itemdata->locked);
-    unset($itemdata->locktime);
-
-    $convert = array('grademax', 'grademin', 'gradepass', 'multfactor', 'plusfactor', 'aggregationcoef', 'aggregationcoef2');
-    foreach ($convert as $param) {
-        if (property_exists($itemdata, $param)) {
-            $itemdata->$param = unformat_float($itemdata->$param);
-        }
-    }
-    if (isset($itemdata->aggregationcoef2)) {
-        $itemdata->aggregationcoef2 = $itemdata->aggregationcoef2 / 100.0;
-    }
-
-    // When creating a new category, a number of grade item fields are filled out automatically, and are required.
-    // If the user leaves these fields empty during creation of a category, we let the default values take effect
-    // Otherwise, we let the user-entered grade item values take effect
-    $grade_item = $grade_category->load_grade_item();
-    $grade_item_copy = fullclone($grade_item);
-    grade_item::set_properties($grade_item, $itemdata);
-
-    if (empty($grade_item->id)) {
-        $grade_item->id = $grade_item_copy->id;
-    }
-    if (empty($grade_item->grademax) && $grade_item->grademax != '0') {
-        $grade_item->grademax = $grade_item_copy->grademax;
-    }
-    if (empty($grade_item->grademin) && $grade_item->grademin != '0') {
-        $grade_item->grademin = $grade_item_copy->grademin;
-    }
-    if (empty($grade_item->gradepass) && $grade_item->gradepass != '0') {
-        $grade_item->gradepass = $grade_item_copy->gradepass;
-    }
-    if (empty($grade_item->aggregationcoef) && $grade_item->aggregationcoef != '0') {
-        $grade_item->aggregationcoef = $grade_item_copy->aggregationcoef;
-    }
-
-    // Handle null decimals value - must be done before update!
-    if (!property_exists($itemdata, 'decimals') or $itemdata->decimals < 0) {
-        $grade_item->decimals = null;
-    }
-
-    // Change weightoverride flag. Check if the value is set, because it is not when the checkbox is not ticked.
-    $itemdata->weightoverride = isset($itemdata->weightoverride) ? $itemdata->weightoverride : 0;
-    if ($grade_item->weightoverride != $itemdata->weightoverride && $grade_category->aggregation == GRADE_AGGREGATE_SUM) {
-        // If we are using natural weight and the weight has been un-overriden, force parent category to recalculate weights.
-        $grade_category->force_regrading();
-    }
-    $grade_item->weightoverride = $itemdata->weightoverride;
-
-    $grade_item->outcomeid = null;
-
-    if (!empty($data->grade_item_rescalegrades) && $data->grade_item_rescalegrades == 'yes') {
-        $grade_item->rescale_grades_keep_percentage($grade_item_copy->grademin, $grade_item_copy->grademax, $grade_item->grademin,
-                $grade_item->grademax, 'gradebook');
-    }
-
-    // update hiding flag
-    if ($hiddenuntil) {
-        $grade_item->set_hidden($hiddenuntil, false);
-    } else {
-        $grade_item->set_hidden($hidden, false);
-    }
-
-    $grade_item->set_locktime($locktime); // locktime first - it might be removed when unlocking
-    $grade_item->set_locked($locked, false, true);
-
-    $grade_item->update(); // We don't need to insert it, it's already created when the category is created
-
-    // set parent if needed
-    if (isset($data->parentcategory)) {
-        $grade_category->set_parent($data->parentcategory, 'gradebook');
-    }
-
+    grade_edit_tree::update_gradecategory($grade_category, $data);
     redirect($returnurl);
 }
 
index 91744d8..4216129 100644 (file)
@@ -565,6 +565,141 @@ class grade_edit_tree {
 
         return $deepest_level;
     }
+
+    /**
+     * Updates the provided gradecategory item with the provided data.
+     *
+     * @param grade_category $gradecategory The category to update.
+     * @param stdClass $data the data to update the category with.
+     * @return void
+     */
+    public static function update_gradecategory(grade_category $gradecategory, stdClass $data) {
+        // If no fullname is entered for a course category, put ? in the DB.
+        if (!isset($data->fullname) || $data->fullname == '') {
+            $data->fullname = '?';
+        }
+
+        if (!isset($data->aggregateonlygraded)) {
+            $data->aggregateonlygraded = 0;
+        }
+        if (!isset($data->aggregateoutcomes)) {
+            $data->aggregateoutcomes = 0;
+        }
+        grade_category::set_properties($gradecategory, $data);
+
+        // CATEGORY.
+        if (empty($gradecategory->id)) {
+            $gradecategory->insert();
+
+        } else {
+            $gradecategory->update();
+        }
+
+        // GRADE ITEM.
+        // Grade item data saved with prefix "grade_item_".
+        $itemdata = new stdClass();
+        foreach ($data as $k => $v) {
+            if (preg_match('/grade_item_(.*)/', $k, $matches)) {
+                $itemdata->{$matches[1]} = $v;
+            }
+        }
+
+        if (!isset($itemdata->aggregationcoef)) {
+            $itemdata->aggregationcoef = 0;
+        }
+
+        if (!isset($itemdata->gradepass) || $itemdata->gradepass == '') {
+            $itemdata->gradepass = 0;
+        }
+
+        if (!isset($itemdata->grademax) || $itemdata->grademax == '') {
+            $itemdata->grademax = 0;
+        }
+
+        if (!isset($itemdata->grademin) || $itemdata->grademin == '') {
+            $itemdata->grademin = 0;
+        }
+
+        $hidden      = empty($itemdata->hidden) ? 0 : $itemdata->hidden;
+        $hiddenuntil = empty($itemdata->hiddenuntil) ? 0 : $itemdata->hiddenuntil;
+        unset($itemdata->hidden);
+        unset($itemdata->hiddenuntil);
+
+        $locked   = empty($itemdata->locked) ? 0 : $itemdata->locked;
+        $locktime = empty($itemdata->locktime) ? 0 : $itemdata->locktime;
+        unset($itemdata->locked);
+        unset($itemdata->locktime);
+
+        $convert = array('grademax', 'grademin', 'gradepass', 'multfactor', 'plusfactor', 'aggregationcoef', 'aggregationcoef2');
+        foreach ($convert as $param) {
+            if (property_exists($itemdata, $param)) {
+                $itemdata->$param = unformat_float($itemdata->$param);
+            }
+        }
+        if (isset($itemdata->aggregationcoef2)) {
+            $itemdata->aggregationcoef2 = $itemdata->aggregationcoef2 / 100.0;
+        }
+
+        // When creating a new category, a number of grade item fields are filled out automatically, and are required.
+        // If the user leaves these fields empty during creation of a category, we let the default values take effect.
+        // Otherwise, we let the user-entered grade item values take effect.
+        $gradeitem = $gradecategory->load_grade_item();
+        $gradeitemcopy = fullclone($gradeitem);
+        grade_item::set_properties($gradeitem, $itemdata);
+
+        if (empty($gradeitem->id)) {
+            $gradeitem->id = $gradeitemcopy->id;
+        }
+        if (empty($gradeitem->grademax) && $gradeitem->grademax != '0') {
+            $gradeitem->grademax = $gradeitemcopy->grademax;
+        }
+        if (empty($gradeitem->grademin) && $gradeitem->grademin != '0') {
+            $gradeitem->grademin = $gradeitemcopy->grademin;
+        }
+        if (empty($gradeitem->gradepass) && $gradeitem->gradepass != '0') {
+            $gradeitem->gradepass = $gradeitemcopy->gradepass;
+        }
+        if (empty($gradeitem->aggregationcoef) && $gradeitem->aggregationcoef != '0') {
+            $gradeitem->aggregationcoef = $gradeitemcopy->aggregationcoef;
+        }
+
+        // Handle null decimals value - must be done before update!
+        if (!property_exists($itemdata, 'decimals') or $itemdata->decimals < 0) {
+            $gradeitem->decimals = null;
+        }
+
+        // Change weightoverride flag. Check if the value is set, because it is not when the checkbox is not ticked.
+        $itemdata->weightoverride = isset($itemdata->weightoverride) ? $itemdata->weightoverride : 0;
+        if ($gradeitem->weightoverride != $itemdata->weightoverride && $gradecategory->aggregation == GRADE_AGGREGATE_SUM) {
+            // If we are using natural weight and the weight has been un-overriden, force parent category to recalculate weights.
+            $gradecategory->force_regrading();
+        }
+        $gradeitem->weightoverride = $itemdata->weightoverride;
+
+        $gradeitem->outcomeid = null;
+
+        if (!empty($data->grade_item_rescalegrades) && $data->grade_item_rescalegrades == 'yes') {
+            $gradeitem->rescale_grades_keep_percentage($gradeitemcopy->grademin, $gradeitemcopy->grademax,
+                $gradeitem->grademin, $gradeitem->grademax, 'gradebook');
+        }
+
+        // Update hiding flag.
+        if ($hiddenuntil) {
+            $gradeitem->set_hidden($hiddenuntil, false);
+        } else {
+            $gradeitem->set_hidden($hidden, false);
+        }
+
+        $gradeitem->set_locktime($locktime); // Locktime first - it might be removed when unlocking.
+        $gradeitem->set_locked($locked, false, true);
+
+        $gradeitem->update(); // We don't need to insert it, it's already created when the category is created.
+
+        // Set parent if needed.
+        if (isset($data->parentcategory)) {
+            $gradecategory->set_parent($data->parentcategory, 'gradebook');
+        }
+    }
 }
 
 /**
@@ -951,5 +1086,4 @@ class grade_edit_tree_column_select extends grade_edit_tree_column {
 
         return $togglegroup;
     }
-}
-
+}
\ No newline at end of file
index 40a9b11..86f968b 100644 (file)
@@ -1531,9 +1531,7 @@ class grade_report_grader extends grade_report {
             // This query returns a count of ungraded grades (NULL finalgrade OR no matching record in grade_grades table)
             $sql = "SELECT gi.id, COUNT(DISTINCT u.id) AS count
                       FROM {grade_items} gi
-                      CROSS JOIN {user} u
-                      JOIN ($enrolledsql) je
-                           ON je.id = u.id
+                      CROSS JOIN ($enrolledsql) u
                       JOIN {role_assignments} ra
                            ON ra.userid = u.id
                       LEFT OUTER JOIN {grade_grades} g
@@ -1542,7 +1540,6 @@ class grade_report_grader extends grade_report {
                      WHERE gi.courseid = :courseid
                            AND ra.roleid $gradebookrolessql
                            AND ra.contextid $relatedctxsql
-                           AND u.deleted = 0
                            AND g.id IS NULL
                            $groupwheresql
                   GROUP BY gi.id";
index f3b4ad7..999fc6f 100644 (file)
@@ -164,7 +164,7 @@ class grade extends tablelike implements selectable_items, filterable_items {
     public function original_headers() {
         return array(
             '', // For filter icon.
-            get_string('firstname') . ' (' . get_string('alternatename') . ') ' . get_string('lastname'),
+            get_string('fullnameuser', 'core'),
             get_string('range', 'grades'),
             get_string('grade', 'grades'),
             get_string('feedback', 'grades'),
@@ -198,8 +198,8 @@ class grade extends tablelike implements selectable_items, filterable_items {
             $lockicon = $OUTPUT->pix_icon('t/locked', 'grade is locked') . ' ';
         }
 
-        if (!empty($item->alternatename)) {
-            $fullname = $lockicon . $item->alternatename . ' (' . $item->firstname . ') ' . $item->lastname;
+        if (has_capability('moodle/site:viewfullnames', \context_course::instance($this->courseid))) {
+            $fullname = $lockicon . fullname($item, true);
         } else {
             $fullname = $lockicon . fullname($item);
         }
index fd5e026..5921fc2 100644 (file)
@@ -161,12 +161,12 @@ class finalgrade extends grade_attribute_format implements unique_value, be_disa
         }
 
         if ($errorstr) {
-            $user = $DB->get_record('user', array('id' => $userid), 'id, firstname, alternatename, lastname');
+            $user = get_complete_user_data('id', $userid);
             $gradestr = new stdClass;
-            if (!empty($user->alternatename)) {
-                $gradestr->username = $user->alternatename . ' (' . $user->firstname . ') ' . $user->lastname;
+            if (has_capability('moodle/site:viewfullnames', \context_course::instance($gradeitem->courseid))) {
+                $gradestr->username = fullname($user, true);
             } else {
-                $gradestr->username = $user->firstname . ' ' . $user->lastname;
+                $gradestr->username = fullname($user);
             }
             $gradestr->itemname = $this->grade->grade_item->get_name();
             $errorstr = get_string($errorstr, 'grades', $gradestr);
index fd1153a..faa5ba2 100644 (file)
@@ -42,33 +42,33 @@ Feature: We can bulk insert grades for students in a course
     And I am on "Course 1" course homepage
     And I navigate to "View > Grader report" in the course gradebook
     And I follow "Single view for Test assignment one"
-    Then the field "Grade for james (Student) 1" matches value "50.00"
-    And the field "Override for james (Student) 1" matches value "0"
+    Then the field "Grade for Student 1" matches value "50.00"
+    And the field "Override for Student 1" matches value "0"
     And I set the field "Perform bulk insert" to "1"
     And I set the field "Insert value" to "1.0"
     And I press "Save"
     And I press "Continue"
-    And the field "Grade for james (Student) 1" matches value "50.00"
-    And the field "Override for james (Student) 1" matches value "0"
-    And the field "Grade for holly (Student) 2" matches value "1.00"
-    And the field "Override for holly (Student) 2" matches value "1"
-    And the field "Grade for anna (Student) 3" matches value "1.00"
-    And the field "Override for anna (Student) 3" matches value "1"
-    And the field "Grade for zac (Student) 4" matches value "1.00"
-    And the field "Override for zac (Student) 4" matches value "1"
+    And the field "Grade for Student 1" matches value "50.00"
+    And the field "Override for Student 1" matches value "0"
+    And the field "Grade for Student 2" matches value "1.00"
+    And the field "Override for Student 2" matches value "1"
+    And the field "Grade for Student 3" matches value "1.00"
+    And the field "Override for Student 3" matches value "1"
+    And the field "Grade for Student 4" matches value "1.00"
+    And the field "Override for Student 4" matches value "1"
     And I set the field "For" to "All grades"
     And I set the field "Perform bulk insert" to "1"
     And I set the field "Insert value" to "2.0"
     And I press "Save"
     And I press "Continue"
-    And the field "Grade for james (Student) 1" matches value "2.00"
-    And the field "Override for james (Student) 1" matches value "1"
-    And the field "Grade for holly (Student) 2" matches value "2.00"
-    And the field "Override for holly (Student) 2" matches value "1"
-    And the field "Grade for anna (Student) 3" matches value "2.00"
-    And the field "Override for anna (Student) 3" matches value "1"
-    And the field "Grade for zac (Student) 4" matches value "2.00"
-    And the field "Override for zac (Student) 4" matches value "1"
+    And the field "Grade for Student 1" matches value "2.00"
+    And the field "Override for Student 1" matches value "1"
+    And the field "Grade for Student 2" matches value "2.00"
+    And the field "Override for Student 2" matches value "1"
+    And the field "Grade for Student 3" matches value "2.00"
+    And the field "Override for Student 3" matches value "1"
+    And the field "Grade for Student 4" matches value "2.00"
+    And the field "Override for Student 4" matches value "1"
 
   Scenario: I can bulk insert grades and check their override flags for user view.
     Given I log in as "teacher1"
@@ -110,8 +110,8 @@ Feature: We can bulk insert grades for students in a course
     And I set the field "Perform bulk insert" to "1"
     When I set the field "Insert value" to "-1"
     And I press "Save"
-    Then I should see "The grade entered for Test assignment one for james (Student) 1 is less than the minimum allowed"
-    And I should see "The grade entered for Test assignment one for holly (Student) 2 is less than the minimum allowed"
-    And I should see "The grade entered for Test assignment one for anna (Student) 3 is less than the minimum allowed"
-    And I should see "The grade entered for Test assignment one for zac (Student) 4 is less than the minimum allowed"
+    Then I should see "The grade entered for Test assignment one for Student 1 is less than the minimum allowed"
+    And I should see "The grade entered for Test assignment one for Student 2 is less than the minimum allowed"
+    And I should see "The grade entered for Test assignment one for Student 3 is less than the minimum allowed"
+    And I should see "The grade entered for Test assignment one for Student 4 is less than the minimum allowed"
     And I should see "Grades were set for 0 items"
index e18eb5c..b04c8a7 100644 (file)
@@ -9,13 +9,13 @@ Feature: We can use Single view
       | fullname | shortname | category |
       | Course 1 | C1 | 0 |
     And the following "users" exist:
-      | username | firstname | lastname | email | idnumber | alternatename |
-      | teacher1 | Teacher | 1 | teacher1@example.com | t1 | fred |
-      | teacher2 | No edit | 1 | teacher2@example.com | t2 | nick |
-      | student1 | Student | 1 | student1@example.com | s1 | james |
-      | student2 | Student | 2 | student1@example.com | s2 | holly |
-      | student3 | Student | 3 | student1@example.com | s3 | anna |
-      | student4 | Student | 4 | student1@example.com | s4 | zac |
+      | username | firstname | lastname    | email                | idnumber | middlename | alternatename | firstnamephonetic | lastnamephonetic |
+      | teacher1 | Teacher   | 1           | teacher1@example.com | t1       |            | fred          |                   |                  |
+      | teacher2 | No edit   | 1           | teacher2@example.com | t2       |            | nick          |                   |                  |
+      | student1 | Grainne   | Beauchamp   | student1@example.com | s1       | Ann        | Jill          | Gronya            | Beecham          |
+      | student2 | Niamh     | Cholmondely | student2@example.com | s2       | Jane       | Nina          | Nee               | Chumlee          |
+      | student3 | Siobhan   | Desforges   | student3@example.com | s3       | Sarah      | Sev           | Shevon            | De-forjay        |
+      | student4 | Student   | 4           | student4@example.com | s4       |            | zac           |                   |                  |
     And the following "scales" exist:
       | name | scale |
       | Test Scale | Disappointing, Good, Very good, Excellent |
@@ -50,6 +50,9 @@ Feature: We can use Single view
       | capability                  | permission | role     | contextlevel  | reference |
       | moodle/grade:edit           | Allow      | teacher  | Course        | C1        |
       | gradereport/singleview:view | Allow      | teacher  | Course        | C1        |
+    And the following config values are set as admin:
+      | fullnamedisplay | firstnamephonetic,lastnamephonetic |
+      | alternativefullnameformat | middlename, alternatename, firstname, lastname |
     And I log in as "teacher1"
     And I am on "Course 1" course homepage
     Given I navigate to "View > Grader report" in the course gradebook
@@ -57,7 +60,7 @@ Feature: We can use Single view
   @javascript
   Scenario: I can update grades, add feedback and exclude grades.
     Given I navigate to "View > Single view" in the course gradebook
-    And I select "Student 4" from the "Select user..." singleselect
+    And I select "Student" from the "Select user..." singleselect
     And I set the field "Override for Test assignment one" to "1"
     When I set the following fields to these values:
         | Grade for Test assignment one | 10.00 |
@@ -76,29 +79,29 @@ Feature: We can use Single view
     And the field "Grade for Test grade item" matches value "45.00"
     And the field "Grade for Course total" matches value "55.00"
     And I click on "Show grades for Test assignment three" "link"
-    And I click on "Override for james (Student) 1" "checkbox"
+    And I click on "Override for Ann, Jill, Grainne, Beauchamp" "checkbox"
     And I set the following fields to these values:
-        | Grade for james (Student) 1 | 12.05 |
-        | Feedback for james (Student) 1 | test data2 |
-    And I set the field "Exclude for holly (Student) 2" to "1"
+        | Grade for Ann, Jill, Grainne, Beauchamp | 12.05 |
+        | Feedback for Ann, Jill, Grainne, Beauchamp | test data2 |
+    And I set the field "Exclude for Jane, Nina, Niamh, Cholmondely" to "1"
     And I press "Save"
     Then I should see "Grades were set for 2 items"
     And I press "Continue"
-    And the field "Grade for james (Student) 1" matches value "12.05"
-    And the field "Exclude for holly (Student) 2" matches value "1"
+    And the field "Grade for Ann, Jill, Grainne, Beauchamp" matches value "12.05"
+    And the field "Exclude for Jane, Nina, Niamh, Cholmondely" matches value "1"
     And I select "new grade item 1" from the "Select grade item..." singleselect
-    And I set the field "Grade for james (Student) 1" to "Very good"
+    And I set the field "Grade for Ann, Jill, Grainne, Beauchamp" to "Very good"
     And I press "Save"
     Then I should see "Grades were set for 1 items"
     And I press "Continue"
     And the following should exist in the "generaltable" table:
         | First name (Alternate name) Surname | Grade |
-        | james (Student) 1 | Very good |
+        | Ann, Jill, Grainne, Beauchamp | Very good |
     And I log out
     And I log in as "teacher2"
     And I am on "Course 1" course homepage
     Given I navigate to "View > Single view" in the course gradebook
-    And I select "Student 4" from the "Select user..." singleselect
+    And I select "Student" from the "Select user..." singleselect
     And the "Exclude for Test assignment one" "checkbox" should be disabled
     And the "Override for Test assignment one" "checkbox" should be enabled
 
@@ -106,12 +109,12 @@ Feature: We can use Single view
     Given I follow "Single view for Test assignment one"
     Then I should see "Test assignment one"
     Then I navigate to "View > Grader report" in the course gradebook
-    And I follow "Single view for Student 1"
-    Then I should see "Student 1"
+    And I follow "Single view for Ann, Jill, Grainne, Beauchamp"
+    Then I should see "Gronya,Beecham"
 
   Scenario: I can bulk update grades.
-    Given I follow "Single view for Student 1"
-    Then I should see "Student 1"
+    Given I follow "Single view for Ann, Jill, Grainne, Beauchamp"
+    Then I should see "Gronya,Beecham"
     When I set the field "For" to "All grades"
     And I set the field "Insert value" to "1.0"
     And I set the field "Perform bulk insert" to "1"
@@ -119,12 +122,12 @@ Feature: We can use Single view
     Then I should see "Grades were set for 6 items"
 
   Scenario: Navigation works in the Single view.
-    Given I follow "Single view for Student 1"
-    Then I should see "Student 1"
-    And I follow "Student 2"
-    Then I should see "Student 2"
-    And I follow "Student 1"
-    Then I should see "Student 1"
+    Given I follow "Single view for Ann, Jill, Grainne, Beauchamp"
+    Then I should see "Gronya,Beecham"
+    And I follow "Nee,Chumlee"
+    Then I should see "Nee,Chumlee"
+    And I follow "Gronya,Beecham"
+    Then I should see "Gronya,Beecham"
     And I click on "Show grades for Test assignment four" "link"
     Then I should see "Test assignment four"
     And I follow "Test assignment three"
@@ -134,7 +137,7 @@ Feature: We can use Single view
 
   Scenario: Activities are clickable only when
     it has a valid activity page.
-    Given I follow "Single view for Student 1"
+    Given I follow "Single view for Ann, Jill, Grainne, Beauchamp"
     And "new grade item 1" "link" should not exist in the "//tbody//tr[position()=1]//td[position()=2]" "xpath_element"
     Then "Category total" "link" should not exist in the "//tbody//tr[position()=2]//td[position()=2]" "xpath_element"
     And "Course total" "link" should not exist in the "//tbody//tr[position()=last()]//td[position()=2]" "xpath_element"
index cc2aeeb..9d69597 100644 (file)
@@ -60,7 +60,7 @@ $preview = '';
 $error = '';
 
 /// Get applicable roles - used in menus etc later on
-$rolenames = role_fix_names(get_profile_roles($context), $context, ROLENAME_ALIAS, true);
+$rolenames = role_fix_names(get_profile_roles($context), $context, ROLENAME_BOTH, true);
 
 /// Create the form
 $editform = new autogroup_form(null, array('roles' => $rolenames));
index 9cbc121..a94d9b1 100644 (file)
@@ -440,7 +440,7 @@ class player {
         }
 
         $template = new \stdClass();
-        $template->embedurl = self::get_embed_url($url)->out();
+        $template->embedurl = self::get_embed_url($url, $this->component)->out(false);
 
         return $OUTPUT->render_from_template('core_h5p/h5pembed', $template);
     }
@@ -448,11 +448,18 @@ class player {
     /**
      * Get the encoded URL for embeding this H5P content.
      * @param  string $url The URL of the .h5p file.
+     * @param string $component optional Moodle component to send xAPI tracking
      *
      * @return \moodle_url The embed URL.
      */
-    public static function get_embed_url(string $url): \moodle_url {
-        return new \moodle_url('/h5p/embed.php', ['url' => $url]);
+    public static function get_embed_url(string $url, string $component = ''): \moodle_url {
+        $params = ['url' => $url];
+        if (!empty($component)) {
+            // If component is not empty, it will be passed too, in order to allow tracking too.
+            $params['component'] = $component;
+        }
+
+        return new \moodle_url('/h5p/embed.php', $params);
     }
 
     /**
index f93e14c..6cbb336 100644 (file)
@@ -78,6 +78,7 @@ $string['authpreventaccountcreation'] = 'Prevent account creation when authentic
 $string['authpreventaccountcreation_help'] = 'When a user authenticates, an account on the site is automatically created if it doesn\'t yet exist. If an external database, such as LDAP, is used for authentication, but you wish to restrict access to the site to users with an existing account only, then this option should be enabled. New accounts will need to be created manually or via the upload users feature. Note that this setting doesn\'t apply to MNet authentication.';
 $string['authsettings'] = 'Manage authentication';
 $string['autolang'] = 'Language autodetect';
+$string['autolangusercreation'] = 'Use language that is auto detected from users browser during user creation';
 $string['autologinguests'] = 'Auto-login guests';
 $string['searchareas'] = 'Search areas';
 $string['availableto'] = 'Available to';
@@ -173,6 +174,7 @@ $string['configallowview'] = 'Select which roles a user will see, be able to fil
 $string['configallusersaresitestudents'] = 'For activities on the front page of the site, should ALL users be considered as students?  If you answer "Yes", then any confirmed user account will be allowed to participate as a student in those activities.  If you answer "No", then only users who are already a participant in at least one course will be able to take part in those front page activities. Only admins and specially assigned teachers can act as teachers for these front page activities.';
 $string['configauthenticationplugins'] = 'Please choose the authentication plugins you wish to use and arrange them in order of failthrough.';
 $string['configautolang'] = 'Detect default language from browser setting, if disabled site default is used.';
+$string['configautolangusercreation'] = 'Use language from users browser during user creation';
 $string['configautologinguests'] = 'Should visitors be logged in as guests automatically when entering courses with guest access?';
 $string['configbloglevel'] = 'This setting allows you to restrict the level to which user blogs can be viewed on this site.  Note that they specify the maximum context of the VIEWER not the poster or the types of blog posts.  Blogs can also be disabled completely if you don\'t want them at all.';
 $string['configcalendarcustomexport'] = 'Enable custom date range export of calendar';
index f614161..6535c93 100644 (file)
@@ -36,6 +36,7 @@ $string['contenttypenoedit'] = 'You can not edit this content';
 $string['emptynamenotallowed'] = 'Empty name is not allowed';
 $string['eventcontentcreated'] = 'Content created';
 $string['eventcontentdeleted'] = 'Content deleted';
+$string['eventcontentreplaced'] = 'Content replaced with file';
 $string['eventcontentupdated'] = 'Content updated';
 $string['eventcontentuploaded'] = 'Content uploaded';
 $string['eventcontentviewed'] = 'Content viewed';
@@ -64,6 +65,7 @@ $string['privacy:metadata:userid'] = 'The ID of the user creating or modifying c
 $string['privacy:request:preference:set'] = 'The value of the setting \'{$a->name}\' was \'{$a->value}\'';
 $string['rename'] = 'Rename';
 $string['renamecontent'] = 'Rename content';
+$string['replacecontent'] = 'Replace with file';
 $string['searchcontentbankbyname'] = 'Search for content by name';
 $string['size'] = 'Size';
 $string['timecreated'] = 'Time created';
index 6a72879..828a2f5 100644 (file)
@@ -440,7 +440,7 @@ $string['noparticipants'] = 'No participants found for this course';
 $string['noparticipatorycms'] = 'Sorry, but you have no participatory course modules to report on';
 $string['nopermissions'] = 'Sorry, but you do not currently have permissions to do that ({$a}).';
 $string['nopermissiontocomment'] = 'You can\'t add comments';
-$string['nopermissiontodelentry'] = 'You can\'t delete other people\'s entries!';
+$string['nopermissiontodelentry'] = 'You can\'t delete this comment!';
 $string['nopermissiontoeditcomment'] = 'You can\'t edit other people\'s comments!';
 $string['nopermissiontohide'] = 'No permission to hide!';
 $string['nopermissiontoimportact'] = 'You do not have the required permissions to import activities to this course';
index 06ef6bb..625c0c0 100644 (file)
@@ -153,6 +153,7 @@ $string['confirmunassignno'] = 'Cancel';
 $string['contentbank:access'] = 'Access the content bank';
 $string['contentbank:deleteanycontent'] = 'Delete any content from the content bank';
 $string['contentbank:deleteowncontent'] = 'Delete content from own content bank';
+$string['contentbank:downloadcontent'] = 'Download a content from the content bank';
 $string['contentbank:manageanycontent'] = 'Manage any content from the content bank';
 $string['contentbank:manageowncontent'] = 'Manage content from own content bank';
 $string['contentbank:upload'] = 'Upload new content to the content bank';
index 7e63155..ab6f09e 100644 (file)
@@ -3163,9 +3163,11 @@ function get_assignable_roles(context $context, $rolenamedisplay = ROLENAME_ALIA
  * test the moodle/role:switchroles to see if the user is allowed to switch in the first place.
  *
  * @param context $context a context.
+ * @param int $rolenamedisplay the type of role name to display. One of the
+ *      ROLENAME_X constants. Default ROLENAME_ALIAS.
  * @return array an array $roleid => $rolename.
  */
-function get_switchable_roles(context $context) {
+function get_switchable_roles(context $context, $rolenamedisplay = ROLENAME_ALIAS) {
     global $USER, $DB;
 
     // You can't switch roles without this capability.
@@ -3208,7 +3210,7 @@ function get_switchable_roles(context $context) {
       ORDER BY r.sortorder";
     $roles = $DB->get_records_sql($query, $params);
 
-    return role_fix_names($roles, $context, ROLENAME_ALIAS, true);
+    return role_fix_names($roles, $context, $rolenamedisplay, true);
 }
 
 /**
@@ -3216,9 +3218,11 @@ function get_switchable_roles(context $context) {
  *
  * @param context $context a context.
  * @param int $userid id of user.
+ * @param int $rolenamedisplay the type of role name to display. One of the
+ *      ROLENAME_X constants. Default ROLENAME_ALIAS.
  * @return array an array $roleid => $rolename.
  */
-function get_viewable_roles(context $context, $userid = null) {
+function get_viewable_roles(context $context, $userid = null, $rolenamedisplay = ROLENAME_ALIAS) {
     global $USER, $DB;
 
     if ($userid == null) {
@@ -3260,7 +3264,7 @@ function get_viewable_roles(context $context, $userid = null) {
       ORDER BY r.sortorder";
     $roles = $DB->get_records_sql($query, $params);
 
-    return role_fix_names($roles, $context, ROLENAME_ALIAS, true);
+    return role_fix_names($roles, $context, $rolenamedisplay, true);
 }
 
 /**
index c6a3219..b93d393 100644 (file)
@@ -5007,12 +5007,12 @@ class admin_settings_num_course_sections extends admin_setting_configselect {
  *
  * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class admin_settings_coursecat_select extends admin_setting_configselect {
+class admin_settings_coursecat_select extends admin_setting_configselect_autocomplete {
     /**
      * Calls parent::__construct with specific arguments
      */
-    public function __construct($name, $visiblename, $description, $defaultsetting) {
-        parent::__construct($name, $visiblename, $description, $defaultsetting, NULL);
+    public function __construct($name, $visiblename, $description, $defaultsetting = 1) {
+        parent::__construct($name, $visiblename, $description, $defaultsetting, $choices = null);
     }
 
     /**
@@ -5021,8 +5021,6 @@ class admin_settings_coursecat_select extends admin_setting_configselect {
      * @return bool
      */
     public function load_choices() {
-        global $CFG;
-        require_once($CFG->dirroot.'/course/lib.php');
         if (is_array($this->choices)) {
             return true;
         }
@@ -5435,6 +5433,54 @@ class admin_setting_configcheckbox_with_lock extends admin_setting_configcheckbo
 
 }
 
+/**
+ * Autocomplete as you type form element.
+ *
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class admin_setting_configselect_autocomplete extends admin_setting_configselect {
+    /** @var boolean $tags Should we allow typing new entries to the field? */
+    protected $tags = false;
+    /** @var string $ajax Name of an AMD module to send/process ajax requests. */
+    protected $ajax = '';
+    /** @var string $placeholder Placeholder text for an empty list. */
+    protected $placeholder = '';
+    /** @var bool $casesensitive Whether the search has to be case-sensitive. */
+    protected $casesensitive = false;
+    /** @var bool $showsuggestions Show suggestions by default - but this can be turned off. */
+    protected $showsuggestions = true;
+    /** @var string $noselectionstring String that is shown when there are no selections. */
+    protected $noselectionstring = '';
+
+    /**
+     * Returns XHTML select field and wrapping div(s)
+     *
+     * @see output_select_html()
+     *
+     * @param string $data the option to show as selected
+     * @param string $query
+     * @return string XHTML field and wrapping div
+     */
+    public function output_html($data, $query='') {
+        global $PAGE;
+
+        $html = parent::output_html($data, $query);
+
+        if ($html === '') {
+            return $html;
+        }
+
+        $this->placeholder = get_string('search');
+
+        $params = array('#' . $this->get_id(), $this->tags, $this->ajax,
+            $this->placeholder, $this->casesensitive, $this->showsuggestions, $this->noselectionstring);
+
+        // Load autocomplete wrapper for select2 library.
+        $PAGE->requires->js_call_amd('core/form-autocomplete', 'enhance', $params);
+
+        return $html;
+    }
+}
 
 /**
  * Dropdown menu with an advanced checkbox, that controls a additional $name.'_adv' setting.
index be2f9ec..0bc5453 100644 (file)
@@ -26,6 +26,7 @@ namespace core;
 
 use coding_exception;
 use core_php_time_limit;
+use stored_file;
 
 /**
  * Dataformat utility class
@@ -144,4 +145,25 @@ class dataformat {
 
         return $filepath;
     }
+
+    /**
+     * Writes a formatted data file to file storage
+     *
+     * @param array $filerecord File record for storage, 'filename' extension should be omitted as it's added by the dataformat
+     * @param string $dataformat
+     * @param array $columns
+     * @param Iterable $iterator Iterable set of records to write
+     * @param callable|null $callback Optional callback method to apply to each record prior to writing
+     * @return stored_file
+     */
+    public static function write_data_to_filearea(array $filerecord, string $dataformat, array $columns, Iterable $iterator,
+            callable $callback = null): stored_file {
+
+        $filepath = self::write_data($filerecord['filename'], $dataformat, $columns, $iterator, $callback);
+
+        // Update filename of returned file record.
+        $filerecord['filename'] = basename($filepath);
+
+        return get_file_storage()->create_file_from_pathname($filerecord, $filepath);
+    }
 }
index 70af39c..559c657 100644 (file)
@@ -27,6 +27,7 @@ defined('MOODLE_INTERNAL') || die;
 
 require_once("$CFG->libdir/externallib.php");
 require_once("$CFG->libdir/gradelib.php");
+require_once("$CFG->dirroot/grade/edit/tree/lib.php");
 require_once("$CFG->dirroot/grade/querylib.php");
 
 /**
@@ -570,4 +571,167 @@ class core_grades_external extends external_api {
             as defined in lib/grade/constants.php'
         );
     }
+
+    /**
+     * Returns description of method parameters
+     *
+     * @return external_function_parameters
+     * @since Moodle 3.10
+     */
+    public static function create_gradecategory_parameters() {
+        return new external_function_parameters(
+            [
+                'courseid' => new external_value(PARAM_INT, 'id of course', VALUE_REQUIRED),
+                'fullname' => new external_value(PARAM_TEXT, 'fullname of category', VALUE_REQUIRED),
+                'options' => new external_single_structure([
+                    'aggregation' => new external_value(PARAM_INT, 'aggregation method', VALUE_OPTIONAL),
+                    'aggregateonlygraded' => new external_value(PARAM_BOOL, 'exclude empty grades', VALUE_OPTIONAL),
+                    'aggregateoutcomes' => new external_value(PARAM_BOOL, 'aggregate outcomes', VALUE_OPTIONAL),
+                    'droplow' => new external_value(PARAM_INT, 'drop low grades', VALUE_OPTIONAL),
+                    'itemname' => new external_value(PARAM_TEXT, 'the category total name', VALUE_OPTIONAL),
+                    'iteminfo' => new external_value(PARAM_TEXT, 'the category iteminfo', VALUE_OPTIONAL),
+                    'idnumber' => new external_value(PARAM_TEXT, 'the category idnumber', VALUE_OPTIONAL),
+                    'gradetype' => new external_value(PARAM_INT, 'the grade type', VALUE_OPTIONAL),
+                    'grademax' => new external_value(PARAM_INT, 'the grade max', VALUE_OPTIONAL),
+                    'grademin' => new external_value(PARAM_INT, 'the grade min', VALUE_OPTIONAL),
+                    'gradepass' => new external_value(PARAM_INT, 'the grade to pass', VALUE_OPTIONAL),
+                    'display' => new external_value(PARAM_INT, 'the display type', VALUE_OPTIONAL),
+                    'decimals' => new external_value(PARAM_INT, 'the decimal count', VALUE_OPTIONAL),
+                    'hiddenuntil' => new external_value(PARAM_INT, 'grades hidden until', VALUE_OPTIONAL),
+                    'locktime' => new external_value(PARAM_INT, 'lock grades after', VALUE_OPTIONAL),
+                    'weightoverride' => new external_value(PARAM_BOOL, 'weight adjusted', VALUE_OPTIONAL),
+                    'aggregationcoef2' => new external_value(PARAM_RAW, 'weight coefficient', VALUE_OPTIONAL),
+                    'parentcategoryid' => new external_value(PARAM_INT, 'The parent category id', VALUE_OPTIONAL),
+                    'parentcategoryidnumber' => new external_value(PARAM_TEXT, 'the parent category idnumber', VALUE_OPTIONAL),
+                ], 'optional category data', VALUE_DEFAULT, [])
+            ]
+        );
+    }
+
+    /**
+     * Creates a gradecategory inside of the specified course.
+     *
+     * @param int $courseid the courseid to create the gradecategory in.
+     * @param string $fullname the fullname of the grade category to create.
+     * @param array $options array of options to set.
+     *
+     * @return array array of created categoryid and warnings.
+     */
+    public static function create_gradecategory(int $courseid, string $fullname, array $options) {
+        global $CFG, $DB;
+
+        $params = self::validate_parameters(self::create_gradecategory_parameters(),
+            ['courseid' => $courseid, 'fullname' => $fullname, 'options' => $options]);
+
+        // Now params are validated, update the references.
+        $courseid = $params['courseid'];
+        $fullname = $params['fullname'];
+        $options = $params['options'];
+
+        // Check that the context and permissions are OK.
+        $context = context_course::instance($courseid);
+        self::validate_context($context);
+        require_capability('moodle/grade:manage', $context);
+
+        $defaultparentcat = new grade_category(['courseid' => $courseid, 'depth' => 1], true);
+
+        // Setup default data so WS call needs to contain only data to set.
+        // This is not done in the Parameters, so that the array of options can be optional.
+        $data = [
+            'fullname' => $fullname,
+            'aggregation' => grade_get_setting($courseid, 'displaytype', $CFG->grade_displaytype),
+            'aggregateonlygraded' => 1,
+            'aggregateoutcomes' => 0,
+            'droplow' => 0,
+            'grade_item_itemname' => '',
+            'grade_item_iteminfo' => '',
+            'grade_item_idnumber' => '',
+            'grade_item_gradetype' => GRADE_TYPE_VALUE,
+            'grade_item_grademax' => 100,
+            'grade_item_grademin' => 1,
+            'grade_item_gradepass' => 1,
+            'grade_item_display' => GRADE_DISPLAY_TYPE_DEFAULT,
+            // Hack. This must be -2 to use the default setting.
+            'grade_item_decimals' => -2,
+            'grade_item_hiddenuntil' => 0,
+            'grade_item_locktime' => 0,
+            'grade_item_weightoverride' => 0,
+            'grade_item_aggregationcoef2' => 0,
+            'parentcategory' => $defaultparentcat->id
+        ];
+
+        // Most of the data items need boilerplate prepended. These are the exceptions.
+        $ignorekeys = ['aggregation', 'aggregateonlygraded', 'aggregateoutcomes', 'droplow', 'parentcategoryid', 'parentcategoryidnumber'];
+        foreach ($options as $key => $value) {
+            if (!in_array($key, $ignorekeys)) {
+                $fullkey = 'grade_item_' . $key;
+                $data[$fullkey] = $value;
+            } else {
+                $data[$key] = $value;
+            }
+        }
+
+        // Handle parent category special case.
+        if (array_key_exists('parentcategoryid', $options) && $parentcat = $DB->get_record('grade_categories',
+            ['id' => $options['parentcategoryid'], 'courseid' => $courseid])) {
+            $data['parentcategory'] = $parentcat->id;
+        } else if (array_key_exists('parentcategoryidnumber', $options) && $parentcatgradeitem = $DB->get_record('grade_items',
+            ['itemtype' => 'category', 'idnumber' => $options['parentcategoryidnumber']], '*', IGNORE_MULTIPLE)) {
+            if ($parentcat = $DB->get_record('grade_categories', ['courseid' => $courseid, 'id' => $parentcatgradeitem->iteminstance])) {
+                $data['parentcategory'] = $parentcat->id;
+            }
+        }
+
+        // Create new gradecategory item.
+        $gradecategory = new grade_category(['courseid' => $courseid], false);
+        $gradecategory->apply_default_settings();
+        $gradecategory->apply_forced_settings();
+
+        // Data Validation.
+        if (array_key_exists('grade_item_gradetype', $data) and $data['grade_item_gradetype'] == GRADE_TYPE_SCALE) {
+            if (empty($data['grade_item_scaleid'])) {
+                $warnings[] = ['item' => 'scaleid', 'warningcode' => 'invalidscale',
+                    'message' => get_string('missingscale', 'grades')];
+            }
+        }
+        if (array_key_exists('grade_item_grademin', $data) and array_key_exists('grade_item_grademax', $data)) {
+            if (($data['grade_item_grademax'] != 0 OR $data['grade_item_grademin'] != 0) AND
+                ($data['grade_item_grademax'] == $data['grade_item_grademin'] OR
+                $data['grade_item_grademax'] < $data['grade_item_grademin'])) {
+                $warnings[] = ['item' => 'grademax', 'warningcode' => 'invalidgrade',
+                    'message' => get_string('incorrectminmax', 'grades')];
+            }
+        }
+
+        if (!empty($warnings)) {
+            return ['categoryid' => null, 'warnings' => $warnings];
+        }
+
+        // Now call the update function with data. Transactioned so the gradebook isn't broken on bad data.
+        try {
+            $transaction = $DB->start_delegated_transaction();
+            grade_edit_tree::update_gradecategory($gradecategory, (object) $data);
+            $transaction->allow_commit();
+        } catch (Exception $e) {
+            // If the submitted data was broken for any reason.
+            $warnings['database'] = $e->getMessage();
+            $transaction->rollback();
+            return ['warnings' => $warnings];
+        }
+
+        return['categoryid' => $gradecategory->id, 'warnings' => []];
+    }
+
+    /**
+     * Returns description of method result value
+     *
+     * @return external_description
+     * @since Moodle 3.10
+     */
+    public static function create_gradecategory_returns() {
+        return new external_single_structure([
+            'categoryid' => new external_value(PARAM_INT, 'The ID of the created category', VALUE_OPTIONAL),
+            'warnings' => new external_warnings(),
+        ]);
+    }
 }
index 7013287..e20dd2c 100644 (file)
@@ -658,7 +658,7 @@ class core_user {
      * @return void
      */
     protected static function fill_properties_cache() {
-        global $CFG;
+        global $CFG, $SESSION;
         if (self::$propertiescache !== null) {
             return;
         }
@@ -694,7 +694,8 @@ class core_user {
         $fields['city'] = array('type' => PARAM_TEXT, 'null' => NULL_NOT_ALLOWED, 'default' => $CFG->defaultcity);
         $fields['country'] = array('type' => PARAM_ALPHA, 'null' => NULL_NOT_ALLOWED, 'default' => $CFG->country,
                 'choices' => array_merge(array('' => ''), get_string_manager()->get_list_of_countries(true, true)));
-        $fields['lang'] = array('type' => PARAM_LANG, 'null' => NULL_NOT_ALLOWED, 'default' => $CFG->lang,
+        $fields['lang'] = array('type' => PARAM_LANG, 'null' => NULL_NOT_ALLOWED,
+                'default' => (!empty($CFG->autolangusercreation) && !empty($SESSION->lang)) ? $SESSION->lang : $CFG->lang,
                 'choices' => array_merge(array('' => ''), get_string_manager()->get_list_of_translations(false)));
         $fields['calendartype'] = array('type' => PARAM_PLUGIN, 'null' => NULL_NOT_ALLOWED, 'default' => $CFG->calendartype,
                 'choices' => array_merge(array('' => ''), \core_calendar\type_factory::get_list_of_calendar_types()));
index 3de202d..5078f4d 100644 (file)
@@ -2565,4 +2565,15 @@ $capabilities = array(
             'editingteacher' => CAP_ALLOW,
         )
     ],
+
+    // Allow users to download content.
+    'moodle/contentbank:downloadcontent' => [
+        'captype' => 'read',
+        'contextlevel' => CONTEXT_COURSE,
+        'archetypes' => [
+            'manager' => CAP_ALLOW,
+            'coursecreator' => CAP_ALLOW,
+            'editingteacher' => CAP_ALLOW,
+        ]
+    ],
 );
index 0be2bf7..abc93f6 100644 (file)
@@ -911,6 +911,13 @@ $functions = array(
         'ajax' => true,
         'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE],
     ],
+    'core_grades_create_gradecategory' => array (
+        'classname' => 'core_grades_external',
+        'methodname' => 'create_gradecategory',
+        'description' => 'Create a grade category inside a course gradebook.',
+        'type' => 'write',
+        'capabilities' => 'moodle/grade:manage',
+    ),
     'core_grading_get_definitions' => array(
         'classname' => 'core_grading_external',
         'methodname' => 'get_definitions',
index 20c0d05..41b12fc 100644 (file)
@@ -34,7 +34,7 @@ require_once($CFG->libdir . '/form/text.php');
  * Duration element
  *
  * HTML class for a length of time. For example, 30 minutes of 4 days. The
- * values returned to PHP is the duration in seconds.
+ * values returned to PHP is the duration in seconds (an int rounded to the nearest second).
  *
  * @package   core_form
  * @category  form
@@ -43,11 +43,12 @@ require_once($CFG->libdir . '/form/text.php');
  */
 class MoodleQuickForm_duration extends MoodleQuickForm_group {
     /**
-     * Control the fieldnames for form elements
+     * Control the field names for form elements
      * optional => if true, show a checkbox beside the element to turn it on (or off)
+     * defaultunit => which unit is default when the form is blank (default Minutes).
      * @var array
      */
-    protected $_options = array('optional' => false, 'defaultunit' => MINSECS);
+    protected $_options = ['optional' => false, 'defaultunit' => MINSECS];
 
     /** @var array associative array of time units (days, hours, minutes, seconds) */
     private $_units = null;
@@ -55,7 +56,7 @@ class MoodleQuickForm_duration extends MoodleQuickForm_group {
    /**
     * constructor
     *
-    * @param string $elementName Element's name
+    * @param ?string $elementName Element's name
     * @param mixed $elementLabel Label(s) for an element
     * @param array $options Options to control the element's display. Recognised values are
     *      'optional' => true/false - whether to display an 'enabled' checkbox next to the element.
@@ -66,16 +67,15 @@ class MoodleQuickForm_duration extends MoodleQuickForm_group {
     * @param mixed $attributes Either a typical HTML attribute string or an associative array
     */
     public function __construct($elementName = null, $elementLabel = null,
-            $options = array(), $attributes = null) {
-        // TODO MDL-52313 Replace with the call to parent::__construct().
-        HTML_QuickForm_element::__construct($elementName, $elementLabel, $attributes);
+            $options = [], $attributes = null) {
+        parent::__construct($elementName, $elementLabel, $attributes);
         $this->_persistantFreeze = true;
         $this->_appendName = true;
         $this->_type = 'duration';
 
         // Set the options, do not bother setting bogus ones
         if (!is_array($options)) {
-            $options = array();
+            $options = [];
         }
         $this->_options['optional'] = !empty($options['optional']);
         if (isset($options['defaultunit'])) {
@@ -111,7 +111,7 @@ class MoodleQuickForm_duration extends MoodleQuickForm_group {
      * @deprecated since Moodle 3.1
      */
     public function MoodleQuickForm_duration($elementName = null, $elementLabel = null,
-            $options = array(), $attributes = null) {
+            $options = [], $attributes = null) {
         debugging('Use of class name as constructor is deprecated', DEBUG_DEVELOPER);
         self::__construct($elementName, $elementLabel, $options, $attributes);
     }
@@ -123,13 +123,13 @@ class MoodleQuickForm_duration extends MoodleQuickForm_group {
      */
     public function get_units() {
         if (is_null($this->_units)) {
-            $this->_units = array(
+            $this->_units = [
                 WEEKSECS => get_string('weeks'),
                 DAYSECS => get_string('days'),
                 HOURSECS => get_string('hours'),
                 MINSECS => get_string('minutes'),
                 1 => get_string('seconds'),
-            );
+            ];
         }
         return $this->_units;
     }
@@ -158,14 +158,14 @@ class MoodleQuickForm_duration extends MoodleQuickForm_group {
      */
     public function seconds_to_unit($seconds) {
         if ($seconds == 0) {
-            return array(0, $this->_options['defaultunit']);
+            return [0, $this->_options['defaultunit']];
         }
         foreach ($this->get_units_used() as $unit => $notused) {
             if (fmod($seconds, $unit) == 0) {
-                return array($seconds / $unit, $unit);
+                return [$seconds / $unit, $unit];
             }
         }
-        return array($seconds, 1);
+        return [$seconds, 1];
     }
 
     /**
@@ -174,12 +174,12 @@ class MoodleQuickForm_duration extends MoodleQuickForm_group {
     function _createElements() {
         $attributes = $this->getAttributes();
         if (is_null($attributes)) {
-            $attributes = array();
+            $attributes = [];
         }
         if (!isset($attributes['size'])) {
             $attributes['size'] = 3;
         }
-        $this->_elements = array();
+        $this->_elements = [];
         // E_STRICT creating elements without forms is nasty because it internally uses $this
         $number = $this->createFormElement('text', 'number',
                 get_string('time', 'form'), $attributes, true);
@@ -226,7 +226,7 @@ class MoodleQuickForm_duration extends MoodleQuickForm_group {
                 }
                 if (!is_array($value)) {
                     list($number, $unit) = $this->seconds_to_unit($value);
-                    $value = array('number' => $number, 'timeunit' => $unit);
+                    $value = ['number' => $number, 'timeunit' => $unit];
                     // If optional, default to off, unless a date was provided
                     if ($this->_options['optional']) {
                         $value['enabled'] = $number != 0;
@@ -245,7 +245,6 @@ class MoodleQuickForm_duration extends MoodleQuickForm_group {
                 }
                 $caller->setType($arg[0] . '[number]', PARAM_FLOAT);
                 return parent::onQuickFormEvent($event, $arg, $caller);
-                break;
 
             default:
                 return parent::onQuickFormEvent($event, $arg, $caller);
@@ -270,7 +269,7 @@ class MoodleQuickForm_duration extends MoodleQuickForm_group {
      *
      * @param HTML_QuickForm_Renderer $renderer An HTML_QuickForm_Renderer object
      * @param bool $required Whether a group is required
-     * @param string $error An error message associated with a group
+     * @param ?string $error An error message associated with a group
      */
     function accept(&$renderer, $required = false, $error = null) {
         $renderer->renderElement($this, $required, $error);
@@ -286,7 +285,7 @@ class MoodleQuickForm_duration extends MoodleQuickForm_group {
      */
     function exportValue(&$submitValues, $assoc = false) {
         // Get the values from all the child elements.
-        $valuearray = array();
+        $valuearray = [];
         foreach ($this->_elements as $element) {
             $thisexport = $element->exportValue($submitValues[$this->getName()], true);
             if (!is_null($thisexport)) {
@@ -301,6 +300,7 @@ class MoodleQuickForm_duration extends MoodleQuickForm_group {
         if ($this->_options['optional'] && empty($valuearray['enabled'])) {
             return $this->_prepareValue(0, $assoc);
         }
-        return $this->_prepareValue($valuearray['number'] * $valuearray['timeunit'], $assoc);
+        return $this->_prepareValue(
+                (int) round($valuearray['number'] * $valuearray['timeunit']), $assoc);
     }
 }
index 4ce9280..629ac4d 100644 (file)
@@ -47,7 +47,7 @@ class core_form_duration_testcase extends basic_testcase {
      *
      * @return MoodleQuickForm
      */
-    protected function get_test_form() {
+    protected function get_test_form(): MoodleQuickForm {
         $form = new temp_form_duration();
         return $form->getform();
     }
@@ -57,27 +57,26 @@ class core_form_duration_testcase extends basic_testcase {
      *
      * @return array with two elements, a MoodleQuickForm and a MoodleQuickForm_duration.
      */
-    protected function get_test_form_and_element() {
+    protected function get_test_form_and_element(): array {
         $mform = $this->get_test_form();
         $element = $mform->addElement('duration', 'duration');
         return [$mform, $element];
     }
 
     /**
-     * Testcase for testing contructor.
-     *
-     * @expectedException coding_exception
+     * Test the constructor error handling.
      */
-    public function test_constructor() {
+    public function test_constructor_rejects_invalid_unit(): void {
         // Test trying to create with an invalid unit.
         $mform = $this->get_test_form();
+        $this->expectException('coding_exception');
         $mform->addElement('duration', 'testel', null, ['defaultunit' => 123, 'optional' => false]);
     }
 
     /**
-     * Test contructor only some units.
+     * Test constructor only some units.
      */
-    public function test_constructor_limited_units() {
+    public function test_constructor_limited_units(): void {
         $mform = $this->get_test_form();
         $mform->addElement('duration', 'testel', null, ['units' => [MINSECS, 1], 'optional' => false]);
         $html = $mform->toHtml();
@@ -90,7 +89,7 @@ class core_form_duration_testcase extends basic_testcase {
     /**
      * Testcase for testing units (seconds, minutes, hours and days)
      */
-    public function test_get_units() {
+    public function test_get_units(): void {
         [$mform, $element] = $this->get_test_form_and_element();
         $units = $element->get_units();
         $this->assertEquals($units, [1 => get_string('seconds'), 60 => get_string('minutes'),
@@ -98,66 +97,95 @@ class core_form_duration_testcase extends basic_testcase {
     }
 
     /**
-     * Testcase for testing conversion of seconds to the best possible unit
+     * Data provider for {@see test_seconds_to_unit()}.
+     *
+     * @return array test cases.
      */
-    public function test_seconds_to_unit() {
-        [$mform, $element] = $this->get_test_form_and_element();
-        $this->assertEquals([0, MINSECS], $element->seconds_to_unit(0)); // Zero minutes, for a nice default unit.
-        $this->assertEquals([1, 1], $element->seconds_to_unit(1));
-        $this->assertEquals([3601, 1], $element->seconds_to_unit(3601));
-        $this->assertEquals([1, MINSECS], $element->seconds_to_unit(60));
-        $this->assertEquals([3, MINSECS], $element->seconds_to_unit(180));
-        $this->assertEquals([1, HOURSECS], $element->seconds_to_unit(3600));
-        $this->assertEquals([2, HOURSECS], $element->seconds_to_unit(7200));
-        $this->assertEquals([1, DAYSECS], $element->seconds_to_unit(86400));
-        $this->assertEquals([25, HOURSECS], $element->seconds_to_unit(90000));
+    public function seconds_to_unit_cases(): array {
+        return [
+            [[0, MINSECS], 0], // Zero minutes, for a nice default unit.
+            [[1, 1], 1],
+            [[3601, 1], 3601],
+            [[1, MINSECS], 60],
+            [[3, MINSECS], 180],
+            [[1, HOURSECS], 3600],
+            [[2, HOURSECS], 7200],
+            [[1, DAYSECS], 86400],
+            [[25, HOURSECS], 90000],
+        ];
+    }
 
+    /**
+     * Testcase for testing conversion of seconds to the best possible unit.
+     *
+     * @dataProvider seconds_to_unit_cases
+     * @param array $expected expected return value from seconds_to_unit
+     * @param int $seconds value to pass to seconds_to_unit
+     */
+    public function test_seconds_to_unit(array $expected, int $seconds): void {
+        [, $element] = $this->get_test_form_and_element();
+        $this->assertEquals($expected, $element->seconds_to_unit($seconds));
+    }
+
+    /**
+     * Testcase for testing conversion of seconds to the best possible unit with a non-default default unit.
+     */
+    public function test_seconds_to_unit_different_default_unit() {
+        $mform = $this->get_test_form();
         $element = $mform->addElement('duration', 'testel', null,
                 ['defaultunit' => DAYSECS, 'optional' => false]);
-        $this->assertEquals([0, DAYSECS], $element->seconds_to_unit(0)); // Zero minutes, for a nice default unit.
+        $this->assertEquals([0, DAYSECS], $element->seconds_to_unit(0));
+    }
+
+    /**
+     * Data provider for {@see test_export_value()}.
+     *
+     * @return array test cases.
+     */
+    public function export_value_cases(): array {
+        return [
+            [10, '10', 1],
+            [9, '9.3', 1],
+            [10, '9.5', 1],
+            [180, '3', MINSECS],
+            [90, '1.5', MINSECS],
+            [7200, '2', HOURSECS],
+            [86400, '1', DAYSECS],
+            [0, '0', HOURSECS],
+            [0, '10', 1, 0, true],
+            [20, '20', 1, 1, true],
+            [0, '10', 1, 0, true, ''],
+            [20, '20', 1, 1, true, ''],
+        ];
     }
 
     /**
      * Testcase to check generated timestamp
+     *
+     * @dataProvider export_value_cases
+     * @param int $expected Expected value returned by the element.
+     * @param string $number Number entered into the element.
+     * @param int $unit Unit selected in the element.
+     * @param int $enabled Whether the enabled checkbox on the form was selected. (Only used if $optional is true.)
+     * @param bool $optional Whether the element has the optional option on.
+     * @param string|null $label The element's label.
      */
-    public function test_exportValue() {
+    public function test_export_value(int $expected, string $number, int $unit, int $enabled = 0,
+            bool $optional = false, ?string $label = null): void {
+
+        // Create the test element.
         $mform = $this->get_test_form();
-        $el = $mform->addElement('duration', 'testel');
-        $values = ['testel' => ['number' => 10, 'timeunit' => 1]];
-        $this->assertEquals(['testel' => 10], $el->exportValue($values, true));
-        $this->assertEquals(10, $el->exportValue($values));
-        $values = ['testel' => ['number' => 3, 'timeunit' => MINSECS]];
-        $this->assertEquals(['testel' => 180], $el->exportValue($values, true));
-        $this->assertEquals(180, $el->exportValue($values));
-        $values = ['testel' => ['number' => 1.5, 'timeunit' => MINSECS]];
-        $this->assertEquals(['testel' => 90], $el->exportValue($values, true));
-        $this->assertEquals(90, $el->exportValue($values));
-        $values = ['testel' => ['number' => 2, 'timeunit' => HOURSECS]];
-        $this->assertEquals(['testel' => 7200], $el->exportValue($values, true));
-        $this->assertEquals(7200, $el->exportValue($values));
-        $values = ['testel' => ['number' => 1, 'timeunit' => DAYSECS]];
-        $this->assertEquals(['testel' => 86400], $el->exportValue($values, true));
-        $this->assertEquals(86400, $el->exportValue($values));
-        $values = ['testel' => ['number' => 0, 'timeunit' => HOURSECS]];
-        $this->assertEquals(['testel' => 0], $el->exportValue($values, true));
-        $this->assertEquals(0, $el->exportValue($values));
-
-        $el = $mform->addElement('duration', 'testel', null, ['optional' => true]);
-        $values = ['testel' => ['number' => 10, 'timeunit' => 1]];
-        $this->assertEquals(['testel' => 0], $el->exportValue($values, true));
-        $this->assertEquals(0, $el->exportValue($values));
-        $values = ['testel' => ['number' => 20, 'timeunit' => 1, 'enabled' => 1]];
-        $this->assertEquals(['testel' => 20], $el->exportValue($values, true));
-        $this->assertEquals(20, $el->exportValue($values));
-
-        // Optional element.
-        $el2 = $mform->addElement('duration', 'testel', '', ['optional' => true]);
-        $values = ['testel' => ['number' => 10, 'timeunit' => 1, 'enabled' => 1]];
-        $this->assertEquals(['testel' => 10], $el2->exportValue($values, true));
-        $this->assertEquals(10, $el2->exportValue($values));
-        $values = ['testel' => ['number' => 10, 'timeunit' => 1, 'enabled' => 0]];
-        $this->assertEquals(['testel' => 0], $el2->exportValue($values, true));
-        $this->assertEquals(null, $el2->exportValue($values));
+        $el = $mform->addElement('duration', 'testel', $label, $optional ? ['optional' => true] : []);
+
+        // Prepare the submitted values.
+        $values = ['testel' => ['number' => $number, 'timeunit' => $unit]];
+        if ($optional) {
+            $values['testel']['enabled'] = $enabled;
+        }
+
+        // Test.
+        $this->assertEquals(['testel' => $expected], $el->exportValue($values, true));
+        $this->assertEquals($expected, $el->exportValue($values));
     }
 }
 
index ab07f4e..d4cb9ed 100644 (file)
Binary files a/lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector-debug.js and b/lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector-debug.js differ
index 8835642..d7fc49d 100644 (file)
Binary files a/lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector-min.js and b/lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector-min.js differ
index ab07f4e..d4cb9ed 100644 (file)
Binary files a/lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector.js and b/lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector.js differ
index b3ba4b1..42b3967 100644 (file)
@@ -86,8 +86,8 @@ CALENDAR.prototype = {
             this.set_date_from_selects();
         }
         M.form.dateselector.currentowner = this;
-        M.form.dateselector.calendar.set('mindate', new Date(this.yearselect.firstOptionValue(), 0, 1));
-        M.form.dateselector.calendar.set('maxdate', new Date(this.yearselect.lastOptionValue(), 11, 31));
+        M.form.dateselector.calendar.set('minimumDate', new Date(this.yearselect.firstOptionValue(), 0, 1));
+        M.form.dateselector.calendar.set('maximumDate', new Date(this.yearselect.lastOptionValue(), 11, 31));
         M.form.dateselector.panel.show();
         M.form.dateselector.calendar.show();
         M.form.dateselector.fix_position();
index 832546a..13d2ad5 100644 (file)
@@ -252,6 +252,10 @@ class grade_grade extends grade_object {
             $this->grade_item = grade_item::fetch(array('id'=>$this->itemid));
         }
 
+        if (empty($this->grade_item)) {
+            debugging("Missing grade item id $this->itemid", DEBUG_DEVELOPER);
+        }
+
         return $this->grade_item;
     }
 
@@ -1117,8 +1121,7 @@ class grade_grade extends grade_object {
         $success = parent::delete($source);
 
         // If the grade was deleted successfully trigger a grade_deleted event.
-        if ($success) {
-            $this->load_grade_item();
+        if ($success && !empty($this->grade_item)) {
             \core\event\grade_deleted::create_from_grade($this)->trigger();
         }
 
@@ -1157,8 +1160,10 @@ class grade_grade extends grade_object {
             return;
         }
 
-        // Load information about grade item
-        $this->load_grade_item();
+        // Load information about grade item, exit if the grade item is missing.
+        if (!$this->load_grade_item()) {
+            return;
+        }
 
         // Only course-modules have completion data
         if ($this->grade_item->itemtype!='mod') {
index d398033..5a003f5 100644 (file)
@@ -40,6 +40,7 @@ class core_grade_grade_testcase extends grade_base_testcase {
         $this->sub_test_grade_grade_set_hidden();
         $this->sub_test_grade_grade_is_hidden();
         $this->sub_test_grade_grade_deleted();
+        $this->sub_test_grade_grade_deleted_event();
     }
 
     protected function sub_test_grade_grade_construct() {
@@ -506,4 +507,67 @@ class core_grade_grade_testcase extends grade_base_testcase {
         $fs = get_file_storage();
         $fs->create_file_from_string($dummy, '');
     }
+
+    /**
+     * Tests grade_deleted event.
+     */
+    public function sub_test_grade_grade_deleted_event() {
+        global $DB;
+        $dg = $this->getDataGenerator();
+
+        // Create the data we need for the tests.
+        $u1 = $dg->create_user();
+        $u2 = $dg->create_user();
+        $c1 = $dg->create_course();
+        $a1 = $dg->create_module('assign', ['course' => $c1->id]);
+
+        $gi = new grade_item($dg->create_grade_item(
+            [
+                'courseid' => $c1->id,
+                'itemtype' => 'mod',
+                'itemmodule' => 'assign',
+                'iteminstance' => $a1->id
+            ]
+        ), false);
+
+        grade_update('mod/assign', $gi->courseid, $gi->itemtype, $gi->itemmodule, $gi->iteminstance,
+            $gi->itemnumber, ['userid' => $u1->id]);
+        grade_update('mod/assign', $gi->courseid, $gi->itemtype, $gi->itemmodule, $gi->iteminstance,
+            $gi->itemnumber, ['userid' => $u2->id]);
+
+        $gg = grade_grade::fetch(array('userid' => $u1->id, 'itemid' => $gi->id));
+        $this->assertEquals($u1->id, $gg->userid);
+        $gg->load_grade_item();
+        $this->assertEquals($gi->id, $gg->grade_item->id);
+
+        // Delete user with valid grade item.
+        $sink = $this->redirectEvents();
+        grade_user_delete($u1->id);
+        $events = $sink->get_events();
+        $event = reset($events);
+        $sink->close();
+        $this->assertInstanceOf('core\event\grade_deleted', $event);
+
+        $gg = grade_grade::fetch(array('userid' => $u2->id, 'itemid' => $gi->id));
+        $this->assertEquals($u2->id, $gg->userid);
+        $gg->load_grade_item();
+        $this->assertEquals($gi->id, $gg->grade_item->id);
+
+        // Delete grade item, mock up orphaned grade_grades.
+        $DB->delete_records('grade_items', ['id' => $gi->id]);
+        $gg = grade_grade::fetch(array('userid' => $u2->id, 'itemid' => $gi->id));
+        $this->assertEquals($u2->id, $gg->userid);
+
+        // No event is triggered and there is a debugging message.
+        $sink = $this->redirectEvents();
+        grade_user_delete($u2->id);
+        $this->assertDebuggingCalled("Missing grade item id $gi->id");
+        $events = $sink->get_events();
+        $sink->close();
+        $this->assertEmpty($events);
+
+        // The grade should be deleted.
+        $gg = grade_grade::fetch(array('userid' => $u2->id, 'itemid' => $gi->id));
+        $this->assertEmpty($gg);
+    }
 }
index c4cab7d..c15e8d6 100644 (file)
@@ -3991,6 +3991,16 @@ function get_user_fieldnames() {
     return $fieldarray;
 }
 
+/**
+ * Returns the string of the language for the new user.
+ *
+ * @return string language for the new user
+ */
+function get_newuser_language() {
+    global $CFG, $SESSION;
+    return (!empty($CFG->autolangusercreation) && !empty($SESSION->lang)) ? $SESSION->lang : $CFG->lang;
+}
+
 /**
  * Creates a bare-bones user record
  *
@@ -4002,7 +4012,7 @@ function get_user_fieldnames() {
  * @return stdClass A complete user object
  */
 function create_user_record($username, $password, $auth = 'manual') {
-    global $CFG, $DB;
+    global $CFG, $DB, $SESSION;
     require_once($CFG->dirroot.'/user/profile/lib.php');
     require_once($CFG->dirroot.'/user/lib.php');
 
@@ -4038,7 +4048,7 @@ function create_user_record($username, $password, $auth = 'manual') {
     // user CFG lang for user if $newuser->lang is empty
     // or $user->lang is not an installed language.
     if (empty($newuser->lang) || !get_string_manager()->translation_exists($newuser->lang)) {
-        $newuser->lang = $CFG->lang;
+        $newuser->lang = get_newuser_language();
     }
     $newuser->confirmed = 1;
     $newuser->lastip = getremoteaddr();
@@ -4378,7 +4388,7 @@ function guest_user() {
 
     if ($newuser = $DB->get_record('user', array('id' => $CFG->siteguest))) {
         $newuser->confirmed = 1;
-        $newuser->lang = $CFG->lang;
+        $newuser->lang = get_newuser_language();
         $newuser->lastip = getremoteaddr();
     }
 
@@ -5026,7 +5036,7 @@ function get_complete_user_data($field, $value, $mnethostid = null, $throwexcept
     }
     if (isguestuser($user)) {
         // Guest language always same as site.
-        $user->lang = $CFG->lang;
+        $user->lang = get_newuser_language();
         // Name always in current language.
         $user->firstname = get_string('guestuser');
         $user->lastname = ' ';
@@ -6460,7 +6470,7 @@ function setnew_password_and_mail($user, $fasthash = false) {
     // We try to send the mail in language the user understands,
     // unfortunately the filter_string() does not support alternative langs yet
     // so multilang will not work properly for site->fullname.
-    $lang = empty($user->lang) ? $CFG->lang : $user->lang;
+    $lang = empty($user->lang) ? get_newuser_language() : $user->lang;
 
     $site  = get_site();
 
@@ -9789,6 +9799,39 @@ function get_performance_info() {
     return $info;
 }
 
+/**
+ * Renames a file or directory to a unique name within the same directory.
+ *
+ * This function is designed to avoid any potential race conditions, and select an unused name.
+ *
+ * @param string $filepath Original filepath
+ * @param string $prefix Prefix to use for the temporary name
+ * @return string|bool New file path or false if failed
+ * @since Moodle 3.10
+ */
+function rename_to_unused_name(string $filepath, string $prefix = '_temp_') {
+    $dir = dirname($filepath);
+    $basename = $dir . '/' . $prefix;
+    $limit = 0;
+    while ($limit < 100) {
+        // Select a new name based on a random number.
+        $newfilepath = $basename . md5(mt_rand());
+
+        // Attempt a rename to that new name.
+        if (@rename($filepath, $newfilepath)) {
+            return $newfilepath;
+        }
+
+        // The first time, do some sanity checks, maybe it is failing for a good reason and there
+        // is no point trying 100 times if so.
+        if ($limit === 0 && (!file_exists($filepath) || !is_writable($dir))) {
+            return false;
+        }
+        $limit++;
+    }
+    return false;
+}
+
 /**
  * Delete directory or only its content
  *
@@ -9801,6 +9844,19 @@ function remove_dir($dir, $contentonly=false) {
         // Nothing to do.
         return true;
     }
+
+    if (!$contentonly) {
+        // Start by renaming the directory; this will guarantee that other processes don't write to it
+        // while it is in the process of being deleted.
+        $tempdir = rename_to_unused_name($dir);
+        if ($tempdir) {
+            // If the rename was successful then delete the $tempdir instead.
+            $dir = $tempdir;
+        }
+        // If the rename fails, we will continue through and attempt to delete the directory
+        // without renaming it since that is likely to at least delete most of the files.
+    }
+
     if (!$handle = opendir($dir)) {
         return false;
     }
index f324c4b..6b22cfc 100644 (file)
@@ -1440,17 +1440,33 @@ class theme_config {
 
         // TODO: MDL-62757 When changing anything in this method please do not forget to check
         // if the validate() method in class admin_setting_configthemepreset needs updating too.
-        $cacheoptions = '';
+
+        $cachedir = make_localcache_directory('scsscache-' . $this->name, false);
+        $cacheoptions = [];
         if ($themedesigner) {
-            $scsscachedir = $CFG->localcachedir . '/scsscache/';
             $cacheoptions = array(
-                  'cacheDir' => $scsscachedir,
+                  'cacheDir' => $cachedir,
                   'prefix' => 'scssphp_',
                   'forceRefresh' => false,
             );
+        } else {
+            if (file_exists($cachedir)) {
+                remove_dir($cachedir);
+            }
         }
+
         // Set-up the compiler.
         $compiler = new core_scss($cacheoptions);
+
+        if ($this->supports_source_maps($themedesigner)) {
+            // Enable source maps.
+            $compiler->setSourceMapOptions([
+                'sourceMapBasepath' => str_replace('\\', '/', $CFG->dirroot),
+                'sourceMapRootpath' => $CFG->wwwroot . '/'
+            ]);
+            $compiler->setSourceMap($compiler::SOURCE_MAP_INLINE);
+        }
+
         $compiler->prepend_raw_scss($this->get_pre_scss_code());
         if (is_string($scss)) {
             $compiler->set_file($scss);
@@ -2192,6 +2208,19 @@ class theme_config {
         $this->rtlmode = $inrtl;
     }
 
+    /**
+     * Checks if source maps are supported
+     *
+     * @param bool $themedesigner True if theme designer is enabled.
+     * @return boolean True if source maps are supported.
+     */
+    public function supports_source_maps($themedesigner): bool {
+        if (empty($this->rtlmode) && $themedesigner) {
+            return true;
+        }
+        return false;
+    }
+
     /**
      * Whether the theme is being served in RTL mode.
      *
index 083b0c0..382c0f5 100644 (file)
 
 namespace core;
 
+use context_system;
 use core_component;
-use core\dataformat;
 
 /**
  * Dataformat tests
  *
  * @package    core
+ * @covers     \core\dataformat
  * @copyright  2020 Paul Holden <paulh@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class dataformat_testcase extends \advanced_testcase {
 
     /**
-     * Data provider for {@see test_write_data)
+     * Data provider to return array of dataformat types
      *
      * @return array
      */
@@ -73,4 +74,42 @@ class dataformat_testcase extends \advanced_testcase {
         $this->assertFileExists($exportfile);
         $this->assertGreaterThan(0, filesize($exportfile));
     }
+
+    /**
+     * Test writing dataformat export to filearea
+     *
+     * @param string $dataformat
+     * @return void
+     *
+     * @dataProvider write_data_provider
+     */
+    public function test_write_data_to_filearea(string $dataformat): void {
+        $this->resetAfterTest();
+
+        $columns = ['fruit', 'colour', 'animal'];
+        $rows = [
+            ['banana', 'yellow', 'monkey'],
+            ['apple', 'red', 'wolf'],
+            ['melon', 'green', 'aardvark'],
+        ];
+
+        // Export to filearea. Assert that the the file exists in file storage and matches the original file record.
+        $filerecord = [
+            'contextid' => context_system::instance()->id,
+            'component' => 'core_dataformat',
+            'filearea' => 'test',
+            'itemid' => 0,
+            'filepath' => '/',
+            'filename' => 'My export',
+        ];
+
+        $file = dataformat::write_data_to_filearea($filerecord, $dataformat, $columns, $rows);
+        $this->assertEquals($filerecord['contextid'], $file->get_contextid());
+        $this->assertEquals($filerecord['component'], $file->get_component());
+        $this->assertEquals($filerecord['filearea'], $file->get_filearea());
+        $this->assertEquals($filerecord['itemid'], $file->get_itemid());
+        $this->assertEquals($filerecord['filepath'], $file->get_filepath());
+        $this->assertStringStartsWith($filerecord['filename'], $file->get_filename());
+        $this->assertGreaterThan(0, $file->get_filesize());
+    }
 }
index d0afb5e..01807e6 100644 (file)
@@ -534,4 +534,82 @@ class core_grades_external_testcase extends externallib_advanced_testcase {
         $this->assertTrue($grades->items[0]->hidden == 1);
     }
 
+    /**
+     * Test create_gradecategory.
+     *
+     * @return void
+     */
+    public function test_create_gradecategory() {
+        global $DB;
+        $this->resetAfterTest(true);
+        $course = $this->getDataGenerator()->create_course();
+        $this->setAdminUser();
+
+        // Test the most basic gradecategory creation.
+        $status1 = core_grades_external::create_gradecategory($course->id, 'Test Category 1', []);
+
+        $courseparentcat = new grade_category(['courseid' => $course->id, 'depth' => 1], true);
+        $record1 = $DB->get_record('grade_categories', ['id' => $status1['categoryid']]);
+        $this->assertEquals('Test Category 1', $record1->fullname);
+        // Confirm that the parent category for this category is the top level category for the course.
+        $this->assertEquals($courseparentcat->id, $record1->parent);
+        $this->assertEquals(2, $record1->depth);
+
+        // Now create a category as a child of the newly created category.
+        $status2 = core_grades_external::create_gradecategory($course->id, 'Test Category 2', ['parentcategoryid' => $record1->id]);
+        $record2 = $DB->get_record('grade_categories', ['id' => $status2['categoryid']]);
+        $this->assertEquals($record1->id, $record2->parent);
+        $this->assertEquals(3, $record2->depth);
+        // Check the path is correct.
+        $this->assertEquals('/' . implode('/', [$courseparentcat->id, $record1->id, $record2->id]) . '/', $record2->path);
+
+        // Now create a category with some customised data and check the returns. This customises every value.
+        $customopts = [
+            'aggregation' => GRADE_AGGREGATE_MEAN,
+            'aggregateonlygraded' => 0,
+            'aggregateoutcomes' => 1,
+            'droplow' => 1,
+            'itemname' => 'item',
+            'iteminfo' => 'info',
+            'idnumber' => 'idnumber',
+            'gradetype' => GRADE_TYPE_TEXT,
+            'grademax' => 5,
+            'grademin' => 2,
+            'gradepass' => 3,
+            'display' => GRADE_DISPLAY_TYPE_LETTER,
+            // Hack. This must be -2 to use the default setting.
+            'decimals' => 3,
+            'hiddenuntil' => time(),
+            'locktime' => time(),
+            'weightoverride' => 1,
+            'aggregationcoef2' => 20,
+            'parentcategoryid' => $record2->id
+        ];
+
+        $status3 = core_grades_external::create_gradecategory($course->id, 'Test Category 3', $customopts);
+        $cat3 = new grade_category(['courseid' => $course->id, 'id' => $status3['categoryid']], true);
+        $cat3->load_grade_item();
+
+        // Lets check all of the data is in the right shape.
+        $this->assertEquals(GRADE_AGGREGATE_MEAN, $cat3->aggregation);
+        $this->assertEquals(0, $cat3->aggregateonlygraded);
+        $this->assertEquals(1, $cat3->aggregateoutcomes);
+        $this->assertEquals(1, $cat3->droplow);
+        $this->assertEquals('item', $cat3->grade_item->itemname);
+        $this->assertEquals('info', $cat3->grade_item->iteminfo);
+        $this->assertEquals('idnumber', $cat3->grade_item->idnumber);
+        $this->assertEquals(GRADE_TYPE_TEXT, $cat3->grade_item->gradetype);
+        $this->assertEquals(5, $cat3->grade_item->grademax);
+        $this->assertEquals(2, $cat3->grade_item->grademin);
+        $this->assertEquals(3, $cat3->grade_item->gradepass);
+        $this->assertEquals(GRADE_DISPLAY_TYPE_LETTER, $cat3->grade_item->display);
+        $this->assertEquals(3, $cat3->grade_item->decimals);
+        $this->assertGreaterThanOrEqual($cat3->grade_item->hidden, time());
+        $this->assertGreaterThanOrEqual($cat3->grade_item->locktime, time());
+        $this->assertEquals(1, $cat3->grade_item->weightoverride);
+        // Coefficient is converted to percentage.
+        $this->assertEquals(0.2, $cat3->grade_item->aggregationcoef2);
+        $this->assertEquals($record2->id, $cat3->parent);
+    }
+
 }
index 3f40d07..cbb7dc2 100644 (file)
@@ -4713,4 +4713,67 @@ class core_moodlelib_testcase extends advanced_testcase {
             ],
         ];
     }
+
+    /**
+     * Tests the rename_to_unused_name function with a file.
+     */
+    public function test_rename_to_unused_name_file() {
+        global $CFG;
+
+        // Create a new file in dataroot.
+        $file = $CFG->dataroot . '/argh.txt';
+        file_put_contents($file, 'Frogs');
+
+        // Rename it.
+        $newname = rename_to_unused_name($file);
+
+        // Check new name has expected format.
+        $this->assertRegExp('~/_temp_[a-f0-9]+$~', $newname);
+
+        // Check it's still in the same folder.
+        $this->assertEquals($CFG->dataroot, dirname($newname));
+
+        // Check file can be loaded.
+        $this->assertEquals('Frogs', file_get_contents($newname));
+
+        // OK, delete the file.
+        unlink($newname);
+    }
+
+    /**
+     * Tests the rename_to_unused_name function with a directory.
+     */
+    public function test_rename_to_unused_name_dir() {
+        global $CFG;
+
+        // Create a new directory in dataroot.
+        $file = $CFG->dataroot . '/arghdir';
+        mkdir($file);
+
+        // Rename it.
+        $newname = rename_to_unused_name($file);
+
+        // Check new name has expected format.
+        $this->assertRegExp('~/_temp_[a-f0-9]+$~', $newname);
+
+        // Check it's still in the same folder.
+        $this->assertEquals($CFG->dataroot, dirname($newname));
+
+        // Check it's still a directory
+        $this->assertTrue(is_dir($newname));
+
+        // OK, delete the directory.
+        rmdir($newname);
+    }
+
+    /**
+     * Tests the rename_to_unused_name function with error cases.
+     */
+    public function test_rename_to_unused_name_failure() {
+        global $CFG;
+
+        // Rename a file that doesn't exist.
+        $file = $CFG->dataroot . '/argh.txt';
+        $this->assertFalse(rename_to_unused_name($file));
+    }
 }
index 520f527..a5f08be 100644 (file)
@@ -31,6 +31,8 @@ information provided here is intended especially for developers.
 * The form element 'htmleditor', which was deprecated in 3.6, has been removed.
 * The `core_output_load_fontawesome_icon_map` web service has been deprecated and replaced by
   `core_output_load_fontawesome_icon_system_map` which takes the name of the theme to generate the icon system map for.
+* A new parameter `$rolenamedisplay` has been added to `get_viewable_roles()` and `get_switchable_roles` to define how role names
+  should be returned.
 * The class coursecat_sortable_records has been removed.
 * Admin setting admin_setting_configselect now supports lazy-loading the options list by supplying
   a callback function instead of an array of options.
@@ -39,6 +41,7 @@ information provided here is intended especially for developers.
 * The task system has new functions adhoc_task_starting() and scheduled_task_starting() which must
   be called before executing a task, and a new function \core\task\manager::get_running_tasks()
   returns information about currently-running tasks.
+* New library function rename_to_unused_name() to rename a file within its current location.
 
 === 3.9 ===
 * Following function has been deprecated, please use \core\task\manager::run_from_cli().
index 01d9a33..3cbe9f7 100644 (file)
@@ -59,33 +59,53 @@ class assignfeedback_file_zip_importer {
             return false;
         }
 
-        $info = explode('_', $fileinfo->get_filepath() . $fileinfo->get_filename(), 5);
+        // Break the full path-name into path parts.
+        $pathparts = explode('/', $fileinfo->get_filepath() . $fileinfo->get_filename());
 
-        if (count($info) < 5) {
-            return false;
-        }
+        while (!empty($pathparts)) {
+            // Get the next path part and break it up by underscores.
+            $pathpart = array_shift($pathparts);
+            $info = explode('_', $pathpart, 5);
 
-        $participantid = $info[1];
-        $filename = $info[4];
-        $plugin = $assignment->get_plugin_by_type($info[2], $info[3]);
+            if (count($info) < 5) {
+                continue;
+            }
 
-        if (!is_numeric($participantid)) {
-            return false;
-        }
+            // Check the participant id.
+            $participantid = $info[1];
 
-        if (!$plugin) {
-            return false;
-        }
+            if (!is_numeric($participantid)) {
+                continue;
+            }
 
-        // Convert to int.
-        $participantid += 0;
+            // Convert to int.
+            $participantid += 0;
 
-        if (empty($participants[$participantid])) {
-            return false;
+            if (empty($participants[$participantid])) {
+                continue;
+            }
+
+            // Set user, which is by reference, so is used by the calling script.
+            $user = $participants[$participantid];
+
+            // Set the plugin. This by reference, and is used by the calling script.
+            $plugin = $assignment->get_plugin_by_type($info[2], $info[3]);
+
+            if (!$plugin) {
+                continue;
+            }
+
+            // Take any remaining text in this part and put it back in the path parts array.
+            array_unshift($pathparts, $info[4]);
+
+            // Combine the remaining parts and set it as the filename.
+            // Note that filename is a 'by reference' variable, so we need to set it before returning.
+            $filename = implode('/', $pathparts);
+
+            return true;
         }
 
-        $user = $participants[$participantid];
-        return true;
+        return false;
     }
 
     /**
diff --git a/mod/assign/feedback/file/tests/importziplib_test.php b/mod/assign/feedback/file/tests/importziplib_test.php
new file mode 100644 (file)
index 0000000..009579b
--- /dev/null
@@ -0,0 +1,148 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Unit tests for importziplib.
+ *
+ * @package    assignfeedback_file
+ * @copyright  2020 Eric Merrill <merrill@oakland.edu>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/mod/assign/tests/generator.php');
+require_once($CFG->dirroot . '/mod/assign/feedback/file/importziplib.php');
+
+/**
+ * Unit tests for importziplib.
+ *
+ * @copyright  2020 Eric Merrill <merrill@oakland.edu>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class assignfeedback_importziplib_testcase extends advanced_testcase {
+
+    // Use the generator helper.
+    use mod_assign_test_generator;
+
+    /**
+     * Test the assignfeedback_file_zip_importer->is_valid_filename_for_import() method.
+     */
+    public function test_is_valid_filename_for_import() {
+        // Do the initial assign setup.
+        $this->resetAfterTest();
+        $course = $this->getDataGenerator()->create_course();
+        $teacher = $this->getDataGenerator()->create_and_enrol($course, 'teacher');
+        $student = $this->getDataGenerator()->create_and_enrol($course, 'student');
+
+        $assign = $this->create_instance($course, [
+                'assignsubmission_onlinetext_enabled' => 1,
+                'assignfeedback_file_enabled' => 1,
+            ]);
+
+        // Create an online text submission.
+        $this->add_submission($student, $assign);
+
+        // Now onto the file work.
+        $fs = get_file_storage();
+
+        // Setup a basic file we will work with. We will keep renaming and repathing it.
+        $record = new stdClass;
+        $record->contextid = $assign->get_context()->id;
+        $record->component = 'assignfeedback_file';
+        $record->filearea  = ASSIGNFEEDBACK_FILE_FILEAREA;
+        $record->itemid    = $assign->get_user_grade($student->id, true)->id;
+        $record->filepath  = '/';
+        $record->filename  = '1.txt';
+        $record->source    = 'test';
+        $file = $fs->create_file_from_string($record, 'file content');
+
+        // The importer we will use.
+        $importer = new assignfeedback_file_zip_importer();
+
+        // Setup some variable we use.
+        $user = null;
+        $plugin = null;
+        $filename = '';
+
+        $allusers = $assign->list_participants(0, false);
+        $participants = array();
+        foreach ($allusers as $user) {
+            $participants[$assign->get_uniqueid_for_user($user->id)] = $user;
+        }
+
+        $file->rename('/import/', '.hiddenfile');
+        $result = $importer->is_valid_filename_for_import($assign, $file, $participants, $user, $plugin, $filename);
+        $this->assertFalse($result);
+
+        $file->rename('/import/', '~hiddenfile');
+        $result = $importer->is_valid_filename_for_import($assign, $file, $participants, $user, $plugin, $filename);
+        $this->assertFalse($result);
+
+        $file->rename('/import/some_path_here/', 'RandomFile.txt');
+        $result = $importer->is_valid_filename_for_import($assign, $file, $participants, $user, $plugin, $filename);
+        $this->assertFalse($result);
+
+        $file->rename('/import/', '~hiddenfile');
+        $result = $importer->is_valid_filename_for_import($assign, $file, $participants, $user, $plugin, $filename);
+        $this->assertFalse($result);
+
+        // Get the students assign id.
+        $studentid = $assign->get_uniqueid_for_user($student->id);
+
+        // Submissions are identified with the format:
+        // StudentName_StudentID_PluginType_Plugin_FilePathAndName.
+
+        // Test a string student id.
+        $badname = 'Student Name_StringID_assignsubmission_file_My_cool_filename.txt';
+        $file->rename('/import/', $badname);
+        $result = $importer->is_valid_filename_for_import($assign, $file, $participants, $user, $plugin, $filename);
+        $this->assertFalse($result);
+
+        // Test an invalid student id.
+        $badname = 'Student Name_' . ($studentid + 100) . '_assignsubmission_file_My_cool_filename.txt';
+        $file->rename('/import/', $badname);
+        $result = $importer->is_valid_filename_for_import($assign, $file, $participants, $user, $plugin, $filename);
+        $this->assertFalse($result);
+
+        // Test an invalid submission plugin.
+        $badname = 'Student Name_' . $studentid . '_assignsubmission_noplugin_My_cool_filename.txt';
+        $file->rename('/import/', $badname);
+        $result = $importer->is_valid_filename_for_import($assign, $file, $participants, $user, $plugin, $filename);
+        $this->assertFalse($result);
+
+        // Test a basic, good file.
+        $goodbase = 'Student Name_' . $studentid . '_assignsubmission_file_';
+        $file->rename('/import/', $goodbase . "My_cool_filename.txt");
+        $result = $importer->is_valid_filename_for_import($assign, $file, $participants, $user, $plugin, $filename);
+        $this->assertTrue($result);
+        $this->assertEquals($participants[$studentid], $user);
+        $this->assertEquals('My_cool_filename.txt', $filename);
+        $this->assertInstanceOf(assign_submission_file::class, $plugin);
+
+        // Test another good file, with some additional path and underscores.
+        $user = null;
+        $plugin = null;
+        $filename = '';
+        $file->rename('/import/some_path_here/' . $goodbase . '/some_path/', 'My File.txt');
+        $result = $importer->is_valid_filename_for_import($assign, $file, $participants, $user, $plugin, $filename);
+        $this->assertTrue($result);
+        $this->assertEquals($participants[$studentid], $user);
+        $this->assertEquals('/some_path/My File.txt', $filename);
+        $this->assertInstanceOf(assign_submission_file::class, $plugin);
+    }
+}
index 40e17dd..2de9409 100644 (file)
@@ -72,6 +72,7 @@ class mod_h5pactivity_mod_form extends moodleform_mod {
 
         $mform->addElement('filemanager', 'packagefile', get_string('package', 'mod_h5pactivity'), null, $options);
         $mform->addHelpButton('packagefile', 'package', 'mod_h5pactivity');
+        $mform->addRule('packagefile', null, 'required');
 
         // Add a link to the Content Bank if the user can access.
         $course = $this->get_course();
index 9feac00..d79308b 100644 (file)
@@ -88,14 +88,11 @@ if (isset($reviewurl)) {
     echo $OUTPUT->render($widget);
 }
 
-if ($manager->is_tracking_enabled()) {
-    $trackcomponent = 'mod_h5pactivity';
-} else {
-    $trackcomponent = '';
+if (!$manager->is_tracking_enabled()) {
     $message = get_string('previewmode', 'mod_h5pactivity');
     echo $OUTPUT->notification($message, \core\output\notification::NOTIFY_WARNING);
 }
 
-echo player::display($fileurl, $config, true, $trackcomponent);
+echo player::display($fileurl, $config, true, 'mod_h5pactivity');
 
 echo $OUTPUT->footer();
index c030e3d..68689f0 100644 (file)
Binary files a/pix/i/completion-auto-enabled.png and b/pix/i/completion-auto-enabled.png differ
index 43f2df4..0ee649a 100644 (file)
@@ -1,3 +1 @@
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [\r
-       <!ENTITY ns_flows "http://ns.adobe.com/Flows/1.0/">\r
-]><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" preserveAspectRatio="xMinYMid meet" overflow="visible"><path d="M10 0v2H6V0h4zm1 2h1c.4 0 .8.1 1.1.3.3-.3.8-.4 1.2-.4.5 0 1 .2 1.4.6l.3.3V2c0-1.1-.9-2-2-2h-3v2zm3 8h2V6.4l-2 2V10zm0 1v1c0 1.1-.9 2-2 2h-1v2h3c1.1 0 2-.9 2-2v-3h-2zm-4 5v-2H6v2h4zm-5-2H4c-1.1 0-2-.9-2-2v-1H0v3c0 1.1.9 2 2 2h3v-2zm-5-4h2V6H0v4zm2-5V4c0-1.1.9-2 2-2h1V0H2C.9 0 0 .9 0 2v3h2zm13.7-1.1l-.7-.7c-.4-.4-1-.4-1.4 0l-6 6L5.4 7c-.4-.3-1-.3-1.4 0l-.7.7c-.4.4-.4 1 0 1.4l3.6 3.6c.4.4 1 .4 1.4 0l7.4-7.4c.4-.4.4-1 0-1.4z" fill="#999"/></svg>
\ No newline at end of file
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M10,0V2H6V0Zm1,2h1a2,2,0,0,1,1.1.3,1.75,1.75,0,0,1,1.2-.4,2,2,0,0,1,1.4.6l.3.3V2a2,2,0,0,0-2-2H11Zm3,8h2V6.4l-2,2Zm0,1v1a2,2,0,0,1-2,2H11v2h3a2,2,0,0,0,2-2V11Zm-4,5V14H6v2ZM5,14H4a2,2,0,0,1-2-2V11H0v3a2,2,0,0,0,2,2H5ZM0,10H2V6H0ZM2,5V4A2,2,0,0,1,4,2H5V0H2A2,2,0,0,0,0,2V5ZM15.7,3.9,15,3.2a1,1,0,0,0-1.4,0l-6,6L5.4,7A1.2,1.2,0,0,0,4,7l-.7.7a1,1,0,0,0,0,1.4l3.6,3.6a1,1,0,0,0,1.4,0l7.4-7.4A1,1,0,0,0,15.7,3.9Z" fill="#949494"/></svg>
\ No newline at end of file
index c2766ac..badd1f0 100644 (file)
Binary files a/pix/i/completion-auto-fail.png and b/pix/i/completion-auto-fail.png differ
index f437d97..1929dfc 100644 (file)
@@ -1,3 +1 @@
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [\r
-       <!ENTITY ns_flows "http://ns.adobe.com/Flows/1.0/">\r
-]><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" preserveAspectRatio="xMinYMid meet" overflow="visible"><path d="M10 0v2H6V0h4zm1 2h1c1.1 0 2 .9 2 2v1h2V2c0-1.1-.9-2-2-2h-3v2zm5 4h-2v4h2V6zm-2 5v1c0 1.1-.9 2-2 2h-1v2h3c1.1 0 2-.9 2-2v-3h-2zm-4 5v-2H6v2h4zm-5-2H4c-1.1 0-2-.9-2-2v-1H0v3c0 1.1.9 2 2 2h3v-2zm-5-4h2V6H0v4zm2-5V4c0-1.1.9-2 2-2h1V0H2C.9 0 0 .9 0 2v3h2z" fill="#999"/><path d="M10.2 8l2.6-2.6c.4-.4.4-1 0-1.4l-.8-.7c-.4-.4-1-.4-1.4 0L8 5.9 5.4 3.3c-.4-.4-1-.4-1.4 0l-.7.7c-.4.4-.4 1 0 1.4L5.9 8l-2.6 2.6c-.3.4-.3 1 0 1.4l.7.7c.4.4 1 .4 1.4 0L8 10.2l2.5 2.5c.4.4 1 .4 1.4 0l.7-.7c.4-.4.4-1 0-1.4L10.2 8z" fill="#FF403C"/></svg>
\ No newline at end of file
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M10,0V2H6V0Zm1,2h1a2,2,0,0,1,2,2V5h2V2a2,2,0,0,0-2-2H11Zm5,4H14v4h2Zm-2,5v1a2,2,0,0,1-2,2H11v2h3a2,2,0,0,0,2-2V11Zm-4,5V14H6v2ZM5,14H4a2,2,0,0,1-2-2V11H0v3a2,2,0,0,0,2,2H5ZM0,10H2V6H0ZM2,5V4A2,2,0,0,1,4,2H5V0H2A2,2,0,0,0,0,2V5Z" fill="#949494"/><path d="M10.2,8l2.6-2.6a1,1,0,0,0,0-1.4L12,3.3a1,1,0,0,0-1.4,0L8,5.9,5.4,3.3A1,1,0,0,0,4,3.3L3.3,4a1,1,0,0,0,0,1.4L5.9,8,3.3,10.6a1.2,1.2,0,0,0,0,1.4l.7.7a1,1,0,0,0,1.4,0L8,10.2l2.5,2.5a1,1,0,0,0,1.4,0l.7-.7a1,1,0,0,0,0-1.4Z" fill="#ff403c"/></svg>
\ No newline at end of file
index c93c86b..01419b9 100644 (file)
Binary files a/pix/i/completion-auto-n.png and b/pix/i/completion-auto-n.png differ
index 56f0b03..e5d4138 100644 (file)
@@ -1,3 +1 @@
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [\r
-       <!ENTITY ns_flows "http://ns.adobe.com/Flows/1.0/">\r
-]><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" preserveAspectRatio="xMinYMid meet" overflow="visible"><path d="M10 0v2H6V0h4zm1 2h1c1.1 0 2 .9 2 2v1h2V2c0-1.1-.9-2-2-2h-3v2zm5 4h-2v4h2V6zm-2 5v1c0 1.1-.9 2-2 2h-1v2h3c1.1 0 2-.9 2-2v-3h-2zm-4 5v-2H6v2h4zm-5-2H4c-1.1 0-2-.9-2-2v-1H0v3c0 1.1.9 2 2 2h3v-2zm-5-4h2V6H0v4zm2-5V4c0-1.1.9-2 2-2h1V0H2C.9 0 0 .9 0 2v3h2z" fill="#999"/></svg>
\ No newline at end of file
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M10,0V2H6V0Zm1,2h1a2,2,0,0,1,2,2V5h2V2a2,2,0,0,0-2-2H11Zm5,4H14v4h2Zm-2,5v1a2,2,0,0,1-2,2H11v2h3a2,2,0,0,0,2-2V11Zm-4,5V14H6v2ZM5,14H4a2,2,0,0,1-2-2V11H0v3a2,2,0,0,0,2,2H5ZM0,10H2V6H0ZM2,5V4A2,2,0,0,1,4,2H5V0H2A2,2,0,0,0,0,2V5Z" fill="#949494"/></svg>
\ No newline at end of file
index f7384ad..0e88881 100644 (file)
Binary files a/pix/i/completion-auto-pass.png and b/pix/i/completion-auto-pass.png differ
index f644de7..9b91a5f 100644 (file)
@@ -1,3 +1 @@
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [\r
-       <!ENTITY ns_flows "http://ns.adobe.com/Flows/1.0/">\r
-]><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" preserveAspectRatio="xMinYMid meet" overflow="visible"><path d="M10 0v2H6V0h4zm1 2h1c.4 0 .8.1 1.1.3.3-.3.8-.4 1.2-.4.5 0 1 .2 1.4.6l.3.3V2c0-1.1-.9-2-2-2h-3v2zm3 8h2V6.4l-2 2V10zm0 1v1c0 1.1-.9 2-2 2h-1v2h3c1.1 0 2-.9 2-2v-3h-2zm-4 5v-2H6v2h4zm-5-2H4c-1.1 0-2-.9-2-2v-1H0v3c0 1.1.9 2 2 2h3v-2zm-5-4h2V6H0v4zm2-5V4c0-1.1.9-2 2-2h1V0H2C.9 0 0 .9 0 2v3h2z" fill="#999"/><path d="M15.7 3.9l-.7-.7c-.4-.4-1-.4-1.4 0l-6 6L5.4 7c-.4-.3-1-.3-1.4 0l-.7.7c-.4.4-.4 1 0 1.4l3.6 3.6c.4.4 1 .4 1.4 0l7.4-7.4c.4-.4.4-1 0-1.4z" fill="#9C3"/></svg>
\ No newline at end of file
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M10,0V2H6V0Zm1,2h1a2,2,0,0,1,1.1.3,1.75,1.75,0,0,1,1.2-.4,2,2,0,0,1,1.4.6l.3.3V2a2,2,0,0,0-2-2H11Zm3,8h2V6.4l-2,2Zm0,1v1a2,2,0,0,1-2,2H11v2h3a2,2,0,0,0,2-2V11Zm-4,5V14H6v2ZM5,14H4a2,2,0,0,1-2-2V11H0v3a2,2,0,0,0,2,2H5ZM0,10H2V6H0ZM2,5V4A2,2,0,0,1,4,2H5V0H2A2,2,0,0,0,0,2V5Z" fill="#949494"/><path d="M15.7,3.9,15,3.2a1,1,0,0,0-1.4,0l-6,6L5.4,7A1.2,1.2,0,0,0,4,7l-.7.7a1,1,0,0,0,0,1.4l3.6,3.6a1,1,0,0,0,1.4,0l7.4-7.4A1,1,0,0,0,15.7,3.9Z" fill="#79a128"/></svg>
\ No newline at end of file
index aeca4ed..7022a81 100644 (file)
Binary files a/pix/i/completion-auto-y-override.png and b/pix/i/completion-auto-y-override.png differ
index 13cf5d7..c849f00 100644 (file)
@@ -1,3 +1 @@
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [\r
-       <!ENTITY ns_flows "http://ns.adobe.com/Flows/1.0/">\r
-]><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" preserveAspectRatio="xMinYMid meet" overflow="visible"><path d="M10 0v2H6V0h4zm1 2h1c.4 0 .8.1 1.1.3.3-.3.8-.4 1.2-.4.5 0 1 .2 1.4.6l.3.3V2c0-1.1-.9-2-2-2h-3v2zm3 8h2V6.4l-2 2V10zm0 1v1c0 1.1-.9 2-2 2h-1v2h3c1.1 0 2-.9 2-2v-3h-2zm-4 5v-2H6v2h4zm-5-2H4c-1.1 0-2-.9-2-2v-1H0v3c0 1.1.9 2 2 2h3v-2zm-5-4h2V6H0v4zm2-5V4c0-1.1.9-2 2-2h1V0H2C.9 0 0 .9 0 2v3h2z" fill="#FF2727"/><path d="M15.7 3.9l-.7-.7c-.4-.4-1-.4-1.4 0l-6 6L5.4 7c-.4-.3-1-.3-1.4 0l-.7.7c-.4.4-.4 1 0 1.4l3.6 3.6c.4.4 1 .4 1.4 0l7.4-7.4c.4-.4.4-1 0-1.4z" fill="#76A1F0"/></svg>
\ No newline at end of file
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M10,0V2H6V0Zm1,2h1a2,2,0,0,1,1.1.3,1.75,1.75,0,0,1,1.2-.4,2,2,0,0,1,1.4.6l.3.3V2a2,2,0,0,0-2-2H11Zm3,8h2V6.4l-2,2Zm0,1v1a2,2,0,0,1-2,2H11v2h3a2,2,0,0,0,2-2V11Zm-4,5V14H6v2ZM5,14H4a2,2,0,0,1-2-2V11H0v3a2,2,0,0,0,2,2H5ZM0,10H2V6H0ZM2,5V4A2,2,0,0,1,4,2H5V0H2A2,2,0,0,0,0,2V5Z" fill="#ff2727"/><path d="M15.7,3.9,15,3.2a1,1,0,0,0-1.4,0l-6,6L5.4,7A1.2,1.2,0,0,0,4,7l-.7.7a1,1,0,0,0,0,1.4l3.6,3.6a1,1,0,0,0,1.4,0l7.4-7.4A1,1,0,0,0,15.7,3.9Z" fill="#6393ee"/></svg>
\ No newline at end of file
index a5440d5..349f8bf 100644 (file)
Binary files a/pix/i/completion-auto-y.png and b/pix/i/completion-auto-y.png differ
index c734b49..9f2809f 100644 (file)
@@ -1,3 +1 @@
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [\r
-       <!ENTITY ns_flows "http://ns.adobe.com/Flows/1.0/">\r
-]><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" preserveAspectRatio="xMinYMid meet" overflow="visible"><path d="M10 0v2H6V0h4zm1 2h1c.4 0 .8.1 1.1.3.3-.3.8-.4 1.2-.4.5 0 1 .2 1.4.6l.3.3V2c0-1.1-.9-2-2-2h-3v2zm3 8h2V6.4l-2 2V10zm0 1v1c0 1.1-.9 2-2 2h-1v2h3c1.1 0 2-.9 2-2v-3h-2zm-4 5v-2H6v2h4zm-5-2H4c-1.1 0-2-.9-2-2v-1H0v3c0 1.1.9 2 2 2h3v-2zm-5-4h2V6H0v4zm2-5V4c0-1.1.9-2 2-2h1V0H2C.9 0 0 .9 0 2v3h2z" fill="#999"/><path d="M15.7 3.9l-.7-.7c-.4-.4-1-.4-1.4 0l-6 6L5.4 7c-.4-.3-1-.3-1.4 0l-.7.7c-.4.4-.4 1 0 1.4l3.6 3.6c.4.4 1 .4 1.4 0l7.4-7.4c.4-.4.4-1 0-1.4z" fill="#76A1F0"/></svg>
\ No newline at end of file
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M10,0V2H6V0Zm1,2h1a2,2,0,0,1,1.1.3,1.75,1.75,0,0,1,1.2-.4,2,2,0,0,1,1.4.6l.3.3V2a2,2,0,0,0-2-2H11Zm3,8h2V6.4l-2,2Zm0,1v1a2,2,0,0,1-2,2H11v2h3a2,2,0,0,0,2-2V11Zm-4,5V14H6v2ZM5,14H4a2,2,0,0,1-2-2V11H0v3a2,2,0,0,0,2,2H5ZM0,10H2V6H0ZM2,5V4A2,2,0,0,1,4,2H5V0H2A2,2,0,0,0,0,2V5Z" fill="#949494"/><path d="M15.7,3.9,15,3.2a1,1,0,0,0-1.4,0l-6,6L5.4,7A1.2,1.2,0,0,0,4,7l-.7.7a1,1,0,0,0,0,1.4l3.6,3.6a1,1,0,0,0,1.4,0l7.4-7.4A1,1,0,0,0,15.7,3.9Z" fill="#6393ee"/></svg>
\ No newline at end of file
index 1850d22..2b8389f 100644 (file)
Binary files a/pix/i/completion-manual-enabled.png and b/pix/i/completion-manual-enabled.png differ
index c415b90..dd45c3f 100644 (file)
@@ -1,3 +1 @@
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [\r
-       <!ENTITY ns_flows "http://ns.adobe.com/Flows/1.0/">\r
-]><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" preserveAspectRatio="xMinYMid meet" overflow="visible"><path d="M16 6.4V14c0 1.1-.9 2-2 2H2c-1.1 0-2-.9-2-2V2C0 .9.9 0 2 0h12c1.1 0 2 .9 2 2v.8l-.3-.3c-.4-.4-.9-.6-1.4-.6-.4 0-.9.1-1.2.4-.3-.2-.7-.3-1.1-.3H4c-1.1 0-2 .9-2 2v8c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V8.4l2-2zm-.3-2.5l-.7-.7c-.4-.4-1-.4-1.4 0l-6 6L5.4 7c-.4-.3-1-.3-1.4 0l-.7.7c-.4.4-.4 1 0 1.4l3.6 3.6c.4.4 1 .4 1.4 0l7.4-7.4c.4-.4.4-1 0-1.4z" fill="#999"/></svg>
\ No newline at end of file
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M16,6.4V14a2,2,0,0,1-2,2H2a2,2,0,0,1-2-2V2A2,2,0,0,1,2,0H14a2,2,0,0,1,2,2v.8l-.3-.3a2,2,0,0,0-1.4-.6,1.75,1.75,0,0,0-1.2.4A2,2,0,0,0,12,2H4A2,2,0,0,0,2,4v8a2,2,0,0,0,2,2h8a2,2,0,0,0,2-2V8.4Zm-.3-2.5L15,3.2a1,1,0,0,0-1.4,0l-6,6L5.4,7A1.2,1.2,0,0,0,4,7l-.7.7a1,1,0,0,0,0,1.4l3.6,3.6a1,1,0,0,0,1.4,0l7.4-7.4A1,1,0,0,0,15.7,3.9Z" fill="#949494"/></svg>
\ No newline at end of file
index 79e5c71..f783abf 100644 (file)
Binary files a/pix/i/completion-manual-n.png and b/pix/i/completion-manual-n.png differ
index 19785de..5c8d283 100644 (file)
@@ -1,3 +1 @@
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [\r
-       <!ENTITY ns_flows "http://ns.adobe.com/Flows/1.0/">\r
-]><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" preserveAspectRatio="xMinYMid meet" overflow="visible"><path d="M14 0H2C.9 0 0 .9 0 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V2c0-1.1-.9-2-2-2zm0 12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h8c1.1 0 2 .9 2 2v8z" fill="#999"/></svg>
\ No newline at end of file
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M14,0H2A2,2,0,0,0,0,2V14a2,2,0,0,0,2,2H14a2,2,0,0,0,2-2V2A2,2,0,0,0,14,0Zm0,12a2,2,0,0,1-2,2H4a2,2,0,0,1-2-2V4A2,2,0,0,1,4,2h8a2,2,0,0,1,2,2Z" fill="#949494"/></svg>
\ No newline at end of file
index bdbc46b..78d5b86 100644 (file)
Binary files a/pix/i/completion-manual-y-override.png and b/pix/i/completion-manual-y-override.png differ
index 69270ba..6ff1757 100644 (file)
@@ -1,3 +1 @@
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [\r
-       <!ENTITY ns_flows "http://ns.adobe.com/Flows/1.0/">\r
-]><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" preserveAspectRatio="xMinYMid meet" overflow="visible"><path d="M14 8.4V12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h8c.4 0 .8.1 1.1.3.3-.3.8-.4 1.2-.4.5 0 1 .2 1.4.6l.3.3V2c0-1.1-.9-2-2-2H2C.9 0 0 .9 0 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V6.4l-2 2z" fill="#FF2727"/><path d="M15.7 3.9l-.7-.7c-.4-.4-1-.4-1.4 0l-6 6L5.4 7c-.4-.3-1-.3-1.4 0l-.7.7c-.4.4-.4 1 0 1.4l3.6 3.6c.4.4 1 .4 1.4 0l7.4-7.4c.4-.4.4-1 0-1.4z" fill="#76A1F0"/></svg>
\ No newline at end of file
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M14,8.4V12a2,2,0,0,1-2,2H4a2,2,0,0,1-2-2V4A2,2,0,0,1,4,2h8a2,2,0,0,1,1.1.3,1.75,1.75,0,0,1,1.2-.4,2,2,0,0,1,1.4.6l.3.3V2a2,2,0,0,0-2-2H2A2,2,0,0,0,0,2V14a2,2,0,0,0,2,2H14a2,2,0,0,0,2-2V6.4Z" fill="#ff2727"/><path d="M15.7,3.9,15,3.2a1,1,0,0,0-1.4,0l-6,6L5.4,7A1.2,1.2,0,0,0,4,7l-.7.7a1,1,0,0,0,0,1.4l3.6,3.6a1,1,0,0,0,1.4,0l7.4-7.4A1,1,0,0,0,15.7,3.9Z" fill="#6393ee"/></svg>
\ No newline at end of file
index d1c85ee..7c79094 100644 (file)
Binary files a/pix/i/completion-manual-y.png and b/pix/i/completion-manual-y.png differ
index 8125e80..b2a39cd 100644 (file)
@@ -1,3 +1 @@
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [\r
-       <!ENTITY ns_flows "http://ns.adobe.com/Flows/1.0/">\r
-]><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" preserveAspectRatio="xMinYMid meet" overflow="visible"><path d="M14 8.4V12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h8c.4 0 .8.1 1.1.3.3-.3.8-.4 1.2-.4.5 0 1 .2 1.4.6l.3.3V2c0-1.1-.9-2-2-2H2C.9 0 0 .9 0 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V6.4l-2 2z" fill="#999"/><path d="M15.7 3.9l-.7-.7c-.4-.4-1-.4-1.4 0l-6 6L5.4 7c-.4-.3-1-.3-1.4 0l-.7.7c-.4.4-.4 1 0 1.4l3.6 3.6c.4.4 1 .4 1.4 0l7.4-7.4c.4-.4.4-1 0-1.4z" fill="#76A1F0"/></svg>
\ No newline at end of file
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M14,8.4V12a2,2,0,0,1-2,2H4a2,2,0,0,1-2-2V4A2,2,0,0,1,4,2h8a2,2,0,0,1,1.1.3,1.75,1.75,0,0,1,1.2-.4,2,2,0,0,1,1.4.6l.3.3V2a2,2,0,0,0-2-2H2A2,2,0,0,0,0,2V14a2,2,0,0,0,2,2H14a2,2,0,0,0,2-2V6.4Z" fill="#949494"/><path d="M15.7,3.9,15,3.2a1,1,0,0,0-1.4,0l-6,6L5.4,7A1.2,1.2,0,0,0,4,7l-.7.7a1,1,0,0,0,0,1.4l3.6,3.6a1,1,0,0,0,1.4,0l7.4-7.4A1,1,0,0,0,15.7,3.9Z" fill="#6293ee"/></svg>
\ No newline at end of file
index be5635d..319750b 100644 (file)
@@ -115,7 +115,7 @@ class repository_equella extends repository {
      * @return int
      */
     public function supported_returntypes() {
-        return FILE_REFERENCE;
+        return (FILE_INTERNAL | FILE_REFERENCE);
     }
 
     /**
index cd80f47..e169aac 100644 (file)
Binary files a/theme/boost/amd/build/form-display-errors.min.js and b/theme/boost/amd/build/form-display-errors.min.js differ
index 4ff6e98..bfeb1b8 100644 (file)
Binary files a/theme/boost/amd/build/form-display-errors.min.js.map and b/theme/boost/amd/build/form-display-errors.min.js.map differ
index 230cbc5..a839184 100644 (file)
@@ -69,7 +69,7 @@ define(['jquery', 'core/event'], function($, Event) {
             });
 
             var form = element.closest('form');
-            if (!('boostFormErrorsEnhanced' in form.dataset)) {
+            if (form && !('boostFormErrorsEnhanced' in form.dataset)) {
                 form.addEventListener('submit', function() {
                     var visibleError = $('.form-control-feedback:visible');
                     if (visibleError.length) {
index 59f1989..9353d2a 100644 (file)
@@ -104,6 +104,8 @@ class autoprefixer {
      * @param Document $tree The CSS tree.
      */
     public function __construct(Document $tree) {
+        debugging('theme_boost\autoprefixer() is deprecated. Required prefixes for Bootstrap ' .
+            'are now in theme/boost/scss/moodle/prefixes.scss', DEBUG_DEVELOPER);
         $this->tree = $tree;
 
         $pseudos = array_map(function($pseudo) {
index a8a727c..613162f 100644 (file)
@@ -148,7 +148,6 @@ $THEME->layouts = [
 
 $THEME->parents = [];
 $THEME->enable_dock = false;
-$THEME->csstreepostprocessor = 'theme_boost_css_tree_post_processor';
 $THEME->extrascsscallback = 'theme_boost_get_extra_scss';
 $THEME->prescsscallback = 'theme_boost_get_pre_scss';
 $THEME->precompiledcsscallback = 'theme_boost_get_precompiled_css';
index 97e6983..a543505 100644 (file)
@@ -34,8 +34,6 @@ $string['bootswatch_desc'] = 'A bootswatch is a set of Bootstrap variables and c
 $string['choosereadme'] = 'Boost is a modern highly-customisable theme. This theme is intended to be used directly, or as a parent theme when creating new themes utilising Bootstrap 4.';
 $string['currentinparentheses'] = '(current)';
 $string['configtitle'] = 'Boost';
-$string['fontsize'] = 'Theme base fontsize';
-$string['fontsize_desc'] = 'Enter a fontsize in %';
 $string['generalsettings'] = 'General settings';
 $string['nobootswatch'] = 'None';
 $string['pluginname'] = 'Boost';
index 68a20f8..cf8b7fe 100644 (file)
@@ -31,6 +31,8 @@ defined('MOODLE_INTERNAL') || die();
  * @param theme_config $theme The theme config object.
  */
 function theme_boost_css_tree_post_processor($tree, $theme) {
+    error_log('theme_boost_css_tree_post_processor() is deprecated. Required' .
+        'prefixes for Bootstrap are now in theme/boost/scss/moodle/prefixes.scss');
     $prefixer = new theme_boost\autoprefixer($tree);
     $prefixer->prefix();
 }
@@ -150,9 +152,5 @@ function theme_boost_get_pre_scss($theme) {
         $scss .= $theme->settings->scsspre;
     }
 
-    if (!empty($theme->settings->fontsize)) {
-        $scss .= '$font-size-base: ' . (1 / 100 * $theme->settings->fontsize) . "rem !default;\n";
-    }
-
     return $scss;
 }
index d79ac1f..d71a4fd 100644 (file)
@@ -40,3 +40,4 @@ $breadcrumb-divider-rtl: "◀" !default;
 @import "moodle/print";
 @import "moodle/modal";
 @import "moodle/layout";
+@import "moodle/prefixes";
index e8977fd..6750fd5 100644 (file)
@@ -1,35 +1,64 @@
 /* calendar.less */
 
 // Calendar colour variables defined.
-$calendarEventCategoryColor: #d8bfd8 !default; // Pale purple.
+$calendarEventCategoryColor: #e0cbe0 !default; // Pale purple.
 $calendarEventCourseColor: #ffd3bd !default; // Pale red.
 $calendarEventGlobalColor: #d6f8cd !default; // Pale green.
 $calendarEventGroupColor: #fee7ae !default; // Pale yellow.
 $calendarEventUserColor: #dce7ec !default; // Pale blue.
 $calendarEventOtherColor: #ced4da !default; // Pale gray.
 
+// Border colours for the event colour indicators.
+$calendarEventCategoryBorder: 2px solid #9e619f !default; // Purple.
+$calendarEventCourseBorder: 2px solid #d34600 !default; // Red-orange.
+$calendarEventGlobalBorder: 2px solid #2b8713 !default; // Green.
+$calendarEventGroupBorder: 2px solid #9a6e02 !default; // Dark orange.
+$calendarEventUserBorder: 2px solid #4e7c91 !default; // Blue.
+$calendarEventOtherBorder: 2px solid #687889 !default; // Gray.
+
+// This will be the colour of mini-calendar links, hide/show filter icons, edit/delete icon buttons.
+$calendarEventColor: #0d5ca1 !default;
+
 // Calendar event background colours defined.
 .calendar_event_category {
     background-color: $calendarEventCategoryColor;
+    .commands a {
+        color: $calendarEventColor;
+    }
 }
 .calendar_event_course {
     background-color: $calendarEventCourseColor;
+    .commands a {
+        color: $calendarEventColor;
+    }
 }
 
 .calendar_event_site {
     background-color: $calendarEventGlobalColor;
+    .commands a {
+        color: $calendarEventColor;
+    }
 }
 
 .calendar_event_group {
     background-color: $calendarEventGroupColor;
+    .commands a {
+        color: $calendarEventColor;
+    }
 }
 
 .calendar_event_user {
     background-color: $calendarEventUserColor;
+    .commands a {
+        color: $calendarEventColor;
+    }
 }
 
 .calendar_event_other {
     background-color: $calendarEventOtherColor;
+    .commands a {
+        color: $calendarEventColor;
+    }
 }
 
 // Calendar restyling.
@@ -139,25 +168,31 @@ $calendarEventOtherColor: #ced4da !default; // Pale gray.
 
                             &.calendar_event_category {
                                 background-color: $calendarEventCategoryColor;
+                                border: $calendarEventCategoryBorder;
                             }
                             &.calendar_event_course {
                                 background-color: $calendarEventCourseColor;
+                                border: $calendarEventCourseBorder;
                             }
 
                             &.calendar_event_site {
                                 background-color: $calendarEventGlobalColor;
+                                border: $calendarEventGlobalBorder;
                             }
 
                             &.calendar_event_group {
                                 background-color: $calendarEventGroupColor;
+                                border: $calendarEventGroupBorder;
                             }
 
                             &.calendar_event_user {
                                 background-color: $calendarEventUserColor;
+                                border: $calendarEventUserBorder;
                             }
 
                             &.calendar_event_other {
                                 background-color: $calendarEventOtherColor;
+                                border: $calendarEventOtherBorder;
                             }
                         }
                     }
@@ -289,6 +324,7 @@ $calendarEventOtherColor: #ced4da !default; // Pale gray.
                 width: 100%;
                 height: 100%;
                 display: block;
+                color: $calendarEventColor;
             }
 
             &.duration_global {
@@ -389,6 +425,36 @@ $calendarEventOtherColor: #ced4da !default; // Pale gray.
             margin-bottom: 0.2em;
 
             span {
+                &.calendar_event_category {
+                    i {
+                        color: $calendarEventColor;
+                    }
+                }
+                &.calendar_event_course {
+                    i {
+                        color: $calendarEventColor;
+                    }
+                }
+                &.calendar_event_site {
+                    i {
+                        color: $calendarEventColor;
+                    }
+                }
+                &.calendar_event_group {
+                    i {
+                        color: $calendarEventColor;
+                    }
+                }
+                &.calendar_event_user {
+                    i {
+                        color: $calendarEventColor;
+                    }
+                }
+                &.calendar_event_other {
+                    i {
+                        color: $calendarEventColor;
+                    }
+                }
                 img {
                     padding: 0 0.2em;
                     margin: 0;
diff --git a/theme/boost/scss/moodle/prefixes.scss b/theme/boost/scss/moodle/prefixes.scss
new file mode 100644 (file)
index 0000000..b7458bf
--- /dev/null
@@ -0,0 +1,65 @@
+.form-control:-ms-input-placeholder {
+    color: $input-placeholder-color;
+}
+
+.custom-select {
+    -webkit-appearance: none;
+    -moz-appearance: none;
+}
+
+.custom-range {
+    -webkit-appearance: none;
+    -moz-appearance: none;
+    &::-webkit-slider-thumb,
+    &::-moz-range-thumb,
+    &::-ms-thumb {
+        -webkit-appearance: none;
+        -moz-appearance: none;
+    }
+}
+
+input[type="date"],
+input[type="time"],
+input[type="datetime-local"],
+input[type="month"] {
+    &.form-control {
+        -webkit-appearance: none;
+        -moz-appearance: none;
+    }
+}
+
+.card-columns {
+    @include media-breakpoint-up(sm) {
+        -webkit-column-gap: $card-columns-gap;
+        -moz-column-gap: $card-columns-gap;
+    }
+}
+
+.carousel-item {
+    -webkit-backface-visibility: hidden;
+}
+
+.card {
+    -webkit-background-clip: border-box;
+}
+
+.carousel-indicators li,
+.dropdown-menu,
+.form-control,
+.modal-content,
+.popover,
+.toast {
+    -webkit-background-clip: padding-box;
+}
+
+.btn {
+    -webkit-user-select: none;
+    -ms-user-select: none;
+}
+
+@each $value in $user-selects {
+    .user-select-#{$value} {
+        -webkit-user-select: $value !important; /* stylelint-disable-line declaration-no-important */
+        -ms-user-select: none;
+    }
+}
\ No newline at end of file
index 3fd1561..1e0b3f2 100644 (file)
@@ -12741,22 +12741,34 @@ input[disabled] {
 
 /* calendar.less */
 .calendar_event_category {
-  background-color: #d8bfd8; }
+  background-color: #e0cbe0; }
+  .calendar_event_category .commands a {
+    color: #0d5ca1; }
 
 .calendar_event_course {
   background-color: #ffd3bd; }
+  .calendar_event_course .commands a {
+    color: #0d5ca1; }
 
 .calendar_event_site {
   background-color: #d6f8cd; }
+  .calendar_event_site .commands a {
+    color: #0d5ca1; }
 
 .calendar_event_group {
   background-color: #fee7ae; }
+  .calendar_event_group .commands a {
+    color: #0d5ca1; }
 
 .calendar_event_user {
   background-color: #dce7ec; }
+  .calendar_event_user .commands a {
+    color: #0d5ca1; }
 
 .calendar_event_other {
   background-color: #ced4da; }
+  .calendar_event_other .commands a {
+    color: #0d5ca1; }
 
 .path-calendar .calendartable {
   width: 100%;
@@ -12833,17 +12845,23 @@ input[disabled] {
           border-radius: 6px;
           vertical-align: middle; }
           .path-calendar .maincalendar .calendarmonth ul li .badge.badge-circle.calendar_event_category {
-            background-color: #d8bfd8; }
+            background-color: #e0cbe0;
+            border: 2px solid #9e619f; }
           .path-calendar .maincalendar .calendarmonth ul li .badge.badge-circle.calendar_event_course {
-            background-color: #ffd3bd; }
+            background-color: #ffd3bd;
+            border: 2px solid #d34600; }
           .path-calendar .maincalendar .calendarmonth ul li .badge.badge-circle.calendar_event_site {
-            background-color: #d6f8cd; }
+            background-color: #d6f8cd;
+            border: 2px solid #2b8713; }
           .path-calendar .maincalendar .calendarmonth ul li .badge.badge-circle.calendar_event_group {
-            background-color: #fee7ae; }
+            background-color: #fee7ae;
+            border: 2px solid #9a6e02; }
           .path-calendar .maincalendar .calendarmonth ul li .badge.badge-circle.calendar_event_user {
-            background-color: #dce7ec; }
+            background-color: #dce7ec;
+            border: 2px solid #4e7c91; }
           .path-calendar .maincalendar .calendarmonth ul li .badge.badge-circle.calendar_event_other {
-            background-color: #ced4da; }
+            background-color: #ced4da;
+            border: 2px solid #687889; }
     .path-calendar .maincalendar .calendarmonth td {
       height: 5em; }
     .path-calendar .maincalendar .calendarmonth .clickable:hover {
@@ -12864,7 +12882,7 @@ input[disabled] {
     color: #0b4f8a;
     text-decoration: underline; }
   .path-calendar .maincalendar .calendar_event_category {
-    border-color: #d8bfd8; }
+    border-color: #e0cbe0; }
   .path-calendar .maincalendar .calendar_event_course {
     border-color: #ffd3bd; }
   .path-calendar .maincalendar .calendar_event_site {
@@ -12907,17 +12925,18 @@ input[disabled] {
   .block .minicalendar td a {
     width: 100%;
     height: 100%;
-    display: block; }
+    display: block;
+    color: #0d5ca1; }
   .block .minicalendar td.duration_global {
     border-top: 1px solid #d6f8cd;
     border-bottom: 1px solid #d6f8cd; }
     .block .minicalendar td.duration_global.duration_finish {
       background-color: #d6f8cd; }
   .block .minicalendar td.duration_category {
-    border-top: 1px solid #d8bfd8;
-    border-bottom: 1px solid #d8bfd8; }
+    border-top: 1px solid #e0cbe0;
+    border-bottom: 1px solid #e0cbe0; }
     .block .minicalendar td.duration_category.duration_finish {
-      background-color: #d8bfd8; }
+      background-color: #e0cbe0; }
   .block .minicalendar td.duration_course {
     border-top: 1px solid #ffd3bd;
     border-bottom: 1px solid #ffd3bd; }
@@ -12969,6 +12988,18 @@ input[disabled] {
 
 .block .calendar_filters li {
   margin-bottom: 0.2em; }
+  .block .calendar_filters li span.calendar_event_category i {
+    color: #0d5ca1; }
+  .block .calendar_filters li span.calendar_event_course i {
+    color: #0d5ca1; }
+  .block .calendar_filters li span.calendar_event_site i {
+    color: #0d5ca1; }
+  .block .calendar_filters li span.calendar_event_group i {
+    color: #0d5ca1; }
+  .block .calendar_filters li span.calendar_event_user i {
+    color: #0d5ca1; }
+  .block .calendar_filters li span.calendar_event_other i {
+    color: #0d5ca1; }
   .block .calendar_filters li span img {
     padding: 0 0.2em;
     margin: 0; }
@@ -19080,6 +19111,65 @@ span[data-flexitour="container"][x-placement="right"], span[data-flexitour="cont
       height: 1em;
       font-size: 4em; }
 
+.form-control:-ms-input-placeholder {
+  color: #6c757d; }
+
+.custom-select {
+  -webkit-appearance: none;
+  -moz-appearance: none; }
+
+.custom-range {
+  -webkit-appearance: none;
+  -moz-appearance: none; }
+  .custom-range::-webkit-slider-thumb, .custom-range::-moz-range-thumb, .custom-range::-ms-thumb {
+    -webkit-appearance: none;
+    -moz-appearance: none; }
+
+input[type="date"].form-control,
+input[type="time"].form-control,
+input[type="datetime-local"].form-control,
+input[type="month"].form-control {
+  -webkit-appearance: none;
+  -moz-appearance: none; }
+
+@media (min-width: 576px) {
+  .card-columns {
+    -webkit-column-gap: 1.25rem;
+    -moz-column-gap: 1.25rem; } }
+
+.carousel-item {
+  -webkit-backface-visibility: hidden; }
+
+.card {
+  -webkit-background-clip: border-box; }
+
+.carousel-indicators li,
+.dropdown-menu,
+.form-control,
+.modal-content,
+.popover,
+.toast {
+  -webkit-background-clip: padding-box; }
+
+.btn {
+  -webkit-user-select: none;
+  -ms-user-select: none; }
+
+.user-select-all {
+  -webkit-user-select: all !important;
+  /* stylelint-disable-line declaration-no-important */
+  -ms-user-select: none; }
+
+.user-select-auto {
+  -webkit-user-select: auto !important;
+  /* stylelint-disable-line declaration-no-important */
+  -ms-user-select: none; }
+
+.user-select-none {
+  -webkit-user-select: none !important;
+  /* stylelint-disable-line declaration-no-important */
+  -ms-user-select: none; }
+
 body {
   -webkit-font-smoothing: antialiased;
   -moz-osx-font-smoothing: grayscale; }
index 06f4fc0..2e4a216 100644 (file)
@@ -12955,22 +12955,34 @@ input[disabled] {
 
 /* calendar.less */
 .calendar_event_category {
-  background-color: #d8bfd8; }
+  background-color: #e0cbe0; }
+  .calendar_event_category .commands a {
+    color: #0d5ca1; }
 
 .calendar_event_course {
   background-color: #ffd3bd; }
+  .calendar_event_course .commands a {
+    color: #0d5ca1; }
 
 .calendar_event_site {
   background-color: #d6f8cd; }
+  .calendar_event_site .commands a {
+    color: #0d5ca1; }
 
 .calendar_event_group {
   background-color: #fee7ae; }
+  .calendar_event_group .commands a {
+    color: #0d5ca1; }
 
 .calendar_event_user {
   background-color: #dce7ec; }
+  .calendar_event_user .commands a {
+    color: #0d5ca1; }
 
 .calendar_event_other {
   background-color: #ced4da; }
+  .calendar_event_other .commands a {
+    color: #0d5ca1; }
 
 .path-calendar .calendartable {
   width: 100%;
@@ -13047,17 +13059,23 @@ input[disabled] {
           border-radius: 6px;
           vertical-align: middle; }
           .path-calendar .maincalendar .calendarmonth ul li .badge.badge-circle.calendar_event_category {
-            background-color: #d8bfd8; }
+            background-color: #e0cbe0;
+            border: 2px solid #9e619f; }
           .path-calendar .maincalendar .calendarmonth ul li .badge.badge-circle.calendar_event_course {
-            background-color: #ffd3bd; }
+            background-color: #ffd3bd;
+            border: 2px solid #d34600; }
           .path-calendar .maincalendar .calendarmonth ul li .badge.badge-circle.calendar_event_site {
-            background-color: #d6f8cd; }
+            background-color: #d6f8cd;
+            border: 2px solid #2b8713; }
           .path-calendar .maincalendar .calendarmonth ul li .badge.badge-circle.calendar_event_group {
-            background-color: #fee7ae; }
+            background-color: #fee7ae;
+            border: 2px solid #9a6e02; }
           .path-calendar .maincalendar .calendarmonth ul li .badge.badge-circle.calendar_event_user {
-            background-color: #dce7ec; }
+            background-color: #dce7ec;
+            border: 2px solid #4e7c91; }
           .path-calendar .maincalendar .calendarmonth ul li .badge.badge-circle.calendar_event_other {
-            background-color: #ced4da; }
+            background-color: #ced4da;
+            border: 2px solid #687889; }
     .path-calendar .maincalendar .calendarmonth td {
       height: 5em; }
     .path-calendar .maincalendar .calendarmonth .clickable:hover {
@@ -13078,7 +13096,7 @@ input[disabled] {
     color: #0b4f8a;
     text-decoration: underline; }
   .path-calendar .maincalendar .calendar_event_category {
-    border-color: #d8bfd8; }
+    border-color: #e0cbe0; }
   .path-calendar .maincalendar .calendar_event_course {
     border-color: #ffd3bd; }
   .path-calendar .maincalendar .calendar_event_site {
@@ -13121,17 +13139,18 @@ input[disabled] {
   .block .minicalendar td a {
     width: 100%;
     height: 100%;
-    display: block; }
+    display: block;
+    color: #0d5ca1; }
   .block .minicalendar td.duration_global {
     border-top: 1px solid #d6f8cd;
     border-bottom: 1px solid #d6f8cd; }
     .block .minicalendar td.duration_global.duration_finish {
       background-color: #d6f8cd; }
   .block .minicalendar td.duration_category {
-    border-top: 1px solid #d8bfd8;
-    border-bottom: 1px solid #d8bfd8; }
+    border-top: 1px solid #e0cbe0;
+    border-bottom: 1px solid #e0cbe0; }
     .block .minicalendar td.duration_category.duration_finish {
-      background-color: #d8bfd8; }
+      background-color: #e0cbe0; }
   .block .minicalendar td.duration_course {
     border-top: 1px solid #ffd3bd;
     border-bottom: 1px solid #ffd3bd; }
@@ -13183,6 +13202,18 @@ input[disabled] {
 
 .block .calendar_filters li {
   margin-bottom: 0.2em; }
+  .block .calendar_filters li span.calendar_event_category i {
+    color: #0d5ca1; }
+  .block .calendar_filters li span.calendar_event_course i {
+    color: #0d5ca1; }
+  .block .calendar_filters li span.calendar_event_site i {
+    color: #0d5ca1; }
+  .block .calendar_filters li span.calendar_event_group i {
+    color: #0d5ca1; }
+  .block .calendar_filters li span.calendar_event_user i {
+    color: #0d5ca1; }
+  .block .calendar_filters li span.calendar_event_other i {
+    color: #0d5ca1; }
   .block .calendar_filters li span img {
     padding: 0 0.2em;
     margin: 0; }
@@ -19262,6 +19293,65 @@ span[data-flexitour="container"][x-placement="right"], span[data-flexitour="cont
       height: 1em;
       font-size: 4em; }
 
+.form-control:-ms-input-placeholder {
+  color: #6c757d; }
+
+.custom-select {
+  -webkit-appearance: none;
+  -moz-appearance: none; }
+
+.custom-range {
+  -webkit-appearance: none;
+  -moz-appearance: none; }
+  .custom-range::-webkit-slider-thumb, .custom-range::-moz-range-thumb, .custom-range::-ms-thumb {
+    -webkit-appearance: none;
+    -moz-appearance: none; }
+
+input[type="date"].form-control,
+input[type="time"].form-control,
+input[type="datetime-local"].form-control,
+input[type="month"].form-control {
+  -webkit-appearance: none;
+  -moz-appearance: none; }
+
+@media (min-width: 576px) {
+  .card-columns {
+    -webkit-column-gap: 1.25rem;
+    -moz-column-gap: 1.25rem; } }
+
+.carousel-item {
+  -webkit-backface-visibility: hidden; }
+
+.card {
+  -webkit-background-clip: border-box; }
+
+.carousel-indicators li,
+.dropdown-menu,
+.form-control,
+.modal-content,
+.popover,
+.toast {
+  -webkit-background-clip: padding-box; }
+
+.btn {
+  -webkit-user-select: none;
+  -ms-user-select: none; }
+
+.user-select-all {
+  -webkit-user-select: all !important;
+  /* stylelint-disable-line declaration-no-important */
+  -ms-user-select: none; }
+
+.user-select-auto {
+  -webkit-user-select: auto !important;
+  /* stylelint-disable-line declaration-no-important */
+  -ms-user-select: none; }
+
+.user-select-none {
+  -webkit-user-select: none !important;
+  /* stylelint-disable-line declaration-no-important */
+  -ms-user-select: none; }
+
 body {
   -webkit-font-smoothing: antialiased;
   -moz-osx-font-smoothing: grayscale; }
index 443c5a2..17dd34c 100644 (file)
@@ -129,10 +129,10 @@ class participants_filter implements renderable, templatable {
     protected function get_roles_filter(): ?stdClass {
         $roles = [];
         $roles += [-1 => get_string('noroles', 'role')];
-        $roles += get_viewable_roles($this->context);
+        $roles += get_viewable_roles($this->context, null, ROLENAME_BOTH);
 
         if (has_capability('moodle/role:assign', $this->context)) {
-            $roles += get_assignable_roles($this->context, ROLENAME_ALIAS);
+            $roles += get_assignable_roles($this->context, ROLENAME_BOTH);
         }
 
         return $this->get_filter_object(
index 4ee1961..cc75328 100644 (file)
@@ -173,8 +173,8 @@ class user_roles_editable extends \core\output\inplace_editable {
         }
 
         // Check that all the groups belong to the course.
-        $allroles = role_fix_names(get_all_roles($context), $context);
-        $assignableroles = get_assignable_roles($context, ROLENAME_ALIAS, false);
+        $allroles = role_fix_names(get_all_roles($context), $context, ROLENAME_BOTH);
+        $assignableroles = get_assignable_roles($context, ROLENAME_BOTH, false);
         $viewableroles = get_viewable_roles($context);
         $userrolesbyid = get_user_roles($context, $userid, true, 'c.contextlevel DESC, r.sortorder ASC');
         $profileroles = get_profile_roles($context);
index 04d47f5..f03abed 100644 (file)
@@ -202,8 +202,11 @@ class participants extends \table_sql implements dynamic_table {
         if ($canseegroups) {
             $this->groups = groups_get_all_groups($this->courseid, 0, 0, 'g.*', true);
         }
-        $this->allroles = role_fix_names(get_all_roles($this->context), $this->context);
-        $this->assignableroles = get_assignable_roles($this->context, ROLENAME_ALIAS, false);
+
+        // If user has capability to review enrol, show them both role names.
+        $allrolesnamedisplay = ($canreviewenrol ? ROLENAME_BOTH : ROLENAME_ALIAS);
+        $this->allroles = role_fix_names(get_all_roles($this->context), $this->context, $allrolesnamedisplay);
+        $this->assignableroles = get_assignable_roles($this->context, ROLENAME_BOTH, false);
         $this->profileroles = get_profile_roles($this->context);
         $this->viewableroles = get_viewable_roles($this->context);
 
index c819ce2..78c4b7a 100644 (file)
@@ -29,7 +29,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$version  = 2021052500.07;              // YYYYMMDD      = weekly release date of this DEV branch.
+$version  = 2021052500.09;              // YYYYMMDD      = weekly release date of this DEV branch.
                                         //         RR    = release increments - 00 in DEV branches.
                                         //           .XX = incremental changes.
 $release  = '4.0dev (Build: 20200827)'; // Human-friendly version name