Merge branch 'MDL-66897-master-1' of git://github.com/mihailges/moodle
authorJun Pataleta <jun@moodle.com>
Wed, 22 Jan 2020 15:17:14 +0000 (23:17 +0800)
committerJun Pataleta <jun@moodle.com>
Wed, 22 Jan 2020 15:17:14 +0000 (23:17 +0800)
102 files changed:
admin/cli/cron.php
admin/settings/courses.php
admin/tool/behat/tests/behat/data_generators.feature
admin/tool/behat/tests/behat/get_and_set_fields.feature
admin/tool/task/cli/adhoc_task.php
admin/tool/uploadcourse/classes/course.php
admin/tool/uploadcourse/lang/en/tool_uploadcourse.php
admin/tool/uploadcourse/tests/course_test.php
admin/tool/uploaduser/tests/behat/upload_users.feature
course/management.php
enrol/self/lib.php
enrol/self/tests/self_test.php
filter/activitynames/tests/filter_test.php
group/autogroup.php
group/classes/output/user_groups_editable.php
group/externallib.php
group/index.php
group/lib.php
group/overview.php
group/tests/behat/auto_creation.feature
group/tests/behat/create_groups.feature
lang/en/admin.php
lib/behat/behat_base.php
lib/classes/local/cli/shutdown.php [new file with mode: 0644]
lib/classes/shutdown_manager.php
lib/cronlib.php
lib/db/upgrade.php
lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor-debug.js
lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor-min.js
lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor.js
lib/editor/atto/yui/src/editor/js/clean.js
lib/filterlib.php
lib/htmlpurifier/HTMLPurifier.php
lib/htmlpurifier/HTMLPurifier/AttrDef/HTML/Bool.php
lib/htmlpurifier/HTMLPurifier/AttrDef/URI/Host.php
lib/htmlpurifier/HTMLPurifier/CSSDefinition.php
lib/htmlpurifier/HTMLPurifier/Config.php
lib/htmlpurifier/HTMLPurifier/ConfigSchema.php
lib/htmlpurifier/HTMLPurifier/ConfigSchema/schema.ser
lib/htmlpurifier/HTMLPurifier/ConfigSchema/schema/AutoFormat.RemoveEmpty.RemoveNbsp.txt
lib/htmlpurifier/HTMLPurifier/ConfigSchema/schema/Core.AllowParseManyTags.txt [new file with mode: 0644]
lib/htmlpurifier/HTMLPurifier/ConfigSchema/schema/Core.ColorKeywords.txt
lib/htmlpurifier/HTMLPurifier/DefinitionCache/Serializer/README [changed mode: 0644->0755]
lib/htmlpurifier/HTMLPurifier/EntityParser.php
lib/htmlpurifier/HTMLPurifier/HTMLModule.php
lib/htmlpurifier/HTMLPurifier/HTMLModule/SafeScripting.php
lib/htmlpurifier/HTMLPurifier/Language/messages/en-x-test.php
lib/htmlpurifier/HTMLPurifier/Language/messages/en-x-testmini.php
lib/htmlpurifier/HTMLPurifier/Lexer/DOMLex.php
lib/htmlpurifier/HTMLPurifier/Printer/ConfigForm.php
lib/htmlpurifier/HTMLPurifier/VarParser.php
lib/htmlpurifier/HTMLPurifier/VarParser/Flexible.php
lib/htmlpurifier/readme_moodle.txt
lib/moodlelib.php
lib/setup.php
lib/tests/behat/behat_general.php
lib/thirdpartylibs.xml
message/output/popup/amd/build/notification_popover_controller.min.js
message/output/popup/amd/build/notification_popover_controller.min.js.map
message/output/popup/amd/src/notification_popover_controller.js
message/output/popup/mark_notification_read.php
message/templates/message_drawer_view_overview_header.mustache
message/templates/message_drawer_view_settings_body_content.mustache
message/templates/message_drawer_view_settings_body_content_notification_preferences.mustache
mod/forum/amd/build/discussion_list.min.js
mod/forum/amd/build/discussion_list.min.js.map
mod/forum/amd/src/discussion_list.js
mod/forum/classes/local/managers/capability.php
mod/forum/lib.php
mod/forum/report/summary/tests/behat/summary_filter_groups.feature [new file with mode: 0644]
mod/forum/report/summary/tests/behat/summary_filter_no_groups.feature [new file with mode: 0644]
mod/forum/templates/discussion_list.mustache
mod/forum/templates/inpage_reply_v2.mustache
mod/forum/templates/setting_switch.mustache
mod/forum/tests/behat/behat_mod_forum.php
mod/forum/tests/behat/discussion_subscriptions.feature
mod/forum/tests/behat/edit_post_student.feature
mod/forum/tests/behat/forum_subscriptions_default.feature
mod/forum/tests/behat/recent_activity.feature [new file with mode: 0644]
mod/forum/tests/managers_capability_test.php
mod/glossary/tests/behat/entries_always_editable.feature
mod/lti/db/access.php
mod/lti/lang/en/lti.php
mod/lti/lib.php
mod/lti/locallib.php
mod/lti/mod_form.js
mod/lti/mod_form.php
mod/lti/version.php
mod/quiz/styles.css
mod/wiki/parser/parser.php
question/engine/renderer.php
report/outline/tests/behat/user.feature
theme/boost/amd/build/loader.min.js
theme/boost/amd/build/loader.min.js.map
theme/boost/amd/src/loader.js
theme/boost/scss/moodle/core.scss
theme/boost/scss/moodle/debug.scss
theme/boost/scss/moodle/question.scss
theme/boost/scss/preset/default.scss
theme/boost/style/moodle.css
theme/classic/style/moodle.css
version.php

index d7d1646..fe72683 100644 (file)
@@ -74,4 +74,6 @@ if ($options['stop']) {
     die;
 }
 
+\core\local\cli\shutdown::script_supports_graceful_exit();
+
 cron_run();
