die;
}
+\core\local\cli\shutdown::script_supports_graceful_exit();
+
cron_run();
$maxkeptoptions = array(
0 => new lang_string('all'), 1 => '1',
2 => '2',
+ 3 => '3',
+ 4 => '4',
5 => '5',
10 => '10',
20 => '20',
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:
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"
$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);
$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();
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) {
$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';
$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;
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
$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;
}
$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();
$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();
$now = time();
- $trace = new null_progress_trace();
+ $trace = new progress_trace_buffer(new text_progress_trace(), false);
// Prepare some data.
// 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)));
class filter_activitynames_filter_testcase extends advanced_testcase {
public function test_links() {
- global $CFG;
$this->resetAfterTest(true);
// Create a test course.
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]);
$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]);
+ }
}
// 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') {
$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'])) {
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']);
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);
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);
}
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;
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;
// 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),
* @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);
}
}
- $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
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
$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;
}
$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.'&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);
| 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"
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
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
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 |
$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';
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
}
}
- 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) {
--- /dev/null
+<?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;
+ }
+
+}
+
*/
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;
*
* @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.
$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 ?? []];
}
/**
*
* @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 ?? []];
}
/**
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
}
}
// 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);
$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) {
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);
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;
}
// 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);
* 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));
/**
*/\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
* 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
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
{\r
\r
/**\r
- * @type bool\r
+ * @type string\r
*/\r
protected $name;\r
\r
public $minimized = true;\r
\r
/**\r
- * @param bool $name\r
+ * @param bool|string $name\r
*/\r
public function __construct($name = false)\r
{\r
\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
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
// 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
* 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
// 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
* @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
<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
--- /dev/null
+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
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
$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
* @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
$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
'HTMLPurifier' => 'HTML Purifier X'\r
);\r
\r
+$errorNames = array();\r
+\r
// vim: et sw=4 sts=4\r
'HTMLPurifier' => 'HTML Purifier XNone'\r
);\r
\r
+$errorNames = array();\r
+\r
// vim: et sw=4 sts=4\r
$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
*/\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
*/\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
$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
$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
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
* 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
// 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
$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
}\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
// 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
-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
// 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.
$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 {";
$html .= "<li class=\"cache-store-stats $cachestoreclass p-x-1\"> </li>";
}
}
- $html .= '</ul>';
+ $html .= '</ul></li>';
$text .= '} ';
}
$html .= '</ul> ';
foreach ($plugins as $function) {
try {
$function();
- } catch (Exception $e) {
+ } catch (Throwable $e) {
debugging("Exception calling '$function'", DEBUG_DEVELOPER, $e->getTrace());
}
}
// 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;
<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>
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)
}
$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]);
}
}
\core_message\api::mark_notification_as_read($notification);
-redirect(new moodle_url($redirecturl));
+redirect($redirecturl);
<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>
<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
<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>
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;
});
});
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.
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;
}
/**
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);
};
$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)) {
* @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
$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
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);
--- /dev/null
+@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"
--- /dev/null
+@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 |
{{#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}}
</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
}}
<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>
$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.
*
$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]);
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.
*
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"
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"
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"
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"
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"
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"
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"
--- /dev/null
+@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"
global $CFG;
require_once(__DIR__ . '/generator_trait.php');
+use mod_forum\local\entities\forum;
use mod_forum\local\managers\capability as capability_manager;
/**
* 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);
$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.
*
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 |
| 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:
| 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
)
),
+ // 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(
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
$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';
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) {
}
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,
$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;
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;
},
/**
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;
$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.
$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);
$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'));
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;
border: 1px solid #dcdcdc;
}
-body.jsenabled .questionflagcheckbox {
- display: none;
-}
-
#page-mod-quiz-attempt #connection-ok,
#page-mod-quiz-attempt #connection-error {
position: fixed;
<?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;
}
}
private $rulestack = array();
- protected $parser_status = 'Before';
+ protected $parserstatus = 'Before';
/**
* Dynamic return values
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;
}
$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();
}
/**
* 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);
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";
}
}
*/
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);
}
}
}
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");
}
$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;
}
}
*/
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) {
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) {
/**
* 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;
+ }
}
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;
}
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"
// 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.
}
}
-$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;
}
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,
vertical-align: text-bottom;
}
-.que input.questionflagimage {
+body.jsenabled .questionflag input[type=checkbox] {
+ display: none;
+}
+
+.que .questionflagimage {
padding-right: 3px;
height: 16px;
width: 16px;
$card-group-margin: .25rem;
+// Custom control size
+$custom-control-indicator-size: 1.25rem;
+
// stylelint-disable
$theme-colors: () !default;
$theme-colors: map-merge((
position: relative;
display: block;
min-height: 1.40625rem;
- padding-left: 1.5rem; }
+ padding-left: 1.75rem; }
.custom-control-inline {
display: inline-flex;
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%; }
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); }
.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; }
.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; }
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 {
.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; }
.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; }
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 {
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