index 5989158..ad5ede6 100644 (file)
@@ -276,6 +276,8 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) {
     $maxkeptoptions = array(
         0 => new lang_string('all'), 1 => '1',
         2 => '2',
+        3 => '3',
+        4 => '4',
         5 => '5',
         10 => '10',
         20 => '20',
index f078fab..19644be 100644 (file)
@@ -259,9 +259,9 @@ Feature: Set up contextual data for tests
     Then the "groups" select box should contain "Group 1 (1)"
     And the "groups" select box should contain "Group 2 (1)"
     And I set the field "groups" to "Group 1 (1)"
-    And the "members" select box should contain "Student 1"
+    And the "members" select box should contain "Student 1 (student1@example.com)"
     And I set the field "groups" to "Group 2 (1)"
-    And the "members" select box should contain "Student 2"
+    And the "members" select box should contain "Student 2 (student2@example.com)"
 
   Scenario: Add cohorts and cohort members with data generator
     Given the following "categories" exist:
index 9ade5e0..3f1d88e 100644 (file)
@@ -147,13 +147,13 @@ Feature: Verify that all form fields values can be get and set
     And I navigate to "Users > Groups" in current page administration
     # Select (multi-select & AJAX) - Checking "I set the field" and "select box should contain".
     And I set the field "groups" to "Group 2"
-    And the "members" select box should contain "Student 2"
-    And the "members" select box should contain "Student 3"
-    And the "members" select box should not contain "Student 1"
+    And the "members" select box should contain "Student 2 (s2@example.com)"
+    And the "members" select box should contain "Student 3 (s3@example.com)"
+    And the "members" select box should not contain "Student 1 (s1@example.com)"
     And I set the field "groups" to "Group 1"
-    And the "members" select box should contain "Student 1"
-    And the "members" select box should contain "Student 2"
-    And the "members" select box should not contain "Student 3"
+    And the "members" select box should contain "Student 1 (s1@example.com)"
+    And the "members" select box should contain "Student 2 (s2@example.com)"
+    And the "members" select box should not contain "Student 3 (s3@example.com)"
     # Checkbox (AJAX) - Checking "I set the field" and "I set the following fields to these values".
     And I am on "Course 1" course homepage
     And I add a "Lesson" to section "1"
index 755026b..1a04ba8 100644 (file)
@@ -115,5 +115,7 @@ cron_setup_user();
 $humantimenow = date('r', time());
 $keepalive = (int)$options['keep-alive'];
 
+\core\local\cli\shutdown::script_supports_graceful_exit();
+
 mtrace("Server Time: {$humantimenow}\n");
 cron_run_adhoc_tasks(time(), $keepalive, $checklimits);
index 6f2cffa..8ae7a8d 100644 (file)
@@ -412,6 +412,12 @@ class tool_uploadcourse_course {
                 $this->error('invalidshortname', new lang_string('invalidshortname', 'tool_uploadcourse'));
                 return false;
             }
+
+            // Ensure we don't overflow the maximum length of the shortname field.
+            if (core_text::strlen($this->shortname) > 255) {
+                $this->error('invalidshortnametoolong', new lang_string('invalidshortnametoolong', 'tool_uploadcourse', 255));
+                return false;
+            }
         }
 
         $exists = $this->exists();
@@ -479,6 +485,12 @@ class tool_uploadcourse_course {
             return false;
         }
 
+        // Ensure we don't overflow the maximum length of the fullname field.
+        if (!empty($coursedata['fullname']) && core_text::strlen($coursedata['fullname']) > 254) {
+            $this->error('invalidfullnametoolong', new lang_string('invalidfullnametoolong', 'tool_uploadcourse', 254));
+            return false;
+        }
+
         // If the course does not exist, or will be forced created.
         if (!$exists || $mode === tool_uploadcourse_processor::MODE_CREATE_ALL) {
 
index 7f3e9d7..9a0e2c6 100644 (file)
@@ -93,6 +93,8 @@ $string['invalideupdatemode'] = 'Invalid update mode selected';
 $string['invalidvisibilitymode'] = 'Invalid visible mode';
 $string['invalidroles'] = 'Invalid role names: {$a}';
 $string['invalidshortname'] = 'Invalid shortname';
+$string['invalidfullnametoolong'] = 'The fullname field is limited to {$a} characters';
+$string['invalidshortnametoolong'] = 'The shortname field is limited to {$a} characters';
 $string['missingmandatoryfields'] = 'Missing value for mandatory fields: {$a}';
 $string['missingshortnamenotemplate'] = 'Missing shortname and shortname template not set';
 $string['mode'] = 'Upload mode';
index c0d29e0..0add18e 100644 (file)
@@ -82,6 +82,37 @@ class tool_uploadcourse_course_testcase extends advanced_testcase {
         $this->assertArrayHasKey('invalidshortname', $co->get_errors());
     }
 
+    public function test_invalid_shortname_too_long() {
+        $this->resetAfterTest();
+
+        $mode = tool_uploadcourse_processor::MODE_CREATE_NEW;
+        $updatemode = tool_uploadcourse_processor::UPDATE_NOTHING;
+
+        $upload = new tool_uploadcourse_course($mode, $updatemode, [
+            'category' => 1,
+            'fullname' => 'New course',
+            'shortname' => str_repeat('X', 2000),
+        ]);
+
+        $this->assertFalse($upload->prepare());
+        $this->assertArrayHasKey('invalidshortnametoolong', $upload->get_errors());
+    }
+
+    public function test_invalid_fullname_too_long() {
+        $this->resetAfterTest();
+
+        $mode = tool_uploadcourse_processor::MODE_CREATE_NEW;
+        $updatemode = tool_uploadcourse_processor::UPDATE_NOTHING;
+
+        $upload = new tool_uploadcourse_course($mode, $updatemode, [
+            'category' => 1,
+            'fullname' => str_repeat('X', 2000),
+        ]);
+
+        $this->assertFalse($upload->prepare());
+        $this->assertArrayHasKey('invalidfullnametoolong', $upload->get_errors());
+    }
+
     public function test_invalid_visibility() {
         $this->resetAfterTest(true);
         $mode = tool_uploadcourse_processor::MODE_CREATE_NEW;
index a92b633..fc55002 100644 (file)
@@ -36,7 +36,7 @@ Feature: Upload users
     And I am on "Maths" course homepage
     And I navigate to "Users > Groups" in current page administration
     And I set the field "groups" to "Section 1 (1)"
-    And the "members" select box should contain "Tom Jones"
+    And the "members" select box should contain "Tom Jones (jonest@example.com)"
 
   @javascript
   Scenario: Upload users enrolling them on courses and groups applying defaults
index 492a9f1..ad74a3e 100644 (file)
@@ -316,7 +316,8 @@ if ($action !== false && confirm_sesskey()) {
                         $notificationsfail[] = get_string('movecategoryownparent', 'error', $cattomove->get_formatted_name());
                         continue;
                     }
-                    if (strpos($movetocat->path, $cattomove->path) === 0) {
+                    // Don't allow user to move selected category into one of it's own sub-categories.
+                    if (strpos($movetocat->path, $cattomove->path . '/') === 0) {
                         $notificationsfail[] = get_string('movecategoryparentconflict', 'error', $cattomove->get_formatted_name());
                         continue;
                     }
index f3bc7b5..65b1440 100644 (file)
@@ -450,8 +450,9 @@ class enrol_self_plugin extends enrol_plugin {
             $userid = $instance->userid;
             unset($instance->userid);
             $this->unenrol_user($instance, $userid);
-            $days = $instance->customint2 / 60*60*24;
-            $trace->output("unenrolling user $userid from course $instance->courseid as they have did not log in for at least $days days", 1);
+            $days = $instance->customint2 / DAYSECS;
+            $trace->output("unenrolling user $userid from course $instance->courseid " .
+                "as they did not log in for at least $days days", 1);
         }
         $rs->close();
 
@@ -467,8 +468,9 @@ class enrol_self_plugin extends enrol_plugin {
             $userid = $instance->userid;
             unset($instance->userid);
             $this->unenrol_user($instance, $userid);
-                $days = $instance->customint2 / 60*60*24;
-            $trace->output("unenrolling user $userid from course $instance->courseid as they have did not access course for at least $days days", 1);
+            $days = $instance->customint2 / DAYSECS;
+            $trace->output("unenrolling user $userid from course $instance->courseid " .
+                "as they did not access the course for at least $days days", 1);
         }
         $rs->close();
 
index 7c701ed..e939eaf 100644 (file)
@@ -61,7 +61,7 @@ class enrol_self_testcase extends advanced_testcase {
 
         $now = time();
 
-        $trace = new null_progress_trace();
+        $trace = new progress_trace_buffer(new text_progress_trace(), false);
 
         // Prepare some data.
 
@@ -133,18 +133,32 @@ class enrol_self_testcase extends advanced_testcase {
         // Execute sync - this is the same thing used from cron.
 
         $selfplugin->sync($trace, $course2->id);
+        $output = $trace->get_buffer();
+        $trace->reset_buffer();
         $this->assertEquals(10, $DB->count_records('user_enrolments'));
-
+        $this->assertStringContainsString('No expired enrol_self enrolments detected', $output);
         $this->assertTrue($DB->record_exists('user_enrolments', array('enrolid'=>$instance1->id, 'userid'=>$user1->id)));
         $this->assertTrue($DB->record_exists('user_enrolments', array('enrolid'=>$instance1->id, 'userid'=>$user2->id)));
         $this->assertTrue($DB->record_exists('user_enrolments', array('enrolid'=>$instance3->id, 'userid'=>$user1->id)));
         $this->assertTrue($DB->record_exists('user_enrolments', array('enrolid'=>$instance3->id, 'userid'=>$user3->id)));
+
         $selfplugin->sync($trace, null);
+        $output = $trace->get_buffer();
+        $trace->reset_buffer();
         $this->assertEquals(6, $DB->count_records('user_enrolments'));
         $this->assertFalse($DB->record_exists('user_enrolments', array('enrolid'=>$instance1->id, 'userid'=>$user1->id)));
         $this->assertFalse($DB->record_exists('user_enrolments', array('enrolid'=>$instance1->id, 'userid'=>$user2->id)));
         $this->assertFalse($DB->record_exists('user_enrolments', array('enrolid'=>$instance3->id, 'userid'=>$user1->id)));
         $this->assertFalse($DB->record_exists('user_enrolments', array('enrolid'=>$instance3->id, 'userid'=>$user3->id)));
+        $this->assertStringContainsString('unenrolling user ' . $user1->id . ' from course ' . $course1->id .
+            ' as they did not log in for at least 14 days', $output);
+        $this->assertStringContainsString('unenrolling user ' . $user1->id . ' from course ' . $course3->id .
+            ' as they did not log in for at least 50 days', $output);
+        $this->assertStringContainsString('unenrolling user ' . $user2->id . ' from course ' . $course1->id .
+            ' as they did not access the course for at least 14 days', $output);
+        $this->assertStringContainsString('unenrolling user ' . $user3->id . ' from course ' . $course3->id .
+            ' as they did not access the course for at least 50 days', $output);
+        $this->assertStringNotContainsString('unenrolling user ' . $user4->id, $output);
 
         $this->assertEquals(6, $DB->count_records('role_assignments'));
         $this->assertEquals(4, $DB->count_records('role_assignments', array('roleid'=>$studentrole->id)));
index 403170e..b21c924 100644 (file)
@@ -37,7 +37,6 @@ require_once($CFG->dirroot . '/filter/activitynames/filter.php'); // Include the
 class filter_activitynames_filter_testcase extends advanced_testcase {
 
     public function test_links() {
-        global $CFG;
         $this->resetAfterTest(true);
 
         // Create a test course.
@@ -59,8 +58,8 @@ class filter_activitynames_filter_testcase extends advanced_testcase {
         preg_match_all('~<a class="autolink" title="([^"]*)" href="[^"]*/mod/page/view.php\?id=([0-9]+)">([^<]*)</a>~',
                 $filtered, $matches);
 
-        // There should be 3 links links.
-        $this->assertEquals(2, count($matches[1]));
+        // There should be 2 links links.
+        $this->assertCount(2, $matches[1]);
 
         // Check text of title attribute.
         $this->assertEquals($page1->name, $matches[1][0]);
@@ -74,4 +73,33 @@ class filter_activitynames_filter_testcase extends advanced_testcase {
         $this->assertEquals($page1->name, $matches[3][0]);
         $this->assertEquals($page2->name, $matches[3][1]);
     }
+
+    public function test_links_activity_named_hyphen() {
+        $this->resetAfterTest(true);
+
+        // Create a test course.
+        $course = $this->getDataGenerator()->create_course();
+        $context = context_course::instance($course->id);
+
+        // Work around an issue with the activity names filter which maintains a static cache
+        // of activities for current course ID. We can re-build the cache by switching user.
+        $this->setUser($this->getDataGenerator()->create_user());
+
+        // Create a page activity named '-' (single hyphen).
+        $page = $this->getDataGenerator()->create_module('page', ['course' => $course->id, 'name' => '-']);
+
+        $html = '<p>Please read the - page.</p>';
+        $filtered = format_text($html, FORMAT_HTML, array('context' => $context));
+
+        // Find the page link in the filtered html.
+        preg_match_all('~<a class="autolink" title="([^"]*)" href="[^"]*/mod/page/view.php\?id=([0-9]+)">([^<]*)</a>~',
+            $filtered, $matches);
+
+        // We should have exactly one match.
+        $this->assertCount(1, $matches[1]);
+
+        $this->assertEquals($page->name, $matches[1][0]);
+        $this->assertEquals($page->cmid, $matches[2][0]);
+        $this->assertEquals($page->name, $matches[3][0]);
+    }
 }
index b66ca45..cc2aeeb 100644 (file)
@@ -99,8 +99,9 @@ if ($editform->is_cancelled()) {
     // Display only active users if the option was selected or they do not have the capability to view suspended users.
     $onlyactive = !empty($data->includeonlyactiveenrol) || !has_capability('moodle/course:viewsuspendedusers', $context);
 
+    $extrafields = get_extra_user_fields($context);
     $users = groups_get_potential_members($data->courseid, $data->roleid, $source, $orderby, !empty($data->notingroup),
-        $onlyactive);
+        $onlyactive, $extrafields);
     $usercnt = count($users);
 
     if ($data->allocateby == 'random') {
@@ -171,7 +172,7 @@ if ($editform->is_cancelled()) {
             $table->width = '90%';
         }
         $table->data  = array();
-
+        $viewfullnames = has_capability('moodle/site:viewfullnames', $context);
         foreach ($groups as $group) {
             $line = array();
             if (groups_get_group_by_name($courseid, $group['name'])) {
@@ -183,7 +184,16 @@ if ($editform->is_cancelled()) {
             if ($data->allocateby != 'no') {
                 $unames = array();
                 foreach ($group['members'] as $user) {
-                    $unames[] = fullname($user, true);
+                    $fullname = fullname($user, $viewfullnames);
+                    if ($extrafields) {
+                        $extrafieldsdisplay = [];
+                        foreach ($extrafields as $field) {
+                            $extrafieldsdisplay[] = s($user->{$field});
+                        }
+                        $fullname .= ' (' . implode(', ', $extrafieldsdisplay) . ')';
+                    }
+
+                    $unames[] = $fullname;
                 }
                 $line[] = implode(', ', $unames);
                 $line[] = count($group['members']);
index 9595a03..a4a61c5 100644 (file)
@@ -77,8 +77,10 @@ class user_groups_editable extends \core\output\inplace_editable {
         foreach ($coursegroups as $group) {
             $options[$group->id] = format_string($group->name, true, ['context' => $this->context]);
         }
-        $this->edithint = get_string('editusersgroupsa', 'group', fullname($user));
-        $this->editlabel = get_string('editusersgroupsa', 'group', fullname($user));
+
+        $fullname = fullname($user, has_capability('moodle/site:viewfullnames', $this->context));
+        $this->edithint = get_string('editusersgroupsa', 'group', $fullname);
+        $this->editlabel = get_string('editusersgroupsa', 'group', $fullname);
 
         $attributes = ['multiple' => true];
         $this->set_type_autocomplete($options, $attributes);
index bebede4..341e727 100644 (file)
@@ -556,7 +556,8 @@ class core_group_external extends external_api {
             require_capability('moodle/course:managegroups', $context);
 
             if (!groups_remove_member_allowed($group, $user)) {
-                throw new moodle_exception('errorremovenotpermitted', 'group', '', fullname($user));
+                $fullname = fullname($user, has_capability('moodle/site:viewfullnames', $context));
+                throw new moodle_exception('errorremovenotpermitted', 'group', '', $fullname);
             }
             groups_remove_member($group, $user);
         }
index 79c9e21..e2aa22b 100644 (file)
@@ -80,7 +80,13 @@ switch ($action) {
 
     case 'ajax_getmembersingroup':
         $roles = array();
-        if ($groupmemberroles = groups_get_members_by_role($groupids[0], $courseid, 'u.id, ' . get_all_user_name_fields(true, 'u'))) {
+
+        $extrafields = get_extra_user_fields($context);
+        if ($groupmemberroles = groups_get_members_by_role($groupids[0], $courseid,
+                'u.id, ' . user_picture::fields('u', $extrafields))) {
+
+            $viewfullnames = has_capability('moodle/site:viewfullnames', $context);
+
             foreach($groupmemberroles as $roleid=>$roledata) {
                 $shortroledata = new stdClass();
                 $shortroledata->name = $roledata->name;
@@ -88,7 +94,15 @@ switch ($action) {
                 foreach($roledata->users as $member) {
                     $shortmember = new stdClass();
                     $shortmember->id = $member->id;
-                    $shortmember->name = fullname($member, true);
+                    $shortmember->name = fullname($member, $viewfullnames);
+                    if ($extrafields) {
+                        $extrafieldsdisplay = [];
+                        foreach ($extrafields as $field) {
+                            $extrafieldsdisplay[] = s($member->{$field});
+                        }
+                        $shortmember->name .= ' (' . implode(', ', $extrafieldsdisplay) . ')';
+                    }
+
                     $shortroledata->users[] = $shortmember;
                 }
                 $roles[] = $shortroledata;
@@ -188,15 +202,27 @@ if ($groups) {
 // Get list of group members to render if there is a single selected group.
 $members = array();
 if ($singlegroup) {
-    $usernamefields = get_all_user_name_fields(true, 'u');
-    if ($groupmemberroles = groups_get_members_by_role(reset($groupids), $courseid, 'u.id, ' . $usernamefields)) {
+    $extrafields = get_extra_user_fields($context);
+    if ($groupmemberroles = groups_get_members_by_role(reset($groupids), $courseid,
+            'u.id, ' . user_picture::fields('u', $extrafields))) {
+
+        $viewfullnames = has_capability('moodle/site:viewfullnames', $context);
+
         foreach ($groupmemberroles as $roleid => $roledata) {
             $users = array();
             foreach ($roledata->users as $member) {
-                $users[] = (object)[
-                    'value' => $member->id,
-                    'text' => fullname($member, true)
-                ];
+                $shortmember = new stdClass();
+                $shortmember->value = $member->id;
+                $shortmember->text = fullname($member, $viewfullnames);
+                if ($extrafields) {
+                    $extrafieldsdisplay = [];
+                    foreach ($extrafields as $field) {
+                        $extrafieldsdisplay[] = s($member->{$field});
+                    }
+                    $shortmember->text .= ' (' . implode(', ', $extrafieldsdisplay) . ')';
+                }
+
+                $users[] = $shortmember;
             }
             $members[] = (object)[
                 'role' => s($roledata->name),
index 42e7fbe..9833016 100644 (file)
@@ -785,11 +785,12 @@ function groups_get_possible_roles($context) {
  * @param string $orderby The column to sort users by
  * @param int $notingroup restrict to users not in existing groups
  * @param bool $onlyactiveenrolments restrict to users who have an active enrolment in the course
+ * @param array $extrafields Extra user fields to return
  * @return array An array of the users
  */
 function groups_get_potential_members($courseid, $roleid = null, $source = null,
                                       $orderby = 'lastname ASC, firstname ASC',
-                                      $notingroup = null, $onlyactiveenrolments = false) {
+                                      $notingroup = null, $onlyactiveenrolments = false, $extrafields = []) {
     global $DB;
 
     $context = context_course::instance($courseid);
@@ -847,7 +848,7 @@ function groups_get_potential_members($courseid, $roleid = null, $source = null,
         }
     }
 
-    $allusernamefields = get_all_user_name_fields(true, 'u');
+    $allusernamefields = user_picture::fields('u', $extrafields);
     $sql = "SELECT DISTINCT u.id, u.username, $allusernamefields, u.idnumber
               FROM {user} u
               JOIN ($esql) e ON e.id = u.id
index 785cc63..5c23992 100644 (file)
@@ -110,7 +110,9 @@ if ($groupingid) {
 
 list($sort, $sortparams) = users_order_by_sql('u');
 
-$allnames = get_all_user_name_fields(true, 'u');
+$extrafields = get_extra_user_fields($context);
+$allnames = 'u.id, ' . user_picture::fields('u', $extrafields);
+
 $sql = "SELECT g.id AS groupid, gg.groupingid, u.id AS userid, $allnames, u.idnumber, u.username
           FROM {groups} g
                LEFT JOIN {groupings_groups} gg ON g.id = gg.groupid
@@ -121,8 +123,9 @@ $sql = "SELECT g.id AS groupid, gg.groupingid, u.id AS userid, $allnames, u.idnu
 
 $rs = $DB->get_recordset_sql($sql, array_merge($params, $sortparams));
 foreach ($rs as $row) {
-    $user = new stdClass();
-    $user = username_load_fields_from_object($user, $row, null, array('id' => 'userid', 'username', 'idnumber'));
+    $user = username_load_fields_from_object((object) [], $row, null,
+        array_merge(['id' => 'userid', 'username', 'idnumber'], $extrafields));
+
     if (!$row->groupingid) {
         $row->groupingid = OVERVIEW_GROUPING_GROUP_NO_GROUPING;
     }
@@ -248,9 +251,20 @@ foreach ($members as $gpgid=>$groupdata) {
             $line[] = html_writer::tag('span', $name, array('class' => 'group_hoverdescription', 'data-groupid' => $gpid));
             $hoverevents[$gpid] = get_string('descriptiona', null, $jsdescription);
         }
+        $viewfullnames = has_capability('moodle/site:viewfullnames', $context);
         $fullnames = array();
         foreach ($users as $user) {
-            $fullnames[] = '<a href="'.$CFG->wwwroot.'/user/view.php?id='.$user->id.'&amp;course='.$course->id.'">'.fullname($user, true).'</a>';
+            $displayname = fullname($user, $viewfullnames);
+            if ($extrafields) {
+                $extrafieldsdisplay = [];
+                foreach ($extrafields as $field) {
+                    $extrafieldsdisplay[] = s($user->{$field});
+                }
+                $displayname .= ' (' . implode(', ', $extrafieldsdisplay) . ')';
+            }
+
+            $fullnames[] = html_writer::link(new moodle_url('/user/view.php', ['id' => $user->id, 'course' => $course->id]),
+                $displayname);
         }
         $line[] = implode(', ', $fullnames);
         $line[] = count($users);
index 54b4763..9d8c4de 100644 (file)
@@ -80,15 +80,13 @@ Feature: Automatic creation of groups
       | Group/member count | 4 |
       | Grouping of auto-created groups | New grouping |
       | Grouping name | Grouping name |
+      | Allocate members | Alphabetically by last name, first name |
     And I press "Preview"
-    Then I should see "Group members"
-    And I should see "User count"
-    And I should see "Group A" in the ".generaltable" "css_element"
-    And I should see "Group B" in the ".generaltable" "css_element"
-    And I should see "Group C" in the ".generaltable" "css_element"
-    And I should see "4" in the "Group A" "table_row"
-    And I should see "4" in the "Group B" "table_row"
-    And I should see "2" in the "Group C" "table_row"
+    Then the following should exist in the "generaltable" table:
+      | Groups (3)   | Group members                    | User count (10) |
+      | Group A      | Student 1 (student1@example.com) | 4               |
+      | Group B      | Student 5 (student5@example.com) | 4               |
+      | Group C      | Student 9 (student9@example.com) | 2               |
     And I set the field "Prevent last small group" to "1"
     And I press "Preview"
     And I should see "Group A" in the ".generaltable" "css_element"
@@ -163,7 +161,7 @@ Feature: Automatic creation of groups
     And I set the field "Auto create based on" to "Members per group"
     When I set the field "Group/member count" to "11"
     And I press "Preview"
-    Then I should see "Suspended student 11"
+    Then I should see "Suspended student 11 (suspendedstudent11@example.com)"
 
   Scenario: Do not display 'Include only active enrolments' if user does not have the 'moodle/course:viewsuspendedusers' capability
     Given I log out
index 8cfa041..ebf2832 100644 (file)
@@ -39,13 +39,15 @@ Feature: Organize students into groups
     And I add "Student 2 (student2@example.com)" user to "Group 2" group members
     And I add "Student 3 (student3@example.com)" user to "Group 2" group members
     Then I set the field "groups" to "Group 1 (2)"
-    And the "members" select box should contain "Student 0"
-    And the "members" select box should contain "Student 1"
-    And the "members" select box should not contain "Student 2"
+    And the "members" select box should contain "Student 0 (student0@example.com)"
+    And the "members" select box should contain "Student 1 (student1@example.com)"
+    And the "members" select box should not contain "Student 2 (student2@example.com)"
+    And the "members" select box should not contain "Student 3 (student3@example.com)"
     And I set the field "groups" to "Group 2 (2)"
-    And the "members" select box should contain "Student 2"
-    And the "members" select box should contain "Student 3"
-    And the "members" select box should not contain "Student 0"
+    And the "members" select box should contain "Student 2 (student2@example.com)"
+    And the "members" select box should contain "Student 3 (student3@example.com)"
+    And the "members" select box should not contain "Student 0 (student0@example.com)"
+    And the "members" select box should not contain "Student 1 (student1@example.com)"
     And I navigate to course participants
     And I open the autocomplete suggestions list
     And I click on "Group: Group 1" item in the autocomplete list
@@ -59,6 +61,36 @@ Feature: Organize students into groups
     And I should see "Student 3"
     And I should not see "Student 0"
 
+  @javascript
+  Scenario: Assign students to groups with site user identity configured
+    Given the following "courses" exist:
+      | fullname | shortname | groupmode |
+      | Course 1 | C1        | 1         |
+    And the following "users" exist:
+      | username | firstname | lastname | email               | country |
+      | teacher  | Teacher   | 1        | teacher@example.com | GB      |
+      | student  | Student   | 1        | student@example.com | DE      |
+    And the following "course enrolments" exist:
+      | user    | course | role           |
+      | teacher | C1     | editingteacher |
+      | student | C1     | student        |
+    And the following config values are set as admin:
+      | showuseridentity | email,country |
+    And I log in as "teacher"
+    And I am on "Course 1" course homepage
+    And I navigate to "Users > Groups" in current page administration
+    And I press "Create group"
+    And I set the following fields to these values:
+      | Group name | Group 1 |
+    And I press "Save changes"
+    When I add "Student 1 (student@example.com, DE)" user to "Group 1" group members
+    And I set the field "groups" to "Group 1 (1)"
+    Then the "members" select box should contain "Student 1 (student@example.com\, DE)"
+    # Non-AJAX version of the groups page.
+    And I press "Add/remove users"
+    And I press "Back to groups"
+    And the "members" select box should contain "Student 1 (student@example.com\, DE)"
+
   Scenario: Create groups and groupings without the 'moodle/course:changeidnumber' capability
     Given the following "courses" exist:
       | fullname | shortname | category | groupmode |
index d78836a..ef9c319 100644 (file)
@@ -120,6 +120,8 @@ $string['cfgwwwrootwarning'] = '$CFG->wwwroot is defined incorrectly in the conf
 $string['cleanup'] = 'Cleanup';
 $string['clianswerno'] = 'n';
 $string['cliansweryes'] = 'y';
+$string['cliexitgraceful'] = 'Exiting gracefully, please wait ...';
+$string['cliexitnow'] = 'Exiting right NOW';
 $string['cliincorrectvalueerror'] = 'Error, incorrect value "{$a->value}" for "{$a->option}"';
 $string['cliincorrectvalueretry'] = 'Incorrect value, please retry';
 $string['clistatusdisabled'] = 'Status: disabled';
index a8aa67d..729ac1e 100644 (file)
@@ -338,20 +338,11 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
         if (!$timeout) {
             $timeout = self::get_timeout();
         }
-        if ($microsleep) {
-            // Will sleep 1/10th of a second by default for self::get_timeout() seconds.
-            $loops = $timeout * 10;
-        } else {
-            // Will sleep for self::get_timeout() seconds.
-            $loops = $timeout;
-        }
 
-        // DOM will never change on non-javascript case; do not wait or try again.
-        if (!$this->running_javascript()) {
-            $loops = 1;
-        }
+        $start = microtime(true);
+        $end = $start + $timeout;
 
-        for ($i = 0; $i < $loops; $i++) {
+        do {
             // We catch the exception thrown by the step definition to execute it again.
             try {
                 // We don't check with !== because most of the time closures will return
@@ -367,14 +358,13 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
                 }
             }
 
-            if ($this->running_javascript()) {
-                if ($microsleep) {
-                    usleep(100000);
-                } else {
-                    sleep(1);
-                }
+            if (!$this->running_javascript()) {
+                break;
             }
-        }
+
+            usleep(100000);
+
+        } while (microtime(true) < $end);
 
         // Using coding_exception as is a development issue if no exception has been provided.
         if (!$exception) {
diff --git a/lib/classes/local/cli/shutdown.php b/lib/classes/local/cli/shutdown.php
new file mode 100644 (file)
index 0000000..93f295c
--- /dev/null
@@ -0,0 +1,81 @@
+<?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/>.
+
+/**
+ * CLI script shutdown helper class.
+ *
+ * @package    core
+ * @copyright  2019 Brendan Heywood <brendan@catalyst-au.net>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\local\cli;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * CLI script shutdown helper class.
+ *
+ * @package    core
+ * @copyright  2019 Brendan Heywood <brendan@catalyst-au.net>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class shutdown {
+
+    /** @var bool Should we exit gracefully at the next opportunity? */
+    protected static $cligracefulexit = false;
+
+    /**
+     * Declares that this CLI script can gracefully handle signals
+     *
+     * @return void
+     */
+    public static function script_supports_graceful_exit(): void {
+        \core_shutdown_manager::register_signal_handler('\core\local\cli\shutdown::signal_handler');
+    }
+
+    /**
+     * Should we gracefully exit?
+     *
+     * @return bool true if we should gracefully exit
+     */
+    public static function should_gracefully_exit(): bool {
+        return self::$cligracefulexit;
+    }
+
+    /**
+     * Handle the signal
+     *
+     * The first signal flags a graceful exit. If a second signal is received
+     * then it immediately exits.
+     *
+     * @param int $signo The signal number
+     * @return bool true if we should exit
+     */
+    public static function signal_handler(int $signo): bool {
+
+        if (self::$cligracefulexit) {
+            cli_heading(get_string('cliexitnow', 'admin'));
+            return true;
+        }
+
+        cli_heading(get_string('cliexitgraceful', 'admin'));
+        self::$cligracefulexit = true;
+        return false;
+    }
+
+}
+
index 9475c92..101b4a1 100644 (file)
@@ -33,7 +33,9 @@ defined('MOODLE_INTERNAL') || die();
  */
 class core_shutdown_manager {
     /** @var array list of custom callbacks */
-    protected static $callbacks = array();
+    protected static $callbacks = [];
+    /** @var array list of custom signal callbacks */
+    protected static $signalcallbacks = [];
     /** @var bool is this manager already registered? */
     protected static $registered = false;
 
@@ -66,7 +68,7 @@ class core_shutdown_manager {
      *
      * @param   int     $signo The signal being handled
      */
-    public static function signal_handler($signo) {
+    public static function signal_handler(int $signo) {
         // Note: There is no need to manually call the shutdown handler.
         // The fact that we are calling exit() in this script means that the standard shutdown handling is performed
         // anyway.
@@ -92,7 +94,41 @@ class core_shutdown_manager {
                 $exitcode = 1;
         }
 
-        exit ($exitcode);
+        // Normally we should exit unless a callback tells us to wait.
+        $shouldexit = true;
+        foreach (self::$signalcallbacks as $data) {
+            list($callback, $params) = $data;
+            try {
+                array_unshift($params, $signo);
+                $shouldexit = call_user_func_array($callback, $params) && $shouldexit;
+            } catch (Throwable $e) {
+                // @codingStandardsIgnoreStart
+                error_log('Exception ignored in signal function ' . get_callable_name($callback) . ': ' . $e->getMessage());
+                // @codingStandardsIgnoreEnd
+            }
+        }
+
+        if ($shouldexit) {
+            exit ($exitcode);
+        }
+    }
+
+    /**
+     * Register custom signal handler function.
+     *
+     * If a handler returns false the signal will be ignored.
+     *
+     * @param callable $callback
+     * @param array $params
+     * @return void
+     */
+    public static function register_signal_handler($callback, array $params = null): void {
+        if (!is_callable($callback)) {
+            // @codingStandardsIgnoreStart
+            error_log('Invalid custom signal function detected ' . var_export($callback, true));
+            // @codingStandardsIgnoreEnd
+        }
+        self::$signalcallbacks[] = [$callback, $params ?? []];
     }
 
     /**
@@ -100,9 +136,15 @@ class core_shutdown_manager {
      *
      * @param callable $callback
      * @param array $params
+     * @return void
      */
-    public static function register_function($callback, array $params = null) {
-        self::$callbacks[] = array($callback, $params);
+    public static function register_function($callback, array $params = null): void {
+        if (!is_callable($callback)) {
+            // @codingStandardsIgnoreStart
+            error_log('Invalid custom shutdown function detected '.var_export($callback, true));
+            // @codingStandardsIgnoreEnd
+        }
+        self::$callbacks[] = [$callback, $params ?? []];
     }
 
     /**
@@ -115,20 +157,11 @@ class core_shutdown_manager {
         foreach (self::$callbacks as $data) {
             list($callback, $params) = $data;
             try {
-                if (!is_callable($callback)) {
-                    error_log('Invalid custom shutdown function detected '.var_export($callback, true));
-                    continue;
-                }
-                if ($params === null) {
-                    call_user_func($callback);
-                } else {
-                    call_user_func_array($callback, $params);
-                }
-            } catch (Exception $e) {
-                error_log('Exception ignored in shutdown function '.get_callable_name($callback).': '.$e->getMessage());
+                call_user_func_array($callback, $params);
             } catch (Throwable $e) {
-                // Engine errors in PHP7 throw exceptions of type Throwable (this "catch" will be ignored in PHP5).
+                // @codingStandardsIgnoreStart
                 error_log('Exception ignored in shutdown function '.get_callable_name($callback).': '.$e->getMessage());
+                // @codingStandardsIgnoreEnd
             }
         }
 
index 0adbcc9..f15fae8 100644 (file)
@@ -114,7 +114,8 @@ function cron_run_scheduled_tasks(int $timenow) {
 
     // Run all scheduled tasks.
     try {
-        while (!\core\task\manager::static_caches_cleared_since($timenow) &&
+        while (!\core\local\cli\shutdown::should_gracefully_exit() &&
+                !\core\task\manager::static_caches_cleared_since($timenow) &&
                 $task = \core\task\manager::get_next_scheduled_task($timenow)) {
             cron_run_inner_scheduled_task($task);
             unset($task);
@@ -167,7 +168,8 @@ function cron_run_adhoc_tasks(int $timenow, $keepalive = 0, $checklimits = true)
     $taskcount = 0;
 
     // Run all adhoc tasks.
-    while (!\core\task\manager::static_caches_cleared_since($timenow)) {
+    while (!\core\local\cli\shutdown::should_gracefully_exit() &&
+            !\core\task\manager::static_caches_cleared_since($timenow)) {
 
         if ($checklimits && (time() - $timenow) >= $maxruntime) {
             if ($waiting) {
index a80612c..1a7ebc4 100644 (file)
@@ -1845,7 +1845,7 @@ function xmldb_main_upgrade($oldversion) {
                   FROM {event_subscriptions} es
              LEFT JOIN {user} u ON u.id = es.userid
                  WHERE u.deleted = 1 OR u.id IS NULL";
-        $deletedusers = $DB->get_field_sql($sql);
+        $deletedusers = $DB->get_fieldset_sql($sql);
         if ($deletedusers) {
             list($sql, $params) = $DB->get_in_or_equal($deletedusers);
 
@@ -2162,5 +2162,15 @@ function xmldb_main_upgrade($oldversion) {
 
         upgrade_main_savepoint(true, 2020010900.02);
     }
+
+    if ($oldversion < 2020011700.02) {
+        // Delete all orphaned subscription events.
+        $select = "subscriptionid IS NOT NULL
+                   AND subscriptionid NOT IN (SELECT id from {event_subscriptions})";
+        $DB->delete_records_select('event', $select);
+
+        upgrade_main_savepoint(true, 2020011700.02);
+    }
+
     return true;
 }
index 18d80ba..f24e521 100644 (file)
Binary files a/lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor-debug.js and b/lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor-debug.js differ
index 2574ec4..7b7b5be 100644 (file)
Binary files a/lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor-min.js and b/lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor-min.js differ
index fea3573..c373535 100644 (file)
Binary files a/lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor.js and b/lib/editor/atto/yui/build/moodle-editor_atto-editor/moodle-editor_atto-editor.js differ
index 1763547..cfb35e7 100644 (file)
@@ -98,7 +98,7 @@ EditorClean.prototype = {
 
             // Source: "http://www.codinghorror.com/blog/2006/01/cleaning-words-nasty-html.html"
             // Remove forbidden tags for content, title, meta, style, st0-9, head, font, html, body, link.
-            {regex: /<\/?(?:title|meta|style|st\d|head|font|html|body|link)[^>]*?>/gi, replace: ""}
+            {regex: /<\/?(?:title|meta|style|st\d|head\b|font|html|body|link)[^>]*?>/gi, replace: ""}
         ];
 
         return this._filterContentWithRules(content, rules);
index f210a25..a68f222 100644 (file)
@@ -38,7 +38,7 @@ define('TEXTFILTER_DISABLED', -9999);
  *  keys. It must be something rare enough to avoid having matches with
  *  filterobjects. MDL-18165
  */
-define('TEXTFILTER_EXCL_SEPARATOR', '-%-');
+define('TEXTFILTER_EXCL_SEPARATOR', chr(0x1F) . '%' . chr(0x1F));
 
 
 /**
index 6f0fa81..a8ccb0d 100644 (file)
@@ -19,7 +19,7 @@
  */\r
 \r
 /*\r
-    HTML Purifier 4.10.0 - Standards Compliant HTML Filtering\r
+    HTML Purifier 4.12.0 - Standards Compliant HTML Filtering\r
     Copyright (C) 2006-2008 Edward Z. Yang\r
 \r
     This library is free software; you can redistribute it and/or\r
@@ -58,12 +58,12 @@ class HTMLPurifier
      * Version of HTML Purifier.\r
      * @type string\r
      */\r
-    public $version = '4.10.0';\r
+    public $version = '4.12.0';\r
 \r
     /**\r
      * Constant with version of HTML Purifier.\r
      */\r
-    const VERSION = '4.10.0';\r
+    const VERSION = '4.12.0';\r
 \r
     /**\r
      * Global configuration object.\r
@@ -240,12 +240,16 @@ class HTMLPurifier
     public function purifyArray($array_of_html, $config = null)\r
     {\r
         $context_array = array();\r
-        foreach ($array_of_html as $key => $html) {\r
-            $array_of_html[$key] = $this->purify($html, $config);\r
+        foreach($array_of_html as $key=>$value){\r
+            if (is_array($value)) {\r
+                $array[$key] = $this->purifyArray($value, $config);\r
+            } else {\r
+                $array[$key] = $this->purify($value, $config);\r
+            }\r
             $context_array[$key] = $this->context;\r
         }\r
         $this->context = $context_array;\r
-        return $array_of_html;\r
+        return $array;\r
     }\r
 \r
     /**\r
index 953a36a..bf54e8e 100644 (file)
@@ -7,7 +7,7 @@ class HTMLPurifier_AttrDef_HTML_Bool extends HTMLPurifier_AttrDef
 {\r
 \r
     /**\r
-     * @type bool\r
+     * @type string\r
      */\r
     protected $name;\r
 \r
@@ -17,7 +17,7 @@ class HTMLPurifier_AttrDef_HTML_Bool extends HTMLPurifier_AttrDef
     public $minimized = true;\r
 \r
     /**\r
-     * @param bool $name\r
+     * @param bool|string $name\r
      */\r
     public function __construct($name = false)\r
     {\r
index c1e2e3c..a61111e 100644 (file)
@@ -97,7 +97,11 @@ class HTMLPurifier_AttrDef_URI_Host extends HTMLPurifier_AttrDef
 \r
         // PHP 5.3 and later support this functionality natively\r
         if (function_exists('idn_to_ascii')) {\r
-            $string = idn_to_ascii($string, IDNA_NONTRANSITIONAL_TO_ASCII, INTL_IDNA_VARIANT_UTS46);\r
+            if (defined('IDNA_NONTRANSITIONAL_TO_ASCII') && defined('INTL_IDNA_VARIANT_UTS46')) {\r
+                $string = idn_to_ascii($string, IDNA_NONTRANSITIONAL_TO_ASCII, INTL_IDNA_VARIANT_UTS46);\r
+            } else {\r
+                $string = idn_to_ascii($string);\r
+            }\r
 \r
         // If we have Net_IDNA2 support, we can support IRIs by\r
         // punycoding them. (This is the most portable thing to do,\r
index 5e7490e..ca3dcd6 100644 (file)
@@ -220,15 +220,25 @@ class HTMLPurifier_CSSDefinition extends HTMLPurifier_Definition
             array(\r
                 new HTMLPurifier_AttrDef_CSS_Length('0'),\r
                 new HTMLPurifier_AttrDef_CSS_Percentage(true),\r
-                new HTMLPurifier_AttrDef_Enum(array('auto'))\r
+                new HTMLPurifier_AttrDef_Enum(array('auto', 'initial', 'inherit'))\r
+            )\r
+        );\r
+        $trusted_min_wh = new HTMLPurifier_AttrDef_CSS_Composite(\r
+            array(\r
+                new HTMLPurifier_AttrDef_CSS_Length('0'),\r
+                new HTMLPurifier_AttrDef_CSS_Percentage(true),\r
+                new HTMLPurifier_AttrDef_Enum(array('initial', 'inherit'))\r
+            )\r
+        );\r
+        $trusted_max_wh = new HTMLPurifier_AttrDef_CSS_Composite(\r
+            array(\r
+                new HTMLPurifier_AttrDef_CSS_Length('0'),\r
+                new HTMLPurifier_AttrDef_CSS_Percentage(true),\r
+                new HTMLPurifier_AttrDef_Enum(array('none', 'initial', 'inherit'))\r
             )\r
         );\r
         $max = $config->get('CSS.MaxImgLength');\r
 \r
-        $this->info['min-width'] =\r
-        $this->info['max-width'] =\r
-        $this->info['min-height'] =\r
-        $this->info['max-height'] =\r
         $this->info['width'] =\r
         $this->info['height'] =\r
             $max === null ?\r
@@ -245,6 +255,38 @@ class HTMLPurifier_CSSDefinition extends HTMLPurifier_Definition
                     // For everyone else:\r
                     $trusted_wh\r
                 );\r
+        $this->info['min-width'] =\r
+        $this->info['min-height'] =\r
+            $max === null ?\r
+                $trusted_min_wh :\r
+                new HTMLPurifier_AttrDef_Switch(\r
+                    'img',\r
+                    // For img tags:\r
+                    new HTMLPurifier_AttrDef_CSS_Composite(\r
+                        array(\r
+                            new HTMLPurifier_AttrDef_CSS_Length('0', $max),\r
+                            new HTMLPurifier_AttrDef_Enum(array('initial', 'inherit'))\r
+                        )\r
+                    ),\r
+                    // For everyone else:\r
+                    $trusted_min_wh\r
+                );\r
+        $this->info['max-width'] =\r
+        $this->info['max-height'] =\r
+            $max === null ?\r
+                $trusted_max_wh :\r
+                new HTMLPurifier_AttrDef_Switch(\r
+                    'img',\r
+                    // For img tags:\r
+                    new HTMLPurifier_AttrDef_CSS_Composite(\r
+                        array(\r
+                            new HTMLPurifier_AttrDef_CSS_Length('0', $max),\r
+                            new HTMLPurifier_AttrDef_Enum(array('none', 'initial', 'inherit'))\r
+                        )\r
+                    ),\r
+                    // For everyone else:\r
+                    $trusted_max_wh\r
+                );\r
 \r
         $this->info['text-decoration'] = new HTMLPurifier_AttrDef_CSS_TextDecoration();\r
 \r
index 0d89bc2..0a7e09f 100644 (file)
@@ -21,7 +21,7 @@ class HTMLPurifier_Config
      * HTML Purifier's version\r
      * @type string\r
      */\r
-    public $version = '4.10.0';\r
+    public $version = '4.12.0';\r
 \r
     /**\r
      * Whether or not to automatically finalize\r
@@ -890,7 +890,7 @@ class HTMLPurifier_Config
             // zip(tail(trace), trace) -- but PHP is not Haskell har har\r
             for ($i = 0, $c = count($trace); $i < $c - 1; $i++) {\r
                 // XXX this is not correct on some versions of HTML Purifier\r
-                if ($trace[$i + 1]['class'] === 'HTMLPurifier_Config') {\r
+                if (isset($trace[$i + 1]['class']) && $trace[$i + 1]['class'] === 'HTMLPurifier_Config') {\r
                     continue;\r
                 }\r
                 $frame = $trace[$i];\r
index cc8f471..446cdf3 100644 (file)
@@ -100,7 +100,7 @@ class HTMLPurifier_ConfigSchema
      * @param string $key Name of directive\r
      * @param mixed $default Default value of directive\r
      * @param string $type Allowed type of the directive. See\r
-     *      HTMLPurifier_DirectiveDef::$type for allowed values\r
+     *      HTMLPurifier_VarParser::$types for allowed values\r
      * @param bool $allow_null Whether or not to allow null values\r
      */\r
     public function add($key, $default, $type, $allow_null)\r
index 371e948..47bd259 100644 (file)
Binary files a/lib/htmlpurifier/HTMLPurifier/ConfigSchema/schema.ser and b/lib/htmlpurifier/HTMLPurifier/ConfigSchema/schema.ser differ
index 5f355d6..e557ad2 100644 (file)
@@ -6,7 +6,7 @@ DEFAULT: false
 <p>\r
   When enabled, HTML Purifier will treat any elements that contain only\r
   non-breaking spaces as well as regular whitespace as empty, and remove\r
-  them when %AutoForamt.RemoveEmpty is enabled.\r
+  them when %AutoFormat.RemoveEmpty is enabled.\r
 </p>\r
 <p>\r
   See %AutoFormat.RemoveEmpty.RemoveNbsp.Exceptions for a list of elements\r
diff --git a/lib/htmlpurifier/HTMLPurifier/ConfigSchema/schema/Core.AllowParseManyTags.txt b/lib/htmlpurifier/HTMLPurifier/ConfigSchema/schema/Core.AllowParseManyTags.txt
new file mode 100644 (file)
index 0000000..b4b9b10
--- /dev/null
@@ -0,0 +1,12 @@
+Core.AllowParseManyTags\r
+TYPE: bool\r
+DEFAULT: false\r
+VERSION: 4.10.1\r
+--DESCRIPTION--\r
+<p>\r
+    This directive allows parsing of many nested tags.\r
+    If you set true, relaxes any hardcoded limit from the parser.\r
+    However, in that case it may cause a Dos attack.\r
+    Be careful when enabling it.\r
+</p>\r
+--# vim: et sw=4 sts=4\r
index f782398..fc10086 100644 (file)
@@ -3,23 +3,154 @@ TYPE: hash
 VERSION: 2.0.0\r
 --DEFAULT--\r
 array (\r
-  'maroon' => '#800000',\r
-  'red' => '#FF0000',\r
-  'orange' => '#FFA500',\r
-  'yellow' => '#FFFF00',\r
-  'olive' => '#808000',\r
-  'purple' => '#800080',\r
+  'aliceblue' => '#F0F8FF',\r
+  'antiquewhite' => '#FAEBD7',\r
+  'aqua' => '#00FFFF',\r
+  'aquamarine' => '#7FFFD4',\r
+  'azure' => '#F0FFFF',\r
+  'beige' => '#F5F5DC',\r
+  'bisque' => '#FFE4C4',\r
+  'black' => '#000000',\r
+  'blanchedalmond' => '#FFEBCD',\r
+  'blue' => '#0000FF',\r
+  'blueviolet' => '#8A2BE2',\r
+  'brown' => '#A52A2A',\r
+  'burlywood' => '#DEB887',\r
+  'cadetblue' => '#5F9EA0',\r
+  'chartreuse' => '#7FFF00',\r
+  'chocolate' => '#D2691E',\r
+  'coral' => '#FF7F50',\r
+  'cornflowerblue' => '#6495ED',\r
+  'cornsilk' => '#FFF8DC',\r
+  'crimson' => '#DC143C',\r
+  'cyan' => '#00FFFF',\r
+  'darkblue' => '#00008B',\r
+  'darkcyan' => '#008B8B',\r
+  'darkgoldenrod' => '#B8860B',\r
+  'darkgray' => '#A9A9A9',\r
+  'darkgrey' => '#A9A9A9',\r
+  'darkgreen' => '#006400',\r
+  'darkkhaki' => '#BDB76B',\r
+  'darkmagenta' => '#8B008B',\r
+  'darkolivegreen' => '#556B2F',\r
+  'darkorange' => '#FF8C00',\r
+  'darkorchid' => '#9932CC',\r
+  'darkred' => '#8B0000',\r
+  'darksalmon' => '#E9967A',\r
+  'darkseagreen' => '#8FBC8F',\r
+  'darkslateblue' => '#483D8B',\r
+  'darkslategray' => '#2F4F4F',\r
+  'darkslategrey' => '#2F4F4F',\r
+  'darkturquoise' => '#00CED1',\r
+  'darkviolet' => '#9400D3',\r
+  'deeppink' => '#FF1493',\r
+  'deepskyblue' => '#00BFFF',\r
+  'dimgray' => '#696969',\r
+  'dimgrey' => '#696969',\r
+  'dodgerblue' => '#1E90FF',\r
+  'firebrick' => '#B22222',\r
+  'floralwhite' => '#FFFAF0',\r
+  'forestgreen' => '#228B22',\r
   'fuchsia' => '#FF00FF',\r
-  'white' => '#FFFFFF',\r
-  'lime' => '#00FF00',\r
+  'gainsboro' => '#DCDCDC',\r
+  'ghostwhite' => '#F8F8FF',\r
+  'gold' => '#FFD700',\r
+  'goldenrod' => '#DAA520',\r
+  'gray' => '#808080',\r
+  'grey' => '#808080',\r
   'green' => '#008000',\r
+  'greenyellow' => '#ADFF2F',\r
+  'honeydew' => '#F0FFF0',\r
+  'hotpink' => '#FF69B4',\r
+  'indianred' => '#CD5C5C',\r
+  'indigo' => '#4B0082',\r
+  'ivory' => '#FFFFF0',\r
+  'khaki' => '#F0E68C',\r
+  'lavender' => '#E6E6FA',\r
+  'lavenderblush' => '#FFF0F5',\r
+  'lawngreen' => '#7CFC00',\r
+  'lemonchiffon' => '#FFFACD',\r
+  'lightblue' => '#ADD8E6',\r
+  'lightcoral' => '#F08080',\r
+  'lightcyan' => '#E0FFFF',\r
+  'lightgoldenrodyellow' => '#FAFAD2',\r
+  'lightgray' => '#D3D3D3',\r
+  'lightgrey' => '#D3D3D3',\r
+  'lightgreen' => '#90EE90',\r
+  'lightpink' => '#FFB6C1',\r
+  'lightsalmon' => '#FFA07A',\r
+  'lightseagreen' => '#20B2AA',\r
+  'lightskyblue' => '#87CEFA',\r
+  'lightslategray' => '#778899',\r
+  'lightslategrey' => '#778899',\r
+  'lightsteelblue' => '#B0C4DE',\r
+  'lightyellow' => '#FFFFE0',\r
+  'lime' => '#00FF00',\r
+  'limegreen' => '#32CD32',\r
+  'linen' => '#FAF0E6',\r
+  'magenta' => '#FF00FF',\r
+  'maroon' => '#800000',\r
+  'mediumaquamarine' => '#66CDAA',\r
+  'mediumblue' => '#0000CD',\r
+  'mediumorchid' => '#BA55D3',\r
+  'mediumpurple' => '#9370DB',\r
+  'mediumseagreen' => '#3CB371',\r
+  'mediumslateblue' => '#7B68EE',\r
+  'mediumspringgreen' => '#00FA9A',\r
+  'mediumturquoise' => '#48D1CC',\r
+  'mediumvioletred' => '#C71585',\r
+  'midnightblue' => '#191970',\r
+  'mintcream' => '#F5FFFA',\r
+  'mistyrose' => '#FFE4E1',\r
+  'moccasin' => '#FFE4B5',\r
+  'navajowhite' => '#FFDEAD',\r
   'navy' => '#000080',\r
-  'blue' => '#0000FF',\r
-  'aqua' => '#00FFFF',\r
-  'teal' => '#008080',\r
-  'black' => '#000000',\r
+  'oldlace' => '#FDF5E6',\r
+  'olive' => '#808000',\r
+  'olivedrab' => '#6B8E23',\r
+  'orange' => '#FFA500',\r
+  'orangered' => '#FF4500',\r
+  'orchid' => '#DA70D6',\r
+  'palegoldenrod' => '#EEE8AA',\r
+  'palegreen' => '#98FB98',\r
+  'paleturquoise' => '#AFEEEE',\r
+  'palevioletred' => '#DB7093',\r
+  'papayawhip' => '#FFEFD5',\r
+  'peachpuff' => '#FFDAB9',\r
+  'peru' => '#CD853F',\r
+  'pink' => '#FFC0CB',\r
+  'plum' => '#DDA0DD',\r
+  'powderblue' => '#B0E0E6',\r
+  'purple' => '#800080',\r
+  'rebeccapurple' => '#663399',\r
+  'red' => '#FF0000',\r
+  'rosybrown' => '#BC8F8F',\r
+  'royalblue' => '#4169E1',\r
+  'saddlebrown' => '#8B4513',\r
+  'salmon' => '#FA8072',\r
+  'sandybrown' => '#F4A460',\r
+  'seagreen' => '#2E8B57',\r
+  'seashell' => '#FFF5EE',\r
+  'sienna' => '#A0522D',\r
   'silver' => '#C0C0C0',\r
-  'gray' => '#808080',\r
+  'skyblue' => '#87CEEB',\r
+  'slateblue' => '#6A5ACD',\r
+  'slategray' => '#708090',\r
+  'slategrey' => '#708090',\r
+  'snow' => '#FFFAFA',\r
+  'springgreen' => '#00FF7F',\r
+  'steelblue' => '#4682B4',\r
+  'tan' => '#D2B48C',\r
+  'teal' => '#008080',\r
+  'thistle' => '#D8BFD8',\r
+  'tomato' => '#FF6347',\r
+  'turquoise' => '#40E0D0',\r
+  'violet' => '#EE82EE',\r
+  'wheat' => '#F5DEB3',\r
+  'white' => '#FFFFFF',\r
+  'whitesmoke' => '#F5F5F5',\r
+  'yellow' => '#FFFF00',\r
+  'yellowgreen' => '#9ACD32'\r
 )\r
 --DESCRIPTION--\r
 \r
index 41059e5..4e44fad 100644 (file)
@@ -118,7 +118,7 @@ class HTMLPurifier_EntityParser
         $entity = $matches[0];\r
         $hex_part = @$matches[1];\r
         $dec_part = @$matches[2];\r
-        $named_part = empty($matches[3]) ? @$matches[4] : $matches[3];\r
+        $named_part = empty($matches[3]) ? (empty($matches[4]) ? "" : $matches[4]) : $matches[3];\r
         if ($hex_part !== NULL && $hex_part !== "") {\r
             return HTMLPurifier_Encoder::unichr(hexdec($hex_part));\r
         } elseif ($dec_part !== NULL && $dec_part !== "") {\r
index 9877f9e..a94b8ea 100644 (file)
@@ -132,9 +132,9 @@ class HTMLPurifier_HTMLModule
      * @param string $element Name of element to add\r
      * @param string|bool $type What content set should element be registered to?\r
      *              Set as false to skip this step.\r
-     * @param string $contents Allowed children in form of:\r
+     * @param string|HTMLPurifier_ChildDef $contents Allowed children in form of:\r
      *              "$content_model_type: $content_model"\r
-     * @param array $attr_includes What attribute collections to register to\r
+     * @param array|string $attr_includes What attribute collections to register to\r
      *              element?\r
      * @param array $attr What unique attributes does the element define?\r
      * @see HTMLPurifier_ElementDef:: for in-depth descriptions of these parameters.\r
index 6e9113c..22669a6 100644 (file)
@@ -23,13 +23,13 @@ class HTMLPurifier_HTMLModule_SafeScripting extends HTMLPurifier_HTMLModule
         $script = $this->addElement(\r
             'script',\r
             'Inline',\r
-            'Empty',\r
+            'Optional:', // Not `Empty` to not allow to autoclose the <script /> tag @see https://www.w3.org/TR/html4/interact/scripts.html\r
             null,\r
             array(\r
                 // While technically not required by the spec, we're forcing\r
                 // it to this value.\r
                 'type' => 'Enum#text/javascript',\r
-                'src*' => new HTMLPurifier_AttrDef_Enum(array_keys($allowed))\r
+                'src*' => new HTMLPurifier_AttrDef_Enum(array_keys($allowed), /*case sensitive*/ true)\r
             )\r
         );\r
         $script->attr_transform_pre[] =\r
index e6a174a..8328054 100644 (file)
@@ -8,4 +8,6 @@ $messages = array(
     'HTMLPurifier' => 'HTML Purifier X'\r
 );\r
 \r
+$errorNames = array();\r
+\r
 // vim: et sw=4 sts=4\r
index ed8560f..b6d1c99 100644 (file)
@@ -9,4 +9,6 @@ $messages = array(
     'HTMLPurifier' => 'HTML Purifier XNone'\r
 );\r
 \r
+$errorNames = array();\r
+\r
 // vim: et sw=4 sts=4\r
index de35258..ffa167c 100644 (file)
@@ -68,8 +68,18 @@ class HTMLPurifier_Lexer_DOMLex extends HTMLPurifier_Lexer
         $doc = new DOMDocument();\r
         $doc->encoding = 'UTF-8'; // theoretically, the above has this covered\r
 \r
+        $options = 0;\r
+        if ($config->get('Core.AllowParseManyTags') && defined('LIBXML_PARSEHUGE')) {\r
+            $options |= LIBXML_PARSEHUGE;\r
+        }\r
+\r
         set_error_handler(array($this, 'muteErrorHandler'));\r
-        $doc->loadHTML($html);\r
+        // loadHTML() fails on PHP 5.3 when second parameter is given\r
+        if ($options) {\r
+            $doc->loadHTML($html, $options);\r
+        } else {\r
+            $doc->loadHTML($html);\r
+        }\r
         restore_error_handler();\r
 \r
         $body = $doc->getElementsByTagName('html')->item(0)-> // <html>\r
@@ -133,11 +143,11 @@ class HTMLPurifier_Lexer_DOMLex extends HTMLPurifier_Lexer
      */\r
     protected function getTagName($node)\r
     {\r
-        if (property_exists($node, 'tagName')) {\r
+        if (isset($node->tagName)) {\r
             return $node->tagName;\r
-        } else if (property_exists($node, 'nodeName')) {\r
+        } else if (isset($node->nodeName)) {\r
             return $node->nodeName;\r
-        } else if (property_exists($node, 'localName')) {\r
+        } else if (isset($node->localName)) {\r
             return $node->localName;\r
         }\r
         return null;\r
@@ -150,11 +160,11 @@ class HTMLPurifier_Lexer_DOMLex extends HTMLPurifier_Lexer
      */\r
     protected function getData($node)\r
     {\r
-        if (property_exists($node, 'data')) {\r
+        if (isset($node->data)) {\r
             return $node->data;\r
-        } else if (property_exists($node, 'nodeValue')) {\r
+        } else if (isset($node->nodeValue)) {\r
             return $node->nodeValue;\r
-        } else if (property_exists($node, 'textContent')) {\r
+        } else if (isset($node->textContent)) {\r
             return $node->textContent;\r
         }\r
         return null;\r
index 3bc4173..8897002 100644 (file)
@@ -48,7 +48,7 @@ class HTMLPurifier_Printer_ConfigForm extends HTMLPurifier_Printer
         $this->compress = $compress;\r
         // initialize sub-printers\r
         $this->fields[0] = new HTMLPurifier_Printer_ConfigForm_default();\r
-        $this->fields[HTMLPurifier_VarParser::BOOL] = new HTMLPurifier_Printer_ConfigForm_bool();\r
+        $this->fields[HTMLPurifier_VarParser::C_BOOL] = new HTMLPurifier_Printer_ConfigForm_bool();\r
     }\r
 \r
     /**\r
@@ -339,7 +339,7 @@ class HTMLPurifier_Printer_ConfigForm_default extends HTMLPurifier_Printer
                     $value = '';\r
             }\r
         }\r
-        if ($type === HTMLPurifier_VarParser::MIXED) {\r
+        if ($type === HTMLPurifier_VarParser::C_MIXED) {\r
             return 'Not supported';\r
             $value = serialize($value);\r
         }\r
index 4bf7771..6a9ef71 100644 (file)
@@ -7,34 +7,34 @@
 class HTMLPurifier_VarParser\r
 {\r
 \r
-    const STRING = 1;\r
+    const C_STRING = 1;\r
     const ISTRING = 2;\r
     const TEXT = 3;\r
     const ITEXT = 4;\r
-    const INT = 5;\r
-    const FLOAT = 6;\r
-    const BOOL = 7;\r
+    const C_INT = 5;\r
+    const C_FLOAT = 6;\r
+    const C_BOOL = 7;\r
     const LOOKUP = 8;\r
     const ALIST = 9;\r
     const HASH = 10;\r
-    const MIXED = 11;\r
+    const C_MIXED = 11;\r
 \r
     /**\r
      * Lookup table of allowed types. Mainly for backwards compatibility, but\r
      * also convenient for transforming string type names to the integer constants.\r
      */\r
     public static $types = array(\r
-        'string' => self::STRING,\r
+        'string' => self::C_STRING,\r
         'istring' => self::ISTRING,\r
         'text' => self::TEXT,\r
         'itext' => self::ITEXT,\r
-        'int' => self::INT,\r
-        'float' => self::FLOAT,\r
-        'bool' => self::BOOL,\r
+        'int' => self::C_INT,\r
+        'float' => self::C_FLOAT,\r
+        'bool' => self::C_BOOL,\r
         'lookup' => self::LOOKUP,\r
         'list' => self::ALIST,\r
         'hash' => self::HASH,\r
-        'mixed' => self::MIXED\r
+        'mixed' => self::C_MIXED\r
     );\r
 \r
     /**\r
@@ -42,7 +42,7 @@ class HTMLPurifier_VarParser
      * allowed value lists.\r
      */\r
     public static $stringTypes = array(\r
-        self::STRING => true,\r
+        self::C_STRING => true,\r
         self::ISTRING => true,\r
         self::TEXT => true,\r
         self::ITEXT => true,\r
@@ -74,7 +74,7 @@ class HTMLPurifier_VarParser
         // These are basic checks, to make sure nothing horribly wrong\r
         // happened in our implementations.\r
         switch ($type) {\r
-            case (self::STRING):\r
+            case (self::C_STRING):\r
             case (self::ISTRING):\r
             case (self::TEXT):\r
             case (self::ITEXT):\r
@@ -85,17 +85,17 @@ class HTMLPurifier_VarParser
                     $var = strtolower($var);\r
                 }\r
                 return $var;\r
-            case (self::INT):\r
+            case (self::C_INT):\r
                 if (!is_int($var)) {\r
                     break;\r
                 }\r
                 return $var;\r
-            case (self::FLOAT):\r
+            case (self::C_FLOAT):\r
                 if (!is_float($var)) {\r
                     break;\r
                 }\r
                 return $var;\r
-            case (self::BOOL):\r
+            case (self::C_BOOL):\r
                 if (!is_bool($var)) {\r
                     break;\r
                 }\r
@@ -119,7 +119,7 @@ class HTMLPurifier_VarParser
                     }\r
                 }\r
                 return $var;\r
-            case (self::MIXED):\r
+            case (self::C_MIXED):\r
                 return $var;\r
             default:\r
                 $this->errorInconsistent(get_class($this), $type);\r
index b2ed860..4ec648b 100644 (file)
@@ -23,23 +23,23 @@ class HTMLPurifier_VarParser_Flexible extends HTMLPurifier_VarParser
             // Note: if code "breaks" from the switch, it triggers a generic\r
             // exception to be thrown. Specific errors can be specifically\r
             // done here.\r
-            case self::MIXED:\r
+            case self::C_MIXED:\r
             case self::ISTRING:\r
-            case self::STRING:\r
+            case self::C_STRING:\r
             case self::TEXT:\r
             case self::ITEXT:\r
                 return $var;\r
-            case self::INT:\r
+            case self::C_INT:\r
                 if (is_string($var) && ctype_digit($var)) {\r
                     $var = (int)$var;\r
                 }\r
                 return $var;\r
-            case self::FLOAT:\r
+            case self::C_FLOAT:\r
                 if ((is_string($var) && is_numeric($var)) || is_int($var)) {\r
                     $var = (float)$var;\r
                 }\r
                 return $var;\r
-            case self::BOOL:\r
+            case self::C_BOOL:\r
                 if (is_int($var) && ($var === 0 || $var === 1)) {\r
                     $var = (bool)$var;\r
                 } elseif (is_string($var)) {\r
index 1195367..6935b70 100644 (file)
@@ -1,23 +1,20 @@
-Description of HTML Purifier v4.10.0 library import into Moodle
+Description of HTML Purifier v4.12.0 library import into Moodle
 
 * Make new (or delete contents of) /lib/htmlpurifier/
 * Copy everything from /library/ folder to /lib/htmlpurifier/
 * Copy CREDITS, LICENSE from root folder to /lib/htmlpurifier/
 * Delete unused files:
     HTMLPurifier.auto.php
-    HTMLPurifier.func.php
-    HTMLPurifier.kses.php
     HTMLPurifier.autoload.php
+    HTMLPurifier.autoload-legacy.php
     HTMLPurifier.composer.php
+    HTMLPurifier.func.php
     HTMLPurifier.includes.php
+    HTMLPurifier.kses.php
     HTMLPurifier.path.php
-    HTMLPurifier.autoload-legacy.php
 * add locallib.php with Moodle specific extensions to /lib/htmlpurifier/
 * add this readme_moodle.txt to /lib/htmlpurifier/
 
 Modifications:
 (verify if we need to apply them on every upgrade, remove when not needed)
-* MDL-60337 use correct IDN variant for converting domain names to ascii
-  Check status of https://github.com/ezyang/htmlpurifier/pull/148
-* MDL-67030 applied https://github.com/ezyang/htmlpurifier/pull/224 towards
-  php74 compatibility.
+(No modification was needed)
\ No newline at end of file
index 1b69c4a..4c6d4b9 100644 (file)
@@ -4250,7 +4250,10 @@ function delete_user(stdClass $user) {
 
     // Now do a brute force cleanup.
 
-    // Remove user's calendar subscriptions.
+    // Delete all user events and subscription events.
+    $DB->delete_records_select('event', 'userid = :userid AND subscriptionid IS NOT NULL', ['userid' => $user->id]);
+
+    // Now, delete all calendar subscription from the user.
     $DB->delete_records('event_subscriptions', ['userid' => $user->id]);
 
     // Remove from all cohorts.
@@ -9509,7 +9512,7 @@ function get_performance_info() {
                     $mode = ' <span title="request cache">[r]</span>';
                     break;
             }
-            $html .= '<ul class="cache-definition-stats list-unstyled ml-1 mb-1 cache-mode-'.$modeclass.' card d-inline-block">';
+            $html .= '<li class="d-inline-flex"><ul class="cache-definition-stats list-unstyled ml-1 mb-1 cache-mode-'.$modeclass.' card d-inline-block">';
             $html .= '<li class="cache-definition-stats-heading p-t-1 card-header bg-dark bg-inverse font-weight-bold">' .
                 $definition . $mode.'</li>';
             $text .= "$definition {";
@@ -9532,7 +9535,7 @@ function get_performance_info() {
                     $html .= "<li class=\"cache-store-stats $cachestoreclass p-x-1\">&nbsp;</li>";
                 }
             }
-            $html .= '</ul>';
+            $html .= '</ul></li>';
             $text .= '} ';
         }
         $html .= '</ul> ';
index e37a08e..cd31d05 100644 (file)
@@ -1052,7 +1052,7 @@ foreach ($pluginswithfunction as $plugins) {
     foreach ($plugins as $function) {
         try {
             $function();
-        } catch (Exception $e) {
+        } catch (Throwable $e) {
             debugging("Exception calling '$function'", DEBUG_DEVELOPER, $e->getTrace());
         }
     }
index 6bdfb85..e3830b7 100644 (file)
@@ -1018,11 +1018,7 @@ EOF;
             // Using the spin method as we want a reduced timeout but there is no need for a 0.1 seconds interval
             // because in the optimistic case we will timeout.
             // If all goes good it will throw an ElementNotFoundExceptionn that we will catch.
-            $this->spin(
-                function($context, $args) use ($selectortype, $element) {
-                    return $this->find($selectortype, $element);
-                }, [], behat_base::get_reduced_timeout(), $exception, false
-            );
+            return $this->find($selectortype, $element, $exception, false, behat_base::get_reduced_timeout());
         } catch (ElementNotFoundException $e) {
             // We expect the element to not be found.
             return;
index b7fd57b..9064aa2 100644 (file)
@@ -39,7 +39,7 @@
     <location>htmlpurifier</location>
     <name>HTML Purifier</name>
     <license>LGPL</license>
-    <version>4.10.0</version>
+    <version>4.12.0</version>
     <licenseversion>2.1+</licenseversion>
   </library>
   <library>
index 1317ee5..aca97e2 100644 (file)
Binary files a/message/output/popup/amd/build/notification_popover_controller.min.js and b/message/output/popup/amd/build/notification_popover_controller.min.js differ
index 4d7f64f..9ccabb5 100644 (file)
Binary files a/message/output/popup/amd/build/notification_popover_controller.min.js.map and b/message/output/popup/amd/build/notification_popover_controller.min.js.map differ
index 8a6b04f..c1e44e6 100644 (file)
@@ -226,9 +226,7 @@ define(['jquery', 'core/ajax', 'core/templates', 'core/str', 'core/url',
             var notificationurlparams = {
                 notificationid: notification.id
             };
-            if (notification.contexturl) {
-                notificationurlparams.redirecturl = encodeURIComponent(notification.contexturl);
-            }
+
             notification.contexturl = URL.relativeUrl('message/output/popup/mark_notification_read.php', notificationurlparams);
 
             var promise = Templates.render('message_popup/notification_content_item', notification)
index 0458678..9a8515a 100644 (file)
@@ -31,11 +31,13 @@ if (isguestuser()) {
 }
 
 $notificationid = required_param('notificationid', PARAM_INT);
-$redirecturl = optional_param('redirecturl', '', PARAM_URL);
+
 $notification = $DB->get_record('notifications', array('id' => $notificationid));
 
 // If the redirect URL after filtering is empty, or it was never passed, then redirect to the notification page.
-if (empty($redirecturl)) {
+if (!empty($notification->contexturl)) {
+    $redirecturl = new moodle_url($notification->contexturl);
+} else {
     $redirecturl = new moodle_url('/message/output/popup/notifications.php', ['notificationid' => $notificationid]);
 }
 
@@ -45,4 +47,4 @@ if ($USER->id != $notification->useridto) {
 }
 
 \core_message\api::mark_notification_as_read($notification);
-redirect(new moodle_url($redirecturl));
+redirect($redirecturl);
index 14c8961..306759f 100644 (file)
@@ -37,7 +37,7 @@
     <div class="d-flex align-items-center">
         <div class="input-group">
             <div class="input-group-prepend">
-                <span class="input-group-text pr-0 bg-white">
+                <span class="input-group-text pr-2 bg-white">
                     {{#pix}} a/search, core {{/pix}}
                 </span>
             </div>
index 9865378..f359f34 100644 (file)
 
     <h3 class="mb-2 mt-4 h6 font-weight-bold">{{#str}} general, core {{/str}}</h3>
     <div data-preference="entertosend">
-        <span class="switch">
-            <input type="checkbox"
-                id="enter-to-send-{{uniqid}}"
-                {{#entertosend}}checked{{/entertosend}}
-            >
-            <label for="enter-to-send-{{uniqid}}">
+        <div class="custom-control custom-switch">
+            <input type="checkbox" class="custom-control-input" id="enter-to-send-{{uniqid}}" {{#entertosend}}checked{{/entertosend}}>
+            <label class="custom-control-label" for="enter-to-send-{{uniqid}}">
                 {{#str}} useentertosend, core_message {{/str}}
             </label>
-        </span>
+        </div>
     </div>
 </div>
 {{/settings}}
\ No newline at end of file
index 19a866b..8d84c8c 100644 (file)
 
 <div data-preference="notifications" class="d-flex flex-column">
     {{#processors}}
-        <span class="switch">
-            <input type="checkbox"
-                id="{{name}}-{{uniqid}}"
+
+        <div class="custom-control custom-switch">
+            <input type="checkbox" class="custom-control-input" id="{{name}}-{{uniqid}}"
                 data-name="{{name}}"
                 {{#checked}}checked{{/checked}}
                 {{#locked}}disabled{{/locked}}
             >
-            <label for="{{name}}-{{uniqid}}">{{displayname}}{{#locked}} ({{lockedmessage}}){{/locked}}</label>
-        </span>
+            <label class="custom-control-label" for="{{name}}-{{uniqid}}">
+                {{displayname}}{{#locked}} ({{lockedmessage}}){{/locked}}
+            </label>
+        </div>
     {{/processors}}
 </div>
index 730af05..6b58d4d 100644 (file)
Binary files a/mod/forum/amd/build/discussion_list.min.js and b/mod/forum/amd/build/discussion_list.min.js differ
index 8da1ac0..c53d199 100644 (file)
Binary files a/mod/forum/amd/build/discussion_list.min.js.map and b/mod/forum/amd/build/discussion_list.min.js.map differ
index 1fb1bcd..1f9477d 100644 (file)
@@ -133,7 +133,7 @@ define([
                 var stringKey = context.userstate.subscribed ? 'unsubscribediscussion' : 'subscribediscussion';
                 return Str.get_string(stringKey, 'mod_forum')
                     .then(function(string) {
-                        toggleElement.closest('td').find('label[for="' + toggleId + '"]').text(string);
+                        toggleElement.closest('td').find('label[for="' + toggleId + '"]').find('span').text(string);
                         return string;
                     });
             });
index c55f84e..b5304b1 100644 (file)
@@ -61,6 +61,8 @@ class capability {
     private $forumrecord;
     /** @var context $context Module context for the forum */
     private $context;
+    /** @var array $canviewpostcache Cache of discussion posts that can be viewed by a user. */
+    protected $canviewpostcache = [];
 
     /**
      * Constructor.
@@ -361,12 +363,23 @@ class capability {
             return false;
         }
 
+        // Return cached can view if possible.
+        if (isset($this->canviewpostcache[$user->id][$post->get_id()])) {
+            return $this->canviewpostcache[$user->id][$post->get_id()];
+        }
+
+        // Otherwise, check if the user can see this post.
         $forum = $this->get_forum();
         $forumrecord = $this->get_forum_record();
         $discussionrecord = $this->get_discussion_record($discussion);
         $postrecord = $this->get_post_record($post);
         $coursemodule = $forum->get_course_module_record();
-        return forum_user_can_see_post($forumrecord, $discussionrecord, $postrecord, $user, $coursemodule, false);
+        $canviewpost = forum_user_can_see_post($forumrecord, $discussionrecord, $postrecord, $user, $coursemodule, false);
+
+        // Then cache the result before returning.
+        $this->canviewpostcache[$user->id][$post->get_id()] = $canviewpost;
+
+        return $canviewpost;
     }
 
     /**
@@ -520,6 +533,9 @@ class capability {
         if ($post->is_private_reply()) {
             // It is not possible to reply to a private reply.
             return false;
+        } else if (!$this->can_view_post($user, $discussion, $post)) {
+            // If the user cannot view the post in the first place, the user should not be able to reply to the post.
+            return false;
         }
 
         return $this->can_post_in_discussion($user, $discussion);
index de85487..bc9c91a 100644 (file)
@@ -546,8 +546,16 @@ function forum_user_complete($course, $user, $mod, $forum) {
     };
 
     $grades = grade_get_grades($course->id, 'mod', 'forum', $forum->id, $user->id);
-    echo $getgradeinfo($grades->items[0]->grades, 'rating');
-    echo $getgradeinfo($grades->items[1]->grades, 'wholeforum');
+
+    // Item 0 is the rating.
+    if (!empty($grades->items[0]->grades)) {
+        echo $getgradeinfo($grades->items[0]->grades, 'rating');
+    }
+
+    // Item 1 is the whole-forum grade.
+    if (!empty($grades->items[1]->grades)) {
+        echo $getgradeinfo($grades->items[1]->grades, 'wholeforum');
+    }
 
     if ($posts = forum_get_user_posts($forum->id, $user->id)) {
         if (!$cm = get_coursemodule_from_instance('forum', $forum->id, $course->id)) {
@@ -632,13 +640,22 @@ function forum_print_overview() {
  * @return bool success
  */
 function forum_print_recent_activity($course, $viewfullnames, $timestart) {
-    global $CFG, $USER, $DB, $OUTPUT;
+    global $USER, $DB, $OUTPUT;
 
     // do not use log table if possible, it may be huge and is expensive to join with other tables
 
     $allnamefields = user_picture::fields('u', null, 'duserid');
-    if (!$posts = $DB->get_records_sql("SELECT p.*, f.type AS forumtype, d.forum, d.groupid,
-                                              d.timestart, d.timeend, $allnamefields
+    if (!$posts = $DB->get_records_sql("SELECT p.*,
+                                              f.course, f.type AS forumtype, f.name AS forumname, f.intro, f.introformat, f.duedate,
+                                              f.cutoffdate, f.assessed AS forumassessed, f.assesstimestart, f.assesstimefinish,
+                                              f.scale, f.grade_forum, f.maxbytes, f.maxattachments, f.forcesubscribe,
+                                              f.trackingtype, f.rsstype, f.rssarticles, f.timemodified, f.warnafter, f.blockafter,
+                                              f.blockperiod, f.completiondiscussions, f.completionreplies, f.completionposts,
+                                              f.displaywordcount, f.lockdiscussionafter, f.grade_forum_notify,
+                                              d.name AS discussionname, d.firstpost, d.userid AS discussionstarter,
+                                              d.assessed AS discussionassessed, d.timemodified, d.usermodified, d.forum, d.groupid,
+                                              d.timestart, d.timeend, d.pinned, d.timelocked,
+                                              $allnamefields
                                          FROM {forum_posts} p
                                               JOIN {forum_discussions} d ON d.id = p.discussion
                                               JOIN {forum} f             ON f.id = d.forum
@@ -650,12 +667,14 @@ function forum_print_recent_activity($course, $viewfullnames, $timestart) {
 
     $modinfo = get_fast_modinfo($course);
 
-    $groupmodes = array();
-    $cms    = array();
-
     $strftimerecent = get_string('strftimerecent');
 
-    $printposts = array();
+    $managerfactory = mod_forum\local\container::get_manager_factory();
+    $entityfactory = mod_forum\local\container::get_entity_factory();
+
+    $discussions = [];
+    $capmanagers = [];
+    $printposts = [];
     foreach ($posts as $post) {
         if (!isset($modinfo->instances['forum'][$post->forum])) {
             // not visible
@@ -665,28 +684,81 @@ function forum_print_recent_activity($course, $viewfullnames, $timestart) {
         if (!$cm->uservisible) {
             continue;
         }
-        $context = context_module::instance($cm->id);
 
-        if (!has_capability('mod/forum:viewdiscussion', $context)) {
-            continue;
+        // Get the discussion. Cache if not yet available.
+        if (!isset($discussions[$post->discussion])) {
+            // Build the discussion record object from the post data.
+            $discussionrecord = (object)[
+                'id' => $post->discussion,
+                'course' => $post->course,
+                'forum' => $post->forum,
+                'name' => $post->discussionname,
+                'firstpost' => $post->firstpost,
+                'userid' => $post->discussionstarter,
+                'groupid' => $post->groupid,
+                'assessed' => $post->discussionassessed,
+                'timemodified' => $post->timemodified,
+                'usermodified' => $post->usermodified,
+                'timestart' => $post->timestart,
+                'timeend' => $post->timeend,
+                'pinned' => $post->pinned,
+                'timelocked' => $post->timelocked
+            ];
+            // Build the discussion entity from the factory and cache it.
+            $discussions[$post->discussion] = $entityfactory->get_discussion_from_stdclass($discussionrecord);
         }
+        $discussionentity = $discussions[$post->discussion];
 
-        if (!empty($CFG->forum_enabletimedposts) and $USER->id != $post->duserid
-          and (($post->timestart > 0 and $post->timestart > time()) or ($post->timeend > 0 and $post->timeend < time()))) {
-            if (!has_capability('mod/forum:viewhiddentimedposts', $context)) {
-                continue;
-            }
+        // Get the capability manager. Cache if not yet available.
+        if (!isset($capmanagers[$post->forum])) {
+            $context = context_module::instance($cm->id);
+            $coursemodule = $cm->get_course_module_record();
+            // Build the forum record object from the post data.
+            $forumrecord = (object)[
+                'id' => $post->forum,
+                'course' => $post->course,
+                'type' => $post->forumtype,
+                'name' => $post->forumname,
+                'intro' => $post->intro,
+                'introformat' => $post->introformat,
+                'duedate' => $post->duedate,
+                'cutoffdate' => $post->cutoffdate,
+                'assessed' => $post->forumassessed,
+                'assesstimestart' => $post->assesstimestart,
+                'assesstimefinish' => $post->assesstimefinish,
+                'scale' => $post->scale,
+                'grade_forum' => $post->grade_forum,
+                'maxbytes' => $post->maxbytes,
+                'maxattachments' => $post->maxattachments,
+                'forcesubscribe' => $post->forcesubscribe,
+                'trackingtype' => $post->trackingtype,
+                'rsstype' => $post->rsstype,
+                'rssarticles' => $post->rssarticles,
+                'timemodified' => $post->timemodified,
+                'warnafter' => $post->warnafter,
+                'blockafter' => $post->blockafter,
+                'blockperiod' => $post->blockperiod,
+                'completiondiscussions' => $post->completiondiscussions,
+                'completionreplies' => $post->completionreplies,
+                'completionposts' => $post->completionposts,
+                'displaywordcount' => $post->displaywordcount,
+                'lockdiscussionafter' => $post->lockdiscussionafter,
+                'grade_forum_notify' => $post->grade_forum_notify
+            ];
+            // Build the forum entity from the factory.
+            $forumentity = $entityfactory->get_forum_from_stdclass($forumrecord, $context, $coursemodule, $course);
+            // Get the capability manager of this forum and cache it.
+            $capmanagers[$post->forum] = $managerfactory->get_capability_manager($forumentity);
         }
+        $capabilitymanager = $capmanagers[$post->forum];
 
-        if (!forum_post_is_visible_privately($post, $cm)) {
-            continue;
-        }
+        // Get the post entity.
+        $postentity = $entityfactory->get_post_from_stdclass($post);
 
-        // Check that the user can see the discussion.
-        if (forum_is_user_group_discussion($cm, $post->groupid)) {
+        // Check if the user can view the post.
+        if ($capabilitymanager->can_view_post($USER, $discussionentity, $postentity)) {
             $printposts[] = $post;
         }
-
     }
     unset($posts);
 
diff --git a/mod/forum/report/summary/tests/behat/summary_filter_groups.feature b/mod/forum/report/summary/tests/behat/summary_filter_groups.feature
new file mode 100644 (file)
index 0000000..3fa3448
--- /dev/null
@@ -0,0 +1,223 @@
+@mod @mod_forum @forumreport @forumreport_summary
+Feature: Groups report filter is available if groups exist
+  In order to retrieve targeted forum data
+  As a teacher
+  I can filter the forum summary report by groups of users
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | teacher1 | Teacher   | 1        | teacher1@example.com |
+      | student1 | Student   | 1        | student1@example.com |
+      | student2 | Student   | 2        | student1@example.com |
+    And the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1        | 0        |
+      | Course 2 | C2        | 0        |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | teacher1 | C1     | editingteacher |
+      | student1 | C1     | student        |
+      | student2 | C1     | student        |
+      | teacher1 | C2     | editingteacher |
+    And the following "groups" exist:
+      | name    | course | idnumber |
+      | Group A | C1     | G1       |
+      | Group B | C1     | G2       |
+      | Group C | C1     | G3       |
+      | Group D | C1     | G4       |
+      | Group E | C2     | G5       |
+    And the following "group members" exist:
+      | user     | group |
+      | teacher1 | G1    |
+      | teacher1 | G2    |
+      | teacher1 | G3    |
+      | student1 | G3    |
+    And the following "activities" exist:
+      | activity | name   | description     | course | idnumber | groupmode |
+      | forum    | forum1 | C1 first forum  | C1     | forum1   | 1         |
+      | forum    | forum2 | C1 second forum | C1     | forum2   | 2         |
+      | forum    | forum1 | C2 first forum  | C2     | forum1   | 0         |
+    And the following forum discussions exist in course "Course 1":
+      | user     | forum  | name        | message    | group            | created           |
+      | teacher1 | forum1 | discussion1 | D1 message | G1               | ## 1 month ago ## |
+      | teacher1 | forum1 | discussion2 | D2 message | G2               | ## 1 week ago ##  |
+      | teacher1 | forum2 | discussion3 | D3 message | G1               | ## 6 days ago ##  |
+      | teacher1 | forum2 | discussion4 | D4 message | G4               | ## 5 days ago ##  |
+      | teacher1 | forum2 | discussion5 | D5 message | All participants | ## 4 days ago ##  |
+      | student1 | forum1 | discussion6 | D6 message | G3               | ## 3 days ago ##  |
+      | student2 | forum2 | discussion7 | D7 message | All participants | ## 2 days ago ##  |
+    And the following forum replies exist in course "Course 1":
+      | user     | forum  | discussion  | message    | created           |
+      | teacher1 | forum1 | discussion1 | D1 reply   | ## 3 weeks ago ## |
+      | teacher1 | forum2 | discussion3 | D3 reply   | ## 4 days ago ##  |
+      | teacher1 | forum1 | discussion6 | D6 reply   | ## 2 days ago ##  |
+      | student1 | forum1 | discussion6 | D6 reply 2 | ## 2 days ago ##  |
+      | student2 | forum2 | discussion4 | D4 reply   | ## 4 days ago ##  |
+      | student2 | forum2 | discussion5 | D5 reply   | ## 3 days ago ##  |
+    And the following forum discussions exist in course "Course 2":
+      | user     | forum  | name        | message         | created          |
+      | teacher1 | forum1 | discussion1 | D1 other course | ## 1 week ago ## |
+      | teacher1 | forum1 | discussion2 | D2 other course | ## 4 days ago ## |
+
+  @javascript
+  Scenario: All groups can be selected or cleared together in the groups filter, and are unchecked by default
+    When I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I follow "forum1"
+    And I navigate to "Summary report" in current page administration
+    Then "Groups" "button" should exist
+    And the following should exist in the "forumreport_summary_table" table:
+    # |                      | Discussions | Replies |
+      | First name / Surname | -3-         | -4-     |
+      | Student 1            | 1           | 1       |
+      | Student 2            | 0           | 0       |
+      | Teacher 1            | 2           | 2       |
+    And I click on "Groups" "button"
+    And "Group A" "checkbox" should exist in the "filter-groups-popover" "region"
+    And "Group B" "checkbox" should exist in the "filter-groups-popover" "region"
+    And "Group C" "checkbox" should exist in the "filter-groups-popover" "region"
+    And "Group D" "checkbox" should exist in the "filter-groups-popover" "region"
+    And "No groups" "checkbox" should exist in the "filter-groups-popover" "region"
+    And "Group E" "checkbox" should not exist in the "filter-groups-popover" "region"
+    And the following fields match these values:
+      | Group A   | 0 |
+      | Group B   | 0 |
+      | Group C   | 0 |
+      | Group D   | 0 |
+      | No groups | 0 |
+    And I click on "Select all" "button" in the "filter-groups-popover" "region"
+    And the following fields match these values:
+      | Group A   | 1 |
+      | Group B   | 1 |
+      | Group C   | 1 |
+      | Group D   | 1 |
+      | No groups | 1 |
+    And I click on "Clear" "button" in the "filter-groups-popover" "region"
+    And the following fields match these values:
+      | Group A   | 0 |
+      | Group B   | 0 |
+      | Group C   | 0 |
+      | Group D   | 0 |
+      | No groups | 0 |
+    And I click on "Select all" "button" in the "filter-groups-popover" "region"
+    And I click on "Save" "button" in the "filter-groups-popover" "region"
+    And "Groups (all)" "button" should exist
+    And the following should exist in the "forumreport_summary_table" table:
+    # |                      | Discussions | Replies |
+      | First name / Surname | -3-         | -4-     |
+      | Student 1            | 1           | 1       |
+      | Student 2            | 0           | 0       |
+      | Teacher 1            | 2           | 2       |
+
+  @javascript
+  Scenario: The summary report can be filtered by a subset of groups, and re-ordering the results retains the filter
+    When I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I follow "forum1"
+    And I navigate to "Summary report" in current page administration
+    Then "Groups" "button" should exist
+    And the following should exist in the "forumreport_summary_table" table:
+    # |                      | Discussions | Replies |
+      | First name / Surname | -3-         | -4-     |
+      | Student 1            | 1           | 1       |
+      | Student 2            | 0           | 0       |
+      | Teacher 1            | 2           | 2       |
+    And I click on "Groups" "button"
+    And I click on "Clear" "button" in the "filter-groups-popover" "region"
+    And I click on "Group A" "checkbox" in the "filter-groups-popover" "region"
+    And I click on "Group C" "checkbox" in the "filter-groups-popover" "region"
+    And I click on "Group D" "checkbox" in the "filter-groups-popover" "region"
+    And I click on "Save" "button"
+    And "Groups (3)" "button" should exist
+    And the following should exist in the "forumreport_summary_table" table:
+    # |                      | Discussions | Replies |
+      | First name / Surname | -3-         | -4-     |
+      | Student 1            | 1           | 1       |
+      | Teacher 1            | 1           | 2       |
+    And I should not see "Student 2"
+    # Ensure re-ordering retains filter.
+    And I click on "Number of discussions posted" "link"
+    And "Groups (3)" "button" should exist
+    And the following should exist in the "forumreport_summary_table" table:
+    # |                      | Discussions | Replies |
+      | First name / Surname | -3-         | -4-     |
+      | Student 1            | 1           | 1       |
+      | Teacher 1            | 1           | 2       |
+    And I should not see "Student 2"
+
+  @javascript
+  Scenario: The summary report can be filtered as a mixture of groups and no groups
+    When I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I follow "forum2"
+    And I navigate to "Summary report" in current page administration
+    Then "Groups" "button" should exist
+    And the following should exist in the "forumreport_summary_table" table:
+    # |                      | Discussions | Replies |
+      | First name / Surname | -3-         | -4-     |
+      | Student 1            | 0           | 0       |
+      | Student 2            | 1           | 2       |
+      | Teacher 1            | 3           | 1       |
+    And I click on "Groups" "button"
+    And I click on "Clear" "button" in the "filter-groups-popover" "region"
+    And I click on "Group A" "checkbox" in the "filter-groups-popover" "region"
+    And I click on "No groups" "checkbox" in the "filter-groups-popover" "region"
+    And I click on "Save" "button" in the "filter-groups-popover" "region"
+    And "Groups (2)" "button" should exist
+    And the following should exist in the "forumreport_summary_table" table:
+    # |                      | Discussions | Replies |
+      | First name / Surname | -3-         | -4-     |
+      | Student 1            | 0           | 0       |
+      | Student 2            | 1           | 1       |
+      | Teacher 1            | 2           | 1       |
+
+  @javascript
+  Scenario: The summary report can be filtered by no groups only
+    When I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I follow "forum2"
+    And I navigate to "Summary report" in current page administration
+    Then the following should exist in the "forumreport_summary_table" table:
+    # |                      | Discussions | Replies |
+      | First name / Surname | -3-         | -4-     |
+      | Student 1            | 0           | 0       |
+      | Student 2            | 1           | 2       |
+      | Teacher 1            | 3           | 1       |
+    And I click on "Groups" "button"
+    And I click on "Clear" "button" in the "filter-groups-popover" "region"
+    And I click on "No groups" "checkbox" in the "filter-groups-popover" "region"
+    And I click on "Save" "button" in the "filter-groups-popover" "region"
+    And "Groups (1)" "button" should exist
+    And the following should exist in the "forumreport_summary_table" table:
+    # |                      | Discussions | Replies |
+      | First name / Surname | -3-         | -4-     |
+      | Student 1            | 0           | 0       |
+      | Student 2            | 1           | 1       |
+      | Teacher 1            | 1           | 0       |
+
+  @javascript
+  Scenario: Filtering by a group containing no users still allows the page to render
+    # Log in as admin so Teacher 1 not existing on page can be confirmed.
+    When I log in as "admin"
+    And I am on "Course 1" course homepage
+    And I follow "forum1"
+    And I navigate to "Summary report" in current page administration
+    Then "Groups" "button" should exist
+    And the following should exist in the "forumreport_summary_table" table:
+    # |                      | Discussions | Replies |
+      | First name / Surname | -3-         | -4-     |
+      | Student 1            | 1           | 1       |
+      | Student 2            | 0           | 0       |
+      | Teacher 1            | 2           | 2       |
+    And I click on "Groups" "button"
+    And I click on "Clear" "button" in the "filter-groups-popover" "region"
+    And I click on "Group D" "checkbox" in the "filter-groups-popover" "region"
+    And I click on "Save" "button" in the "filter-groups-popover" "region"
+    And "Groups (1)" "button" should exist
+    And I should see "Nothing to display"
+    And I should not see "Teacher 1"
+    And I should not see "Student 1"
+    And I should not see "Student 2"
+    And I should not see "With selected users..."
+    And I should not see "Download table data as"
diff --git a/mod/forum/report/summary/tests/behat/summary_filter_no_groups.feature b/mod/forum/report/summary/tests/behat/summary_filter_no_groups.feature
new file mode 100644 (file)
index 0000000..bd010fc
--- /dev/null
@@ -0,0 +1,62 @@
+@mod @mod_forum @forumreport @forumreport_summary
+Feature: Groups report filter is not available if no groups exist
+  When no groups exist
+  As a teacher
+  I can view the forum summary report for all users of a forum
+
+  Scenario: Report data is available without groups filter if no groups exist
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | teacher1 | Teacher   | 1        | teacher1@example.com |
+      | student1 | Student   | 1        | student1@example.com |
+      | student2 | Student   | 2        | student1@example.com |
+    And the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1        | 0        |
+      | Course 2 | C2        | 0        |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | teacher1 | C1     | editingteacher |
+      | student1 | C1     | student        |
+      | student2 | C1     | student        |
+      | teacher1 | C2     | editingteacher |
+    And the following "groups" exist:
+      | name    | course | idnumber |
+      | Group A | C2     | G1       |
+    And the following "group members" exist:
+      | user     | group |
+      | teacher1 | G1    |
+    And the following "activities" exist:
+      | activity | name   | description     | course | idnumber | groupmode |
+      | forum    | forum1 | C1 first forum  | C1     | forum1   | 0         |
+      | forum    | forum2 | C1 second forum | C1     | forum2   | 0         |
+      | forum    | forum1 | C2 first forum  | C2     | forum1   | 2         |
+    And the following forum discussions exist in course "Course 1":
+      | user     | forum  | name        | message    | created           |
+      | teacher1 | forum1 | discussion1 | D1 message | ## 1 month ago ## |
+      | teacher1 | forum1 | discussion2 | D2 message | ## 1 week ago ##  |
+      | teacher1 | forum2 | discussion3 | D3 message | ## 4 days ago ##  |
+      | student1 | forum1 | discussion4 | D4 message | ## 3 days ago ##  |
+      | student2 | forum2 | discussion5 | D5 message | ## 2 days ago##   |
+    And the following forum replies exist in course "Course 1":
+      | user     | forum  | discussion  | message    | created           |
+      | teacher1 | forum1 | discussion1 | D1 reply   | ## 3 weeks ago ## |
+      | teacher1 | forum1 | discussion2 | D2 reply   | ## 6 days ago ##  |
+      | teacher1 | forum2 | discussion3 | D3 reply   | ## 3 days ago ##  |
+      | student1 | forum1 | discussion1 | D1 reply 2 | ## 2 weeks ago ## |
+      | student2 | forum2 | discussion3 | D3 reply   | ## 2 days ago ##  |
+    And the following forum discussions exist in course "Course 2":
+      | user     | forum  | name        | message         | created          |
+      | teacher1 | forum1 | discussion1 | D1 other course | ## 1 week ago ## |
+      | teacher1 | forum1 | discussion2 | D2 other course | ## 4 days ago ## |
+    When I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I follow "forum1"
+    And I navigate to "Summary report" in current page administration
+    Then "Groups" "button" should not exist
+    And the following should exist in the "forumreport_summary_table" table:
+    # |                      | Discussions |
+      | First name / Surname | -3-         |
+      | Teacher 1            | 2           |
+      | Student 1            | 1           |
+      | Student 2            | 0           |
index b72498f..50cc04d 100644 (file)
                             {{#forum.capabilities.subscribe}}
                                 <td class="text-center align-middle fit-content px-2">
                                     {{#discussion}}
-                                        <span class="switch sr-only-label">
+                                        <div class="d-inline custom-control custom-switch mb-1">
                                             <input
                                                 type="checkbox"
+                                                class="custom-control-input"
                                                 id="subscription-toggle-{{id}}"
                                                 data-type="subscription-toggle"
                                                 data-action="toggle"
                                                 data-discussionid="{{id}}"
                                                 data-forumid="{{forumid}}"
                                                 {{#userstate.subscribed}}data-targetstate="0" checked{{/userstate.subscribed}}
-                                                {{^userstate.subscribed}}data-targetstate="1"{{/userstate.subscribed}}
-                                            >
-                                            <label for="subscription-toggle-{{id}}">
-                                                {{#userstate.subscribed}}
-                                                    {{#str}}unsubscribediscussion, forum{{/str}}
-                                                {{/userstate.subscribed}}
-                                                {{^userstate.subscribed}}
-                                                    {{#str}}subscribediscussion, forum{{/str}}
-                                                {{/userstate.subscribed}}
+                                                {{^userstate.subscribed}}data-targetstate="1"{{/userstate.subscribed}}>
+                                            <label class="custom-control-label" for="subscription-toggle-{{id}}">
+                                                <span class="sr-only">
+                                                    {{#userstate.subscribed}}
+                                                        {{#str}}unsubscribediscussion, forum{{/str}}
+                                                    {{/userstate.subscribed}}
+                                                    {{^userstate.subscribed}}
+                                                        {{#str}}subscribediscussion, forum{{/str}}
+                                                    {{/userstate.subscribed}}
+                                                </span>
                                             </label>
-                                        </span>
+                                        </div>
                                     {{/discussion}}
                                 </td>
                             {{/forum.capabilities.subscribe}}
index b94c83d..4794aa3 100644 (file)
                     </button>
                     {{#canreplyprivately}}
                     <div class="form-check form-check-inline">
-                        <span class="switch">
-                            <input name="privatereply" type="checkbox" id="private-reply-checkbox-{{uniqid}}">
-                            <label class="mb-0" for="private-reply-checkbox-{{uniqid}}">
+                        <div class="custom-control custom-switch">
+                            <input name="privatereply" type="checkbox" class="custom-control-input" id="private-reply-checkbox-{{uniqid}}">
+                            <label class="custom-control-label" for="private-reply-checkbox-{{uniqid}}">
                                 {{#str}} privatereply, forum {{/str}}
                             </label>
-                        </span>
+                        </div>
                     </div>
                     {{/canreplyprivately}}
                     <button
index 35d283b..20f50ab 100644 (file)
 
 }}
 <div>
-    <span class="switch">
-        <input type="checkbox" id="{{$switchid}}{{uniqid}}{{/switchid}}"
-               data-type="{{$type}}toggle-switch{{/type}}"
-               data-action="toggle" class="hidden"
-            {{$otherattributes}}{{/otherattributes}}/>
-        <label for="{{$switchid}}{{uniqid}}{{/switchid}}" class="line-height-4">
+    <div class="custom-control custom-switch mb-1">
+        <input type="checkbox" class="custom-control-input" id="{{$switchid}}{{uniqid}}{{/switchid}}"
+            data-type="{{$type}}toggle-switch{{/type}}" data-action="toggle"
+            {{$otherattributes}}{{/otherattributes}}>
+        <label class="custom-control-label" for="{{$switchid}}{{uniqid}}{{/switchid}}">
             {{$labeltext}}{{/labeltext}}
         </label>
-    </span>
+    </div>
 </div>
index 6dfdb7e..58937ce 100644 (file)
@@ -49,6 +49,17 @@ class behat_mod_forum extends behat_base {
         $this->add_new_discussion($forumname, $table, get_string('addanewtopic', 'forum'));
     }
 
+    /**
+     * Adds a Q&A discussion to the Q&A-type forum specified by it's name with the provided table data.
+     *
+     * @Given /^I add a new question to "(?P<forum_name_string>(?:[^"]|\\")*)" forum with:$/
+     * @param string $forumname
+     * @param TableNode $table
+     */
+    public function i_add_a_new_question_to_forum_with($forumname, TableNode $table) {
+        $this->add_new_discussion($forumname, $table, get_string('addanewquestion', 'forum'));
+    }
+
     /**
      * Adds a discussion to the forum specified by it's name with the provided table data (usually Subject and Message). The step begins from the forum's course page.
      *
@@ -166,6 +177,12 @@ class behat_mod_forum extends behat_base {
                 $cm = get_coursemodule_from_instance('forum', $discussioninfo['forum']);
             }
 
+            // Prepare data for groups if needed.
+            if (!empty($discussioninfo['group'])) {
+                $discussioninfo['groupid'] = $this->get_group_id($courseid, $discussioninfo['group']);
+                unset($discussioninfo['group']);
+            }
+
             // Create the discussion post.
             $discussion = $forumgenerator->create_discussion($discussioninfo);
             $postid = $DB->get_field('forum_posts', 'id', ['discussion' => $discussion->id]);
@@ -368,6 +385,33 @@ class behat_mod_forum extends behat_base {
         return $forumid;
     }
 
+    /**
+     * Fetch Group ID using group name.
+     *
+     * @param int $courseid The course ID the forum exists within.
+     * @param string $groupname The short name of the group.
+     * @return int The group ID.
+     * @throws Exception
+     */
+    protected function get_group_id(int $courseid, string $groupname): int {
+        global $DB;
+
+        if ($groupname === 'All participants') {
+            return -1;
+        }
+
+        $conditions = [
+            'courseid' => $courseid,
+            'idnumber' => $groupname,
+        ];
+
+        if (!$groupid = $DB->get_field('groups', 'id', $conditions)) {
+            throw new Exception("A group with name '{$groupname}' does not exist in the provided course");
+        }
+
+        return $groupid;
+    }
+
     /**
      * Fetch discussion ID and first post ID by discussion name.
      *
index e193928..212b724 100644 (file)
@@ -37,15 +37,15 @@ Feature: A user can control their own subscription preferences for a discussion
     Then I can subscribe to this forum
     And "Subscribe to this discussion" "checkbox" should exist in the "Test post subject one" "table_row"
     And "Subscribe to this discussion" "checkbox" should exist in the "Test post subject two" "table_row"
-    And I click on "label[for^=subscription-toggle]" "css_element" in the "Test post subject one" "table_row"
+    And I click on "input[id^=subscription-toggle]" "css_element" in the "Test post subject one" "table_row"
     And I can subscribe to this forum
     And "Unsubscribe from this discussion" "checkbox" should exist in the "Test post subject one" "table_row"
     And "Subscribe to this discussion" "checkbox" should exist in the "Test post subject two" "table_row"
-    And I click on "label[for^=subscription-toggle]" "css_element" in the "Test post subject one" "table_row"
+    And I click on "input[id^=subscription-toggle]" "css_element" in the "Test post subject one" "table_row"
     And I can subscribe to this forum
     And "Subscribe to this discussion" "checkbox" should exist in the "Test post subject one" "table_row"
     And "Subscribe to this discussion" "checkbox" should exist in the "Test post subject two" "table_row"
-    And I click on "label[for^=subscription-toggle]" "css_element" in the "Test post subject one" "table_row"
+    And I click on "input[id^=subscription-toggle]" "css_element" in the "Test post subject one" "table_row"
     And I can subscribe to this forum
     And "Unsubscribe from this discussion" "checkbox" should exist in the "Test post subject one" "table_row"
     And "Subscribe to this discussion" "checkbox" should exist in the "Test post subject two" "table_row"
@@ -80,15 +80,15 @@ Feature: A user can control their own subscription preferences for a discussion
     Then I can unsubscribe from this forum
     And "Unsubscribe from this discussion" "checkbox" should exist in the "Test post subject one" "table_row"
     And "Unsubscribe from this discussion" "checkbox" should exist in the "Test post subject two" "table_row"
-    And I click on "label[for^=subscription-toggle]" "css_element" in the "Test post subject one" "table_row"
+    And I click on "input[id^=subscription-toggle]" "css_element" in the "Test post subject one" "table_row"
     And I can unsubscribe from this forum
     And "Subscribe to this discussion" "checkbox" should exist in the "Test post subject one" "table_row"
     And "Unsubscribe from this discussion" "checkbox" should exist in the "Test post subject two" "table_row"
-    And I click on "label[for^=subscription-toggle]" "css_element" in the "Test post subject one" "table_row"
+    And I click on "input[id^=subscription-toggle]" "css_element" in the "Test post subject one" "table_row"
     And I can unsubscribe from this forum
     And "Unsubscribe from this discussion" "checkbox" should exist in the "Test post subject one" "table_row"
     And "Unsubscribe from this discussion" "checkbox" should exist in the "Test post subject two" "table_row"
-    And I click on "label[for^=subscription-toggle]" "css_element" in the "Test post subject one" "table_row"
+    And I click on "input[id^=subscription-toggle]" "css_element" in the "Test post subject one" "table_row"
     And I can unsubscribe from this forum
     And "Subscribe to this discussion" "checkbox" should exist in the "Test post subject one" "table_row"
     And "Unsubscribe from this discussion" "checkbox" should exist in the "Test post subject two" "table_row"
@@ -123,7 +123,7 @@ Feature: A user can control their own subscription preferences for a discussion
     And I can subscribe to this forum
     And "Subscribe to this discussion" "checkbox" should exist in the "Test post subject one" "table_row"
     And "Subscribe to this discussion" "checkbox" should exist in the "Test post subject two" "table_row"
-    And I click on "label[for^=subscription-toggle]" "css_element" in the "Test post subject one" "table_row"
+    And I click on "input[id^=subscription-toggle]" "css_element" in the "Test post subject one" "table_row"
     And I can subscribe to this forum
     And "Unsubscribe from this discussion" "checkbox" should exist in the "Test post subject one" "table_row"
     And "Subscribe to this discussion" "checkbox" should exist in the "Test post subject two" "table_row"
@@ -168,7 +168,7 @@ Feature: A user can control their own subscription preferences for a discussion
     And I can subscribe to this forum
     And "Subscribe to this discussion" "checkbox" should exist in the "Test post subject one" "table_row"
     And "Subscribe to this discussion" "checkbox" should exist in the "Test post subject two" "table_row"
-    And I click on "label[for^=subscription-toggle]" "css_element" in the "Test post subject one" "table_row"
+    And I click on "input[id^=subscription-toggle]" "css_element" in the "Test post subject one" "table_row"
     And I can subscribe to this forum
     And "Unsubscribe from this discussion" "checkbox" should exist in the "Test post subject one" "table_row"
     And "Subscribe to this discussion" "checkbox" should exist in the "Test post subject two" "table_row"
index 7a44ae7..e257fa4 100644 (file)
@@ -40,7 +40,7 @@ Feature: Students can edit or delete their forum posts within a set time limit
     And I press "Continue"
     Then I should not see "Forum post subject"
 
-  @javascript @block_recent_activity
+  @block_recent_activity
   Scenario: Time limit expires
     Given I log out
     And I log in as "admin"
index af266b3..637e8b3 100644 (file)
@@ -123,7 +123,7 @@ Feature: A user can control their default discussion subscription settings
     And I log in as "student1"
     And I am on "Course 1" course homepage
     And I follow "Test forum name"
-    And I click on "label[for^=subscription-toggle]" "css_element" in the "Test post subject" "table_row"
+    And I click on "input[id^=subscription-toggle]" "css_element" in the "Test post subject" "table_row"
     And I follow "Test post subject"
     When I follow "Reply"
     And I click on "Advanced" "button"
@@ -132,7 +132,7 @@ Feature: A user can control their default discussion subscription settings
     And I log in as "student2"
     And I am on "Course 1" course homepage
     And I follow "Test forum name"
-    And I click on "label[for^=subscription-toggle]" "css_element" in the "Test post subject" "table_row"
+    And I click on "input[id^=subscription-toggle]" "css_element" in the "Test post subject" "table_row"
     And I follow "Test post subject"
     And I follow "Reply"
     And I click on "Advanced" "button"
diff --git a/mod/forum/tests/behat/recent_activity.feature b/mod/forum/tests/behat/recent_activity.feature
new file mode 100644 (file)
index 0000000..382e2f0
--- /dev/null
@@ -0,0 +1,141 @@
+@mod @mod_forum @block_recent_activity
+Feature: Users can see the relevant recent forum posts from the recent activity block
+  In order to quickly see the updates from forums in my course
+  As a user
+  I need to be able to see the recent forum posts in the recent activity block
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname  | lastname  | email                 |
+      | student1 | Student    | 1         | student1@example.com  |
+      | student2 | Student    | 2         | student2@example.com  |
+      | teacher1 | Teacher    | 1         | teacher1@example.com  |
+    And the following "courses" exist:
+      | fullname | shortname  | category  |
+      | Course 1 | C1         | 0         |
+    And the following "course enrolments" exist:
+      | user      | course  | role            |
+      | student1  | C1      | student         |
+      | student2  | C1      | student         |
+      | teacher1  | C1      | editingteacher  |
+    And the following "groups" exist:
+      | name    | course  | idnumber  |
+      | Group 1 | C1      | G1        |
+      | Group 2 | C1      | G2        |
+    And the following "group members" exist:
+      | user     | group  |
+      | student1 | G1     |
+      | student2 | G2     |
+      | teacher1 | G1     |
+      | teacher1 | G2     |
+    And the following "activities" exist:
+      | activity   | name                   | intro                       | course  | idnumber  | type    | groupmode | visible |
+      | forum      | Separate groups forum  | Separate groups description | C1      | forum1    | general | 1         | 1       |
+      | forum      | Visible groups forum   | Visible groups description  | C1      | forum2    | general | 2         | 1       |
+      | forum      | Standard forum         | Standard description        | C1      | forum3    | general | 0         | 1       |
+      | forum      | Hidden forum           | Hidden description          | C1      | forum4    | general | 0         | 0       |
+      | forum      | Q&A forum              | Q&A description             | C1      | forum5    | qanda   | 0         | 1       |
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+    And I add the "Recent activity" block
+
+  Scenario: Recent forum activity with separate group discussion
+    Given I add a new discussion to "Separate groups forum" forum with:
+      | Subject | Group 1 separate discussion |
+      | Message | Group 1 members only        |
+      | Group   | Group 1                     |
+    And I log out
+    And I log in as "student1"
+    When I am on "Course 1" course homepage
+    Then I should see "Group 1 separate discussion" in the "Recent activity" "block"
+    And I log out
+    And I log in as "student2"
+    And I am on "Course 1" course homepage
+    And I should not see "Group 1 separate discussion" in the "Recent activity" "block"
+
+  Scenario: Recent forum activity with visible groups discussion
+    Given I add a new discussion to "Visible groups forum" forum with:
+      | Subject | Group 1 visible discussion    |
+      | Message | Not just for group 1 members  |
+      | Group   | Group 1                       |
+    And I log out
+    And I log in as "student1"
+    When I am on "Course 1" course homepage
+    Then I should see "Group 1 visible discussion" in the "Recent activity" "block"
+    And I log out
+    And I log in as "student2"
+    And I am on "Course 1" course homepage
+    And I should see "Group 1 visible discussion" in the "Recent activity" "block"
+
+  Scenario: Recent forum activity with recent post as a private reply
+    Given I add a new discussion to "Standard forum" forum with:
+      | Subject | Standard forum discussion             |
+      | Message | Discuss anything under the sun here!  |
+    And I reply "Standard forum discussion" post from "Standard forum" forum with:
+      | Subject         | Teacher's private reply |
+      | Message         | This is a private reply |
+      | Reply privately | 1                       |
+    And I am on "Course 1" course homepage
+    And I should see "Standard forum discussion" in the "Recent activity" "block"
+    And I should see "Teacher's private reply" in the "Recent activity" "block"
+    And I log out
+    When I log in as "student1"
+    And I am on "Course 1" course homepage
+    Then I should see "Standard forum discussion" in the "Recent activity" "block"
+    But I should not see "Teacher's private reply" in the "Recent activity" "block"
+
+  Scenario: Recent forum activity with recent post in a hidden forum
+    Given I add a new discussion to "Hidden forum" forum with:
+      | Subject | Hidden discussion |
+      | Message | Should be hidden! |
+    And I am on "Course 1" course homepage
+    And I should see "Hidden discussion" in the "Recent activity" "block"
+    And I log out
+    And I log in as "student1"
+    When I am on "Course 1" course homepage
+    Then I should not see "Hidden discussion" in the "Recent activity" "block"
+
+  Scenario: Recent forum activity with question and answer forum
+    Given I add a new question to "Q&A forum" forum with:
+      | Subject | The egg vs the chicken                    |
+      | Message | Which came first? The egg or the chicken? |
+    And I log out
+    And I log in as "student1"
+    And I am on "Course 1" course homepage
+    And I reply "The egg vs the chicken" post from "Q&A forum" forum with:
+      | Subject | Student 1's answer  |
+      | Message | The egg!            |
+    And I am on "Course 1" course homepage
+    And I should see "The egg vs the chicken" in the "Recent activity" "block"
+    And I should see "Student 1's answer" in the "Recent activity" "block"
+    And I log out
+    And I log in as "admin"
+    And the following config values are set as admin:
+      | maxeditingtime | 1 |
+    And I log out
+    When I log in as "student2"
+    And I am on "Course 1" course homepage
+    Then I should see "The egg vs the chicken" in the "Recent activity" "block"
+    But I should not see "Student 1's answer" in the "Recent activity" "block"
+    And I reply "The egg vs the chicken" post from "Q&A forum" forum with:
+      | Subject | Student 2's answer  |
+      | Message | The chicken, duh!   |
+    And I wait "2" seconds
+    And I am on "Course 1" course homepage
+    And I should see "Student 1's answer" in the "Recent activity" "block"
+    And I should see "Student 2's answer" in the "Recent activity" "block"
+
+  Scenario: Recent forum activity with timed discussion
+    Given I add a new discussion to "Standard forum" forum with:
+      | Subject           | Timed discussion                                  |
+      | Message           | Discuss anything under the sun here... no more!!! |
+      | timeend[enabled]  | 1                                                 |
+      | timeend[year]     | 2020                                              |
+      | timeend[month]    | 1                                                 |
+      | timeend[day]      | 1                                                 |
+    And I am on "Course 1" course homepage
+    And I should see "Timed discussion" in the "Recent activity" "block"
+    And I log out
+    When I log in as "student1"
+    And I am on "Course 1" course homepage
+    Then I should not see "Timed discussion" in the "Recent activity" "block"
index 3c1e68e..d0064df 100644 (file)
@@ -27,6 +27,7 @@ defined('MOODLE_INTERNAL') || die();
 global $CFG;
 require_once(__DIR__ . '/generator_trait.php');
 
+use mod_forum\local\entities\forum;
 use mod_forum\local\managers\capability as capability_manager;
 
 /**
@@ -122,7 +123,7 @@ class mod_forum_managers_capability_testcase extends advanced_testcase {
      * Helper function to create a forum entity.
      *
      * @param array $forumproperties List of properties to override the prebuilt forum
-     * @return forum_entity
+     * @return forum
      */
     private function create_forum(array $forumproperties = []) {
         $forumrecord = (object) array_merge((array) $this->forumrecord, $forumproperties);
@@ -941,6 +942,52 @@ class mod_forum_managers_capability_testcase extends advanced_testcase {
         $this->assertFalse($capabilitymanager->can_reply_to_post($user, $discussion, $post));
     }
 
+    /**
+     * Test for \mod_forum\local\managers\capability::can_reply_to_post() involving Q & A forums.
+     */
+    public function test_can_reply_to_post_in_qanda_forum() {
+        global $CFG;
+
+        $this->resetAfterTest();
+
+        // Set max editing time to 10 seconds.
+        $CFG->maxeditingtime = 10;
+
+        $qandaforum = $this->create_forum(['type' => 'qanda']);
+        $datagenerator = $this->getDataGenerator();
+        $capabilitymanager = $this->managerfactory->get_capability_manager($qandaforum);
+
+        // Student 1.
+        $student1 = $datagenerator->create_user(['firstname' => 'S1']);
+        $datagenerator->enrol_user($student1->id, $this->course->id, 'student');
+        // Confirm Student 1 can reply to the question.
+        $this->assertTrue($capabilitymanager->can_reply_to_post($student1, $this->discussion, $this->post));
+
+        // Student 2.
+        $student2 = $datagenerator->create_user(['firstname' => 'S2']);
+        $datagenerator->enrol_user($student2->id, $this->course->id, 'student');
+        // Confirm Student 2 can reply to the question.
+        $this->assertTrue($capabilitymanager->can_reply_to_post($student2, $this->discussion, $this->post));
+
+        // Reply to the question as student 1.
+        $now = time();
+        $options = ['parent' => $this->post->get_id(), 'created' => $now - 100];
+        $student1post = $this->helper_post_to_discussion($this->forumrecord, $this->discussionrecord, $student1, $options);
+        $student1postentity = $this->entityfactory->get_post_from_stdclass($student1post);
+
+        // Confirm Student 2 cannot reply student 1's answer yet.
+        $this->assertFalse($capabilitymanager->can_reply_to_post($student2, $this->discussion, $student1postentity));
+
+        // Reply to the question as student 2.
+        $this->helper_post_to_discussion($this->forumrecord, $this->discussionrecord, $student2, $options);
+
+        // Reinitialise capability manager first to ensure we don't return cached values.
+        $capabilitymanager = $this->managerfactory->get_capability_manager($qandaforum);
+
+        // Confirm Student 2 can now reply to student 1's answer.
+        $this->assertTrue($capabilitymanager->can_reply_to_post($student2, $this->discussion, $student1postentity));
+    }
+
     /**
      * Ensure that can_reply_privately_to_post works as expected.
      *
index 0d693a0..906b27f 100644 (file)
@@ -4,7 +4,6 @@ Feature: A teacher can set whether glossary entries are always editable or not
   As a teacher
   I need to prevent entries to be always editable
 
-  @javascript
   Scenario: Glossary entries are not always editable
     Given the following "users" exist:
       | username | firstname | lastname | email |
@@ -18,7 +17,7 @@ Feature: A teacher can set whether glossary entries are always editable or not
       | teacher1 | C1 | editingteacher |
       | student1 | C1 | student |
     And the following config values are set as admin:
-      | maxeditingtime | 60 |
+      | maxeditingtime | 5 |
     And I log in as "teacher1"
     And I am on "Course 1" course homepage with editing mode on
     And I add a "Glossary" to section "1" and I fill the form with:
@@ -34,7 +33,7 @@ Feature: A teacher can set whether glossary entries are always editable or not
       | Definition | Test concept description |
     Then "Delete entry: Test concept name" "link" should exist
     And "Edit entry: Test concept name" "link" should exist
-    And I wait "65" seconds
+    And I wait "6" seconds
     And I reload the page
-    Then "Delete entry: Test concept name" "link" should not exist
+    And "Delete entry: Test concept name" "link" should not exist
     And "Edit entry: Test concept name" "link" should not exist
index f699c47..6722495 100644 (file)
@@ -91,6 +91,28 @@ $capabilities = array(
         )
     ),
 
+    // The ability to a preconfigured instance to the course.
+    'mod/lti:addpreconfiguredinstance' => array(
+        'captype' => 'write',
+        'contextlevel' => CONTEXT_COURSE,
+        'archetypes' => array(
+            'editingteacher' => CAP_ALLOW,
+            'manager' => CAP_ALLOW
+        ),
+        'clonepermissionsfrom' => 'mod/lti:addinstance',
+    ),
+
+    // The ability to add a manual instance (i.e. not from a preconfigured tool) to the course.
+    'mod/lti:addmanualinstance' => array(
+        'captype' => 'write',
+        'contextlevel' => CONTEXT_COURSE,
+        'archetypes' => array(
+            'editingteacher' => CAP_ALLOW,
+            'manager' => CAP_ALLOW
+        ),
+        'clonepermissionsfrom' => 'mod/lti:addinstance',
+    ),
+
     // The ability to request the administrator to configure a particular
     // External tool globally.
     'mod/lti:requesttooladd' => array(
index e9c8a2b..906f364 100644 (file)
@@ -210,7 +210,7 @@ In addition, all web service requests from the tool provider will use SSL.
 
 If using this option, confirm that this Moodle site and the tool provider support SSL.';
 $string['generaltool'] = 'General tool';
-$string['global_tool_types'] = 'Global preconfigured tools';
+$string['global_tool_types'] = 'Preconfigured tools';
 $string['grading'] = 'Grade routing';
 $string['icon_url'] = 'Icon URL';
 $string['icon_url_help'] = 'The icon URL allows the icon that shows up in the course listing for this activity to be modified. Instead of using the default
@@ -254,8 +254,10 @@ real estate to the tool, and others provide a more integrated feel with the Mood
 $string['launchoptions'] = 'Launch options';
 $string['leaveblank'] = 'Leave blank if you do not need them';
 $string['lti'] = 'LTI';
-$string['lti:addinstance'] = 'Add a new external tool';
 $string['lti:addcoursetool'] = 'Add course-specific tool configurations';
+$string['lti:addmanualinstance'] = 'Add a manually-configured tool';
+$string['lti:addinstance'] = 'Add a new external tool';
+$string['lti:addpreconfiguredinstance'] = 'Add a preconfigured tool';
 $string['lti:grade'] = 'View grades returned by the external tool';
 $string['lti:manage'] = 'Be an Instructor when the tool is launched';
 $string['lti:admin'] = 'Be an administrator when the tool is launched';
index 0d98e57..e450cd6 100644 (file)
@@ -219,7 +219,9 @@ function lti_get_shortcuts($defaultitem) {
     require_once($CFG->dirroot.'/mod/lti/locallib.php');
 
     $types = lti_get_configured_types($COURSE->id, $defaultitem->link->param('sr'));
-    $types[] = $defaultitem;
+    if (has_capability('mod/lti:addmanualinstance', context_course::instance($COURSE->id))) {
+        $types[] = $defaultitem;
+    }
 
     // Add items defined in ltisource plugins.
     foreach (core_component::get_plugin_list('ltisource') as $pluginname => $dir) {
index ec9bd2f..c69d3ab 100644 (file)
@@ -2129,10 +2129,21 @@ function lti_get_lti_types_by_course($courseid, $coursevisible = null) {
     }
 
     list($coursevisiblesql, $coursevisparams) = $DB->get_in_or_equal($coursevisible, SQL_PARAMS_NAMED, 'coursevisible');
+    $courseconds = [];
+    if (has_capability('mod/lti:addmanualinstance', context_course::instance($courseid))) {
+        $courseconds[] = "course = :courseid";
+    }
+    if (has_capability('mod/lti:addpreconfiguredinstance', context_course::instance($courseid))) {
+        $courseconds[] = "course = :siteid";
+    }
+    if (!$courseconds) {
+        return [];
+    }
+    $coursecond = implode(" OR ", $courseconds);
     $query = "SELECT *
                 FROM {lti_types}
                WHERE coursevisible $coursevisiblesql
-                 AND (course = :siteid OR course = :courseid)
+                 AND ($coursecond)
                  AND state = :active";
 
     return $DB->get_records_sql($query,
@@ -2149,7 +2160,9 @@ function lti_get_types_for_add_instance() {
     $admintypes = lti_get_lti_types_by_course($COURSE->id);
 
     $types = array();
-    $types[0] = (object)array('name' => get_string('automatic', 'lti'), 'course' => 0, 'toolproxyid' => null);
+    if (has_capability('mod/lti:addmanualinstance', context_course::instance($COURSE->id))) {
+        $types[0] = (object)array('name' => get_string('automatic', 'lti'), 'course' => 0, 'toolproxyid' => null);
+    }
 
     foreach ($admintypes as $type) {
         $types[$type->id] = $type;
index f341ccc..2b21157 100644 (file)
             this.urlCache = {};
             this.toolTypeCache = {};
 
-            this.addOptGroups();
-
             var updateToolMatches = function(){
                 self.updateAutomaticToolMatch(Y.one('#id_toolurl'));
                 self.updateAutomaticToolMatch(Y.one('#id_securetoolurl'));
             };
 
             var typeSelector = Y.one('#id_typeid');
-            typeSelector.on('change', function(e){
-                // Reset configuration fields when another preconfigured tool is selected.
-                self.resetToolFields();
+            if (typeSelector) {
+                this.addOptGroups();
 
-                updateToolMatches();
+                typeSelector.on('change', function(e){
+                    // Reset configuration fields when another preconfigured tool is selected.
+                    self.resetToolFields();
 
-                self.toggleEditButtons();
+                    updateToolMatches();
 
-                if (self.getSelectedToolTypeOption().getAttribute('toolproxy')){
-                    var allowname = Y.one('#id_instructorchoicesendname');
-                    allowname.set('checked', !self.getSelectedToolTypeOption().getAttribute('noname'));
+                    self.toggleEditButtons();
 
-                    var allowemail = Y.one('#id_instructorchoicesendemailaddr');
-                    allowemail.set('checked', !self.getSelectedToolTypeOption().getAttribute('noemail'));
+                    if (self.getSelectedToolTypeOption().getAttribute('toolproxy')){
+                        var allowname = Y.one('#id_instructorchoicesendname');
+                        allowname.set('checked', !self.getSelectedToolTypeOption().getAttribute('noname'));
 
-                    var allowgrades = Y.one('#id_instructorchoiceacceptgrades');
-                    allowgrades.set('checked', !self.getSelectedToolTypeOption().getAttribute('nogrades'));
-                    self.toggleGradeSection();
-                }
-            });
+                        var allowemail = Y.one('#id_instructorchoicesendemailaddr');
+                        allowemail.set('checked', !self.getSelectedToolTypeOption().getAttribute('noemail'));
 
-            var contentItemButton = Y.one('[name="selectcontent"]');
-            var contentItemUrl = contentItemButton.getAttribute('data-contentitemurl');
-            // Handle configure from link button click.
-            contentItemButton.on('click', function() {
-                var contentItemId = self.getContentItemId();
-                if (contentItemId) {
-                    // Get activity name and description values.
-                    var title = Y.one('#id_name').get('value').trim();
-                    var text = Y.one('#id_introeditor').get('value').trim();
-
-                    // Set data to be POSTed.
-                    var postData = {
-                        id: contentItemId,
-                        course: self.settings.courseId,
-                        title: title,
-                        text: text
-                    };
+                        var allowgrades = Y.one('#id_instructorchoiceacceptgrades');
+                        allowgrades.set('checked', !self.getSelectedToolTypeOption().getAttribute('nogrades'));
+                        self.toggleGradeSection();
+                    }
+                });
 
-                    require(['mod_lti/contentitem'], function(contentitem) {
-                        contentitem.init(contentItemUrl, postData, function() {
-                            M.mod_lti.editor.toggleGradeSection();
-                        });
-                    });
-                }
-            });
+                this.createTypeEditorButtons();
 
-            this.createTypeEditorButtons();
+                this.toggleEditButtons();
+            }
 
-            this.toggleEditButtons();
+            var contentItemButton = Y.one('[name="selectcontent"]');
+            if (contentItemButton) {
+                var contentItemUrl = contentItemButton.getAttribute('data-contentitemurl');
+                // Handle configure from link button click.
+                contentItemButton.on('click', function() {
+                    var contentItemId = self.getContentItemId();
+                    if (contentItemId) {
+                        // Get activity name and description values.
+                        var title = Y.one('#id_name').get('value').trim();
+                        var text = Y.one('#id_introeditor').get('value').trim();
+
+                        // Set data to be POSTed.
+                        var postData = {
+                            id: contentItemId,
+                            course: self.settings.courseId,
+                            title: title,
+                            text: text
+                        };
+
+                        require(['mod_lti/contentitem'], function(contentitem) {
+                            contentitem.init(contentItemUrl, postData, function() {
+                                M.mod_lti.editor.toggleGradeSection();
+                            });
+                        });
+                    }
+                });
+            }
 
             var textAreas = new Y.NodeList([
                 Y.one('#id_toolurl'),
             var allowgrades = Y.one('#id_instructorchoiceacceptgrades');
             allowgrades.on('change', this.toggleGradeSection, this);
 
-            updateToolMatches();
+            if (typeSelector) {
+                updateToolMatches();
+            }
         },
 
         toggleGradeSection: function(e) {
         },
 
         updateAutomaticToolMatch: function(field){
+            if (!field) {
+                return;
+            }
+
             var self = this;
 
             var toolurl = field;
          * @returns {number|boolean} The ID of the tool type if it supports Content-Item selection. False, otherwise.
          */
         getContentItemId: function() {
-            var selected = this.getSelectedToolTypeOption();
-            if (selected.getAttribute('data-contentitem')) {
-                return selected.getAttribute('data-id');
+            try {
+                var selected = this.getSelectedToolTypeOption();
+                if (selected.getAttribute('data-contentitem')) {
+                    return selected.getAttribute('data-id');
+                }
+                return false;
+            } catch (err) {
+                // Tool selector not available - check for hidden fields instead.
+                var content = Y.one('input[name="contentitem"]');
+                if (!content || !content.get('value')) {
+                    return false;
+                }
+                return Y.one('input[name="typeid"]').get('value');
             }
-            return false;
         },
 
         /**
index bc3ba4e..8eb9fac 100644 (file)
@@ -60,6 +60,23 @@ class mod_lti_mod_form extends moodleform_mod {
             component_callback("ltisource_$type", 'add_instance_hook');
         }
 
+        // Type ID parameter being passed when adding an preconfigured tool from activity chooser.
+        $typeid = optional_param('typeid', false, PARAM_INT);
+
+        // Show configuration details only if not preset (when new) or user has the capabilities to do so (when editing).
+        if ($this->_instance) {
+            $showtypes = has_capability('mod/lti:addpreconfiguredinstance', $this->context);
+            $showoptions = has_capability('mod/lti:addmanualinstance', $this->context);
+            if (!$showoptions && $this->current->typeid == 0) {
+                // If you cannot add a manual instance and this is already a manual instance, then
+                // remove the 'types' selector.
+                $showtypes = false;
+            }
+        } else {
+            $showtypes = !$typeid;
+            $showoptions = !$typeid && has_capability('mod/lti:addmanualinstance', $this->context);
+        }
+
         $this->typeid = 0;
 
         $mform =& $this->_form;
@@ -95,51 +112,63 @@ class mod_lti_mod_form extends moodleform_mod {
         $mform->addHelpButton('showdescriptionlaunch', 'display_description', 'lti');
 
         // Tool settings.
-        $tooltypes = $mform->addElement('select', 'typeid', get_string('external_tool_type', 'lti'));
-        // Type ID parameter being passed when adding an preconfigured tool from activity chooser.
-        $typeid = optional_param('typeid', false, PARAM_INT);
-        if ($typeid) {
-            $mform->getElement('typeid')->setValue($typeid);
-        }
-        $mform->addHelpButton('typeid', 'external_tool_type', 'lti');
         $toolproxy = array();
-
         // Array of tool type IDs that don't support ContentItemSelectionRequest.
         $noncontentitemtypes = [];
 
-        foreach (lti_get_types_for_add_instance() as $id => $type) {
-            if (!empty($type->toolproxyid)) {
-                $toolproxy[] = $type->id;
-                $attributes = array( 'globalTool' => 1, 'toolproxy' => 1);
-                $enabledcapabilities = explode("\n", $type->enabledcapability);
-                if (!in_array('Result.autocreate', $enabledcapabilities) || in_array('BasicOutcome.url', $enabledcapabilities)) {
-                    $attributes['nogrades'] = 1;
-                }
-                if (!in_array('Person.name.full', $enabledcapabilities) && !in_array('Person.name.family', $enabledcapabilities) &&
-                    !in_array('Person.name.given', $enabledcapabilities)) {
-                    $attributes['noname'] = 1;
+        if ($showtypes) {
+            $tooltypes = $mform->addElement('select', 'typeid', get_string('external_tool_type', 'lti'));
+            if ($typeid) {
+                $mform->getElement('typeid')->setValue($typeid);
+            }
+            $mform->addHelpButton('typeid', 'external_tool_type', 'lti');
+
+            foreach (lti_get_types_for_add_instance() as $id => $type) {
+                if (!empty($type->toolproxyid)) {
+                    $toolproxy[] = $type->id;
+                    $attributes = array('globalTool' => 1, 'toolproxy' => 1);
+                    $enabledcapabilities = explode("\n", $type->enabledcapability);
+                    if (!in_array('Result.autocreate', $enabledcapabilities) ||
+                        in_array('BasicOutcome.url', $enabledcapabilities)) {
+                        $attributes['nogrades'] = 1;
+                    }
+                    if (!in_array('Person.name.full', $enabledcapabilities) &&
+                        !in_array('Person.name.family', $enabledcapabilities) &&
+                        !in_array('Person.name.given', $enabledcapabilities)) {
+                        $attributes['noname'] = 1;
+                    }
+                    if (!in_array('Person.email.primary', $enabledcapabilities)) {
+                        $attributes['noemail'] = 1;
+                    }
+                } else if ($type->course == $COURSE->id) {
+                    $attributes = array('editable' => 1, 'courseTool' => 1, 'domain' => $type->tooldomain);
+                } else if ($id != 0) {
+                    $attributes = array('globalTool' => 1, 'domain' => $type->tooldomain);
+                } else {
+                    $attributes = array();
                 }
-                if (!in_array('Person.email.primary', $enabledcapabilities)) {
-                    $attributes['noemail'] = 1;
+
+                if ($id) {
+                    $config = lti_get_type_config($id);
+                    if (!empty($config['contentitem'])) {
+                        $attributes['data-contentitem'] = 1;
+                        $attributes['data-id'] = $id;
+                    } else {
+                        $noncontentitemtypes[] = $id;
+                    }
                 }
-            } else if ($type->course == $COURSE->id) {
-                $attributes = array( 'editable' => 1, 'courseTool' => 1, 'domain' => $type->tooldomain );
-            } else if ($id != 0) {
-                $attributes = array( 'globalTool' => 1, 'domain' => $type->tooldomain);
-            } else {
-                $attributes = array();
+                $tooltypes->addOption($type->name, $id, $attributes);
             }
-
-            if ($id) {
-                $config = lti_get_type_config($id);
+        } else {
+            $mform->addElement('hidden', 'typeid', $typeid);
+            $mform->setType('typeid', PARAM_INT);
+            if ($typeid) {
+                $config = lti_get_type_config($typeid);
                 if (!empty($config['contentitem'])) {
-                    $attributes['data-contentitem'] = 1;
-                    $attributes['data-id'] = $id;
-                } else {
-                    $noncontentitemtypes[] = $id;
+                    $mform->addElement('hidden', 'contentitem', 1);
+                    $mform->setType('contentitem', PARAM_INT);
                 }
             }
-            $tooltypes->addOption($type->name, $id, $attributes);
         }
 
         // Add button that launches the content-item selection dialogue.
@@ -148,23 +177,32 @@ class mod_lti_mod_form extends moodleform_mod {
         $contentbuttonattributes = [
             'data-contentitemurl' => $contentitemurl->out(false)
         ];
+        if (!$showtypes) {
+            if (!$typeid || empty(lti_get_type_config($typeid)['contentitem'])) {
+                $contentbuttonattributes['disabled'] = 'disabled';
+            }
+        }
         $contentbuttonlabel = get_string('selectcontent', 'lti');
         $contentbutton = $mform->addElement('button', 'selectcontent', $contentbuttonlabel, $contentbuttonattributes);
         // Disable select content button if the selected tool doesn't support content item or it's set to Automatic.
-        $allnoncontentitemtypes = $noncontentitemtypes;
-        $allnoncontentitemtypes[] = '0'; // Add option value for "Automatic, based on tool URL".
-        $mform->disabledIf('selectcontent', 'typeid', 'in', $allnoncontentitemtypes);
-
-        $mform->addElement('text', 'toolurl', get_string('launch_url', 'lti'), array('size' => '64'));
-        $mform->setType('toolurl', PARAM_URL);
-        $mform->addHelpButton('toolurl', 'launch_url', 'lti');
-        $mform->hideIf('toolurl', 'typeid', 'in', $noncontentitemtypes);
+        if ($showtypes) {
+            $allnoncontentitemtypes = $noncontentitemtypes;
+            $allnoncontentitemtypes[] = '0'; // Add option value for "Automatic, based on tool URL".
+            $mform->disabledIf('selectcontent', 'typeid', 'in', $allnoncontentitemtypes);
+        }
 
-        $mform->addElement('text', 'securetoolurl', get_string('secure_launch_url', 'lti'), array('size' => '64'));
-        $mform->setType('securetoolurl', PARAM_URL);
-        $mform->setAdvanced('securetoolurl');
-        $mform->addHelpButton('securetoolurl', 'secure_launch_url', 'lti');
-        $mform->hideIf('securetoolurl', 'typeid', 'in', $noncontentitemtypes);
+        if ($showoptions) {
+            $mform->addElement('text', 'toolurl', get_string('launch_url', 'lti'), array('size' => '64'));
+            $mform->setType('toolurl', PARAM_URL);
+            $mform->addHelpButton('toolurl', 'launch_url', 'lti');
+            $mform->hideIf('toolurl', 'typeid', 'in', $noncontentitemtypes);
+
+            $mform->addElement('text', 'securetoolurl', get_string('secure_launch_url', 'lti'), array('size' => '64'));
+            $mform->setType('securetoolurl', PARAM_URL);
+            $mform->setAdvanced('securetoolurl');
+            $mform->addHelpButton('securetoolurl', 'secure_launch_url', 'lti');
+            $mform->hideIf('securetoolurl', 'typeid', 'in', $noncontentitemtypes);
+        }
 
         $mform->addElement('hidden', 'urlmatchedtypeid', '', array( 'id' => 'id_urlmatchedtypeid' ));
         $mform->setType('urlmatchedtypeid', PARAM_INT);
@@ -181,36 +219,38 @@ class mod_lti_mod_form extends moodleform_mod {
         $mform->addHelpButton('launchcontainer', 'launchinpopup', 'lti');
         $mform->setAdvanced('launchcontainer');
 
-        $mform->addElement('text', 'resourcekey', get_string('resourcekey', 'lti'));
-        $mform->setType('resourcekey', PARAM_TEXT);
-        $mform->setAdvanced('resourcekey');
-        $mform->addHelpButton('resourcekey', 'resourcekey', 'lti');
-        $mform->setForceLtr('resourcekey');
-        $mform->hideIf('resourcekey', 'typeid', 'in', $noncontentitemtypes);
-
-        $mform->addElement('passwordunmask', 'password', get_string('password', 'lti'));
-        $mform->setType('password', PARAM_TEXT);
-        $mform->setAdvanced('password');
-        $mform->addHelpButton('password', 'password', 'lti');
-        $mform->hideIf('password', 'typeid', 'in', $noncontentitemtypes);
-
-        $mform->addElement('textarea', 'instructorcustomparameters', get_string('custom', 'lti'), array('rows' => 4, 'cols' => 60));
-        $mform->setType('instructorcustomparameters', PARAM_TEXT);
-        $mform->setAdvanced('instructorcustomparameters');
-        $mform->addHelpButton('instructorcustomparameters', 'custom', 'lti');
-        $mform->setForceLtr('instructorcustomparameters');
-
-        $mform->addElement('text', 'icon', get_string('icon_url', 'lti'), array('size' => '64'));
-        $mform->setType('icon', PARAM_URL);
-        $mform->setAdvanced('icon');
-        $mform->addHelpButton('icon', 'icon_url', 'lti');
-        $mform->hideIf('icon', 'typeid', 'in', $noncontentitemtypes);
-
-        $mform->addElement('text', 'secureicon', get_string('secure_icon_url', 'lti'), array('size' => '64'));
-        $mform->setType('secureicon', PARAM_URL);
-        $mform->setAdvanced('secureicon');
-        $mform->addHelpButton('secureicon', 'secure_icon_url', 'lti');
-        $mform->hideIf('secureicon', 'typeid', 'in', $noncontentitemtypes);
+        if ($showoptions) {
+            $mform->addElement('text', 'resourcekey', get_string('resourcekey', 'lti'));
+            $mform->setType('resourcekey', PARAM_TEXT);
+            $mform->setAdvanced('resourcekey');
+            $mform->addHelpButton('resourcekey', 'resourcekey', 'lti');
+            $mform->setForceLtr('resourcekey');
+            $mform->hideIf('resourcekey', 'typeid', 'in', $noncontentitemtypes);
+
+            $mform->addElement('passwordunmask', 'password', get_string('password', 'lti'));
+            $mform->setType('password', PARAM_TEXT);
+            $mform->setAdvanced('password');
+            $mform->addHelpButton('password', 'password', 'lti');
+            $mform->hideIf('password', 'typeid', 'in', $noncontentitemtypes);
+
+            $mform->addElement('textarea', 'instructorcustomparameters', get_string('custom', 'lti'), array('rows' => 4, 'cols' => 60));
+            $mform->setType('instructorcustomparameters', PARAM_TEXT);
+            $mform->setAdvanced('instructorcustomparameters');
+            $mform->addHelpButton('instructorcustomparameters', 'custom', 'lti');
+            $mform->setForceLtr('instructorcustomparameters');
+
+            $mform->addElement('text', 'icon', get_string('icon_url', 'lti'), array('size' => '64'));
+            $mform->setType('icon', PARAM_URL);
+            $mform->setAdvanced('icon');
+            $mform->addHelpButton('icon', 'icon_url', 'lti');
+            $mform->hideIf('icon', 'typeid', 'in', $noncontentitemtypes);
+
+            $mform->addElement('text', 'secureicon', get_string('secure_icon_url', 'lti'), array('size' => '64'));
+            $mform->setType('secureicon', PARAM_URL);
+            $mform->setAdvanced('secureicon');
+            $mform->addHelpButton('secureicon', 'secure_icon_url', 'lti');
+            $mform->hideIf('secureicon', 'typeid', 'in', $noncontentitemtypes);
+        }
 
         // Add privacy preferences fieldset where users choose whether to send their data.
         $mform->addElement('header', 'privacy', get_string('privacy', 'lti'));
index 7921b8b..42ae637 100644 (file)
@@ -48,7 +48,7 @@
 
 defined('MOODLE_INTERNAL') || die;
 
-$plugin->version   = 2019111800;    // The current module version (Date: YYYYMMDDXX).
+$plugin->version   = 2020010800;    // The current module version (Date: YYYYMMDDXX).
 $plugin->requires  = 2019111200;    // Requires this Moodle version.
 $plugin->component = 'mod_lti';     // Full name of the plugin (used for diagnostics).
 $plugin->cron      = 0;
index eeea35e..ca93082 100644 (file)
     border: 1px solid #dcdcdc;
 }
 
-body.jsenabled .questionflagcheckbox {
-    display: none;
-}
-
 #page-mod-quiz-attempt #connection-ok,
 #page-mod-quiz-attempt #connection-error {
     position: fixed;
index 4fefa00..bfb0aa4 100644 (file)
@@ -1,4 +1,39 @@
 <?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/>.
+
+/**
+ * Library of functions and constants for module wiki
+ *
+ * It contains the great majority of functions defined by Moodle
+ * that are mandatory to develop a module.
+ *
+ * @package mod_wiki
+ * @copyright 2009 Marc Alier, Jordi Piguillem marc.alier@upc.edu
+ * @copyright 2009 Universitat Politecnica de Catalunya http://www.upc.edu
+ *
+ * @author Jordi Piguillem
+ * @author Marc Alier
+ * @author David Jimenez
+ * @author Josep Arus
+ * @author Kenneth Riba
+ *
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
 
 /**
  * Generic parser implementation
  * @license http://www.gnu.org/copyleft/gpl.html GNU Public License
  * @package mod_wiki
  */
-
 class wiki_parser_proxy {
     private static $parsers = array();
     private static $basepath = "";
 
     public static function parse(&$string, $type, $options = array()) {
 
-        if(empty(self::$basepath)) {
+        if (empty(self::$basepath)) {
             global $CFG;
-            self::$basepath = $CFG->dirroot.'/mod/wiki/parser/';
+            self::$basepath = $CFG->dirroot . '/mod/wiki/parser/';
         }
 
         $type = strtolower($type);
         self::$parsers[$type] = null; // Reset the current parser because it may have other options.
-        if(self::create_parser_instance($type)) {
+        if (self::create_parser_instance($type)) {
             return self::$parsers[$type]->parse($string, $options);
-        }
-        else {
+        } else {
             return false;
         }
     }
 
     public static function get_token($name, $type) {
-        if(self::create_parser_instance($type)) {
+        if (self::create_parser_instance($type)) {
             return self::$parsers[$type]->get_token($name);
-        }
-        else {
+        } else {
             return false;
         }
     }
 
-    public static function get_section(&$string, $type, $section, $all_content = false) {
-        if(self::create_parser_instance($type)) {
+    public static function get_section(&$string, $type, $section, $allcontent = false) {
+        if (self::create_parser_instance($type)) {
             $content = self::$parsers[$type]->get_section($section, $string, true);
 
-            if($all_content) {
+            if ($allcontent) {
                 return $content;
-            }
-            else {
+            } else {
                 return is_array($content) ? $content[1] : null;
             }
-        }
-        else {
+        } else {
             return false;
         }
     }
 
     private static function create_parser_instance($type) {
-        if(empty(self::$parsers[$type])) {
-            include_once(self::$basepath."markups/$type.php");
-            $class = strtolower($type)."_parser";
-            if(class_exists($class)) {
+        if (empty(self::$parsers[$type])) {
+            include_once(self::$basepath . "markups/$type.php");
+            $class = strtolower($type) . "_parser";
+            if (class_exists($class)) {
                 self::$parsers[$type] = new $class;
                 return true;
-            }
-            else {
+            } else {
                 return false;
             }
-        }
-        else {
+        } else {
             return true;
         }
     }
@@ -83,7 +111,7 @@ abstract class generic_parser {
 
     private $rulestack = array();
 
-    protected $parser_status = 'Before';
+    protected $parserstatus = 'Before';
 
     /**
      * Dynamic return values
@@ -95,14 +123,14 @@ abstract class generic_parser {
 
     protected $nowikitoken = "%!";
 
-    public function __construct() {}
+    public function __construct() {
+    }
 
     /**
      * Parse function
      */
-
     public function parse(&$string, $options = array()) {
-        if(!is_string($string)) {
+        if (!is_string($string)) {
             return false;
         }
 
@@ -112,21 +140,21 @@ abstract class generic_parser {
 
         $this->initialize_nowiki_index();
 
-        if(method_exists($this, 'before_parsing')) {
+        if (method_exists($this, 'before_parsing')) {
             $this->before_parsing();
         }
 
-        $this->parser_status = 'Parsing';
+        $this->parserstatus = 'Parsing';
 
-        foreach($this->blockrules as $name => $block) {
+        foreach ($this->blockrules as $name => $block) {
             $this->process_block_rule($name, $block);
         }
 
         $this->commit_nowiki_index();
 
-        $this->parser_status = 'After';
+        $this->parserstatus = 'After';
 
-        if(method_exists($this, 'after_parsing')) {
+        if (method_exists($this, 'after_parsing')) {
             $this->after_parsing();
         }
 
@@ -136,15 +164,15 @@ abstract class generic_parser {
     /**
      * Initialize options
      */
-
-    protected function set_options($options) {}
+    protected function set_options($options) {
+    }
 
     /**
      * Block processing function & callbacks
      */
-
     protected function process_block_rule($name, $block) {
-        $this->rulestack[] = array('callback' => method_exists($this, $name."_block_rule") ? $name."_block_rule" : null, 'rule' => $block);
+        $this->rulestack[] = array('callback' => method_exists($this, $name . "_block_rule") ? $name . "_block_rule" : null,
+            'rule' => $block);
 
         $this->string = preg_replace_callback($block['expression'], array($this, 'block_callback'), $this->string);
 
@@ -153,24 +181,22 @@ abstract class generic_parser {
 
     private function block_callback($match) {
         $rule = end($this->rulestack);
-        if(!empty($rule['callback'])) {
+        if (!empty($rule['callback'])) {
             $stuff = $this->{$rule['callback']}($match);
-        }
-        else {
+        } else {
             $stuff = $match[1];
         }
 
-        if(is_array($stuff) && $rule['rule']['tag']) {
+        if (is_array($stuff) && $rule['rule']['tag']) {
             $this->rules($stuff[0], $rule['rule']['tags']);
-            $stuff = "\n".parser_utils::h($rule['rule']['tag'], $stuff[0], $stuff[1])."\n";
-        }
-        else {
-            if(!isset($rule['rule']['tags'])) {
+            $stuff = "\n" . parser_utils::h($rule['rule']['tag'], $stuff[0], $stuff[1]) . "\n";
+        } else {
+            if (!isset($rule['rule']['tags'])) {
                 $rule['rule']['tags'] = null;
             }
             $this->rules($stuff, $rule['rule']['tags']);
-            if(isset($rule['rule']['tag']) && is_string($rule['rule']['tag'])) {
-                $stuff = "\n".parser_utils::h($rule['rule']['tag'], $stuff)."\n";
+            if (isset($rule['rule']['tag']) && is_string($rule['rule']['tag'])) {
+                $stuff = "\n" . parser_utils::h($rule['rule']['tag'], $stuff) . "\n";
             }
         }
 
@@ -182,25 +208,23 @@ abstract class generic_parser {
      */
 
     protected final function rules(&$text, $rules = null) {
-        if($rules === null) {
+        if ($rules === null) {
             $rules = array('except' => array());
-        }
-        else if(is_array($rules) && count($rules) > 1) {
+        } else if (is_array($rules) && count($rules) > 1) {
             $rules = array('only' => $rules);
         }
 
-        if(isset($rules['only']) && is_array($rules['only'])) {
+        if (isset($rules['only']) && is_array($rules['only'])) {
             $rules = $rules['only'];
-            foreach($rules as $r) {
-                if(!empty($this->tagrules[$r])) {
+            foreach ($rules as $r) {
+                if (!empty($this->tagrules[$r])) {
                     $this->process_tag_rule($r, $this->tagrules[$r], $text);
                 }
             }
-        }
-        else if(isset($rules['except']) && is_array($rules['except'])) {
+        } else if (isset($rules['except']) && is_array($rules['except'])) {
             $rules = $rules['except'];
-            foreach($this->tagrules as $r => $tr) {
-                if(!in_array($r, $rules)) {
+            foreach ($this->tagrules as $r => $tr) {
+                if (!in_array($r, $rules)) {
                     $this->process_tag_rule($r, $tr, $text);
                 }
             }
@@ -208,16 +232,14 @@ abstract class generic_parser {
     }
 
     private function process_tag_rule($name, $rule, &$text) {
-        if(method_exists($this, $name."_tag_rule")) {
-            $this->rulestack[] = array('callback' => $name."_tag_rule", 'rule' => $rule);
+        if (method_exists($this, $name . "_tag_rule")) {
+            $this->rulestack[] = array('callback' => $name . "_tag_rule", 'rule' => $rule);
             $text = preg_replace_callback($rule['expression'], array($this, 'tag_callback'), $text);
             array_pop($this->rulestack);
-        }
-        else {
-            if(isset($rule['simple'])) {
+        } else {
+            if (isset($rule['simple'])) {
                 $replace = "<{$rule['tag']} />";
-            }
-            else {
+            } else {
                 $replace = parser_utils::h($rule['tag'], "$1");
             }
 
@@ -229,10 +251,9 @@ abstract class generic_parser {
         $rule = end($this->rulestack);
         $stuff = $this->{$rule['callback']}($match);
 
-        if(is_array($stuff)) {
+        if (is_array($stuff)) {
             return parser_utils::h($rule['rule']['tag'], $stuff[0], $stuff[1]);
-        }
-        else {
+        } else {
             return $stuff;
         }
     }
@@ -242,8 +263,9 @@ abstract class generic_parser {
      */
 
     private function initialize_nowiki_index() {
-        $token = "\Q".$this->nowikitoken."\E";
-        $this->string = preg_replace_callback("/".$token."\d+".$token."/", array($this, "initialize_nowiki_index_callback"), $this->string);
+        $token = "\Q" . $this->nowikitoken . "\E";
+        $this->string = preg_replace_callback("/" . $token . "\d+" . $token . "/",
+            array($this, "initialize_nowiki_index_callback"), $this->string);
     }
 
     private function initialize_nowiki_index_callback($match) {
@@ -253,12 +275,13 @@ abstract class generic_parser {
     protected function protect($text) {
         $this->nowikiindex[] = $text;
 
-        return $this->nowikitoken.(count($this->nowikiindex)-1).$this->nowikitoken;
+        return $this->nowikitoken . (count($this->nowikiindex) - 1) . $this->nowikitoken;
     }
 
     private function commit_nowiki_index() {
-        $token = "\Q".$this->nowikitoken."\E";
-        $this->string = preg_replace_callback("/".$token."(\d+)".$token."/", array($this, "commit_nowiki_index_callback"), $this->string);
+        $token = "\Q" . $this->nowikitoken . "\E";
+        $this->string = preg_replace_callback("/" . $token . "(\d+)" . $token . "/",
+            array($this, "commit_nowiki_index_callback"), $this->string);
     }
 
     private function commit_nowiki_index_callback($match) {
@@ -268,14 +291,13 @@ abstract class generic_parser {
     /**
      * Get token of the parsable element $name.
      */
-
-     public function get_token($name) {
-         foreach(array_merge($this->blockrules, $this->tagrules) as $n => $v) {
-             if($name == $n && isset($v['token'])) {
+    public function get_token($name) {
+        foreach (array_merge($this->blockrules, $this->tagrules) as $n => $v) {
+            if ($name == $n && isset($v['token'])) {
                 return $v['token'] ? $v['token'] : false;
-             }
-         }
+            }
+        }
 
-         return false;
-     }
+        return false;
+    }
 }
index 4ce351e..ab35247 100644 (file)
@@ -323,21 +323,23 @@ class core_question_renderer extends plugin_renderer_base {
         if ($flagged) {
             $icon = 'i/flagged';
             $alt = get_string('flagged', 'question');
+            $label = get_string('clickunflag', 'question');
         } else {
             $icon = 'i/unflagged';
             $alt = get_string('notflagged', 'question');
+            $label = get_string('clickflag', 'question');
         }
         $attributes = array(
             'src' => $this->image_url($icon),
             'alt' => $alt,
+            'class' => 'questionflagimage',
         );
         if ($id) {
             $attributes['id'] = $id;
         }
         $img = html_writer::empty_tag('img', $attributes);
-        if ($flagged) {
-            $img .= ' ' . get_string('flagged', 'question');
-        }
+        $img .= html_writer::span($label);
+
         return $img;
     }
 
index fdc417a..e9b1edf 100644 (file)
@@ -113,3 +113,17 @@ Feature: View the user page for the outline report
     When I follow "Complete report"
     And I should see "4 views"
     And I should see "3 views"
+
+  Scenario: View the user complete report page when there is a no-grade forum
+    Given the following "activities" exist:
+      | activity | name   | description     | course | idnumber |
+      | forum    | forum1 | C1 first forum  | C1     | forum1   |
+    And I am on "Course 1" course homepage
+    When I follow "Participants"
+    And I follow "Student 1"
+    And I follow "Outline report"
+    Then I should see "Outline report"
+    When I follow "Participants"
+    And I follow "Student 1"
+    And I follow "Complete report"
+    Then I should see "Complete report"
index 2b710fc..27cf1fa 100644 (file)
Binary files a/theme/boost/amd/build/loader.min.js and b/theme/boost/amd/build/loader.min.js differ
index f3c1ad4..6ac8fe5 100644 (file)
Binary files a/theme/boost/amd/build/loader.min.js.map and b/theme/boost/amd/build/loader.min.js.map differ
index a81bc8d..03fef8e 100644 (file)
@@ -47,7 +47,8 @@ define(['jquery', './tether', 'core/event', 'core/custom_interaction_events'], f
         // We do twice because: https://github.com/twbs/bootstrap/issues/10547
         jQuery('body').popover({
             trigger: 'focus',
-            selector: "[data-toggle=popover][data-trigger!=hover]"
+            selector: "[data-toggle=popover][data-trigger!=hover]",
+            placement: 'auto'
         });
 
         // Popovers must close on Escape for accessibility reasons.
index 995a0f9..cd8d25a 100644 (file)
@@ -2144,125 +2144,6 @@ div.editor_atto_toolbar button .icon {
     }
 }
 
-$switch-height: 1.25rem !default;
-$switch-height-half: ($switch-height / 2) !default;
-$switch-width: ($switch-height * 2) !default;
-$switch-border-radius: $switch-height !default;
-$switch-bg: $gray-300 !default;
-$switch-bg-height: 1rem !default;
-$switch-bg-height-half: ($switch-bg-height / 2) !default;
-$switch-checked-bg: rgba(map-get($theme-colors, 'primary'), .4) !default;
-$switch-checked-thumb-bg: map-get($theme-colors, 'primary') !default;
-$switch-disabled-bg: $gray-200 !default;
-$switch-disabled-color: $gray-600 !default;
-$switch-disabled-thumb-bg: $gray-600 !default;
-$switch-thumb-bg: $white !default;
-$switch-thumb-border-radius: 50% !default;
-$switch-thumb-size: $switch-height !default;
-$switch-thumb-size-half: $switch-height-half !default;
-$switch-focus-box-shadow: 0 0 0 ($input-btn-focus-width * 2) rgba(map-get($theme-colors, 'primary'), .25);
-$switch-transition: .2s all !default;
-
-.switch {
-    position: relative;
-    display: inline-block;
-
-    input {
-        float: left;
-        width: 1px;
-        transform: translateX(1px);
-        padding: 0;
-        margin: 0;
-        opacity: 0;
-        line-height: $switch-height;
-
-        + label {
-            position: relative;
-            min-height: $switch-height;
-            min-width: $switch-width;
-            line-height: $switch-height;
-            border-radius: $switch-border-radius;
-            display: inline-block;
-            cursor: pointer;
-            outline: none;
-            user-select: none;
-            padding-left: ($switch-width + .5rem);
-        }
-
-        + label::before,
-        + label::after {
-            content: '';
-            position: absolute;
-            left: 0;
-            bottom: 0;
-            display: block;
-        }
-
-        + label::before {
-            right: 0;
-            height: $switch-bg-height;
-            width: $switch-width;
-            top: calc(50% - #{$switch-bg-height-half});
-            background-color: $switch-bg;
-            border-radius: $switch-border-radius;
-            transition: $switch-transition;
-        }
-
-        + label::after {
-            left: 0;
-            width: $switch-thumb-size;
-            height: $switch-thumb-size;
-            top: calc(50% - #{$switch-thumb-size-half});
-            border-radius: $switch-thumb-border-radius;
-            background-color: $switch-thumb-bg;
-            transition: $switch-transition;
-            box-shadow:
-                0 1px 3px 0 rgba(0, 0, 0, 0.2),
-                0 1px 1px 0 rgba(0, 0, 0, 0.14),
-                0 2px 1px -1px rgba(0, 0, 0, 0.12);
-        }
-
-        &:checked + label::before {
-            background-color: $switch-checked-bg;
-        }
-
-        &:checked + label::after {
-            margin-left: $switch-height;
-            background-color: $switch-checked-thumb-bg;
-        }
-
-        &:focus + label::before {
-            outline: none;
-        }
-
-        &:focus + label::after {
-            outline: none;
-            box-shadow: $switch-focus-box-shadow;
-        }
-
-        &:disabled + label {
-            color: $switch-disabled-color;
-            cursor: not-allowed;
-        }
-
-        &:disabled + label::before {
-            background-color: $switch-disabled-bg;
-        }
-
-        &:disabled + label::after {
-            background-color: $switch-disabled-thumb-bg;
-        }
-    }
-
-    &.sr-only-label {
-        input + label {
-            text-indent: -9999px;
-            margin-bottom: 0;
-            padding-left: 0;
-        }
-    }
-}
-
 .paged-content-page-container {
     min-height: 3.125rem;
 }
index 1e6ef52..81faedf 100644 (file)
@@ -25,6 +25,21 @@ body.behat-site {
         transition: initial;
         position: absolute;
     }
+    .custom-control,
+    .custom-switch {
+        padding-left: 0;
+    }
+    .custom-control-input {
+        position: static;
+        z-index: 0;
+        opacity: 1;
+    }
+    .custom-control-label {
+        &::before,
+        &::after {
+            content: none;
+        }
+    }
 }
 
 .phpinfo table,
index ce19643..4831fe5 100644 (file)
@@ -409,7 +409,11 @@ body.path-question-type {
     vertical-align: text-bottom;
 }
 
-.que input.questionflagimage {
+body.jsenabled .questionflag input[type=checkbox] {
+    display: none;
+}
+
+.que .questionflagimage {
     padding-right: 3px;
     height: 16px;
     width: 16px;
index 39d8daf..c44d3ac 100644 (file)
@@ -60,6 +60,9 @@ $alert-border-width:                0 !default;
 
 $card-group-margin: .25rem;
 
+// Custom control size
+$custom-control-indicator-size: 1.25rem;
+
 // stylelint-disable
 $theme-colors: () !default;
 $theme-colors: map-merge((
index 01b77d5..43bf02f 100644 (file)
@@ -4811,7 +4811,7 @@ input[type="button"].btn-block {
   position: relative;
   display: block;
   min-height: 1.40625rem;
-  padding-left: 1.5rem; }
+  padding-left: 1.75rem; }
 
 .custom-control-inline {
   display: inline-flex;
@@ -4844,22 +4844,22 @@ input[type="button"].btn-block {
   vertical-align: top; }
   .custom-control-label::before {
     position: absolute;
-    top: 0.203125rem;
-    left: -1.5rem;
+    top: 0.078125rem;
+    left: -1.75rem;
     display: block;
-    width: 1rem;
-    height: 1rem;
+    width: 1.25rem;
+    height: 1.25rem;
     pointer-events: none;
     content: "";
     background-color: #fff;
     border: #adb5bd solid 1px; }
   .custom-control-label::after {
     position: absolute;
-    top: 0.203125rem;
-    left: -1.5rem;
+    top: 0.078125rem;
+    left: -1.75rem;
     display: block;
-    width: 1rem;
-    height: 1rem;
+    width: 1.25rem;
+    height: 1.25rem;
     content: "";
     background: no-repeat 50% / 50% 50%; }
 
@@ -4889,26 +4889,26 @@ input[type="button"].btn-block {
   background-color: rgba(17, 119, 209, 0.5); }
 
 .custom-switch {
-  padding-left: 2.25rem; }
+  padding-left: 2.6875rem; }
   .custom-switch .custom-control-label::before {
-    left: -2.25rem;
-    width: 1.75rem;
+    left: -2.6875rem;
+    width: 2.1875rem;
     pointer-events: all;
-    border-radius: 0.5rem; }
+    border-radius: 0.625rem; }
   .custom-switch .custom-control-label::after {
-    top: calc(0.203125rem + 2px);
-    left: calc(-2.25rem + 2px);
-    width: calc(1rem - 4px);
-    height: calc(1rem - 4px);
+    top: calc(0.078125rem + 2px);
+    left: calc(-2.6875rem + 2px);
+    width: calc(1.25rem - 4px);
+    height: calc(1.25rem - 4px);
     background-color: #adb5bd;
-    border-radius: 0.5rem;
+    border-radius: 0.625rem;
     transition: transform 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; }
     @media (prefers-reduced-motion: reduce) {
       .custom-switch .custom-control-label::after {
         transition: none; } }
   .custom-switch .custom-control-input:checked ~ .custom-control-label::after {
     background-color: #fff;
-    transform: translateX(0.75rem); }
+    transform: translateX(0.9375rem); }
   .custom-switch .custom-control-input:disabled:checked ~ .custom-control-label::before {
     background-color: rgba(17, 119, 209, 0.5); }
 
@@ -11140,74 +11140,6 @@ div.editor_atto_toolbar button .icon {
 .dir-ltr .dir-ltr-hide {
   display: none; }
 
-.switch {
-  position: relative;
-  display: inline-block; }
-  .switch input {
-    float: left;
-    width: 1px;
-    transform: translateX(1px);
-    padding: 0;
-    margin: 0;
-    opacity: 0;
-    line-height: 1.25rem; }
-    .switch input + label {
-      position: relative;
-      min-height: 1.25rem;
-      min-width: 2.5rem;
-      line-height: 1.25rem;
-      border-radius: 1.25rem;
-      display: inline-block;
-      cursor: pointer;
-      outline: none;
-      user-select: none;
-      padding-left: 3rem; }
-    .switch input + label::before,
-    .switch input + label::after {
-      content: '';
-      position: absolute;
-      left: 0;
-      bottom: 0;
-      display: block; }
-    .switch input + label::before {
-      right: 0;
-      height: 1rem;
-      width: 2.5rem;
-      top: calc(50% - 0.5rem);
-      background-color: #dee2e6;
-      border-radius: 1.25rem;
-      transition: 0.2s all; }
-    .switch input + label::after {
-      left: 0;
-      width: 1.25rem;
-      height: 1.25rem;
-      top: calc(50% - 0.625rem);
-      border-radius: 50%;
-      background-color: #fff;
-      transition: 0.2s all;
-      box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.2), 0 1px 1px 0 rgba(0, 0, 0, 0.14), 0 2px 1px -1px rgba(0, 0, 0, 0.12); }
-    .switch input:checked + label::before {
-      background-color: rgba(17, 119, 209, 0.4); }
-    .switch input:checked + label::after {
-      margin-left: 1.25rem;
-      background-color: #1177d1; }
-    .switch input:focus + label::before {
-      outline: none; }
-    .switch input:focus + label::after {
-      outline: none;
-      box-shadow: 0 0 0 0.4rem rgba(17, 119, 209, 0.25); }
-    .switch input:disabled + label {
-      color: #868e96;
-      cursor: not-allowed; }
-    .switch input:disabled + label::before {
-      background-color: #e9ecef; }
-    .switch input:disabled + label::after {
-      background-color: #868e96; }
-  .switch.sr-only-label input + label {
-    text-indent: -9999px;
-    margin-bottom: 0;
-    padding-left: 0; }
-
 .paged-content-page-container {
   min-height: 3.125rem; }
 
@@ -15014,7 +14946,10 @@ body.path-question-type {
 .que .questioncorrectnessicon {
   vertical-align: text-bottom; }
 
-.que input.questionflagimage {
+body.jsenabled .questionflag input[type=checkbox] {
+  display: none; }
+
+.que .questionflagimage {
   padding-right: 3px;
   height: 16px;
   width: 16px; }
@@ -17761,6 +17696,18 @@ body.behat-site [data-region="drawer"] {
   transition: initial;
   position: absolute; }
 
+body.behat-site .custom-control,
+body.behat-site .custom-switch {
+  padding-left: 0; }
+
+body.behat-site .custom-control-input {
+  position: static;
+  z-index: 0;
+  opacity: 1; }
+
+body.behat-site .custom-control-label::before, body.behat-site .custom-control-label::after {
+  content: none; }
+
 .phpinfo table,
 .phpinfo th,
 .phpinfo h2 {
index b3e5902..7052d1a 100644 (file)
@@ -11351,74 +11351,6 @@ div.editor_atto_toolbar button .icon {
 .dir-ltr .dir-ltr-hide {
   display: none; }
 
-.switch {
-  position: relative;
-  display: inline-block; }
-  .switch input {
-    float: left;
-    width: 1px;
-    transform: translateX(1px);
-    padding: 0;
-    margin: 0;
-    opacity: 0;
-    line-height: 1.25rem; }
-    .switch input + label {
-      position: relative;
-      min-height: 1.25rem;
-      min-width: 2.5rem;
-      line-height: 1.25rem;
-      border-radius: 1.25rem;
-      display: inline-block;
-      cursor: pointer;
-      outline: none;
-      user-select: none;
-      padding-left: 3rem; }
-    .switch input + label::before,
-    .switch input + label::after {
-      content: '';
-      position: absolute;
-      left: 0;
-      bottom: 0;
-      display: block; }
-    .switch input + label::before {
-      right: 0;
-      height: 1rem;
-      width: 2.5rem;
-      top: calc(50% - 0.5rem);
-      background-color: #dee2e6;
-      border-radius: 1.25rem;
-      transition: 0.2s all; }
-    .switch input + label::after {
-      left: 0;
-      width: 1.25rem;
-      height: 1.25rem;
-      top: calc(50% - 0.625rem);
-      border-radius: 50%;
-      background-color: #fff;
-      transition: 0.2s all;
-      box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.2), 0 1px 1px 0 rgba(0, 0, 0, 0.14), 0 2px 1px -1px rgba(0, 0, 0, 0.12); }
-    .switch input:checked + label::before {
-      background-color: rgba(17, 119, 209, 0.4); }
-    .switch input:checked + label::after {
-      margin-left: 1.25rem;
-      background-color: #1177d1; }
-    .switch input:focus + label::before {
-      outline: none; }
-    .switch input:focus + label::after {
-      outline: none;
-      box-shadow: 0 0 0 0.4rem rgba(17, 119, 209, 0.25); }
-    .switch input:disabled + label {
-      color: #868e96;
-      cursor: not-allowed; }
-    .switch input:disabled + label::before {
-      background-color: #e9ecef; }
-    .switch input:disabled + label::after {
-      background-color: #868e96; }
-  .switch.sr-only-label input + label {
-    text-indent: -9999px;
-    margin-bottom: 0;
-    padding-left: 0; }
-
 .paged-content-page-container {
   min-height: 3.125rem; }
 
@@ -15234,7 +15166,10 @@ body.path-question-type {
 .que .questioncorrectnessicon {
   vertical-align: text-bottom; }
 
-.que input.questionflagimage {
+body.jsenabled .questionflag input[type=checkbox] {
+  display: none; }
+
+.que .questionflagimage {
   padding-right: 3px;
   height: 16px;
   width: 16px; }
@@ -17941,6 +17876,18 @@ body.behat-site [data-region="drawer"] {
   transition: initial;
   position: absolute; }
 
+body.behat-site .custom-control,
+body.behat-site .custom-switch {
+  padding-left: 0; }
+
+body.behat-site .custom-control-input {
+  position: static;
+  z-index: 0;
+  opacity: 1; }
+
+body.behat-site .custom-control-label::before, body.behat-site .custom-control-label::after {
+  content: none; }
+
 .phpinfo table,
 .phpinfo th,
 .phpinfo h2 {
index 3f61943..4121111 100644 (file)
@@ -29,7 +29,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$version  = 2020011700.00;              // YYYYMMDD      = weekly release date of this DEV branch.
+$version  = 2020011700.02;              // YYYYMMDD      = weekly release date of this DEV branch.
                                         //         RR    = release increments - 00 in DEV branches.
                                         //           .XX = incremental changes.
 $release  = '3.9dev (Build: 20200117)'; // Human-friendly version name