Compare commits
130 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cbd60e5a9b | |||
| 6f24f29cde | |||
| 3ba2ebb887 | |||
| 501fc68186 | |||
| 42eab03367 | |||
| 9bbcab02b8 | |||
| 0dad8a8369 | |||
| 1387393a3b | |||
| 5919d99222 | |||
| a86c05ee7c | |||
| d58329f1e1 | |||
| 1fcd367669 | |||
| d4f1cb1078 | |||
| ba75747f2e | |||
| 2de34d5c0e | |||
| 85d85a6a25 | |||
| eda944f427 | |||
| 5df88228a5 | |||
| f35f246d19 | |||
| d00f94ff44 | |||
| 25a13c0394 | |||
| 159976d1ef | |||
| fa36c75597 | |||
| cf1e71be2b | |||
| 0a50f4c904 | |||
| 683c62f3c6 | |||
| 633ddeb157 | |||
| 3c5accff73 | |||
| d0afa76de6 | |||
| bc2f8102ce | |||
| 532ef50ed6 | |||
| 8f4cb8c4d5 | |||
| 1fc38904e1 | |||
| 4df87b3130 | |||
| 551568aca2 | |||
| 3576ec553c | |||
| 7b8510441f | |||
| e7d7d84aa3 | |||
| ec9b50b135 | |||
| aa4fce5f6a | |||
| 6bf326fb9f | |||
| 482834493e | |||
| a4bd8cec97 | |||
| fa63ee1a8a | |||
| f252b16672 | |||
| 405a99a70b | |||
| 1b50220d96 | |||
| ce9d97bd40 | |||
| 2c2aa043c9 | |||
| 5556f401e0 | |||
| 5e2a70cb74 | |||
| 6a2092db12 | |||
| 31f09682df | |||
| 02e0fdb3fe | |||
| 244166e01d | |||
| 5ad933815c | |||
| c111e4fe87 | |||
| 847068d11d | |||
| b577e8d621 | |||
| 98ad4352a9 | |||
| df2723c6a8 | |||
| 201f222789 | |||
| 6c4bf9098e | |||
| 05682343be | |||
| afdc24fc07 | |||
| 4f1662e90a | |||
| 9e41cd6609 | |||
| b132ff1ed8 | |||
| 377d56c835 | |||
| 12bf1da9e5 | |||
| 52fbae2907 | |||
| 5eaaf5e016 | |||
| 045db899be | |||
| 16c9172c63 | |||
| 6b12fa32e1 | |||
| b9ce55d66d | |||
| cdce19d0d0 | |||
| 168ca6fdef | |||
| f063ef682f | |||
| e06475d8ae | |||
| f0199e8038 | |||
| cca044ce15 | |||
| a780d14710 | |||
| 3af76654fd | |||
| de8176c553 | |||
| c6e0071808 | |||
| 04b0132b3a | |||
| 6e3cb19969 | |||
| 8ca7f282bc | |||
| 33e58ab61a | |||
| 1d40ca9de8 | |||
| c0f24543fc | |||
| fe0c832dcd | |||
| b19296b629 | |||
| 6a34598550 | |||
| 9508120293 | |||
| 0891666ec7 | |||
| 17c3e8ecc8 | |||
| c6a31786f7 | |||
| eb0342fa4f | |||
| da47f4dafb | |||
| 8cc95faa8a | |||
| d5ba48ab32 | |||
| d7c719f597 | |||
| 1145b31059 | |||
| 90ebc61746 | |||
| 650efc4ff7 | |||
| 51f425ec29 | |||
| bec6456309 | |||
| 3a9fd31020 | |||
| f7f8b14c3e | |||
| 298a3f5107 | |||
| 70efbd07af | |||
| 5b90cc9653 | |||
| b0423acc58 | |||
| c275ca43c9 | |||
| 746a17f9b0 | |||
| eb18a07be2 | |||
| e0930fce1e | |||
| 488a48e5f1 | |||
| ddf3665d9c | |||
| 0f75ce3ebf | |||
| 0df63ca7da | |||
| 1a3e762198 | |||
| 5b8a7b7d73 | |||
| 402ea0f8d2 | |||
| 56a109d2c3 | |||
| 0d8b41f727 | |||
| ee87a1bcac | |||
| 9567305f70 |
+2
-1
@@ -2180,10 +2180,11 @@ class core_admin_renderer extends plugin_renderer_base {
|
||||
|
||||
$output = '';
|
||||
$output .= $this->header();
|
||||
$output .= $this->container_start('upgradekeyreq');
|
||||
$output .= $this->heading(get_string('upgradekeyreq', 'core_admin'));
|
||||
$output .= $this->container_start('upgradekeyreq w-25');
|
||||
$output .= html_writer::start_tag('form', array('method' => 'POST', 'action' => $url));
|
||||
$output .= html_writer::empty_tag('input', [
|
||||
'id' => 'upgradekey',
|
||||
'name' => 'upgradekey',
|
||||
'type' => 'password',
|
||||
'class' => 'form-control w-auto',
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
@tool @tool_analytics
|
||||
Feature: Manager can obtain prediction models insights
|
||||
In order to view prediction models insights
|
||||
As a manager
|
||||
I should be able to obtain the prediction models insights
|
||||
|
||||
Background:
|
||||
Given the following "users" exist:
|
||||
| username | firstname | lastname | email |
|
||||
| s1 | Student | One | s1@example.com |
|
||||
| m1 | Manager | One | m1@example.com |
|
||||
| m2 | Manager | Two | m2@example.com |
|
||||
| t1 | Teacher | One | t1@example.com |
|
||||
And the following "role assigns" exist:
|
||||
| user | role | contextlevel | reference |
|
||||
| m1 | manager | System | |
|
||||
| m2 | manager | System | |
|
||||
# To ensure that course start date is in the future, set course start date to "tomorrow".
|
||||
And the following "courses" exist:
|
||||
| fullname | shortname | startdate |
|
||||
| Course 1 | C1 | ##tomorrow## |
|
||||
| Course 2 | C2 | ##tomorrow## |
|
||||
| Course 3 | C3 | ##tomorrow## |
|
||||
And the following "course enrolments" exist:
|
||||
| user | course | role |
|
||||
| s1 | C1 | student |
|
||||
| s1 | C2 | student |
|
||||
| s1 | C3 | student |
|
||||
| t1 | C3 | editingteacher |
|
||||
# Disable "Analytics processes execution via command line only".
|
||||
And the following config values are set as admin:
|
||||
| onlycli | 0 | analytics |
|
||||
|
||||
@javascript
|
||||
Scenario: Manager can obtain prediction models insights
|
||||
Given I log in as "m1"
|
||||
And I navigate to "Analytics > Analytics models" in site administration
|
||||
And I click on "Actions" "link" in the "Courses at risk of not starting" "table_row"
|
||||
And I click on "Execute scheduled analysis" "link" in the "Courses at risk of not starting" "table_row"
|
||||
And I press "Continue"
|
||||
When I select "All predictions" from the "contextid" singleselect
|
||||
# Confirm that only courses without teachers, Course 1 and Course 2, are listed.
|
||||
Then "Course 1" "text" should exist
|
||||
And "Course 2" "text" should exist
|
||||
And "Course 3" "text" should not exist
|
||||
And I click on "Select Course 1 for bulk action" "checkbox"
|
||||
And I press "Not applicable"
|
||||
And I press "Confirm"
|
||||
# After m1 marks Course 1 as Not applicable, only Course 2 should be listed in the predictions for manager 1.
|
||||
And "Course 1" "text" should not exist
|
||||
And "Course 2" "text" should exist
|
||||
And I log in as "m2"
|
||||
And I click on ".popover-region-notifications" "css_element"
|
||||
# Notification of new insight exists.
|
||||
And "Upcoming courses have no teachers or students" "text" should exist
|
||||
And I click on "View full notification" "link" in the ".popover-region-notifications" "css_element"
|
||||
And I click on "View insight" "link"
|
||||
# Course 1, previously marked as Not applicable by manager 1, is still listed for manager 2.
|
||||
And "Course 1" "text" should exist
|
||||
And I click on "View details" "link" in the "Course 1" "table_row"
|
||||
# Prediction details and indicators are displayed for the selected course.
|
||||
And the following should exist in the "insights-list" table:
|
||||
| Description |
|
||||
| Course 1 |
|
||||
And the following should exist in the "prediction-calculations" table:
|
||||
| -1- | -2- |
|
||||
| Teacher availability | No |
|
||||
| Student enrolments | Yes |
|
||||
# Date of prediction analysis execution.
|
||||
And "##today##%A, %d %B %Y##" "text" should exist in the "Time predicted" "table_row"
|
||||
And I click on "Select Course 1 for bulk action" "checkbox"
|
||||
And I press "Accept"
|
||||
And I press "Confirm"
|
||||
# Verify that you end up on the page listing predictions for 'No teaching'.
|
||||
And "Courses at risk of not starting" "text" should exist
|
||||
And "The following courses due to start in the upcoming days are at risk of not starting because they don't have teachers or students enrolled." "text" should exist
|
||||
And the following should not exist in the "insights-list" table:
|
||||
| Description |
|
||||
| Course 1 |
|
||||
+1
-5
@@ -30,10 +30,6 @@ use tool_brickfield\local\htmlchecker\common\brickfield_accessibility_test;
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class img_alt_is_too_long extends brickfield_accessibility_test {
|
||||
|
||||
/** @var int The default severity code for this test. */
|
||||
public $defaultseverity = \tool_brickfield\local\htmlchecker\brickfield_accessibility::BA_TEST_SEVERE;
|
||||
|
||||
/**
|
||||
* The main check function. This is called by the parent class to actually check content.
|
||||
*/
|
||||
@@ -41,7 +37,7 @@ class img_alt_is_too_long extends brickfield_accessibility_test {
|
||||
global $alttextlengthlimit;
|
||||
|
||||
foreach ($this->get_all_elements('img') as $img) {
|
||||
$alttextlengthlimit = 125;
|
||||
$alttextlengthlimit = 750;
|
||||
if ($img->hasAttribute('alt') && core_text::strlen($img->getAttribute('alt')) > $alttextlengthlimit) {
|
||||
$this->add_report($img);
|
||||
}
|
||||
|
||||
@@ -402,6 +402,8 @@ class manager {
|
||||
}
|
||||
}
|
||||
|
||||
$rs->close();
|
||||
|
||||
if (count($batchinserts) > 0) {
|
||||
$DB->insert_records(self::DB_PROCESS, $batchinserts);
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ $string['checkdesc:headerh3'] = 'Headers following after H3 headers (the editor
|
||||
$string['checkdesc:headershavetext'] = 'A header needs to contain text to be perceivable.';
|
||||
$string['checkdesc:iisnotused'] = 'Italic (i) elements should not be used; "em" should be used instead.';
|
||||
$string['checkdesc:imgaltisdifferent'] = 'Image alt (alternative) text should not be the image filename.';
|
||||
$string['checkdesc:imgaltistoolong'] = 'Image alt (alternative) text should not be more than the maximum allowed (125) characters.';
|
||||
$string['checkdesc:imgaltistoolong'] = 'Ensure that the image alt (alternative) text is concise enough to describe the image.';
|
||||
$string['checkdesc:imgaltnotemptyinanchor'] = 'Image alt (alternative) text should not be empty, especially when the image has a link going elsewhere.';
|
||||
$string['checkdesc:imgaltnotplaceholder'] = 'Image alt (alternative) text should not be a simple placeholder text, such as "image".';
|
||||
$string['checkdesc:imghasalt'] = 'Image alt (alternative) text should not be missing for image elements, unless purely decorative with no meaning.';
|
||||
|
||||
+22
-9
@@ -24,6 +24,8 @@
|
||||
|
||||
namespace tool_brickfield\local\htmlchecker\common\checks;
|
||||
|
||||
use tool_brickfield\local\htmlchecker\brickfield_accessibility;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
require_once('all_checks.php');
|
||||
@@ -64,21 +66,21 @@ EOD;
|
||||
*/
|
||||
public static function img_alt_text_provider(): array {
|
||||
return [
|
||||
'Alt text <= 125 characters' => [
|
||||
'Alt text <= 750 characters' => [
|
||||
true,
|
||||
str_repeat("Hello world!", 10),
|
||||
str_repeat("Hello world!", 60),
|
||||
],
|
||||
'Alt text > 125 characters' => [
|
||||
'Alt text > 750 characters' => [
|
||||
false,
|
||||
str_repeat("Hello world!", 25),
|
||||
str_repeat("Hello world!", 65),
|
||||
],
|
||||
'Multi-byte alt text <= 125 characters' => [
|
||||
'Multi-byte alt text <= 750 characters' => [
|
||||
true,
|
||||
str_repeat('こんにちは、世界!', 13),
|
||||
str_repeat('こんにちは、世界!', 83),
|
||||
],
|
||||
'Multi-byte alt text > 125 characters' => [
|
||||
'Multi-byte alt text > 750 characters' => [
|
||||
false,
|
||||
str_repeat('こんにちは、世界!', 30),
|
||||
str_repeat('こんにちは、世界!', 90),
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -96,7 +98,18 @@ EOD;
|
||||
if ($expectedpass) {
|
||||
$this->assertEmpty($results);
|
||||
} else {
|
||||
$this->assertTrue($results[0]->element->tagName === 'img');
|
||||
$this->assertEquals('img', $results[0]->element->tagName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the severity of the {@see img_alt_is_too_long} check.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function test_severity(): void {
|
||||
$html = $this->get_test_html('Some alt text');
|
||||
$checker = new brickfield_accessibility($html, 'brickfield', 'string');
|
||||
$this->assertEquals(brickfield_accessibility::BA_TEST_SUGGESTION, $checker->get_test_severity(img_alt_is_too_long::class));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,7 +175,7 @@ class cohort_role_assignments_table extends table_sql {
|
||||
* @return array containing sql to use and an array of params.
|
||||
*/
|
||||
protected function get_sql_and_params($count = false) {
|
||||
$fields = 'uca.id, uca.cohortid, uca.userid, uca.roleid, ';
|
||||
$fields = 'uca.id, uca.cohortid, uca.userid, uca.roleid, r.shortname AS rolename, ';
|
||||
$fields .= 'c.name as cohortname, c.idnumber as cohortidnumber, c.contextid as cohortcontextid, ';
|
||||
$fields .= 'c.visible as cohortvisible, c.description as cohortdescription, c.theme as cohorttheme';
|
||||
|
||||
@@ -193,6 +193,7 @@ class cohort_role_assignments_table extends table_sql {
|
||||
$sql = "SELECT $select
|
||||
FROM {tool_cohortroles} uca
|
||||
JOIN {user} u ON u.id = uca.userid
|
||||
JOIN {role} r ON r.id = uca.roleid
|
||||
JOIN {cohort} c ON c.id = uca.cohortid";
|
||||
|
||||
// Check if any additional filtering is required.
|
||||
|
||||
@@ -4,13 +4,13 @@ Feature: See the competencies for an activity on the course competencies page.
|
||||
In order to see only the competencies for an activity in the course competencies page.
|
||||
|
||||
Background:
|
||||
Given the following lp "frameworks" exist:
|
||||
| shortname | idnumber |
|
||||
| Test-Framework | ID-FW1 |
|
||||
And the following lp "competencies" exist:
|
||||
| shortname | framework |
|
||||
| Test-Comp1 | ID-FW1 |
|
||||
| Test-Comp2 | ID-FW1 |
|
||||
Given the following "core_competency > frameworks" exist:
|
||||
| shortname | idnumber |
|
||||
| Test-Framework | ID-FW1 |
|
||||
And the following "core_competency > competencies" exist:
|
||||
| shortname | competencyframework |
|
||||
| Test-Comp1 | ID-FW1 |
|
||||
| Test-Comp2 | ID-FW1 |
|
||||
Given the following "courses" exist:
|
||||
| shortname | fullname | enablecompletion |
|
||||
| C1 | Course 1 | 1 |
|
||||
|
||||
@@ -29,18 +29,18 @@ Feature: Manage competency frameworks
|
||||
And I should see "Science Year-1"
|
||||
|
||||
Scenario: Read a framework
|
||||
Given the following lp "frameworks" exist:
|
||||
| shortname | idnumber |
|
||||
| Science Year-2 | sc-y-2 |
|
||||
Given the following "core_competency > frameworks" exist:
|
||||
| shortname | idnumber |
|
||||
| Science Year-2 | sc-y-2 |
|
||||
And I navigate to "Competencies > Competency frameworks" in site administration
|
||||
And I should see "Science Year-2"
|
||||
When I click on "Science Year-2" "link"
|
||||
Then I should see "Science Year-2"
|
||||
|
||||
Scenario: Edit a framework
|
||||
Given the following lp "frameworks" exist:
|
||||
| shortname | idnumber |
|
||||
| Science Year-3 | sc-y-3 |
|
||||
Given the following "core_competency > frameworks" exist:
|
||||
| shortname | idnumber |
|
||||
| Science Year-3 | sc-y-3 |
|
||||
And I navigate to "Competencies > Competency frameworks" in site administration
|
||||
And I should see "Science Year-3"
|
||||
And I click on "Edit" of edit menu in the "Science Year-3" row
|
||||
@@ -52,9 +52,9 @@ Feature: Manage competency frameworks
|
||||
And I should see "sc-y-3"
|
||||
|
||||
Scenario: Delete a framework
|
||||
Given the following lp "frameworks" exist:
|
||||
| shortname | idnumber |
|
||||
| Science Year-4 | sc-y-4 |
|
||||
Given the following "core_competency > frameworks" exist:
|
||||
| shortname | idnumber |
|
||||
| Science Year-4 | sc-y-4 |
|
||||
And I navigate to "Competencies > Competency frameworks" in site administration
|
||||
And I should see "Science Year-4"
|
||||
And I should see "sc-y-4"
|
||||
@@ -70,24 +70,24 @@ Feature: Manage competency frameworks
|
||||
And I should not see "sc-y-4"
|
||||
|
||||
Scenario: Edit a framework with competencies in user competency
|
||||
Given the following lp "frameworks" exist:
|
||||
| shortname | idnumber |
|
||||
| Science Year-5 | sc-y-5 |
|
||||
And the following lp "competencies" exist:
|
||||
| shortname | framework |
|
||||
| Comp1 | sc-y-5 |
|
||||
| Comp2 | sc-y-5 |
|
||||
And the following lp "plans" exist:
|
||||
| name | user | description |
|
||||
Given the following "core_competency > frameworks" exist:
|
||||
| shortname | idnumber |
|
||||
| Science Year-5 | sc-y-5 |
|
||||
And the following "core_competency > competencies" exist:
|
||||
| shortname | competencyframework | idnumber |
|
||||
| Comp1 | sc-y-5 | Comp1 |
|
||||
| Comp2 | sc-y-5 | Comp2 |
|
||||
And the following "core_competency > plans" exist:
|
||||
| name | user | description |
|
||||
| Plan Science-5 | admin | Plan description |
|
||||
And the following lp "plancompetencies" exist:
|
||||
| plan | competency |
|
||||
| Plan Science-5 | Comp1 |
|
||||
| Plan Science-5 | Comp2 |
|
||||
And the following lp "usercompetencies" exist:
|
||||
| user | competency |
|
||||
| admin | Comp1 |
|
||||
| admin | Comp2 |
|
||||
And the following "core_competency > plan_competency" exist:
|
||||
| plan | competency |
|
||||
| Plan Science-5 | Comp1 |
|
||||
| Plan Science-5 | Comp2 |
|
||||
And the following "core_competency > user_competency" exist:
|
||||
| user | competency |
|
||||
| admin | Comp1 |
|
||||
| admin | Comp2 |
|
||||
And I navigate to "Competencies > Competency frameworks" in site administration
|
||||
And I should see "Science Year-5"
|
||||
And I click on "Edit" of edit menu in the "Science Year-5" row
|
||||
@@ -100,24 +100,24 @@ Feature: Manage competency frameworks
|
||||
And I should see "sc-y-5"
|
||||
|
||||
Scenario: Edit a framework with competencies in user competency plan
|
||||
Given the following lp "frameworks" exist:
|
||||
| shortname | idnumber |
|
||||
| Science Year-6 | sc-y-6 |
|
||||
And the following lp "competencies" exist:
|
||||
| shortname | framework |
|
||||
| Comp1 | sc-y-6 |
|
||||
| Comp2 | sc-y-6 |
|
||||
And the following lp "plans" exist:
|
||||
| name | user | description |
|
||||
Given the following "core_competency > frameworks" exist:
|
||||
| shortname | idnumber |
|
||||
| Science Year-6 | sc-y-6 |
|
||||
And the following "core_competency > competencies" exist:
|
||||
| shortname | competencyframework | idnumber |
|
||||
| Comp1 | sc-y-6 | Comp1 |
|
||||
| Comp2 | sc-y-6 | Comp2 |
|
||||
And the following "core_competency > plans" exist:
|
||||
| name | user | description |
|
||||
| Plan Science-6 | admin | Plan description |
|
||||
And the following lp "plancompetencies" exist:
|
||||
| plan | competency |
|
||||
| Plan Science-6 | Comp1 |
|
||||
| Plan Science-6 | Comp2 |
|
||||
And the following lp "usercompetencyplans" exist:
|
||||
| user | competency | plan |
|
||||
| admin | Comp1 | Plan Science-6 |
|
||||
| admin | Comp2 | Plan Science-6 |
|
||||
And the following "core_competency > plan_competency" exist:
|
||||
| plan | competency |
|
||||
| Plan Science-6 | Comp1 |
|
||||
| Plan Science-6 | Comp2 |
|
||||
And the following "core_competency > user_competency_plans" exist:
|
||||
| user | competency | plan |
|
||||
| admin | Comp1 | Plan Science-6 |
|
||||
| admin | Comp2 | Plan Science-6 |
|
||||
And I navigate to "Competencies > Competency frameworks" in site administration
|
||||
And I should see "Science Year-6"
|
||||
And I click on "Edit" of edit menu in the "Science Year-6" row
|
||||
@@ -130,12 +130,12 @@ Feature: Manage competency frameworks
|
||||
And I should see "sc-y-6"
|
||||
|
||||
Scenario: Duplicate a competency framework
|
||||
Given the following lp "frameworks" exist:
|
||||
Given the following "core_competency > frameworks" exist:
|
||||
| shortname | idnumber |
|
||||
| CF1 | CF1 |
|
||||
And the following lp "competencies" exist:
|
||||
| shortname | framework |
|
||||
| C1 | CF1 |
|
||||
And the following "core_competency > competencies" exist:
|
||||
| shortname | competencyframework |
|
||||
| C1 | CF1 |
|
||||
And I navigate to "Competencies > Competency frameworks" in site administration
|
||||
# Duplicate the selected competency framework
|
||||
When I click on "Duplicate" of edit menu in the "CF1" row
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
@tool @tool_lp @tool_lp_plan
|
||||
Feature: Admin can create learning plans from template
|
||||
In order to link and unlink a learning plan from a student
|
||||
As an admin
|
||||
I need to be able to create a learning plan from template
|
||||
|
||||
Background:
|
||||
Given the following "users" exist:
|
||||
| username | firstname | lastname | email |
|
||||
| student1 | Student | One | student1@email.com |
|
||||
And the following "core_competency > frameworks" exist:
|
||||
| shortname | idnumber |
|
||||
| CF1 | CF1 |
|
||||
| CF2 | CF2 |
|
||||
And the following "core_competency > competencies" exist:
|
||||
| shortname | competencyframework | idnumber |
|
||||
| CF1 Competency 1 | CF1 | CF1C1 |
|
||||
| CF2 Competency 1 | CF2 | CF2C1 |
|
||||
And the following "core_competency > templates" exist:
|
||||
| shortname | description |
|
||||
| LPT1 | LPT 1 description |
|
||||
And the following "core_competency > template_competencies" exist:
|
||||
| template | competency |
|
||||
| LPT1 | CF1C1 |
|
||||
|
||||
@javascript
|
||||
Scenario: Admin can assign competencies from different frameworks to a learning plan
|
||||
Given I log in as "admin"
|
||||
And I navigate to "Competencies > Learning plan templates" in site administration
|
||||
And I follow "LPT1"
|
||||
When I press "Add competencies to learning plan template"
|
||||
# Add another competency from a different framework in the learning plan template.
|
||||
And I set the field with xpath "//select[@data-action='chooseframework']" to "CF2 CF2"
|
||||
And I select "CF2 Competency 1" of the competency tree
|
||||
And I click on "Add" "button" in the "Competency picker" "dialogue"
|
||||
# Confirm that both competencies from different frameworks are successfully added to learning plan template.
|
||||
Then "LPT1" "text" should exist
|
||||
And "Learning plan template competencies" "text" should exist
|
||||
And "CF1 Competency 1" "text" should exist
|
||||
And "CF2 Competency 1" "text" should exist
|
||||
|
||||
@javascript
|
||||
Scenario: Admin can link and unlink learning plans created from template
|
||||
Given I log in as "admin"
|
||||
And I navigate to "Competencies > Learning plan templates" in site administration
|
||||
# Initially, the number of learning plans is 0.
|
||||
And the following should exist in the "generaltable" table:
|
||||
| Name | Learning plans |
|
||||
| LPT1 | 0 |
|
||||
And I click on ".template-userplans" "css_element" in the "LPT1" "table_row"
|
||||
# Create a learning plan for selected student using template.
|
||||
When I set the field "Select users" to "student1"
|
||||
And I press "Create learning plans"
|
||||
# Confirm that selected student is now in the list of learning plans.
|
||||
Then "A learning plan was created" "text" should exist
|
||||
And the following should exist in the "generaltable" table:
|
||||
| Name | Email address |
|
||||
| LPT1 | student1@email.com |
|
||||
And I click on "LPT1" "link" in the "LPT1" "table_row"
|
||||
# Template title and Unlink from learning plan template link exists.
|
||||
And "Learning plan template" "text" should exist
|
||||
And "Unlink from learning plan template" "link" should exist
|
||||
# Unlink the template.
|
||||
And I click on "Unlink from learning plan template" "link"
|
||||
And I press "Unlink from learning plan template"
|
||||
# Learning plan still exists but Template name and Unlink from learning plan template link no longer exist.
|
||||
And "LPT1" "text" should exist
|
||||
And "Learning plan template" "text" should not exist
|
||||
And "Unlink from learning plan template" "link" should not exist
|
||||
@@ -22,8 +22,8 @@ Feature: Manage plearning plan
|
||||
And I should see "Science plan"
|
||||
|
||||
Scenario: Create a learning plan based on template
|
||||
Given the following lp "templates" exist:
|
||||
| shortname | description |
|
||||
Given the following "core_competency > templates" exist:
|
||||
| shortname | description |
|
||||
| Science template | science template description |
|
||||
And I am on homepage
|
||||
And I navigate to "Competencies > Learning plan templates" in site administration
|
||||
@@ -36,8 +36,8 @@ Feature: Manage plearning plan
|
||||
And I should see "Admin User" in the "Science template" "table_row"
|
||||
|
||||
Scenario: Create a learning plan from template cohort
|
||||
Given the following lp "templates" exist:
|
||||
| shortname | description |
|
||||
Given the following "core_competency > templates" exist:
|
||||
| shortname | description |
|
||||
| Science template cohort | science template description |
|
||||
And the following "users" exist:
|
||||
| username | firstname | lastname | email |
|
||||
@@ -62,8 +62,8 @@ Feature: Manage plearning plan
|
||||
And I should see "Student 2"
|
||||
|
||||
Scenario: Read a learning plan
|
||||
Given the following lp "plans" exist:
|
||||
| name | user | description |
|
||||
Given the following "core_competency > plans" exist:
|
||||
| name | user | description |
|
||||
| Science plan Year-1 | admin | science plan description |
|
||||
And I follow "Learning plans"
|
||||
And I should see "Science plan Year-1"
|
||||
@@ -72,16 +72,16 @@ Feature: Manage plearning plan
|
||||
And I should see "Learning plan competencies"
|
||||
|
||||
Scenario: Manage a learning plan competencies
|
||||
Given the following lp "plans" exist:
|
||||
| name | user | description |
|
||||
Given the following "core_competency > plans" exist:
|
||||
| name | user | description |
|
||||
| Science plan Year-manage | admin | science plan description |
|
||||
And the following lp "frameworks" exist:
|
||||
And the following "core_competency > frameworks" exist:
|
||||
| shortname | idnumber |
|
||||
| Framework 1 | sc-y-2 |
|
||||
And the following lp "competencies" exist:
|
||||
| shortname | framework |
|
||||
| comp1 | sc-y-2 |
|
||||
| comp2 | sc-y-2 |
|
||||
And the following "core_competency > competencies" exist:
|
||||
| shortname | competencyframework |
|
||||
| comp1 | sc-y-2 |
|
||||
| comp2 | sc-y-2 |
|
||||
And I follow "Learning plans"
|
||||
And I should see "Science plan Year-manage"
|
||||
And I follow "Science plan Year-manage"
|
||||
@@ -98,8 +98,8 @@ Feature: Manage plearning plan
|
||||
And "comp1" "table_row" should not exist
|
||||
|
||||
Scenario: Edit a learning plan
|
||||
Given the following lp "plans" exist:
|
||||
| name | user | description |
|
||||
Given the following "core_competency > plans" exist:
|
||||
| name | user | description |
|
||||
| Science plan Year-2 | admin | science plan description |
|
||||
| Science plan Year-3 | admin | science plan description |
|
||||
And I follow "Learning plans"
|
||||
@@ -113,8 +113,8 @@ Feature: Manage plearning plan
|
||||
And I should see "Science plan Year-3 Edited"
|
||||
|
||||
Scenario: Delete a learning plan
|
||||
Given the following lp "plans" exist:
|
||||
| name | user | description |
|
||||
Given the following "core_competency > plans" exist:
|
||||
| name | user | description |
|
||||
| Science plan Year-4 | admin | science plan description |
|
||||
And I follow "Learning plans"
|
||||
And I should see "Science plan Year-4"
|
||||
@@ -130,16 +130,16 @@ Feature: Manage plearning plan
|
||||
Then I should not see "Science plan Year-4"
|
||||
|
||||
Scenario: See a learning plan from a course
|
||||
Given the following lp "plans" exist:
|
||||
| name | user | description |
|
||||
Given the following "core_competency > plans" exist:
|
||||
| name | user | description |
|
||||
| Science plan Year-manage | admin | science plan description |
|
||||
And the following lp "frameworks" exist:
|
||||
| shortname | idnumber |
|
||||
| Framework 1 | sc-y-2 |
|
||||
And the following lp "competencies" exist:
|
||||
| shortname | framework |
|
||||
| comp1 | sc-y-2 |
|
||||
| comp2 | sc-y-2 |
|
||||
And the following "core_competency > frameworks" exist:
|
||||
| shortname | idnumber |
|
||||
| Framework 1 | sc-y-2 |
|
||||
And the following "core_competency > competencies" exist:
|
||||
| shortname | competencyframework |
|
||||
| comp1 | sc-y-2 |
|
||||
| comp2 | sc-y-2 |
|
||||
And I follow "Learning plans"
|
||||
And I should see "Science plan Year-manage"
|
||||
And I follow "Science plan Year-manage"
|
||||
|
||||
@@ -31,19 +31,19 @@ Feature: Manage plan workflow
|
||||
| user1 | usermanageowndraftplan | System | |
|
||||
| user2 | usermanageownplan | System | |
|
||||
| manager1 | manageplan | System | |
|
||||
And the following lp "frameworks" exist:
|
||||
| shortname | idnumber |
|
||||
| Test-Framework | ID-FW1 |
|
||||
And the following lp "competencies" exist:
|
||||
| shortname | framework |
|
||||
| Test-Comp1 | ID-FW1 |
|
||||
| Test-Comp2 | ID-FW1 |
|
||||
And the following lp "plans" exist:
|
||||
| name | user | description |
|
||||
| Test-Plan1 | user1 | Description of plan for user 1 |
|
||||
| Test-Plan2 | user2 | Description of plan for user 2 |
|
||||
And the following lp "plancompetencies" exist:
|
||||
| plan | competency |
|
||||
And the following "core_competency > frameworks" exist:
|
||||
| shortname | idnumber |
|
||||
| Test-Framework | ID-FW1 |
|
||||
And the following "core_competency > competencies" exist:
|
||||
| shortname | competencyframework | idnumber |
|
||||
| Test-Comp1 | ID-FW1 | Test-Comp1 |
|
||||
| Test-Comp2 | ID-FW1 | Test-Comp2 |
|
||||
And the following "core_competency > plans" exist:
|
||||
| name | user | description | reviewer | status |
|
||||
| Test-Plan1 | user1 | Description of plan for user 1 | user1 | draft |
|
||||
| Test-Plan2 | user2 | Description of plan for user 2 | user2 | draft |
|
||||
And the following "core_competency > plan_competency" exist:
|
||||
| plan | competency |
|
||||
| Test-Plan1 | Test-Comp1 |
|
||||
| Test-Plan1 | Test-Comp2 |
|
||||
| Test-Plan2 | Test-Comp1 |
|
||||
@@ -89,18 +89,18 @@ Feature: Manage plan workflow
|
||||
And I log out
|
||||
|
||||
Scenario: Manager can see learning plan with status waiting for review
|
||||
Given the following lp "plans" exist:
|
||||
| name | user | description | status |
|
||||
Given the following "core_competency > plans" exist:
|
||||
| name | user | description | status |
|
||||
| Test-Plan3 | user2 | Description of plan 3 for user 1 | waiting for review |
|
||||
| Test-Plan4 | user1 | Description of plan 3 for user 1 | draft |
|
||||
| Test-Plan4 | user1 | Description of plan 3 for user 1 | draft |
|
||||
When I log in as "manager1"
|
||||
Then I should see "Test-Plan3"
|
||||
And I should not see "Test-Plan4"
|
||||
And I log out
|
||||
|
||||
Scenario: Manager can start review of learning plan with status waiting for review
|
||||
Given the following lp "plans" exist:
|
||||
| name | user | description | status |
|
||||
Given the following "core_competency > plans" exist:
|
||||
| name | user | description | status |
|
||||
| Test-Plan3 | user1 | Description of plan 3 for user 1 | waiting for review |
|
||||
And I log in as "manager1"
|
||||
And I follow "Test-Plan3"
|
||||
@@ -111,8 +111,8 @@ Feature: Manage plan workflow
|
||||
And I log out
|
||||
|
||||
Scenario: Manager can reject a learning plan with status in review
|
||||
Given the following lp "plans" exist:
|
||||
| name | user | description | status | reviewer |
|
||||
Given the following "core_competency > plans" exist:
|
||||
| name | user | description | status | reviewer |
|
||||
| Test-Plan3 | user1 | Description of plan 3 for user 1 | in review | manager1 |
|
||||
And I log in as "manager1"
|
||||
And I follow "Test-Plan3"
|
||||
@@ -124,8 +124,8 @@ Feature: Manage plan workflow
|
||||
And I log out
|
||||
|
||||
Scenario: Manager can accept a learning plan with status in review
|
||||
Given the following lp "plans" exist:
|
||||
| name | user | description | status | reviewer |
|
||||
Given the following "core_competency > plans" exist:
|
||||
| name | user | description | status | reviewer |
|
||||
| Test-Plan3 | user1 | Description of plan 3 for user 1 | in review | manager1 |
|
||||
And I log in as "manager1"
|
||||
And I follow "Test-Plan3"
|
||||
@@ -137,8 +137,8 @@ Feature: Manage plan workflow
|
||||
And I log out
|
||||
|
||||
Scenario: Manager send back to draft an active learning plan
|
||||
Given the following lp "plans" exist:
|
||||
| name | user | description | status | reviewer |
|
||||
Given the following "core_competency > plans" exist:
|
||||
| name | user | description | status | reviewer |
|
||||
| Test-Plan3 | user1 | Description of plan 3 for user 1 | active | manager1 |
|
||||
| Test-Plan4 | user1 | Description of plan 4 for user 1 | active | manager1 |
|
||||
And I am on the "user1" "user > profile" page logged in as "manager1"
|
||||
@@ -153,8 +153,8 @@ Feature: Manage plan workflow
|
||||
And I log out
|
||||
|
||||
Scenario: Manager change an active learning plan to completed
|
||||
Given the following lp "plans" exist:
|
||||
| name | user | description | status | reviewer |
|
||||
Given the following "core_competency > plans" exist:
|
||||
| name | user | description | status | reviewer |
|
||||
| Test-Plan3 | user1 | Description of plan 3 for user 1 | active | manager1 |
|
||||
| Test-Plan4 | user1 | Description of plan 4 for user 1 | active | manager1 |
|
||||
And I am on the "user1" "user > profile" page logged in as "manager1"
|
||||
@@ -172,8 +172,8 @@ Feature: Manage plan workflow
|
||||
And I log out
|
||||
|
||||
Scenario: Manager reopen a complete learning plan
|
||||
Given the following lp "plans" exist:
|
||||
| name | user | description | status | reviewer |
|
||||
Given the following "core_competency > plans" exist:
|
||||
| name | user | description | status | reviewer |
|
||||
| Test-Plan3 | user1 | Description of plan 3 for user 1 | complete | manager1 |
|
||||
| Test-Plan4 | user1 | Description of plan 4 for user 1 | complete | manager1 |
|
||||
And I am on the "user1" "user > profile" page logged in as "manager1"
|
||||
|
||||
@@ -21,8 +21,8 @@ Feature: Manage plearning plan templates
|
||||
And I should see "Science template"
|
||||
|
||||
Scenario: Read a learning plan template
|
||||
Given the following lp "templates" exist:
|
||||
| shortname | description |
|
||||
Given the following "core_competency > templates" exist:
|
||||
| shortname | description |
|
||||
| Science template Year-2 | science template description |
|
||||
And I navigate to "Competencies > Learning plan templates" in site administration
|
||||
And I should see "Science template Year-2"
|
||||
@@ -31,8 +31,8 @@ Feature: Manage plearning plan templates
|
||||
And I should see "Learning plan template competencies"
|
||||
|
||||
Scenario: Edit a learning plan template
|
||||
Given the following lp "templates" exist:
|
||||
| shortname | description |
|
||||
Given the following "core_competency > templates" exist:
|
||||
| shortname | description |
|
||||
| Science template Year-3 | science template description |
|
||||
And I navigate to "Competencies > Learning plan templates" in site administration
|
||||
And I should see "Science template Year-3"
|
||||
@@ -44,8 +44,8 @@ Feature: Manage plearning plan templates
|
||||
And I should see "Science template Year-3 Edited"
|
||||
|
||||
Scenario: Delete a learning plan template
|
||||
Given the following lp "templates" exist:
|
||||
| shortname | description |
|
||||
Given the following "core_competency > templates" exist:
|
||||
| shortname | description |
|
||||
| Science template Year-4 | science template description |
|
||||
And I navigate to "Competencies > Learning plan templates" in site administration
|
||||
And I should see "Science template Year-4"
|
||||
|
||||
@@ -5,22 +5,22 @@ Feature: Manage competencies linked to evidence of prior learning
|
||||
I need to link and unlink competencies from evidence of prior learning
|
||||
|
||||
Background:
|
||||
Given the following lp "frameworks" exist:
|
||||
| shortname | idnumber |
|
||||
| Test-Framework | ID-FW1 |
|
||||
And the following lp "competencies" exist:
|
||||
| shortname | framework |
|
||||
| Test-Comp1 | ID-FW1 |
|
||||
| Test-Comp2 | ID-FW1 |
|
||||
And the following lp "plans" exist:
|
||||
| name | user | description |
|
||||
Given the following "core_competency > frameworks" exist:
|
||||
| shortname | idnumber |
|
||||
| Test-Framework | ID-FW1 |
|
||||
And the following "core_competency > competencies" exist:
|
||||
| shortname | competencyframework | idnumber |
|
||||
| Test-Comp1 | ID-FW1 | Test-Comp1 |
|
||||
| Test-Comp2 | ID-FW1 | Test-Comp2 |
|
||||
And the following "core_competency > plans" exist:
|
||||
| name | user | description |
|
||||
| Test-Plan | admin | Plan description |
|
||||
And the following lp "plancompetencies" exist:
|
||||
| plan | competency |
|
||||
And the following "core_competency > plan_competency" exist:
|
||||
| plan | competency |
|
||||
| Test-Plan | Test-Comp1 |
|
||||
| Test-Plan | Test-Comp2 |
|
||||
And the following lp "userevidence" exist:
|
||||
| name | description | user |
|
||||
And the following "core_competency > user_evidence" exist:
|
||||
| name | description | user |
|
||||
| Test-Evidence | Description evidence | admin |
|
||||
When I log in as "admin"
|
||||
And I follow "Profile" in the user menu
|
||||
@@ -51,12 +51,12 @@ Feature: Manage competencies linked to evidence of prior learning
|
||||
Then "Test-Comp2" "table_row" should exist
|
||||
|
||||
Scenario: Unlink competency from evidence of prior learning
|
||||
Given the following lp "userevidencecompetencies" exist:
|
||||
| userevidence | competency |
|
||||
Given the following "core_competency > user_evidence_competency" exist:
|
||||
| userevidence | competency |
|
||||
| Test-Evidence | Test-Comp1 |
|
||||
| Test-Evidence | Test-Comp2 |
|
||||
Given the following lp "usercompetencies" exist:
|
||||
| user | competency |
|
||||
And the following "core_competency > user_competency" exist:
|
||||
| user | competency |
|
||||
| admin | Test-Comp1 |
|
||||
| admin | Test-Comp2 |
|
||||
And I follow "Evidence of prior learning"
|
||||
|
||||
@@ -24,8 +24,8 @@ Feature: Manage evidence of prior learning
|
||||
And I should see "Evidence-1"
|
||||
|
||||
Scenario: Read an evidence of prior learning
|
||||
Given the following lp "userevidence" exist:
|
||||
| name | description | user |
|
||||
Given the following "core_competency > user_evidence" exist:
|
||||
| name | description | user |
|
||||
| Evidence-2 | Evidence-2 description | user1 |
|
||||
And I follow "Evidence of prior learning"
|
||||
And I should see "List of evidence"
|
||||
@@ -34,8 +34,8 @@ Feature: Manage evidence of prior learning
|
||||
Then I should see "Evidence-2"
|
||||
|
||||
Scenario: Edit an evidence of prior learning
|
||||
Given the following lp "userevidence" exist:
|
||||
| name | description | user |
|
||||
Given the following "core_competency > user_evidence" exist:
|
||||
| name | description | user |
|
||||
| Evidence-3 | Evidence-3 description | user1 |
|
||||
And I follow "Evidence of prior learning"
|
||||
And I should see "List of evidence"
|
||||
@@ -47,8 +47,8 @@ Feature: Manage evidence of prior learning
|
||||
And I should see "Evidence-3 Edited"
|
||||
|
||||
Scenario: Delete an evidence of prior learning
|
||||
Given the following lp "userevidence" exist:
|
||||
| name | description | user |
|
||||
Given the following "core_competency > user_evidence" exist:
|
||||
| name | description | user |
|
||||
| Evidence-4 | Evidence-4 description | user1 |
|
||||
And I follow "Evidence of prior learning"
|
||||
And I should see "List of evidence"
|
||||
@@ -62,10 +62,10 @@ Feature: Manage evidence of prior learning
|
||||
|
||||
Scenario: List evidences of prior learning
|
||||
Given the following "users" exist:
|
||||
| username | firstname | lastname | email |
|
||||
|user2 | User | 2 | user2@example.com |
|
||||
And the following lp "userevidence" exist:
|
||||
| name | description | user |
|
||||
| username | firstname | lastname | email |
|
||||
| user2 | User | 2 | user2@example.com |
|
||||
And the following "core_competency > user_evidence" exist:
|
||||
| name | description | user |
|
||||
| Evidence-5 | Evidence-5 description | user1 |
|
||||
| Evidence-6 | Evidence-6 description | user2 |
|
||||
And I follow "Evidence of prior learning"
|
||||
|
||||
@@ -40,9 +40,9 @@ Feature: View competencies
|
||||
| competency | relatedcompetency |
|
||||
| desserts | cakes |
|
||||
And the following "core_competency > plans" exist:
|
||||
| name | description | competencies | user |
|
||||
| Cookery | Cookery is important | salads, desserts, cakes | student1 |
|
||||
| Literacy | Literacy is important | reading, writing | student1 |
|
||||
| name | description | competencies | user | status |
|
||||
| Cookery | Cookery is important | salads, desserts, cakes | student1 | active |
|
||||
| Literacy | Literacy is important | reading, writing | student1 | active |
|
||||
And the following "core_competency > course_competencies" exist:
|
||||
| course | competency |
|
||||
| C1 | salads |
|
||||
|
||||
@@ -47,6 +47,11 @@ abstract class restore_qtype_plugin extends restore_plugin {
|
||||
*/
|
||||
private $questionanswercacheid = null;
|
||||
|
||||
/**
|
||||
* @var array List of fields to exclude form hashing during restore.
|
||||
*/
|
||||
protected array $excludedhashfields = [];
|
||||
|
||||
/**
|
||||
* Add to $paths the restore_path_elements needed
|
||||
* to handle question_answers for a given question
|
||||
@@ -62,6 +67,10 @@ abstract class restore_qtype_plugin extends restore_plugin {
|
||||
$elename = 'question_answer';
|
||||
$elepath = $this->get_pathfor('/answers/answer'); // we used get_recommended_name() so this works
|
||||
$paths[] = new restore_path_element($elename, $elepath);
|
||||
$this->exclude_identity_hash_fields([
|
||||
'/options/answers/id',
|
||||
'/options/answers/question',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,6 +87,10 @@ abstract class restore_qtype_plugin extends restore_plugin {
|
||||
$elename = 'question_numerical_unit';
|
||||
$elepath = $this->get_pathfor('/numerical_units/numerical_unit'); // we used get_recommended_name() so this works
|
||||
$paths[] = new restore_path_element($elename, $elepath);
|
||||
$this->exclude_identity_hash_fields([
|
||||
'/options/units/id',
|
||||
'/options/units/question',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,6 +107,7 @@ abstract class restore_qtype_plugin extends restore_plugin {
|
||||
$elename = 'question_numerical_option';
|
||||
$elepath = $this->get_pathfor('/numerical_options/numerical_option'); // we used get_recommended_name() so this works
|
||||
$paths[] = new restore_path_element($elename, $elepath);
|
||||
$this->exclude_identity_hash_fields(['/options/question']);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -114,6 +128,21 @@ abstract class restore_qtype_plugin extends restore_plugin {
|
||||
$elename = 'question_dataset_item';
|
||||
$elepath = $this->get_pathfor('/dataset_definitions/dataset_definition/dataset_items/dataset_item');
|
||||
$paths[] = new restore_path_element($elename, $elepath);
|
||||
$this->exclude_identity_hash_fields([
|
||||
'/options/datasets/id',
|
||||
'/options/datasets/question',
|
||||
'/options/datasets/category',
|
||||
'/options/datasets/type',
|
||||
'/options/datasets/items/id',
|
||||
// The following fields aren't included in the backup or DB structure, but are parsed from the options field.
|
||||
'/options/datasets/status',
|
||||
'/options/datasets/distribution',
|
||||
'/options/datasets/minimum',
|
||||
'/options/datasets/maximum',
|
||||
'/options/datasets/decimals',
|
||||
// This field is set dynamically from the count of items in the dataset, it is not backed up.
|
||||
'/options/datasets/number_of_items',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -395,4 +424,183 @@ abstract class restore_qtype_plugin extends restore_plugin {
|
||||
|
||||
return $contents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add fields to the list of fields excluded from hashing.
|
||||
*
|
||||
* This allows common methods to add fields to the exclusion list.
|
||||
*
|
||||
* @param array $fields
|
||||
* @return void
|
||||
*/
|
||||
private function exclude_identity_hash_fields(array $fields): void {
|
||||
$this->excludedhashfields = array_merge($this->excludedhashfields, $fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return fields to be excluded from hashing during restores.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
final public function get_excluded_identity_hash_fields(): array {
|
||||
return array_unique(array_merge(
|
||||
$this->excludedhashfields,
|
||||
$this->define_excluded_identity_hash_fields(),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a list of paths to fields to be removed from questiondata before creating an identity hash.
|
||||
*
|
||||
* Fields that should be excluded from common elements such as answers or numerical units that are used by the plugin will
|
||||
* be excluded automatically. This method just needs to define any specific to this plugin, such as foreign keys used in the
|
||||
* plugin's tables.
|
||||
*
|
||||
* The returned array should be a list of slash-delimited paths to locate the fields to be removed from the questiondata object.
|
||||
* For example, if you want to remove the field `$questiondata->options->questionid`, the path would be '/options/questionid'.
|
||||
* If a field in the path is an array, the rest of the path will be applied to each object in the array. So if you have
|
||||
* `$questiondata->options->answers[]`, the path '/options/answers/id' will remove the 'id' field from each element of the
|
||||
* 'answers' array.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function define_excluded_identity_hash_fields(): array {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the backup structure of this question type into a structure matching its question data
|
||||
*
|
||||
* This should take the hierarchical array of tags from the question's backup structure, and return a structure that matches
|
||||
* that returned when calling {@see get_question_options()} for this question type.
|
||||
* See https://docs.moodle.org/dev/Question_data_structures#Representation_1:_%24questiondata for an explanation of this
|
||||
* structure.
|
||||
*
|
||||
* This data will then be used to produce an identity hash for comparison with questions in the database.
|
||||
*
|
||||
* This base implementation deals with all common backup elements created by the add_question_*_options() methods in this class,
|
||||
* plus elements added by ::define_question_plugin_structure() named for the qtype. The question type will need to extend
|
||||
* this function if ::define_question_plugin_structure() adds any other elements to the backup.
|
||||
*
|
||||
* @param array $backupdata The hierarchical array of tags from the backup.
|
||||
* @return \stdClass The questiondata object.
|
||||
*/
|
||||
public static function convert_backup_to_questiondata(array $backupdata): \stdClass {
|
||||
// Create an object from the top-level fields.
|
||||
$questiondata = (object) array_filter($backupdata, fn($tag) => !is_array($tag));
|
||||
$qtype = $questiondata->qtype;
|
||||
$questiondata->options = new stdClass();
|
||||
if (isset($backupdata["plugin_qtype_{$qtype}_question"][$qtype])) {
|
||||
$questiondata->options = (object) $backupdata["plugin_qtype_{$qtype}_question"][$qtype][0];
|
||||
}
|
||||
if (isset($backupdata["plugin_qtype_{$qtype}_question"]['answers'])) {
|
||||
$questiondata->options->answers = array_map(
|
||||
fn($answer) => (object) $answer,
|
||||
$backupdata["plugin_qtype_{$qtype}_question"]['answers']['answer'],
|
||||
);
|
||||
}
|
||||
if (isset($backupdata["plugin_qtype_{$qtype}_question"]['numerical_options'])) {
|
||||
$questiondata->options = (object) array_merge(
|
||||
(array) $questiondata->options,
|
||||
$backupdata["plugin_qtype_{$qtype}_question"]['numerical_options']['numerical_option'][0],
|
||||
);
|
||||
}
|
||||
if (isset($backupdata["plugin_qtype_{$qtype}_question"]['numerical_units'])) {
|
||||
$questiondata->options->units = array_map(
|
||||
fn($unit) => (object) $unit,
|
||||
$backupdata["plugin_qtype_{$qtype}_question"]['numerical_units']['numerical_unit'],
|
||||
);
|
||||
}
|
||||
if (isset($backupdata["plugin_qtype_{$qtype}_question"]['dataset_definitions'])) {
|
||||
$questiondata->options->datasets = array_map(
|
||||
fn($dataset) => (object) $dataset,
|
||||
$backupdata["plugin_qtype_{$qtype}_question"]['dataset_definitions']['dataset_definition'],
|
||||
);
|
||||
}
|
||||
if (isset($questiondata->options->datasets)) {
|
||||
foreach ($questiondata->options->datasets as $dataset) {
|
||||
if (isset($dataset->dataset_items)) {
|
||||
$dataset->items = array_map(
|
||||
fn($item) => (object) $item,
|
||||
$dataset->dataset_items['dataset_item'],
|
||||
);
|
||||
unset($dataset->dataset_items);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isset($backupdata['question_hints'])) {
|
||||
$questiondata->hints = array_map(
|
||||
fn($hint) => (object) $hint,
|
||||
$backupdata['question_hints']['question_hint'],
|
||||
);
|
||||
}
|
||||
|
||||
return $questiondata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove excluded fields from the questiondata structure.
|
||||
*
|
||||
* This removes fields that will not match or not be present in the question data structure produced by
|
||||
* {@see self::convert_backup_to_questiondata()} and {@see get_question_options()} (such as IDs), so that the remaining data can
|
||||
* be used to produce an identity hash for comparing the two.
|
||||
*
|
||||
* For plugins, it should be sufficient to override {@see self::define_excluded_identity_hash_fields()} with a list of paths
|
||||
* specific to the plugin type. Overriding this method is only necessary if the plugin's
|
||||
* {@see question_type::get_question_options()} method adds additional data to the question that is not included in the backup.
|
||||
*
|
||||
* @param stdClass $questiondata
|
||||
* @param array $excludefields Paths to the fields to exclude.
|
||||
* @return stdClass The $questiondata with excluded fields removed.
|
||||
*/
|
||||
public static function remove_excluded_question_data(stdClass $questiondata, array $excludefields = []): stdClass {
|
||||
// All questions will need to exclude 'id' (used by question and other tables), 'questionid' (used by hints and options),
|
||||
// 'createdby' and 'modifiedby' (since they won't map between sites).
|
||||
$defaultexcludes = [
|
||||
'/id',
|
||||
'/createdby',
|
||||
'/modifiedby',
|
||||
'/hints/id',
|
||||
'/hints/questionid',
|
||||
'/options/id',
|
||||
'/options/questionid',
|
||||
];
|
||||
$excludefields = array_unique(array_merge($excludefields, $defaultexcludes));
|
||||
|
||||
foreach ($excludefields as $excludefield) {
|
||||
$pathparts = explode('/', ltrim($excludefield, '/'));
|
||||
$data = $questiondata;
|
||||
self::unset_excluded_fields($data, $pathparts);
|
||||
}
|
||||
|
||||
return $questiondata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate through the elements of path to an excluded field, and unset the final element.
|
||||
*
|
||||
* If any of the elements in the path is an array, this is called recursively on each element in the array to unset fields
|
||||
* in each child of the array.
|
||||
*
|
||||
* @param stdClass|array $data The questiondata object, or a subsection of it.
|
||||
* @param array $pathparts The remaining elements in the path to the excluded field.
|
||||
* @return void
|
||||
*/
|
||||
private static function unset_excluded_fields(stdClass|array $data, array $pathparts): void {
|
||||
$element = array_shift($pathparts);
|
||||
if (!isset($data->{$element})) {
|
||||
// This element is not present in the data structure, nothing to unset.
|
||||
return;
|
||||
}
|
||||
if (is_object($data->{$element})) {
|
||||
self::unset_excluded_fields($data->{$element}, $pathparts);
|
||||
} else if (is_array($data->{$element})) {
|
||||
foreach ($data->{$element} as $item) {
|
||||
self::unset_excluded_fields($item, $pathparts);
|
||||
}
|
||||
} else if (empty($pathparts)) {
|
||||
// This is the last element of the path and it's a scalar value, unset it.
|
||||
unset($data->{$element});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,8 @@ final class quiz_restore_decode_links_test extends \advanced_testcase {
|
||||
|
||||
$questiondata = \question_bank::load_question_data($question->id);
|
||||
|
||||
$DB->set_field('question', 'questiontext', $CFG->wwwroot . '/mod/quiz/view.php?id=' . $quiz->cmid, ['id' => $question->id]);
|
||||
|
||||
$firstanswer = array_shift($questiondata->options->answers);
|
||||
$DB->set_field('question_answers', 'answer', $CFG->wwwroot . '/course/view.php?id=' . $course->id,
|
||||
['id' => $firstanswer->id]);
|
||||
@@ -87,6 +89,7 @@ final class quiz_restore_decode_links_test extends \advanced_testcase {
|
||||
$questionids = [];
|
||||
foreach ($quizquestions as $quizquestion) {
|
||||
if ($quizquestion->questionid) {
|
||||
$this->assertEquals($CFG->wwwroot . '/mod/quiz/view.php?id=' . $quiz->cmid, $quizquestion->questiontext);
|
||||
$questionids[] = $quizquestion->questionid;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -567,7 +567,7 @@ abstract class restore_dbops {
|
||||
* @return array A separate list of all error and warnings detected
|
||||
*/
|
||||
public static function prechek_precheck_qbanks_by_level($restoreid, $courseid, $userid, $samesite, $contextlevel) {
|
||||
global $DB;
|
||||
global $DB, $CFG;
|
||||
|
||||
// To return any errors and warnings found
|
||||
$errors = array();
|
||||
@@ -669,21 +669,37 @@ abstract class restore_dbops {
|
||||
} else {
|
||||
self::set_backup_ids_record($restoreid, 'question_category', $category->id, $matchcat->id, $targetcontext->id);
|
||||
$questions = self::restore_get_questions($restoreid, $category->id);
|
||||
$transformer = self::get_backup_xml_transformer($courseid);
|
||||
|
||||
// Collect all the questions for this category into memory so we only talk to the DB once.
|
||||
$questioncache = $DB->get_records_sql_menu('SELECT q.stamp, q.id
|
||||
FROM {question} q
|
||||
JOIN {question_versions} qv
|
||||
ON qv.questionid = q.id
|
||||
JOIN {question_bank_entries} qbe
|
||||
ON qbe.id = qv.questionbankentryid
|
||||
JOIN {question_categories} qc
|
||||
ON qc.id = qbe.questioncategoryid
|
||||
WHERE qc.id = ?', array($matchcat->id));
|
||||
$recordset = $DB->get_recordset_sql(
|
||||
"SELECT q.*
|
||||
FROM {question} q
|
||||
JOIN {question_versions} qv ON qv.questionid = q.id
|
||||
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
|
||||
JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
|
||||
WHERE qc.id = ?",
|
||||
[$matchcat->id],
|
||||
);
|
||||
|
||||
// Compute a hash of question and answer fields to differentiate between identical stamp-version questions.
|
||||
$questioncache = [];
|
||||
foreach ($recordset as $question) {
|
||||
$question->export_process = true; // Include all question options required for export.
|
||||
get_question_options($question);
|
||||
unset($question->export_process);
|
||||
// Remove some additional properties from get_question_options() that isn't included in backups
|
||||
// before we produce the identity hash.
|
||||
unset($question->categoryobject);
|
||||
unset($question->questioncategoryid);
|
||||
$cachekey = restore_questions_parser_processor::generate_question_identity_hash($question, $transformer);
|
||||
$questioncache[$cachekey] = $question->id;
|
||||
}
|
||||
$recordset->close();
|
||||
|
||||
foreach ($questions as $question) {
|
||||
if (isset($questioncache[$question->stamp])) {
|
||||
$matchqid = $questioncache[$question->stamp];
|
||||
if (isset($questioncache[$question->questionhash])) {
|
||||
$matchqid = $questioncache[$question->questionhash];
|
||||
} else {
|
||||
$matchqid = false;
|
||||
}
|
||||
@@ -1918,6 +1934,22 @@ abstract class restore_dbops {
|
||||
private static function password_should_be_discarded(#[\SensitiveParameter] string $password): bool {
|
||||
return (bool) preg_match('/^[0-9a-f]{32}$/', $password);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load required classes and return a backup XML transformer for the specified course.
|
||||
*
|
||||
* These classes may not have been loaded if we're only doing a restore in the current process,
|
||||
* so make sure we have them here.
|
||||
*
|
||||
* @param int $courseid
|
||||
* @return backup_xml_transformer
|
||||
*/
|
||||
protected static function get_backup_xml_transformer(int $courseid): backup_xml_transformer {
|
||||
global $CFG;
|
||||
require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php');
|
||||
require_once($CFG->dirroot . '/backup/moodle2/backup_plan_builder.class.php');
|
||||
return new backup_xml_transformer($courseid);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -45,6 +45,9 @@ class restore_questions_parser_processor extends grouped_parser_processor {
|
||||
/** @var string XML path in the questions.xml to question elements within question_category (before Moodle 4.0). */
|
||||
protected const LEGACY_QUESTION_SUBPATH = '/questions/question';
|
||||
|
||||
/** @var string String for concatenating data into a string for hashing.*/
|
||||
protected const HASHDATA_SEPARATOR = '|HASHDATA|';
|
||||
|
||||
/** @var string identifies the current restore. */
|
||||
protected string $restoreid;
|
||||
|
||||
@@ -52,13 +55,44 @@ class restore_questions_parser_processor extends grouped_parser_processor {
|
||||
protected int $lastcatid;
|
||||
|
||||
public function __construct($restoreid) {
|
||||
global $CFG;
|
||||
$this->restoreid = $restoreid;
|
||||
$this->lastcatid = 0;
|
||||
parent::__construct();
|
||||
// Set the paths we are interested on
|
||||
$this->add_path(self::CATEGORY_PATH);
|
||||
$this->add_path(self::CATEGORY_PATH . self::QUESTION_SUBPATH);
|
||||
$this->add_path(self::CATEGORY_PATH . self::LEGACY_QUESTION_SUBPATH);
|
||||
$this->add_path(self::CATEGORY_PATH . self::QUESTION_SUBPATH, true);
|
||||
$this->add_path(self::CATEGORY_PATH . self::LEGACY_QUESTION_SUBPATH, true);
|
||||
|
||||
// Add all sub-elements, including those from plugins, as grouped paths with the question tag so that
|
||||
// we can create a hash of all question data for comparison with questions in the database.
|
||||
$this->add_path(self::CATEGORY_PATH . self::QUESTION_SUBPATH . '/question_hints');
|
||||
$this->add_path(self::CATEGORY_PATH . self::LEGACY_QUESTION_SUBPATH . '/question_hints');
|
||||
$this->add_path(self::CATEGORY_PATH . self::QUESTION_SUBPATH . '/question_hints/question_hint');
|
||||
$this->add_path(self::CATEGORY_PATH . self::LEGACY_QUESTION_SUBPATH . '/question_hints/question_hint');
|
||||
|
||||
$connectionpoint = new restore_path_element('question', self::CATEGORY_PATH . self::QUESTION_SUBPATH);
|
||||
foreach (\core\plugin_manager::instance()->get_plugins_of_type('qtype') as $qtype) {
|
||||
$restore = $this->get_qtype_restore($qtype->name);
|
||||
if (!$restore) {
|
||||
continue;
|
||||
}
|
||||
$structure = $restore->define_plugin_structure($connectionpoint);
|
||||
foreach ($structure as $element) {
|
||||
$subpath = str_replace(self::CATEGORY_PATH . self::QUESTION_SUBPATH . '/', '', $element->get_path());
|
||||
$pathparts = explode('/', $subpath);
|
||||
$path = self::CATEGORY_PATH . self::QUESTION_SUBPATH;
|
||||
$legacypath = self::CATEGORY_PATH . self::LEGACY_QUESTION_SUBPATH;
|
||||
foreach ($pathparts as $part) {
|
||||
$path .= '/' . $part;
|
||||
$legacypath .= '/' . $part;
|
||||
if (!in_array($path, $this->paths)) {
|
||||
$this->add_path($path);
|
||||
$this->add_path($legacypath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function dispatch_chunk($data) {
|
||||
@@ -73,10 +107,19 @@ class restore_questions_parser_processor extends grouped_parser_processor {
|
||||
// Prepare question record
|
||||
} else if ($data['path'] == self::CATEGORY_PATH . self::QUESTION_SUBPATH ||
|
||||
$data['path'] == self::CATEGORY_PATH . self::LEGACY_QUESTION_SUBPATH) {
|
||||
$info = (object)$data['tags'];
|
||||
// Remove sub-elements from the question info we're going to save.
|
||||
$info = (object) array_filter($data['tags'], fn($tag) => !is_array($tag));
|
||||
$itemname = 'question';
|
||||
$itemid = $info->id;
|
||||
$parentitemid = $this->lastcatid;
|
||||
$restore = $this->get_qtype_restore($data['tags']['qtype']);
|
||||
if ($restore) {
|
||||
$questiondata = $restore->convert_backup_to_questiondata($data['tags']);
|
||||
} else {
|
||||
$questiondata = restore_qtype_plugin::convert_backup_to_questiondata($data['tags']);
|
||||
}
|
||||
// Store a hash of question fields for comparison with existing questions.
|
||||
$info->questionhash = $this->generate_question_identity_hash($questiondata);
|
||||
|
||||
// Not question_category nor question, impossible. Throw exception.
|
||||
} else {
|
||||
@@ -106,4 +149,80 @@ class restore_questions_parser_processor extends grouped_parser_processor {
|
||||
}
|
||||
return $cdata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and instantiate the restore class for the given question type.
|
||||
*
|
||||
* If there is no restore class, null is returned.
|
||||
*
|
||||
* @param string $qtype The question type name (no qtype_ prefix)
|
||||
* @return ?restore_qtype_plugin
|
||||
*/
|
||||
protected static function get_qtype_restore(string $qtype): ?restore_qtype_plugin {
|
||||
global $CFG;
|
||||
$step = new restore_quiz_activity_structure_step('questions', 'question.xml');
|
||||
$filepath = "{$CFG->dirroot}/question/type/{$qtype}/backup/moodle2/restore_qtype_{$qtype}_plugin.class.php";
|
||||
if (!file_exists($filepath)) {
|
||||
return null;
|
||||
}
|
||||
require_once($filepath);
|
||||
$restoreclass = "restore_qtype_{$qtype}_plugin";
|
||||
if (!class_exists($restoreclass)) {
|
||||
return null;
|
||||
}
|
||||
return new $restoreclass('qtype', $qtype, $step);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a data structure containing the data for a question, reduce it to a flat array and return a sha1 hash of the data.
|
||||
*
|
||||
* @param stdClass $questiondata An array containing all the data for a question, including hints and qtype plugin data.
|
||||
* @param ?backup_xml_transformer $transformer If provided, run the backup transformer process on all text fields. This ensures
|
||||
* that values from the database are compared like-for-like with encoded values from the backup.
|
||||
* @return string A sha1 hash of all question data, normalised and concatenated together.
|
||||
*/
|
||||
public static function generate_question_identity_hash(
|
||||
stdClass $questiondata,
|
||||
?backup_xml_transformer $transformer = null,
|
||||
): string {
|
||||
$questiondata = clone($questiondata);
|
||||
$restore = self::get_qtype_restore($questiondata->qtype);
|
||||
if ($restore) {
|
||||
$restore->define_plugin_structure(new restore_path_element('question', self::CATEGORY_PATH . self::QUESTION_SUBPATH));
|
||||
// Combine default exclusions with those specified by the plugin.
|
||||
$questiondata = $restore->remove_excluded_question_data($questiondata, $restore->get_excluded_identity_hash_fields());
|
||||
} else {
|
||||
// The qtype has no restore class, use the default reduction method.
|
||||
$questiondata = restore_qtype_plugin::remove_excluded_question_data($questiondata);
|
||||
}
|
||||
|
||||
// Convert questiondata to a flat array of values.
|
||||
$hashdata = [];
|
||||
// Convert the object to a multi-dimensional array for compatibility with array_walk_recursive.
|
||||
$questiondata = json_decode(json_encode($questiondata), true);
|
||||
array_walk_recursive($questiondata, function($value) use (&$hashdata) {
|
||||
// Normalise data types. Depending on where the data comes from, it may be a mixture of nulls, strings,
|
||||
// ints and floats. Convert everything to strings, then all numbers to floats to ensure we are doing
|
||||
// like-for-like comparisons without losing accuracy.
|
||||
$value = (string) $value;
|
||||
if (is_numeric($value)) {
|
||||
$value = (float) ($value);
|
||||
} else if (str_contains($value, "\r\n")) {
|
||||
// Normalise line breaks.
|
||||
$value = str_replace("\r\n", "\n", $value);
|
||||
}
|
||||
$hashdata[] = $value;
|
||||
});
|
||||
|
||||
sort($hashdata, SORT_STRING);
|
||||
$hashstring = implode(self::HASHDATA_SEPARATOR, $hashdata);
|
||||
if ($transformer) {
|
||||
$hashstring = $transformer->process($hashstring);
|
||||
// Need to re-sort the hashdata with the transformed strings.
|
||||
$hashdata = explode(self::HASHDATA_SEPARATOR, $hashstring);
|
||||
sort($hashdata, SORT_STRING);
|
||||
$hashstring = implode(self::HASHDATA_SEPARATOR, $hashdata);
|
||||
}
|
||||
return sha1($hashstring);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,13 +14,13 @@ Feature: Award badges based on competency completion
|
||||
And the following "course enrolments" exist:
|
||||
| user | course | role |
|
||||
| user1 | C1 | student |
|
||||
And the following lp "frameworks" exist:
|
||||
| shortname | idnumber |
|
||||
| Framework 1 | sc-y-2 |
|
||||
And the following lp "competencies" exist:
|
||||
| shortname | framework |
|
||||
| comp1 | sc-y-2 |
|
||||
| comp2 | sc-y-2 |
|
||||
And the following "core_competency > frameworks" exist:
|
||||
| shortname | idnumber |
|
||||
| Framework 1 | sc-y-2 |
|
||||
And the following "core_competency > competencies" exist:
|
||||
| shortname | competencyframework |
|
||||
| comp1 | sc-y-2 |
|
||||
| comp2 | sc-y-2 |
|
||||
And I log in as "admin"
|
||||
|
||||
@javascript
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
<?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/>.
|
||||
|
||||
namespace core_badges\tests;
|
||||
|
||||
/**
|
||||
* Helper trait for external function tests.
|
||||
*
|
||||
* @package core_badges
|
||||
* @copyright 2025 Moodle Pty Ltd
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
trait external_helper {
|
||||
|
||||
/**
|
||||
* Asserts that an issued badge returned by an external function matches the given data.
|
||||
*
|
||||
* @param array $expected Expected badge data.
|
||||
* @param array $actual Actual badge data returned by the external function.
|
||||
* @param bool $isrecipient True if user is the badge recipient.
|
||||
* @param bool $canconfiguredetails True if user has capability "moodle/badges:configuredetails".
|
||||
* @throws \PHPUnit\Framework\ExpectationFailedException
|
||||
*/
|
||||
protected function assert_issued_badge(array $expected, array $actual, bool $isrecipient, bool $canconfiguredetails): void {
|
||||
$this->assertEquals($expected['id'], $actual['id']);
|
||||
$this->assertEquals($expected['name'], $actual['name']);
|
||||
$this->assertEquals($expected['type'], $actual['type']);
|
||||
$this->assertEquals($expected['description'], $actual['description']);
|
||||
$this->assertEquals($expected['issuername'], $actual['issuername']);
|
||||
$this->assertEquals($expected['issuerurl'], $actual['issuerurl']);
|
||||
$this->assertEquals($expected['issuercontact'], $actual['issuercontact']);
|
||||
$this->assertEquals($expected['uniquehash'], $actual['uniquehash']);
|
||||
$this->assertEquals($expected['dateissued'], $actual['dateissued']);
|
||||
$this->assertEquals($expected['dateexpire'], $actual['dateexpire']);
|
||||
$this->assertEquals($expected['version'], $actual['version']);
|
||||
$this->assertEquals($expected['language'], $actual['language']);
|
||||
$this->assertEquals($expected['imageauthorname'], $actual['imageauthorname']);
|
||||
$this->assertEquals($expected['imageauthoremail'], $actual['imageauthoremail']);
|
||||
$this->assertEquals($expected['imageauthorurl'], $actual['imageauthorurl']);
|
||||
$this->assertEquals($expected['imagecaption'], $actual['imagecaption']);
|
||||
$this->assertEquals($expected['badgeurl'], $actual['badgeurl']);
|
||||
$this->assertEquals($expected['endorsement'] ?? null, $actual['endorsement'] ?? null);
|
||||
|
||||
if ($isrecipient || $canconfiguredetails) {
|
||||
$this->assertTimeCurrent($expected['timecreated']);
|
||||
$this->assertTimeCurrent($expected['timemodified']);
|
||||
$this->assertEquals($expected['usercreated'], $actual['usercreated']);
|
||||
$this->assertEquals($expected['usermodified'], $actual['usermodified']);
|
||||
$this->assertEquals($expected['expiredate'], $actual['expiredate']);
|
||||
$this->assertEquals($expected['expireperiod'], $actual['expireperiod']);
|
||||
$this->assertEquals($expected['courseid'], $actual['courseid']);
|
||||
$this->assertEquals($expected['message'], $actual['message']);
|
||||
$this->assertEquals($expected['messagesubject'], $actual['messagesubject']);
|
||||
$this->assertEquals($expected['attachment'], $actual['attachment']);
|
||||
$this->assertEquals($expected['notification'], $actual['notification']);
|
||||
$this->assertEquals($expected['nextcron'], $actual['nextcron']);
|
||||
$this->assertEquals($expected['status'], $actual['status']);
|
||||
$this->assertEquals($expected['issuedid'], $actual['issuedid']);
|
||||
$this->assertEquals($expected['visible'], $actual['visible']);
|
||||
$this->assertEquals($expected['email'], $actual['email']);
|
||||
} else {
|
||||
$this->assertEquals(0, $actual['timecreated']);
|
||||
$this->assertEquals(0, $actual['timemodified']);
|
||||
$this->assertArrayNotHasKey('usercreated', $actual);
|
||||
$this->assertArrayNotHasKey('usermodified', $actual);
|
||||
$this->assertArrayNotHasKey('expiredate', $actual);
|
||||
$this->assertArrayNotHasKey('expireperiod', $actual);
|
||||
$this->assertArrayNotHasKey('courseid', $actual);
|
||||
$this->assertArrayNotHasKey('message', $actual);
|
||||
$this->assertArrayNotHasKey('messagesubject', $actual);
|
||||
$this->assertEquals(1, $actual['attachment']);
|
||||
$this->assertEquals(1, $actual['notification']);
|
||||
$this->assertArrayNotHasKey('nextcron', $actual);
|
||||
$this->assertEquals(0, $actual['status']);
|
||||
$this->assertArrayNotHasKey('issuedid', $actual);
|
||||
$this->assertEquals(0, $actual['visible']);
|
||||
$this->assertArrayNotHasKey('email', $actual);
|
||||
}
|
||||
|
||||
$alignments = $expected['alignment'];
|
||||
if (!$canconfiguredetails) {
|
||||
foreach ($alignments as $index => $alignment) {
|
||||
$alignments[$index] = [
|
||||
'id' => $alignment['id'],
|
||||
'badgeid' => $alignment['badgeid'],
|
||||
'targetName' => $alignment['targetName'],
|
||||
'targetUrl' => $alignment['targetUrl'],
|
||||
];
|
||||
}
|
||||
}
|
||||
$this->assertEquals($alignments, $actual['alignment']);
|
||||
|
||||
$relatedbadges = $expected['relatedbadges'];
|
||||
if (!$canconfiguredetails) {
|
||||
foreach ($relatedbadges as $index => $relatedbadge) {
|
||||
$relatedbadges[$index] = [
|
||||
'id' => $relatedbadge['id'],
|
||||
'name' => $relatedbadge['name'],
|
||||
];
|
||||
}
|
||||
}
|
||||
$this->assertEquals($relatedbadges, $actual['relatedbadges']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and returns test data for external functions.
|
||||
*
|
||||
* The test data includes:
|
||||
* - A teacher.
|
||||
* - A student.
|
||||
* - A site badge, with an endorsment and 2 alignments, issued to the student by the teacher.
|
||||
* - A course badge, related to the site badge, issued to the student by the teacher.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function prepare_test_data(): array {
|
||||
global $DB;
|
||||
|
||||
$this->resetAfterTest();
|
||||
$this->setAdminUser();
|
||||
$generator = $this->getDataGenerator();
|
||||
|
||||
// Setup test data.
|
||||
$course = $generator->create_course();
|
||||
|
||||
// Create users and enrolments.
|
||||
$student = $generator->create_and_enrol($course);
|
||||
$teacher = $generator->create_and_enrol($course, 'editingteacher');
|
||||
$badgegenerator = $generator->get_plugin_generator('core_badges');
|
||||
|
||||
$systemcontext = \context_system::instance();
|
||||
$coursecontext = \context_course::instance($course->id);
|
||||
|
||||
// Create a site badge.
|
||||
$now = time();
|
||||
$sitebadge = $badgegenerator->create_badge([
|
||||
'name' => "Test badge site",
|
||||
'description' => "Testing badges site",
|
||||
'timecreated' => $now,
|
||||
'timemodified' => $now,
|
||||
'usercreated' => (int) $teacher->id,
|
||||
'usermodified' => (int) $teacher->id,
|
||||
'expiredate' => $now + YEARSECS,
|
||||
'expireperiod' => YEARSECS,
|
||||
'type' => BADGE_TYPE_SITE,
|
||||
]);
|
||||
|
||||
$sitebadge->issue($student->id, true);
|
||||
$siteissuedbadge = $DB->get_record('badge_issued', [ 'badgeid' => $sitebadge->id ]);
|
||||
|
||||
// Change issued time to ensure badges are fetched in a consistent order.
|
||||
$siteissuedbadge->dateissued = $now - 1;
|
||||
$DB->update_record('badge_issued', $siteissuedbadge);
|
||||
|
||||
$sitebadgedata = [
|
||||
...(array) $sitebadge,
|
||||
'issuedid' => (int) $siteissuedbadge->id,
|
||||
'uniquehash' => $siteissuedbadge->uniquehash,
|
||||
'dateissued' => (int) $siteissuedbadge->dateissued,
|
||||
'dateexpire' => $siteissuedbadge->dateexpire,
|
||||
'visible' => (int) $siteissuedbadge->visible,
|
||||
'badgeurl' => \moodle_url::make_webservice_pluginfile_url($systemcontext->id, 'badges', 'badgeimage',
|
||||
$sitebadge->id, '/', 'f3')->out(false),
|
||||
'email' => $student->email,
|
||||
'endorsement' => null,
|
||||
'alignment' => [],
|
||||
'relatedbadges' => [],
|
||||
];
|
||||
|
||||
// Add an endorsement for the site badge.
|
||||
$endorsement = new \stdClass();
|
||||
$endorsement->badgeid = $sitebadge->id;
|
||||
$endorsement->issuername = 'Issuer name';
|
||||
$endorsement->issuerurl = 'http://endorsement-issuer-url.domain.co.nz';
|
||||
$endorsement->issueremail = 'endorsementissuer@example.com';
|
||||
$endorsement->claimid = 'http://claim-url.domain.co.nz';
|
||||
$endorsement->claimcomment = 'Claim comment';
|
||||
$endorsement->dateissued = $now;
|
||||
$endorsement->id = $sitebadge->save_endorsement($endorsement);
|
||||
$sitebadgedata['endorsement'] = (array) $endorsement;
|
||||
|
||||
// Add 2 alignments to the site badge.
|
||||
$alignment = new \stdClass();
|
||||
$alignment->badgeid = $sitebadge->id;
|
||||
$alignment->targetname = 'Alignment 1';
|
||||
$alignment->targeturl = 'http://a1-target-url.domain.co.nz';
|
||||
$alignment->targetdescription = 'A1 target description';
|
||||
$alignment->targetframework = 'A1 framework';
|
||||
$alignment->targetcode = 'A1 code';
|
||||
$alignment->id = $sitebadge->save_alignment($alignment);
|
||||
$sitebadgedata['alignment'][] = [
|
||||
'id' => $alignment->id,
|
||||
'badgeid' => $alignment->badgeid,
|
||||
'targetName' => $alignment->targetname,
|
||||
'targetUrl' => $alignment->targeturl,
|
||||
'targetDescription' => $alignment->targetdescription,
|
||||
'targetFramework' => $alignment->targetframework,
|
||||
'targetCode' => $alignment->targetcode,
|
||||
];
|
||||
|
||||
$alignment = new \stdClass();
|
||||
$alignment->badgeid = $sitebadge->id;
|
||||
$alignment->targetname = 'Alignment 2';
|
||||
$alignment->targeturl = 'http://a2-target-url.domain.co.nz';
|
||||
$alignment->targetdescription = 'A2 target description';
|
||||
$alignment->targetframework = 'A2 framework';
|
||||
$alignment->targetcode = 'A2 code';
|
||||
$alignment->id = $sitebadge->save_alignment($alignment);
|
||||
$sitebadgedata['alignment'][] = [
|
||||
'id' => $alignment->id,
|
||||
'badgeid' => $alignment->badgeid,
|
||||
'targetName' => $alignment->targetname,
|
||||
'targetUrl' => $alignment->targeturl,
|
||||
'targetDescription' => $alignment->targetdescription,
|
||||
'targetFramework' => $alignment->targetframework,
|
||||
'targetCode' => $alignment->targetcode,
|
||||
];
|
||||
|
||||
// Create a course badge.
|
||||
$coursebadge = $badgegenerator->create_badge([
|
||||
'name' => "Test badge course",
|
||||
'description' => "Testing badges course",
|
||||
'timecreated' => $now,
|
||||
'timemodified' => $now,
|
||||
'usercreated' => (int) $teacher->id,
|
||||
'usermodified' => (int) $teacher->id,
|
||||
'expiredate' => $now + YEARSECS,
|
||||
'expireperiod' => YEARSECS,
|
||||
'type' => BADGE_TYPE_COURSE,
|
||||
'courseid' => (int) $course->id,
|
||||
]);
|
||||
$coursebadge->issue($student->id, true);
|
||||
|
||||
$courseissuedbadge = $DB->get_record('badge_issued', [ 'badgeid' => $coursebadge->id ]);
|
||||
$coursebadgedata = [
|
||||
...(array) $coursebadge,
|
||||
'issuedid' => (int) $courseissuedbadge->id,
|
||||
'uniquehash' => $courseissuedbadge->uniquehash,
|
||||
'dateissued' => (int) $courseissuedbadge->dateissued,
|
||||
'dateexpire' => $courseissuedbadge->dateexpire,
|
||||
'visible' => (int) $courseissuedbadge->visible,
|
||||
'badgeurl' => \moodle_url::make_webservice_pluginfile_url($coursecontext->id, 'badges', 'badgeimage',
|
||||
$coursebadge->id, '/', 'f3')->out(false),
|
||||
'email' => $student->email,
|
||||
'endorsement' => null,
|
||||
'alignment' => [],
|
||||
'relatedbadges' => [],
|
||||
];
|
||||
|
||||
// Add the course badge to the site badge.
|
||||
$sitebadge->add_related_badges([$coursebadge->id]);
|
||||
$sitebadgedata['relatedbadges'][] = [
|
||||
'id' => (int) $coursebadge->id,
|
||||
'name' => $coursebadge->name,
|
||||
'version' => $coursebadge->version,
|
||||
'language' => $coursebadge->language,
|
||||
'type' => $coursebadge->type,
|
||||
];
|
||||
$coursebadgedata['relatedbadges'][] = [
|
||||
'id' => (int) $sitebadge->id,
|
||||
'name' => $sitebadge->name,
|
||||
'version' => $sitebadge->version,
|
||||
'language' => $sitebadge->language,
|
||||
'type' => $sitebadge->type,
|
||||
];
|
||||
|
||||
return [
|
||||
'coursebadge' => $coursebadgedata,
|
||||
'sitebadge' => $sitebadgedata,
|
||||
'student' => $student,
|
||||
'teacher' => $teacher,
|
||||
];
|
||||
}
|
||||
}
|
||||
+31
-185
@@ -27,6 +27,7 @@
|
||||
namespace core_badges\external;
|
||||
|
||||
use core_badges_external;
|
||||
use core_badges\tests\external_helper;
|
||||
use core_external\external_api;
|
||||
use core_external\external_settings;
|
||||
use externallib_advanced_testcase;
|
||||
@@ -48,221 +49,66 @@ require_once($CFG->libdir . '/badgeslib.php');
|
||||
* @since Moodle 3.1
|
||||
*/
|
||||
final class external_test extends externallib_advanced_testcase {
|
||||
|
||||
/** @var stdClass $course */
|
||||
private $course;
|
||||
|
||||
/** @var stdClass $student */
|
||||
private $student;
|
||||
|
||||
/** @var stdClass $teacher */
|
||||
private $teacher;
|
||||
|
||||
/**
|
||||
* Set up for every test
|
||||
*/
|
||||
public function setUp(): void {
|
||||
global $DB;
|
||||
$this->resetAfterTest();
|
||||
$this->setAdminUser();
|
||||
|
||||
// Setup test data.
|
||||
$this->course = $this->getDataGenerator()->create_course();
|
||||
|
||||
// Create users and enrolments.
|
||||
$this->student = $this->getDataGenerator()->create_and_enrol($this->course, 'student');
|
||||
$this->teacher = $this->getDataGenerator()->create_and_enrol($this->course, 'editingteacher');
|
||||
|
||||
// Mock up a site badge.
|
||||
$now = time();
|
||||
$badge = new \stdClass();
|
||||
$badge->id = null;
|
||||
$badge->name = "Test badge site";
|
||||
$badge->description = "Testing badges site";
|
||||
$badge->timecreated = $now - 12;
|
||||
$badge->timemodified = $now - 12;
|
||||
$badge->usercreated = $this->teacher->id;
|
||||
$badge->usermodified = $this->teacher->id;
|
||||
$badge->issuername = "Test issuer";
|
||||
$badge->issuerurl = "http://issuer-url.domain.co.nz";
|
||||
$badge->issuercontact = "issuer@example.com";
|
||||
$badge->expiredate = null;
|
||||
$badge->expireperiod = null;
|
||||
$badge->type = BADGE_TYPE_SITE;
|
||||
$badge->courseid = null;
|
||||
$badge->messagesubject = "Test message subject for badge";
|
||||
$badge->message = "Test message body for badge";
|
||||
$badge->attachment = 1;
|
||||
$badge->notification = 0;
|
||||
$badge->status = BADGE_STATUS_ACTIVE;
|
||||
$badge->version = '1';
|
||||
$badge->language = 'en';
|
||||
$badge->imageauthorname = 'Image author';
|
||||
$badge->imageauthoremail = 'imageauthor@example.com';
|
||||
$badge->imageauthorurl = 'http://image-author-url.domain.co.nz';
|
||||
$badge->imagecaption = 'Caption';
|
||||
|
||||
$badgeid = $DB->insert_record('badge', $badge, true);
|
||||
$badge = new \badge($badgeid);
|
||||
$badge->issue($this->student->id, true);
|
||||
|
||||
// Hack the database to adjust the time each badge was issued.
|
||||
$DB->set_field('badge_issued', 'dateissued', $now - 11, array('userid' => $this->student->id, 'badgeid' => $badgeid));
|
||||
|
||||
// Add an endorsement for the badge.
|
||||
$endorsement = new \stdClass();
|
||||
$endorsement->badgeid = $badgeid;
|
||||
$endorsement->issuername = 'Issuer name';
|
||||
$endorsement->issuerurl = 'http://endorsement-issuer-url.domain.co.nz';
|
||||
$endorsement->issueremail = 'endorsementissuer@example.com';
|
||||
$endorsement->claimid = 'http://claim-url.domain.co.nz';
|
||||
$endorsement->claimcomment = 'Claim comment';
|
||||
$endorsement->dateissued = $now;
|
||||
$badge->save_endorsement($endorsement);
|
||||
|
||||
// Add 2 alignments.
|
||||
$alignment = new \stdClass();
|
||||
$alignment->badgeid = $badgeid;
|
||||
$alignment->targetname = 'Alignment 1';
|
||||
$alignment->targeturl = 'http://a1-target-url.domain.co.nz';
|
||||
$alignment->targetdescription = 'A1 target description';
|
||||
$alignment->targetframework = 'A1 framework';
|
||||
$alignment->targetcode = 'A1 code';
|
||||
$badge->save_alignment($alignment);
|
||||
|
||||
$alignment->targetname = 'Alignment 2';
|
||||
$alignment->targeturl = 'http://a2-target-url.domain.co.nz';
|
||||
$alignment->targetdescription = 'A2 target description';
|
||||
$alignment->targetframework = 'A2 framework';
|
||||
$alignment->targetcode = 'A2 code';
|
||||
$badge->save_alignment($alignment);
|
||||
|
||||
// Now a course badge.
|
||||
$badge->id = null;
|
||||
$badge->name = "Test badge course";
|
||||
$badge->description = "Testing badges course";
|
||||
$badge->type = BADGE_TYPE_COURSE;
|
||||
$badge->courseid = $this->course->id;
|
||||
|
||||
$coursebadgeid = $DB->insert_record('badge', $badge, true);
|
||||
$badge = new \badge($coursebadgeid);
|
||||
$badge->issue($this->student->id, true);
|
||||
|
||||
// Hack the database to adjust the time each badge was issued.
|
||||
$DB->set_field('badge_issued', 'dateissued', $now - 10, array('userid' => $this->student->id, 'badgeid' => $coursebadgeid));
|
||||
|
||||
// Make the site badge a related badge.
|
||||
$badge->add_related_badges(array($badgeid));
|
||||
}
|
||||
use external_helper;
|
||||
|
||||
/**
|
||||
* Test get user badges.
|
||||
* These is a basic test since the badges_get_my_user_badges used by the external function already has unit tests.
|
||||
*
|
||||
* @covers \core_badges_external::get_user_badges
|
||||
*/
|
||||
public function test_get_my_user_badges(): void {
|
||||
$data = $this->prepare_test_data();
|
||||
|
||||
$this->setUser($this->student);
|
||||
|
||||
$badges = (array) badges_get_user_badges($this->student->id);
|
||||
$expectedbadges = array();
|
||||
$coursebadge = null;
|
||||
|
||||
foreach ($badges as $badge) {
|
||||
$context = ($badge->type == BADGE_TYPE_SITE) ? \context_system::instance() : \context_course::instance($badge->courseid);
|
||||
$badge->badgeurl = \moodle_url::make_webservice_pluginfile_url($context->id, 'badges', 'badgeimage', $badge->id, '/',
|
||||
'f3')->out(false);
|
||||
|
||||
// Get the endorsement, alignments and related badges.
|
||||
$badgeinstance = new \badge($badge->id);
|
||||
$endorsement = $badgeinstance->get_endorsement();
|
||||
$alignments = $badgeinstance->get_alignments();
|
||||
$relatedbadges = $badgeinstance->get_related_badges();
|
||||
$badge->alignment = array();
|
||||
$badge->relatedbadges = array();
|
||||
|
||||
if ($endorsement) {
|
||||
$badge->endorsement = (array) $endorsement;
|
||||
}
|
||||
|
||||
if (!empty($alignments)) {
|
||||
foreach ($alignments as $alignment) {
|
||||
// Students cannot see some fields of the alignments.
|
||||
unset($alignment->targetname);
|
||||
unset($alignment->targeturl);
|
||||
unset($alignment->targetdescription);
|
||||
unset($alignment->targetframework);
|
||||
unset($alignment->targetcode);
|
||||
|
||||
$badge->alignment[] = (array) $alignment;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($relatedbadges)) {
|
||||
foreach ($relatedbadges as $relatedbadge) {
|
||||
// Students cannot see some fields of the related badges.
|
||||
unset($relatedbadge->version);
|
||||
unset($relatedbadge->language);
|
||||
unset($relatedbadge->type);
|
||||
|
||||
$badge->relatedbadges[] = (array) $relatedbadge;
|
||||
}
|
||||
}
|
||||
|
||||
$expectedbadges[] = (array) $badge;
|
||||
if (isset($badge->courseid)) {
|
||||
// Save the course badge to be able to compare it in our tests.
|
||||
$coursebadge = (array) $badge;
|
||||
}
|
||||
}
|
||||
|
||||
$this->setUser($data['student']);
|
||||
$result = core_badges_external::get_user_badges();
|
||||
$result = external_api::clean_returnvalue(core_badges_external::get_user_badges_returns(), $result);
|
||||
$this->assertEquals($expectedbadges, $result['badges']);
|
||||
$this->assertCount(2, $result['badges']);
|
||||
$this->assert_issued_badge($data['coursebadge'], $result['badges'][0], true, false);
|
||||
$this->assert_issued_badge($data['sitebadge'], $result['badges'][1], true, false);
|
||||
|
||||
// Pagination and filtering.
|
||||
$result = core_badges_external::get_user_badges(0, $this->course->id, 0, 1, '', true);
|
||||
$result = core_badges_external::get_user_badges(0, 0, 0, 1, '', true);
|
||||
$result = external_api::clean_returnvalue(core_badges_external::get_user_badges_returns(), $result);
|
||||
$this->assertCount(1, $result['badges']);
|
||||
$this->assertEquals($coursebadge, $result['badges'][0]);
|
||||
$this->assert_issued_badge($data['coursebadge'], $result['badges'][0], true, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test get user badges.
|
||||
*
|
||||
* @covers \core_badges_external::get_user_badges
|
||||
*/
|
||||
public function test_get_other_user_badges(): void {
|
||||
$data = $this->prepare_test_data();
|
||||
|
||||
$this->setUser($this->teacher);
|
||||
|
||||
$result = core_badges_external::get_user_badges($this->student->id);
|
||||
// User with "moodle/badges:configuredetails" capability.
|
||||
$this->setAdminUser();
|
||||
$result = core_badges_external::get_user_badges($data['student']->id);
|
||||
$result = external_api::clean_returnvalue(core_badges_external::get_user_badges_returns(), $result);
|
||||
|
||||
$this->assertCount(2, $result['badges']);
|
||||
$this->assert_issued_badge($data['coursebadge'], $result['badges'][0], false, true);
|
||||
$this->assert_issued_badge($data['sitebadge'], $result['badges'][1], false, true);
|
||||
|
||||
// Check that we don't have permissions for view the complete information for site badges.
|
||||
foreach ($result['badges'] as $badge) {
|
||||
if (isset($badge['type']) and $badge['type'] == BADGE_TYPE_COURSE) {
|
||||
$this->assertTrue(isset($badge['message']));
|
||||
|
||||
// Check that we have permissions to see all the data in alignments and related badges.
|
||||
foreach ($badge['alignment'] as $alignment) {
|
||||
$this->assertTrue(isset($alignment['targetdescription']));
|
||||
}
|
||||
|
||||
foreach ($badge['relatedbadges'] as $relatedbadge) {
|
||||
$this->assertTrue(isset($relatedbadge['type']));
|
||||
}
|
||||
} else {
|
||||
$this->assertFalse(isset($badge['message']));
|
||||
}
|
||||
}
|
||||
// User without "moodle/badges:configuredetails" capability.
|
||||
$this->setUser($this->getDataGenerator()->create_user());
|
||||
$result = core_badges_external::get_user_badges($data['student']->id);
|
||||
$result = external_api::clean_returnvalue(core_badges_external::get_user_badges_returns(), $result);
|
||||
$this->assertCount(2, $result['badges']);
|
||||
$this->assert_issued_badge($data['coursebadge'], $result['badges'][0], false, false);
|
||||
$this->assert_issued_badge($data['sitebadge'], $result['badges'][1], false, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test get_user_badges where issuername contains text to be filtered
|
||||
*
|
||||
* @covers \core_badges_external::get_user_badges
|
||||
*/
|
||||
public function test_get_user_badges_filter_issuername(): void {
|
||||
global $DB;
|
||||
|
||||
$data = $this->prepare_test_data();
|
||||
|
||||
filter_set_global_state('multilang', TEXTFILTER_ON);
|
||||
filter_set_applies_to_strings('multilang', true);
|
||||
|
||||
@@ -273,7 +119,7 @@ final class external_test extends externallib_advanced_testcase {
|
||||
$DB->set_field('badge', 'issuername', $issuername, ['name' => 'Test badge site']);
|
||||
|
||||
// Retrieve student badges.
|
||||
$result = core_badges_external::get_user_badges($this->student->id);
|
||||
$result = core_badges_external::get_user_badges($data['student']->id);
|
||||
$result = external_api::clean_returnvalue(core_badges_external::get_user_badges_returns(), $result);
|
||||
|
||||
// Site badge will be last, because it has the earlier issued date.
|
||||
|
||||
+38
-187
@@ -16,6 +16,7 @@
|
||||
|
||||
namespace core_badges\external;
|
||||
|
||||
use core_badges\tests\external_helper;
|
||||
use externallib_advanced_testcase;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
@@ -23,7 +24,6 @@ defined('MOODLE_INTERNAL') || die();
|
||||
global $CFG;
|
||||
|
||||
require_once($CFG->dirroot . '/webservice/tests/helpers.php');
|
||||
require_once($CFG->libdir . '/badgeslib.php');
|
||||
|
||||
/**
|
||||
* Tests for external function get_user_badge_by_hash.
|
||||
@@ -36,141 +36,7 @@ require_once($CFG->libdir . '/badgeslib.php');
|
||||
* @coversDefaultClass \core_badges\external\get_user_badge_by_hash
|
||||
*/
|
||||
final class get_user_badge_by_hash_test extends externallib_advanced_testcase {
|
||||
|
||||
/**
|
||||
* Prepare the test.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function prepare_test_data(): array {
|
||||
global $DB;
|
||||
$this->resetAfterTest();
|
||||
$this->setAdminUser();
|
||||
|
||||
// Setup test data.
|
||||
$course = $this->getDataGenerator()->create_course();
|
||||
|
||||
// Create users and enrolments.
|
||||
$student1 = $this->getDataGenerator()->create_and_enrol($course);
|
||||
$student2 = $this->getDataGenerator()->create_and_enrol($course);
|
||||
$teacher = $this->getDataGenerator()->create_and_enrol($course, 'editingteacher');
|
||||
|
||||
// Mock up a site badge.
|
||||
$now = time();
|
||||
$badge = new \stdClass();
|
||||
$badge->id = null;
|
||||
$badge->name = "Test badge site";
|
||||
$badge->description = "Testing badges site";
|
||||
$badge->timecreated = $now;
|
||||
$badge->timemodified = $now;
|
||||
$badge->usercreated = (int) $teacher->id;
|
||||
$badge->usermodified = (int) $teacher->id;
|
||||
$badge->expiredate = null;
|
||||
$badge->expireperiod = null;
|
||||
$badge->type = BADGE_TYPE_SITE;
|
||||
$badge->courseid = null;
|
||||
$badge->messagesubject = "Test message subject for badge";
|
||||
$badge->message = "Test message body for badge";
|
||||
$badge->attachment = 1;
|
||||
$badge->notification = 0;
|
||||
$badge->status = BADGE_STATUS_ACTIVE;
|
||||
$badge->version = '1';
|
||||
$badge->language = 'en';
|
||||
$badge->imageauthorname = 'Image author';
|
||||
$badge->imageauthoremail = 'imageauthor@example.com';
|
||||
$badge->imageauthorurl = 'http://image-author-url.domain.co.nz';
|
||||
$badge->imagecaption = 'Caption';
|
||||
|
||||
$badgeid = $DB->insert_record('badge', $badge, true);
|
||||
$badge->id = $badgeid;
|
||||
$sitebadge = new \badge($badgeid);
|
||||
$sitebadge->issue($student1->id, true);
|
||||
$siteissuedbadge = $DB->get_record('badge_issued', [ 'badgeid' => $badge->id ]);
|
||||
|
||||
$badge->issuername = $sitebadge->issuername;
|
||||
$badge->issuercontact = $sitebadge->issuercontact;
|
||||
$badge->issuerurl = $sitebadge->issuerurl;
|
||||
$badge->nextcron = $sitebadge->nextcron;
|
||||
$badge->issuedid = (int) $siteissuedbadge->id;
|
||||
$badge->uniquehash = $siteissuedbadge->uniquehash;
|
||||
$badge->dateissued = (int) $siteissuedbadge->dateissued;
|
||||
$badge->dateexpire = $siteissuedbadge->dateexpire;
|
||||
$badge->visible = (int) $siteissuedbadge->visible;
|
||||
$badge->email = $student1->email;
|
||||
$context = \context_system::instance();
|
||||
$badge->badgeurl = \moodle_url::make_webservice_pluginfile_url($context->id, 'badges', 'badgeimage', $badge->id, '/',
|
||||
'f3')->out(false);
|
||||
$badge->status = BADGE_STATUS_ACTIVE_LOCKED;
|
||||
|
||||
// Add an endorsement for the badge.
|
||||
$endorsement = new \stdClass();
|
||||
$endorsement->badgeid = $badgeid;
|
||||
$endorsement->issuername = 'Issuer name';
|
||||
$endorsement->issuerurl = 'http://endorsement-issuer-url.domain.co.nz';
|
||||
$endorsement->issueremail = 'endorsementissuer@example.com';
|
||||
$endorsement->claimid = 'http://claim-url.domain.co.nz';
|
||||
$endorsement->claimcomment = 'Claim comment';
|
||||
$endorsement->dateissued = $now;
|
||||
$endorsement->id = $sitebadge->save_endorsement($endorsement);
|
||||
$badge->endorsement = (array) $endorsement;
|
||||
|
||||
// Add 2 alignments.
|
||||
$alignment = new \stdClass();
|
||||
$alignment->badgeid = $badgeid;
|
||||
$alignment->id = $sitebadge->save_alignment($alignment);
|
||||
$badge->alignment[] = (array) $alignment;
|
||||
|
||||
$alignment->id = $sitebadge->save_alignment($alignment);
|
||||
$badge->alignment[] = (array) $alignment;
|
||||
$badge->relatedbadges = [];
|
||||
$usersitebadge[] = (array) $badge;
|
||||
|
||||
// Now a course badge.
|
||||
$badge->id = null;
|
||||
$badge->name = "Test badge course";
|
||||
$badge->description = "Testing badges course";
|
||||
$badge->type = BADGE_TYPE_COURSE;
|
||||
$badge->courseid = (int) $course->id;
|
||||
|
||||
$badge->id = $DB->insert_record('badge', $badge, true);
|
||||
$coursebadge = new \badge($badge->id );
|
||||
$coursebadge->issue($student1->id, true);
|
||||
$courseissuedbadge = $DB->get_record('badge_issued', [ 'badgeid' => $badge->id ]);
|
||||
|
||||
$badge->issuername = $coursebadge->issuername;
|
||||
$badge->issuercontact = $coursebadge->issuercontact;
|
||||
$badge->issuerurl = $coursebadge->issuerurl;
|
||||
$badge->nextcron = $coursebadge->nextcron;
|
||||
$badge->issuedid = (int) $courseissuedbadge->id;
|
||||
$badge->uniquehash = $courseissuedbadge->uniquehash;
|
||||
$badge->dateissued = (int) $courseissuedbadge->dateissued;
|
||||
$badge->dateexpire = $courseissuedbadge->dateexpire;
|
||||
$badge->visible = (int) $courseissuedbadge->visible;
|
||||
$badge->email = $student1->email;
|
||||
$context = \context_course::instance($badge->courseid);
|
||||
$badge->badgeurl = \moodle_url::make_webservice_pluginfile_url($context->id, 'badges', 'badgeimage', $badge->id , '/',
|
||||
'f3')->out(false);
|
||||
|
||||
unset($badge->endorsement);
|
||||
$badge->alignment = [];
|
||||
$usercoursebadge[] = (array) $badge;
|
||||
// Make the site badge a related badge.
|
||||
$sitebadge->add_related_badges([$badge->id]);
|
||||
$usersitebadge[0]['relatedbadges'][0] = [
|
||||
'id' => (int) $coursebadge->id,
|
||||
'name' => $coursebadge->name
|
||||
];
|
||||
$usercoursebadge[0]['relatedbadges'][0] = [
|
||||
'id' => (int) $sitebadge->id,
|
||||
'name' => $sitebadge->name
|
||||
];
|
||||
return [
|
||||
'coursebadge' => $usercoursebadge,
|
||||
'sitebadge' => $usersitebadge,
|
||||
'student1' => $student1,
|
||||
'student2' => $student2
|
||||
];
|
||||
}
|
||||
use external_helper;
|
||||
|
||||
/**
|
||||
* Test get user badge by hash.
|
||||
@@ -179,18 +45,47 @@ final class get_user_badge_by_hash_test extends externallib_advanced_testcase {
|
||||
*/
|
||||
public function test_get_user_badge_by_hash(): void {
|
||||
$data = $this->prepare_test_data();
|
||||
$this->setUser($data['student1']);
|
||||
|
||||
// Site badge.
|
||||
$result = get_user_badge_by_hash::execute($data['sitebadge'][0]['uniquehash']);
|
||||
// Site badge fetched by recipient.
|
||||
$this->setUser($data['student']);
|
||||
$result = get_user_badge_by_hash::execute($data['sitebadge']['uniquehash']);
|
||||
$result = \core_external\external_api::clean_returnvalue(get_user_badge_by_hash::execute_returns(), $result);
|
||||
$this->assertEquals($data['sitebadge'][0]['uniquehash'], $result['badge'][0]['uniquehash']);
|
||||
$this->assert_issued_badge($data['sitebadge'], $result['badge'][0], true, false);
|
||||
$this->assertEmpty($result['warnings']);
|
||||
|
||||
// Course badge.
|
||||
$result = get_user_badge_by_hash::execute($data['coursebadge'][0]['uniquehash']);
|
||||
// Site badge fetched by user without "moodle/badges:configuredetails" capability.
|
||||
$this->setGuestUser();
|
||||
$result = get_user_badge_by_hash::execute($data['sitebadge']['uniquehash']);
|
||||
$result = \core_external\external_api::clean_returnvalue(get_user_badge_by_hash::execute_returns(), $result);
|
||||
$this->assertEquals($data['coursebadge'][0]['uniquehash'], $result['badge'][0]['uniquehash']);
|
||||
$this->assert_issued_badge($data['sitebadge'], $result['badge'][0], false, false);
|
||||
$this->assertEmpty($result['warnings']);
|
||||
|
||||
// Site badge fetched by user with "moodle/badges:configuredetails" capability.
|
||||
$this->setAdminUser();
|
||||
$result = get_user_badge_by_hash::execute($data['sitebadge']['uniquehash']);
|
||||
$result = \core_external\external_api::clean_returnvalue(get_user_badge_by_hash::execute_returns(), $result);
|
||||
$this->assert_issued_badge($data['sitebadge'], $result['badge'][0], false, true);
|
||||
$this->assertEmpty($result['warnings']);
|
||||
|
||||
// Course badge fetched by recipient.
|
||||
$this->setUser($data['student']);
|
||||
$result = get_user_badge_by_hash::execute($data['coursebadge']['uniquehash']);
|
||||
$result = \core_external\external_api::clean_returnvalue(get_user_badge_by_hash::execute_returns(), $result);
|
||||
$this->assert_issued_badge($data['coursebadge'], $result['badge'][0], true, false);
|
||||
$this->assertEmpty($result['warnings']);
|
||||
|
||||
// Course badge fetched by user without "moodle/badges:configuredetails" capability.
|
||||
$this->setGuestUser();
|
||||
$result = get_user_badge_by_hash::execute($data['coursebadge']['uniquehash']);
|
||||
$result = \core_external\external_api::clean_returnvalue(get_user_badge_by_hash::execute_returns(), $result);
|
||||
$this->assert_issued_badge($data['coursebadge'], $result['badge'][0], false, false);
|
||||
$this->assertEmpty($result['warnings']);
|
||||
|
||||
// Course badge fetched by user with "moodle/badges:configuredetails" capability.
|
||||
$this->setAdminUser();
|
||||
$result = get_user_badge_by_hash::execute($data['coursebadge']['uniquehash']);
|
||||
$result = \core_external\external_api::clean_returnvalue(get_user_badge_by_hash::execute_returns(), $result);
|
||||
$this->assert_issued_badge($data['coursebadge'], $result['badge'][0], false, true);
|
||||
$this->assertEmpty($result['warnings']);
|
||||
|
||||
// Wrong hash.
|
||||
@@ -200,48 +95,4 @@ final class get_user_badge_by_hash_test extends externallib_advanced_testcase {
|
||||
$this->assertNotEmpty($result['warnings']);
|
||||
$this->assertEquals('badgeawardnotfound', $result['warnings'][0]['warningcode']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test get user badge by hash with restrictions.
|
||||
* @covers ::execute
|
||||
*/
|
||||
public function test_get_user_badge_by_hash_with_restrictions(): void {
|
||||
$data = $this->prepare_test_data();
|
||||
$this->setUser($data['student2']);
|
||||
|
||||
// Site badge.
|
||||
$result = get_user_badge_by_hash::execute($data['sitebadge'][0]['uniquehash']);
|
||||
$result = \core_external\external_api::clean_returnvalue(get_user_badge_by_hash::execute_returns(), $result);
|
||||
$this->assertNotEmpty($result['badge']);
|
||||
$this->assertEmpty($result['warnings']);
|
||||
|
||||
// Check that we don't have permissions for view the complete information for site badges.
|
||||
if (isset($result['badge'][0]['type']) && $result['badge'][0]['type'] == BADGE_TYPE_SITE) {
|
||||
$this->assertFalse(isset($result['badge'][0]['message']));
|
||||
|
||||
// Check that we have permissions to see all the data in alignments and related badges.
|
||||
foreach ($result['badge'][0]['alignment'] as $alignment) {
|
||||
$this->assertTrue(isset($alignment['id']));
|
||||
}
|
||||
|
||||
foreach ($result['badge'][0]['relatedbadges'] as $relatedbadge) {
|
||||
$this->assertTrue(isset($relatedbadge['id']));
|
||||
}
|
||||
} else {
|
||||
$this->assertTrue(isset($result['badge'][0]['message']));
|
||||
}
|
||||
|
||||
// Course badge.
|
||||
$result = get_user_badge_by_hash::execute($data['coursebadge'][0]['uniquehash']);
|
||||
$result = \core_external\external_api::clean_returnvalue(get_user_badge_by_hash::execute_returns(), $result);
|
||||
$this->assertNotEmpty($result['badge']);
|
||||
$this->assertEmpty($result['warnings']);
|
||||
|
||||
// Check that we don't have permissions for view the complete information for course badges.
|
||||
if (isset($result['badge'][0]['type']) && $result['badge'][0]['type'] == BADGE_TYPE_COURSE) {
|
||||
$this->assertFalse(isset($result['badge'][0]['message']));
|
||||
} else {
|
||||
$this->assertTrue(isset($result['badge'][0]['message']));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,13 @@ class behat_core_competency_generator extends behat_generator_base {
|
||||
'singular' => 'plan',
|
||||
'datagenerator' => 'plan',
|
||||
'required' => ['name'],
|
||||
'switchids' => ['user' => 'userid'],
|
||||
'switchids' => ['user' => 'userid', 'status' => 'status'],
|
||||
],
|
||||
'plan_competencies' => [
|
||||
'singular' => 'plan_competency',
|
||||
'datagenerator' => 'plan_competency',
|
||||
'required' => ['plan', 'competency'],
|
||||
'switchids' => ['competency' => 'competencyid', 'plan' => 'planid'],
|
||||
],
|
||||
'related_competencies' => [
|
||||
'singular' => 'related_competency',
|
||||
@@ -83,14 +89,38 @@ class behat_core_competency_generator extends behat_generator_base {
|
||||
'required' => ['plan', 'competency', 'user'],
|
||||
'switchids' => ['plan' => 'planid', 'competency' => 'competencyid', 'user' => 'userid'],
|
||||
],
|
||||
'user_evidence' => [
|
||||
'singular' => 'user_evidence',
|
||||
'datagenerator' => 'user_evidence',
|
||||
'required' => ['user', 'name'],
|
||||
'switchids' => ['user' => 'userid'],
|
||||
],
|
||||
'user_evidence_competency' => [
|
||||
'singular' => 'user_evidence_competency',
|
||||
'datagenerator' => 'user_evidence_competency',
|
||||
'required' => ['userevidence', 'competency'],
|
||||
'switchids' => ['userevidence' => 'userevidenceid', 'competency' => 'competencyid'],
|
||||
],
|
||||
'templates' => [
|
||||
'singular' => 'template',
|
||||
'datagenerator' => 'template',
|
||||
'required' => ['shortname'],
|
||||
'switchids' => ['context' => 'contextid'],
|
||||
],
|
||||
'template_competencies' => [
|
||||
'singular' => 'template_competency',
|
||||
'datagenerator' => 'template_competency',
|
||||
'required' => ['template', 'competency'],
|
||||
'switchids' => ['template' => 'templateid', 'competency' => 'competencyid'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the competecy framework id using an idnumber.
|
||||
* Get the competency framework id using an idnumber.
|
||||
*
|
||||
* @param string $idnumber
|
||||
* @return int The competecy framework id
|
||||
* @return int The competency framework id
|
||||
*/
|
||||
protected function get_competencyframework_id(string $idnumber): int {
|
||||
global $DB;
|
||||
@@ -103,10 +133,10 @@ class behat_core_competency_generator extends behat_generator_base {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the competecy id using an idnumber.
|
||||
* Get the competency id using an idnumber.
|
||||
*
|
||||
* @param string $idnumber
|
||||
* @return int The competecy id
|
||||
* @return int The competency id
|
||||
*/
|
||||
protected function get_competency_id(string $idnumber): int {
|
||||
global $DB;
|
||||
@@ -135,15 +165,31 @@ class behat_core_competency_generator extends behat_generator_base {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the related competecy id using an idnumber.
|
||||
* Get the related competency id using an idnumber.
|
||||
*
|
||||
* @param string $idnumber
|
||||
* @return int The related competecy id
|
||||
* @return int The related competency id
|
||||
*/
|
||||
protected function get_relatedcompetency_id(string $idnumber): int {
|
||||
return $this->get_competency_id($idnumber);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the template id by shortname.
|
||||
*
|
||||
* @param string $shortname The template name.
|
||||
* @return int
|
||||
*/
|
||||
protected function get_template_id(string $shortname): int {
|
||||
global $DB;
|
||||
|
||||
if (!$id = $DB->get_field('competency_template', 'id', ['shortname' => $shortname])) {
|
||||
throw new Exception('The specified template with name "' . $shortname . '" could not be found.');
|
||||
}
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a plan.
|
||||
*
|
||||
@@ -208,6 +254,8 @@ class behat_core_competency_generator extends behat_generator_base {
|
||||
* @return array Processed data.
|
||||
*/
|
||||
protected function preprocess_plan(array $data): array {
|
||||
global $DB, $USER;
|
||||
|
||||
if (isset($data['competencies'])) {
|
||||
$competencies = array_map('trim', str_getcsv($data['competencies']));
|
||||
$data['competencyids'] = array_map([$this, 'get_competency_id'], $competencies);
|
||||
@@ -215,11 +263,20 @@ class behat_core_competency_generator extends behat_generator_base {
|
||||
unset($data['competencies']);
|
||||
}
|
||||
|
||||
global $USER;
|
||||
if (isset($data['reviewer'])) {
|
||||
if (is_number($data['reviewer'])) {
|
||||
$data['reviewerid'] = $data['reviewer'];
|
||||
} else {
|
||||
if (!$userid = $DB->get_field('user', 'id', ['username' => $data['reviewer']])) {
|
||||
throw new Exception('The specified user "' . $data['reviewer'] . '" could not be found.');
|
||||
}
|
||||
$data['reviewerid'] = $userid;
|
||||
}
|
||||
unset($data['reviewer']);
|
||||
}
|
||||
|
||||
return $data + [
|
||||
'userid' => $USER->id,
|
||||
'status' => plan::STATUS_ACTIVE,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -258,4 +315,81 @@ class behat_core_competency_generator extends behat_generator_base {
|
||||
protected function get_data_generator(): core_competency_generator {
|
||||
return $this->componentdatagenerator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user evidence id using a name.
|
||||
*
|
||||
* @param string $name User evidence name.
|
||||
* @return int The user evidence id
|
||||
*/
|
||||
protected function get_userevidence_id(string $name): int {
|
||||
global $DB;
|
||||
|
||||
if (!$id = $DB->get_field('competency_userevidence', 'id', ['name' => $name])) {
|
||||
throw new Exception('The specified user evidence with name "' . $name . '" could not be found.');
|
||||
}
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the template competency id using a name.
|
||||
*
|
||||
* @param string $name Template competency name.
|
||||
* @return int The template competency id
|
||||
*/
|
||||
protected function get_templatecompetency_id(string $name): int {
|
||||
global $DB;
|
||||
|
||||
if (!$id = $DB->get_field('competency_template', 'id', ['name' => $name])) {
|
||||
throw new Exception('The specified template competency with name "' . $name . '" could not be found.');
|
||||
}
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the context id using a contextid.
|
||||
*
|
||||
* @param string $contextid Context id.
|
||||
* @return int The context id
|
||||
*/
|
||||
protected function get_context_id(string $contextid): int {
|
||||
global $DB;
|
||||
|
||||
if (!$id = $DB->get_field('context', 'id', ['id' => $contextid])) {
|
||||
throw new Exception('The specified context with id "' . $contextid . '" could not be found.');
|
||||
}
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the status id by status name.
|
||||
*
|
||||
* @param string $name Status name.
|
||||
* @return int
|
||||
*/
|
||||
protected function get_status_id(string $name): int {
|
||||
|
||||
switch ($name) {
|
||||
case 'draft':
|
||||
$status = plan::STATUS_DRAFT;
|
||||
break;
|
||||
case 'in review':
|
||||
$status = plan::STATUS_IN_REVIEW;
|
||||
break;
|
||||
case 'waiting for review':
|
||||
$status = plan::STATUS_WAITING_FOR_REVIEW;
|
||||
break;
|
||||
case 'complete':
|
||||
$status = plan::STATUS_COMPLETE;
|
||||
break;
|
||||
default:
|
||||
$status = plan::STATUS_ACTIVE;
|
||||
break;
|
||||
}
|
||||
|
||||
return $status;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,7 +272,7 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
|
||||
if (!$coursecat) {
|
||||
// Course category not found.
|
||||
if ($strictness == MUST_EXIST) {
|
||||
throw new moodle_exception('unknowncategory');
|
||||
throw new moodle_exception('unknowncategory', a: $id);
|
||||
}
|
||||
$coursecat = null;
|
||||
} else if (!$alwaysreturnhidden && !$coursecat->is_uservisible($user)) {
|
||||
|
||||
@@ -186,7 +186,7 @@ class course_reset_form extends moodleform {
|
||||
$coursedata['enddate'] = $data['reset_end_date'];
|
||||
} else if ($data['reset_start_date'] > 0 && $course->enddate != 0) {
|
||||
// Otherwise, if the current course enddate is set, reset_course_userdata will add the start date time shift to it.
|
||||
$timeshift = $data['reset_start_date'] - usergetmidnight($course->startdate);
|
||||
$timeshift = $data['reset_start_date'] - $course->startdate;
|
||||
$coursedata['enddate'] = $course->enddate + $timeshift;
|
||||
} else {
|
||||
$coursedata['enddate'] = $course->enddate;
|
||||
|
||||
@@ -3696,7 +3696,16 @@ final class courselib_test extends advanced_testcase {
|
||||
$time + YEARSECS,
|
||||
$time + WEEKSECS,
|
||||
$time + YEARSECS
|
||||
]
|
||||
],
|
||||
// Time shift is between exact times, not midnight(s) (MDL-65233).
|
||||
[
|
||||
$time + HOURSECS,
|
||||
$time + DAYSECS,
|
||||
$time + WEEKSECS + HOURSECS,
|
||||
false,
|
||||
$time + WEEKSECS + HOURSECS,
|
||||
$time + WEEKSECS + DAYSECS,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Feature: Teacher can edit a marking guide state
|
||||
| activity | course | name | advancedgradingmethod_submissions |
|
||||
| assign | C1 | Assign 1 | guide |
|
||||
|
||||
Scenario: Marking guide state can be changed to draft
|
||||
Scenario Outline: Marking guide state can be changed
|
||||
Given I am on the "Course 1" course page logged in as teacher1
|
||||
And I go to "Assign 1" advanced grading definition page
|
||||
And I set the following fields to these values:
|
||||
@@ -28,12 +28,12 @@ Feature: Teacher can edit a marking guide state
|
||||
| Criterion name | Description for students | Description for markers | Maximum score |
|
||||
| Grade Criteria 1 | Grade 1 description for students | Grade 1 description for markers | 70 |
|
||||
| Grade Criteria 2 | Grade 2 description for students | Grade 2 description for markers | 30 |
|
||||
And I press "Save marking guide and make it ready"
|
||||
And I should not see "Please note: the advanced grading form is not ready at the moment. Simple grading method will be used until the form has a valid status."
|
||||
And I should not see "Assign 1 marking guide Draft"
|
||||
And I should see "Assign 1 marking guide Ready for use"
|
||||
And I click on "Edit the current form definition" "link"
|
||||
When I press "Save as draft"
|
||||
Then I should see "Please note: the advanced grading form is not ready at the moment. Simple grading method will be used until the form has a valid status."
|
||||
And I should see "Assign 1 marking guide Draft"
|
||||
And I should not see "Assign 1 marking guide Ready for use"
|
||||
When I press "<buttonname>"
|
||||
Then I <drafttextvisibility> see "Please note: the advanced grading form is not ready at the moment. Simple grading method will be used until the form has a valid status."
|
||||
And I <drafttextvisibility> see "Assign 1 marking guide Draft"
|
||||
And I <readytextvisibility> see "Assign 1 marking guide Ready for use"
|
||||
|
||||
Examples:
|
||||
| buttonname | drafttextvisibility | readytextvisibility |
|
||||
| Save marking guide and make it ready | should not | should |
|
||||
| Save as draft | should | should not |
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
@gradingform @gradingform_guide
|
||||
Feature: Marking guide can handle maximum grade mismatches
|
||||
In order to handle maximum grade mismatches
|
||||
As a teacher
|
||||
I should be able to set the maximum grade
|
||||
|
||||
Background:
|
||||
Given the following "user" exist:
|
||||
| username | firstname | lastname | email |
|
||||
| teacher1 | Teacher | 1 | teacher1@example.com |
|
||||
And the following "courses" exist:
|
||||
| fullname | shortname |
|
||||
| Course 1 | C1 |
|
||||
And the following "course enrolments" exist:
|
||||
| user | course | role |
|
||||
| teacher1 | C1 | editingteacher |
|
||||
|
||||
Scenario Outline: Marking guide maximum grade handling
|
||||
Given the following "activities" exist:
|
||||
| activity | course | name | advancedgradingmethod_submissions |
|
||||
| assign | C1 | Assign 1 | guide |
|
||||
And I am on the "Course 1" course page logged in as teacher1
|
||||
And I go to "Assign 1" advanced grading definition page
|
||||
And I set the following fields to these values:
|
||||
| Name | Assign 1 marking guide |
|
||||
| Description | Marking guide description |
|
||||
And I define the following marking guide:
|
||||
| Criterion name | Description for students | Description for markers | Maximum score |
|
||||
| Grade Criteria 1 | Grade 1 description for students | Grade 1 description for markers | <maxscore> |
|
||||
| Grade Criteria 2 | Grade 2 description for students | Grade 2 description for markers | 30 |
|
||||
When I press "Save marking guide and make it ready"
|
||||
Then I should see "Assign 1 marking guide Ready for use"
|
||||
# Please note: We need to add the no-break space unicode character to the warning message otherwise it will fail.
|
||||
And I should see "WARNING: Your marking guide has a maximum grade of <totalmaxscore> points but the maximum grade set in your activity is 100 The maximum score set in your marking guide will be scaled to the maximum grade in the module."
|
||||
And I should see "Intermediate scores will be converted respectively and rounded to the nearest available grade."
|
||||
|
||||
Examples:
|
||||
# <totalmaxscore> value is derived from <maxscore> + maximum score assigned to criteria 2 (30).
|
||||
# Case 1: total > max score of 100.
|
||||
# Case 2: total < max score of 100.
|
||||
| maxscore | totalmaxscore |
|
||||
| 90 | 120 |
|
||||
| 50 | 80 |
|
||||
@@ -0,0 +1,102 @@
|
||||
@gradereport @gradereport_grader
|
||||
Feature: We don't show hidden grades for users without the 'moodle/grade:viewhidden' capability on grader report
|
||||
In order to show grader report in secure way
|
||||
As a teacher without the 'moodle/grade:viewhidden' capability
|
||||
I should not see hidden grades in the grader report
|
||||
|
||||
Background:
|
||||
Given the following "courses" exist:
|
||||
| fullname | shortname | format |
|
||||
| Course 1 | C1 | topics |
|
||||
And the following "users" exist:
|
||||
| username | firstname | lastname | email |
|
||||
| teacher1 | Teacher | 1 | teacher1@example.com |
|
||||
| student1 | Student | 1 | student1@example.com |
|
||||
| student2 | Student | 2 | student2@example.com |
|
||||
And the following "course enrolments" exist:
|
||||
| user | course | role |
|
||||
| teacher1 | C1 | editingteacher |
|
||||
| student1 | C1 | student |
|
||||
| student2 | C1 | student |
|
||||
And the following "activities" exist:
|
||||
| activity | course | section | name | intro | assignsubmission_onlinetext_enabled | submissiondrafts |
|
||||
| assign | C1 | 1 | Test assignment name 1 | Submit your online text | 1 | 0 |
|
||||
| assign | C1 | 1 | Test assignment name 2 | submit your online text | 1 | 0 |
|
||||
| assign | C1 | 1 | Test assignment name 3 | submit your online text | 1 | 0 |
|
||||
# Hidden manual grade item.
|
||||
And the following "grade items" exist:
|
||||
| itemname | grademin | grademax | course | hidden |
|
||||
| Manual grade | 20 | 40 | C1 | 1 |
|
||||
And the following "grade grades" exist:
|
||||
| gradeitem | user | grade |
|
||||
| Test assignment name 1 | student1 | 80 |
|
||||
| Test assignment name 1 | student2 | 70 |
|
||||
| Test assignment name 2 | student1 | 90 |
|
||||
| Test assignment name 2 | student2 | 60 |
|
||||
| Test assignment name 3 | student1 | 10 |
|
||||
| Test assignment name 3 | student2 | 50 |
|
||||
| Manual grade | student1 | 30 |
|
||||
| Manual grade | student2 | 40 |
|
||||
And I log in as "admin"
|
||||
And I am on "Course 1" course homepage with editing mode on
|
||||
# Hide assignment 2 activity.
|
||||
And I open "Test assignment name 2" actions menu
|
||||
And I choose "Hide" in the open action menu
|
||||
And I navigate to "View > Grader report" in the course gradebook
|
||||
# Hide grade.
|
||||
And I click on grade menu "Test assignment name 1" for user "student1"
|
||||
And I choose "Hide" in the open action menu
|
||||
# Hide assignment 3 grade item.
|
||||
And I set the following settings for grade item "Test assignment name 3" of type "gradeitem" on "grader" page:
|
||||
| Hidden | 1 |
|
||||
|
||||
@javascript
|
||||
Scenario: View grader report containing hidden activities or grade items or grades
|
||||
Given I log in as "teacher1"
|
||||
And I am on "Course 1" course homepage
|
||||
And I navigate to "View > Grader report" in the course gradebook
|
||||
And the following should exist in the "user-grades" table:
|
||||
| -1- | -2- | -3- | -4- | -5- | -6- | -7- |
|
||||
| Student 1 | student1@example.com | 80 | 90 | 10 | 30 | 210 |
|
||||
| Student 2 | student2@example.com | 70 | 60 | 50 | 40 | 220 |
|
||||
And I turn editing mode on
|
||||
|
||||
And the field "Student 1 Test assignment name 1 grade" matches value "80"
|
||||
And the field "Student 1 Test assignment name 2 grade" matches value "90"
|
||||
And the field "Student 1 Test assignment name 3 grade" matches value "10"
|
||||
And the field "Student 1 Manual grade" matches value "30"
|
||||
And the field "Student 1 Course total grade" matches value "210"
|
||||
And "Hidden" "icon" should exist in the "Student 1" "table_row"
|
||||
|
||||
And the field "Student 2 Test assignment name 1 grade" matches value "70"
|
||||
And the field "Student 2 Test assignment name 2 grade" matches value "60"
|
||||
And the field "Student 2 Test assignment name 3 grade" matches value "50"
|
||||
And the field "Student 2 Manual grade" matches value "40"
|
||||
And the field "Student 2 Course total grade" matches value "220"
|
||||
And "Hidden" "icon" should exist in the "Student 2" "table_row"
|
||||
|
||||
# Remove the capability to see hidden grades.
|
||||
When the following "role capability" exists:
|
||||
| role | editingteacher |
|
||||
| moodle/grade:viewhidden | prohibit |
|
||||
And I navigate to "View > Grader report" in the course gradebook
|
||||
And I turn editing mode off
|
||||
And the following should exist in the "user-grades" table:
|
||||
| -1- | -2- | -3- | -4- | -5- | -6- | -7- |
|
||||
| Student 1 | student1@example.com | - | - | - | - | - |
|
||||
| Student 2 | student2@example.com | 70 | - | - | - | 70 |
|
||||
And "Hidden" "icon" should not exist in the "Student 1" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Student 2" "table_row"
|
||||
And I turn editing mode on
|
||||
And the following should exist in the "user-grades" table:
|
||||
| -1- | -2- | -3- | -4- | -5- | -6- |
|
||||
| Student 1 | student1@example.com | - | - | - | - |
|
||||
And the following should exist in the "user-grades" table:
|
||||
| -1- | -2- | -4- | -5- | -6- |
|
||||
| Student 2 | student2@example.com | - | - | - |
|
||||
# Check how totals should behave!!!!!!!!!!!
|
||||
And the field "Student 1 Course total grade" matches value ""
|
||||
And the field "Student 2 Test assignment name 1 grade" matches value "70"
|
||||
And the field "Student 2 Course total grade" matches value "70"
|
||||
And "Hidden" "icon" should not exist in the "Student 1" "table_row"
|
||||
Then "Hidden" "icon" should not exist in the "Student 2" "table_row"
|
||||
@@ -24,6 +24,7 @@
|
||||
|
||||
namespace gradereport_singleview\local\screen;
|
||||
|
||||
use context_course;
|
||||
use grade_report;
|
||||
use gradereport_singleview\local\ui\range;
|
||||
use gradereport_singleview\local\ui\bulk_insert;
|
||||
@@ -217,8 +218,13 @@ class grade extends tablelike implements selectable_items, filterable_items {
|
||||
$grade = $this->fetch_grade_or_default($this->item, $item->id);
|
||||
|
||||
$gradestatus = '';
|
||||
|
||||
// Show hidden icon if the grade is hidden and the user has permission to view hidden grades.
|
||||
$showhiddenicon = $grade->is_hidden() &&
|
||||
has_capability('moodle/grade:viewhidden', context_course::instance($this->courseid));
|
||||
|
||||
$context = [
|
||||
'hidden' => $grade->is_hidden(),
|
||||
'hidden' => $showhiddenicon,
|
||||
'locked' => $grade->is_locked(),
|
||||
];
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
|
||||
namespace gradereport_singleview\local\screen;
|
||||
|
||||
use context_course;
|
||||
use grade_seq;
|
||||
use gradereport_singleview;
|
||||
use moodle_url;
|
||||
@@ -171,8 +172,12 @@ class user extends tablelike implements selectable_items {
|
||||
$grade = $this->fetch_grade_or_default($item, $this->item->id);
|
||||
$gradestatus = '';
|
||||
|
||||
// Show hidden icon if the grade is hidden and the user has permission to view hidden grades.
|
||||
$showhiddenicon = ($grade->is_hidden() || $item->is_hidden()) &&
|
||||
has_capability('moodle/grade:viewhidden', context_course::instance($item->courseid));
|
||||
|
||||
$context = [
|
||||
'hidden' => $grade->is_hidden(),
|
||||
'hidden' => $showhiddenicon,
|
||||
'locked' => $grade->is_locked(),
|
||||
];
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ namespace gradereport_singleview\local\ui;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die;
|
||||
|
||||
use context_course;
|
||||
use grade_grade;
|
||||
|
||||
/**
|
||||
@@ -83,6 +84,10 @@ class exclude extends grade_attribute_format implements be_checked, be_disabled,
|
||||
* @return element
|
||||
*/
|
||||
public function determine_format(): element {
|
||||
if (($this->grade->is_hidden() || $this->grade->grade_item->is_hidden()) &&
|
||||
!has_capability('moodle/grade:viewhidden', context_course::instance($this->grade->grade_item->courseid))) {
|
||||
return new empty_element();
|
||||
}
|
||||
return new checkbox_attribute(
|
||||
$this->get_name(),
|
||||
$this->get_label(),
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
|
||||
namespace gradereport_singleview\local\ui;
|
||||
|
||||
use context_course;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die;
|
||||
|
||||
/**
|
||||
@@ -104,6 +106,10 @@ class feedback extends grade_attribute_format implements unique_value, be_disabl
|
||||
* @return element
|
||||
*/
|
||||
public function determine_format(): element {
|
||||
if (($this->grade->is_hidden() || $this->grade->grade_item->is_hidden()) &&
|
||||
!has_capability('moodle/grade:viewhidden', context_course::instance($this->grade->grade_item->courseid))) {
|
||||
return new empty_element();
|
||||
}
|
||||
return new text_attribute(
|
||||
$this->get_name(),
|
||||
$this->get_value(),
|
||||
|
||||
@@ -26,6 +26,7 @@ namespace gradereport_singleview\local\ui;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die;
|
||||
|
||||
use context_course;
|
||||
use stdClass;
|
||||
/**
|
||||
* UI element representing the finalgrade column.
|
||||
@@ -115,6 +116,11 @@ class finalgrade extends grade_attribute_format implements unique_value, be_disa
|
||||
public function determine_format(): element {
|
||||
global $CFG;
|
||||
|
||||
if (($this->grade->is_hidden() || $this->grade->grade_item->is_hidden()) &&
|
||||
!has_capability('moodle/grade:viewhidden', context_course::instance($this->grade->grade_item->courseid))) {
|
||||
return new empty_element();
|
||||
}
|
||||
|
||||
if ($this->grade->grade_item->load_scale()) {
|
||||
$scale = $this->grade->grade_item->load_scale();
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
|
||||
namespace gradereport_singleview\local\ui;
|
||||
|
||||
use context_course;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die;
|
||||
|
||||
/**
|
||||
@@ -94,7 +96,11 @@ class override extends grade_attribute_format implements be_checked, be_disabled
|
||||
* @return element
|
||||
*/
|
||||
public function determine_format(): element {
|
||||
if (!$this->grade->grade_item->is_overridable_item()) {
|
||||
// If the grade is hidden and the user does not have permission to view hidden grades,
|
||||
// then we don't show the override checkbox.
|
||||
$allowhiddenoverride = (!$this->grade->is_hidden() && !$this->grade->grade_item->is_hidden()) ||
|
||||
has_capability('moodle/grade:viewhidden', context_course::instance($this->grade->grade_item->courseid));
|
||||
if (!$allowhiddenoverride || !$this->grade->grade_item->is_overridable_item()) {
|
||||
return new empty_element();
|
||||
}
|
||||
return new checkbox_attribute(
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
@gradereport @gradereport_singleview
|
||||
Feature: We don't show hidden grades for users without the 'moodle/grade:viewhidden' capability on singleview report
|
||||
In order to show singleview report in secure way
|
||||
As a teacher without the 'moodle/grade:viewhidden' capability
|
||||
I should not see hidden grades in the singleview report
|
||||
|
||||
Background:
|
||||
Given the following "courses" exist:
|
||||
| fullname | shortname | format |
|
||||
| Course 1 | C1 | topics |
|
||||
And the following "users" exist:
|
||||
| username | firstname | lastname | email |
|
||||
| teacher1 | Teacher | 1 | teacher1@example.com |
|
||||
| student1 | Student1 | 1 | student1@example.com |
|
||||
| student2 | Student2 | 2 | student2@example.com |
|
||||
And the following "course enrolments" exist:
|
||||
| user | course | role |
|
||||
| teacher1 | C1 | editingteacher |
|
||||
| student1 | C1 | student |
|
||||
| student2 | C1 | student |
|
||||
And the following "activities" exist:
|
||||
| activity | course | section | name | intro | assignsubmission_onlinetext_enabled | submissiondrafts |
|
||||
| assign | C1 | 1 | Test assignment name 1 | Submit your online text | 1 | 0 |
|
||||
| assign | C1 | 1 | Test assignment name 2 | submit your online text | 1 | 0 |
|
||||
| assign | C1 | 1 | Test assignment name 3 | submit your online text | 1 | 0 |
|
||||
# Hidden manual grade item.
|
||||
And the following "grade items" exist:
|
||||
| itemname | grademin | grademax | course | hidden |
|
||||
| Manual grade | 20 | 40 | C1 | 1 |
|
||||
And the following "grade grades" exist:
|
||||
| gradeitem | user | grade |
|
||||
| Test assignment name 1 | student1 | 80 |
|
||||
| Test assignment name 1 | student2 | 70 |
|
||||
| Test assignment name 2 | student1 | 90 |
|
||||
| Test assignment name 2 | student2 | 60 |
|
||||
| Test assignment name 3 | student1 | 10 |
|
||||
| Test assignment name 3 | student2 | 50 |
|
||||
| Manual grade | student1 | 30 |
|
||||
| Manual grade | student2 | 40 |
|
||||
And I log in as "admin"
|
||||
And I am on "Course 1" course homepage with editing mode on
|
||||
# Hide assignment 2 activity.
|
||||
And I open "Test assignment name 2" actions menu
|
||||
And I choose "Hide" in the open action menu
|
||||
And I navigate to "View > Grader report" in the course gradebook
|
||||
# Hide grade.
|
||||
And I click on grade menu "Test assignment name 1" for user "student1"
|
||||
And I choose "Hide" in the open action menu
|
||||
# Hide assignment 3 grade item.
|
||||
And I set the following settings for grade item "Test assignment name 3" of type "gradeitem" on "grader" page:
|
||||
| Hidden | 1 |
|
||||
|
||||
@javascript
|
||||
Scenario: View singleview report containing hidden activities or grade items or grades with editing on and required capabilities
|
||||
Given I log in as "teacher1"
|
||||
And I am on "Course 1" course homepage with editing mode on
|
||||
And I navigate to "View > Single view" in the course gradebook
|
||||
And I click on "Users" "link" in the ".page-toggler" "css_element"
|
||||
|
||||
When I set the field "Search users" to "Student1"
|
||||
And I click on "Student1" "list_item"
|
||||
And the field "Grade for Test assignment name 1" matches value "80"
|
||||
And the field "Grade for Test assignment name 2" matches value "90"
|
||||
And the field "Grade for Test assignment name 3" matches value "10"
|
||||
And the field "Grade for Manual grade" matches value "30"
|
||||
And the field "Grade for Course total" matches value "210"
|
||||
|
||||
And "Hidden" "icon" should exist in the "Test assignment name 1" "table_row"
|
||||
And "Hidden" "icon" should exist in the "Test assignment name 2" "table_row"
|
||||
And "Hidden" "icon" should exist in the "Test assignment name 3" "table_row"
|
||||
And "Hidden" "icon" should exist in the "Manual grade" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Course total" "table_row"
|
||||
|
||||
And the field "Feedback for Test assignment name 1" matches value ""
|
||||
And the field "Feedback for Test assignment name 2" matches value ""
|
||||
And the field "Feedback for Test assignment name 3" matches value ""
|
||||
And the field "Feedback for Manual grade" matches value ""
|
||||
And the field "Feedback for Course total" matches value ""
|
||||
|
||||
And "Override for Test assignment name 1" "checkbox" should exist in the "Test assignment name 1" "table_row"
|
||||
And "Override for Test assignment name 2" "checkbox" should exist in the "Test assignment name 2" "table_row"
|
||||
And "Override for Test assignment name 3" "checkbox" should exist in the "Test assignment name 3" "table_row"
|
||||
And "Override for Manual grade" "checkbox" should not exist in the "Manual grade" "table_row"
|
||||
And "Override for Course total" "checkbox" should exist in the "Course total" "table_row"
|
||||
|
||||
And "Exclude for Test assignment name 1" "checkbox" should exist in the "Test assignment name 1" "table_row"
|
||||
And "Exclude for Test assignment name 2" "checkbox" should exist in the "Test assignment name 2" "table_row"
|
||||
And "Exclude for Test assignment name 3" "checkbox" should exist in the "Test assignment name 3" "table_row"
|
||||
And "Exclude for Manual grade" "checkbox" should exist in the "Manual grade" "table_row"
|
||||
And "Exclude for Course total" "checkbox" should exist in the "Course total" "table_row"
|
||||
|
||||
And I set the field "Search users" to "Student2"
|
||||
And I click on "Student2" "list_item"
|
||||
And the field "Grade for Test assignment name 1" matches value "70"
|
||||
And the field "Grade for Test assignment name 2" matches value "60"
|
||||
And the field "Grade for Test assignment name 3" matches value "50"
|
||||
And the field "Grade for Manual grade" matches value "40"
|
||||
And the field "Grade for Course total" matches value "220"
|
||||
|
||||
And "Hidden" "icon" should not exist in the "Test assignment name 1" "table_row"
|
||||
And "Hidden" "icon" should exist in the "Test assignment name 2" "table_row"
|
||||
And "Hidden" "icon" should exist in the "Test assignment name 3" "table_row"
|
||||
And "Hidden" "icon" should exist in the "Manual grade" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Course total" "table_row"
|
||||
|
||||
And the field "Feedback for Test assignment name 1" matches value ""
|
||||
And the field "Feedback for Test assignment name 2" matches value ""
|
||||
And the field "Feedback for Test assignment name 3" matches value ""
|
||||
And the field "Feedback for Manual grade" matches value ""
|
||||
And the field "Feedback for Course total" matches value ""
|
||||
|
||||
And "Override for Test assignment name 1" "checkbox" should exist in the "Test assignment name 1" "table_row"
|
||||
And "Override for Test assignment name 2" "checkbox" should exist in the "Test assignment name 2" "table_row"
|
||||
And "Override for Test assignment name 3" "checkbox" should exist in the "Test assignment name 3" "table_row"
|
||||
And "Override for Manual grade" "checkbox" should not exist in the "Manual grade" "table_row"
|
||||
And "Override for Course total" "checkbox" should exist in the "Course total" "table_row"
|
||||
|
||||
And "Exclude for Test assignment name 1" "checkbox" should exist in the "Test assignment name 1" "table_row"
|
||||
And "Exclude for Test assignment name 2" "checkbox" should exist in the "Test assignment name 2" "table_row"
|
||||
And "Exclude for Test assignment name 3" "checkbox" should exist in the "Test assignment name 3" "table_row"
|
||||
And "Exclude for Manual grade" "checkbox" should exist in the "Manual grade" "table_row"
|
||||
And "Exclude for Course total" "checkbox" should exist in the "Course total" "table_row"
|
||||
|
||||
And I click on "Grade items" "link" in the ".page-toggler" "css_element"
|
||||
And I click on ".gradesearchwidget" "css_element"
|
||||
And I set the field "Search items" to "Test assignment name 1"
|
||||
And I click on "Test assignment name 1" in the "grade" search widget
|
||||
And the field "Grade for Student1 1" matches value "80"
|
||||
And the field "Grade for Student2 2" matches value "70"
|
||||
And "Hidden" "icon" should exist in the "Student1 1" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Student2 2" "table_row"
|
||||
And the field "Feedback for Student1 1" matches value ""
|
||||
And the field "Feedback for Student2 2" matches value ""
|
||||
And "Override for Student1 1" "checkbox" should exist in the "Student1 1" "table_row"
|
||||
And "Override for Student2 2" "checkbox" should exist in the "Student2 2" "table_row"
|
||||
And "Exclude for Student1 1" "checkbox" should exist in the "Student1 1" "table_row"
|
||||
And "Exclude for Student2 2" "checkbox" should exist in the "Student2 2" "table_row"
|
||||
|
||||
And I click on ".gradesearchwidget" "css_element"
|
||||
And I set the field "Search items" to "Test assignment name 2"
|
||||
And I click on "Test assignment name 2" in the "grade" search widget
|
||||
And the field "Grade for Student1 1" matches value "90"
|
||||
And the field "Grade for Student2 2" matches value "60"
|
||||
And "Hidden" "icon" should exist in the "Student1 1" "table_row"
|
||||
And "Hidden" "icon" should exist in the "Student2 2" "table_row"
|
||||
And the field "Feedback for Student1 1" matches value ""
|
||||
And the field "Feedback for Student2 2" matches value ""
|
||||
And "Override for Student1 1" "checkbox" should exist in the "Student1 1" "table_row"
|
||||
And "Override for Student2 2" "checkbox" should exist in the "Student2 2" "table_row"
|
||||
And "Exclude for Student1 1" "checkbox" should exist in the "Student1 1" "table_row"
|
||||
And "Exclude for Student2 2" "checkbox" should exist in the "Student2 2" "table_row"
|
||||
|
||||
And I click on ".gradesearchwidget" "css_element"
|
||||
And I set the field "Search items" to "Test assignment name 3"
|
||||
And I click on "Test assignment name 3" in the "grade" search widget
|
||||
And the field "Grade for Student1 1" matches value "10"
|
||||
And the field "Grade for Student2 2" matches value "50"
|
||||
And "Hidden" "icon" should exist in the "Student1 1" "table_row"
|
||||
And "Hidden" "icon" should exist in the "Student2 2" "table_row"
|
||||
And the field "Feedback for Student1 1" matches value ""
|
||||
And the field "Feedback for Student2 2" matches value ""
|
||||
And "Exclude for Student1 1" "checkbox" should exist in the "Student1 1" "table_row"
|
||||
And "Exclude for Student2 2" "checkbox" should exist in the "Student2 2" "table_row"
|
||||
And "Override for Student1 1" "checkbox" should exist in the "Student1 1" "table_row"
|
||||
And "Override for Student2 2" "checkbox" should exist in the "Student2 2" "table_row"
|
||||
|
||||
And I click on ".gradesearchwidget" "css_element"
|
||||
And I set the field "Search items" to "Manual grade"
|
||||
And I click on "Manual grade" in the "grade" search widget
|
||||
And the field "Grade for Student1 1" matches value "30"
|
||||
And the field "Grade for Student2 2" matches value "40"
|
||||
And "Hidden" "icon" should exist in the "Student1 1" "table_row"
|
||||
And "Hidden" "icon" should exist in the "Student2 2" "table_row"
|
||||
And the field "Feedback for Student1 1" matches value ""
|
||||
And the field "Feedback for Student2 2" matches value ""
|
||||
And "Override for Student1 1" "checkbox" should not exist in the "Student1 1" "table_row"
|
||||
And "Override for Student2 2" "checkbox" should not exist in the "Student2 2" "table_row"
|
||||
And "Exclude for Student1 1" "checkbox" should exist in the "Student1 1" "table_row"
|
||||
And "Exclude for Student2 2" "checkbox" should exist in the "Student2 2" "table_row"
|
||||
|
||||
And I click on ".gradesearchwidget" "css_element"
|
||||
And I set the field "Search items" to "Course total"
|
||||
And I click on "Course total" in the "grade" search widget
|
||||
And the field "Grade for Student1 1" matches value "210"
|
||||
And the field "Grade for Student2 2" matches value "220"
|
||||
And "Hidden" "icon" should not exist in the "Student1 1" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Student2 2" "table_row"
|
||||
And the field "Feedback for Student1 1" matches value ""
|
||||
And the field "Feedback for Student2 2" matches value ""
|
||||
And "Override for Student1 1" "checkbox" should exist in the "Student1 1" "table_row"
|
||||
And "Override for Student2 2" "checkbox" should exist in the "Student2 2" "table_row"
|
||||
And "Exclude for Student1 1" "checkbox" should exist in the "Student1 1" "table_row"
|
||||
Then "Exclude for Student2 2" "checkbox" should exist in the "Student2 2" "table_row"
|
||||
|
||||
@javascript
|
||||
Scenario: View singleview report containing hidden activities or grade items or grades with editing off and required capabilities
|
||||
Given I log in as "teacher1"
|
||||
And I am on "Course 1" course homepage with editing mode off
|
||||
And I navigate to "View > Single view" in the course gradebook
|
||||
When I click on "Grade items" "link" in the ".page-toggler" "css_element"
|
||||
And I click on ".gradesearchwidget" "css_element"
|
||||
And I set the field "Search items" to "Course total"
|
||||
And I click on "Course total" in the "grade" search widget
|
||||
And the following should exist in the "generaltable" table:
|
||||
| User full name | Grade |
|
||||
| Student1 1 | 210 |
|
||||
| Student2 2 | 220 |
|
||||
And "Hidden" "icon" should not exist in the "Student1 1" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Student2 2" "table_row"
|
||||
|
||||
And I click on ".gradesearchwidget" "css_element"
|
||||
And I set the field "Search items" to "Manual grade"
|
||||
And I click on "Manual grade" in the "grade" search widget
|
||||
And the following should exist in the "generaltable" table:
|
||||
| User full name | Grade |
|
||||
| Student1 1 | 30 |
|
||||
| Student2 2 | 40 |
|
||||
And "Hidden" "icon" should exist in the "Student1 1" "table_row"
|
||||
And "Hidden" "icon" should exist in the "Student2 2" "table_row"
|
||||
|
||||
And I click on ".gradesearchwidget" "css_element"
|
||||
And I set the field "Search items" to "Test assignment name 3"
|
||||
And I click on "Test assignment name 3" in the "grade" search widget
|
||||
And the following should exist in the "generaltable" table:
|
||||
| User full name | Grade |
|
||||
| Student1 1 | 10 |
|
||||
| Student2 2 | 50 |
|
||||
And "Hidden" "icon" should exist in the "Student1 1" "table_row"
|
||||
And "Hidden" "icon" should exist in the "Student2 2" "table_row"
|
||||
|
||||
And I click on ".gradesearchwidget" "css_element"
|
||||
And I set the field "Search items" to "Test assignment name 2"
|
||||
And I click on "Test assignment name 2" in the "grade" search widget
|
||||
And the following should exist in the "generaltable" table:
|
||||
| User full name | Grade |
|
||||
| Student1 1 | 90 |
|
||||
| Student2 2 | 60 |
|
||||
And "Hidden" "icon" should exist in the "Student1 1" "table_row"
|
||||
And "Hidden" "icon" should exist in the "Student2 2" "table_row"
|
||||
|
||||
And I click on ".gradesearchwidget" "css_element"
|
||||
And I set the field "Search items" to "Test assignment name 1"
|
||||
And I click on "Test assignment name 1" in the "grade" search widget
|
||||
And the following should exist in the "generaltable" table:
|
||||
| User full name | Grade |
|
||||
| Student1 1 | 80 |
|
||||
| Student2 2 | 70 |
|
||||
And "Hidden" "icon" should exist in the "Student1 1" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Student2 2" "table_row"
|
||||
|
||||
And I click on "Users" "link" in the ".page-toggler" "css_element"
|
||||
And I set the field "Search users" to "Student1"
|
||||
And I click on "Student1" "list_item"
|
||||
And the following should exist in the "generaltable" table:
|
||||
| Grade item | Grade |
|
||||
| Test assignment name 1 | 80 |
|
||||
| Test assignment name 2 | 90 |
|
||||
| Test assignment name 3 | 10 |
|
||||
| Manual grade | 30 |
|
||||
| Course total | 210 |
|
||||
And "Hidden" "icon" should exist in the "Test assignment name 1" "table_row"
|
||||
And "Hidden" "icon" should exist in the "Test assignment name 2" "table_row"
|
||||
And "Hidden" "icon" should exist in the "Test assignment name 3" "table_row"
|
||||
And "Hidden" "icon" should exist in the "Manual grade" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Course total" "table_row"
|
||||
|
||||
And I set the field "Search users" to "Student2"
|
||||
And I click on "Student2" "list_item"
|
||||
And the following should exist in the "generaltable" table:
|
||||
| Grade item | Grade |
|
||||
| Test assignment name 1 | 70 |
|
||||
| Test assignment name 2 | 60 |
|
||||
| Test assignment name 3 | 50 |
|
||||
| Manual grade | 40 |
|
||||
| Course total | 220 |
|
||||
And "Hidden" "icon" should not exist in the "Test assignment name 1" "table_row"
|
||||
And "Hidden" "icon" should exist in the "Test assignment name 2" "table_row"
|
||||
And "Hidden" "icon" should exist in the "Test assignment name 3" "table_row"
|
||||
And "Hidden" "icon" should exist in the "Manual grade" "table_row"
|
||||
Then "Hidden" "icon" should not exist in the "Course total" "table_row"
|
||||
|
||||
@javascript
|
||||
Scenario: View singleview report containing hidden activities or grade items or grades with editing off without required capabilities
|
||||
Given I log in as "teacher1"
|
||||
And the following "role capability" exists:
|
||||
| role | editingteacher |
|
||||
| moodle/grade:viewhidden | prohibit |
|
||||
And I am on "Course 1" course homepage with editing mode off
|
||||
And I navigate to "View > Single view" in the course gradebook
|
||||
When I click on "Users" "link" in the ".page-toggler" "css_element"
|
||||
And I set the field "Search users" to "Student2"
|
||||
And I click on "Student2" "list_item"
|
||||
And the following should exist in the "generaltable" table:
|
||||
# Total is weird!!!!!!!!!!!!!!.
|
||||
| Grade item | Grade |
|
||||
| Test assignment name 1 | 70 |
|
||||
| Test assignment name 2 | |
|
||||
| Test assignment name 3 | |
|
||||
| Manual grade | |
|
||||
| Course total | 220 |
|
||||
And "Hidden" "icon" should not exist in the "Test assignment name 1" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Test assignment name 2" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Test assignment name 3" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Manual grade" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Course total" "table_row"
|
||||
|
||||
And I set the field "Search users" to "Student1"
|
||||
And I click on "Student1" "list_item"
|
||||
And the following should exist in the "generaltable" table:
|
||||
| Grade item | Grade |
|
||||
| Test assignment name 1 | |
|
||||
| Test assignment name 2 | |
|
||||
| Test assignment name 3 | |
|
||||
| Manual grade | |
|
||||
| Course total | 210 |
|
||||
And "Hidden" "icon" should not exist in the "Test assignment name 1" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Test assignment name 2" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Test assignment name 3" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Manual grade" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Course total" "table_row"
|
||||
|
||||
And I click on "Grade items" "link" in the ".page-toggler" "css_element"
|
||||
And I click on ".gradesearchwidget" "css_element"
|
||||
And I set the field "Search items" to "Test assignment name 1"
|
||||
And I click on "Test assignment name 1" in the "grade" search widget
|
||||
And the following should exist in the "generaltable" table:
|
||||
| User full name | Grade |
|
||||
| Student1 1 | |
|
||||
| Student2 2 | 70 |
|
||||
And "Hidden" "icon" should not exist in the "Student1 1" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Student2 2" "table_row"
|
||||
|
||||
And I click on ".gradesearchwidget" "css_element"
|
||||
And I set the field "Search items" to "Test assignment name 2"
|
||||
And I click on "Test assignment name 2" in the "grade" search widget
|
||||
And the following should exist in the "generaltable" table:
|
||||
| User full name | Grade |
|
||||
| Student1 1 | |
|
||||
| Student2 2 | |
|
||||
And "Hidden" "icon" should not exist in the "Student1 1" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Student2 2" "table_row"
|
||||
|
||||
And I click on ".gradesearchwidget" "css_element"
|
||||
And I set the field "Search items" to "Test assignment name 3"
|
||||
And I click on "Test assignment name 3" in the "grade" search widget
|
||||
And the following should exist in the "generaltable" table:
|
||||
| User full name | Grade |
|
||||
| Student1 1 | |
|
||||
| Student2 2 | |
|
||||
And "Hidden" "icon" should not exist in the "Student1 1" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Student2 2" "table_row"
|
||||
|
||||
And I click on ".gradesearchwidget" "css_element"
|
||||
And I set the field "Search items" to "Manual grade"
|
||||
And I click on "Manual grade" in the "grade" search widget
|
||||
And the following should exist in the "generaltable" table:
|
||||
| User full name | Grade |
|
||||
| Student1 1 | |
|
||||
| Student2 2 | |
|
||||
And "Hidden" "icon" should not exist in the "Student1 1" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Student2 2" "table_row"
|
||||
|
||||
And I click on ".gradesearchwidget" "css_element"
|
||||
And I set the field "Search items" to "Course total"
|
||||
And I click on "Course total" in the "grade" search widget
|
||||
And the following should exist in the "generaltable" table:
|
||||
| User full name | Grade |
|
||||
| Student1 1 | 210 |
|
||||
| Student2 2 | 220 |
|
||||
And "Hidden" "icon" should not exist in the "Student1 1" "table_row"
|
||||
Then "Hidden" "icon" should not exist in the "Student2 2" "table_row"
|
||||
|
||||
@javascript
|
||||
Scenario: View singleview report containing hidden activities or grade items or grades with editing on without required capabilities
|
||||
Given I log in as "teacher1"
|
||||
And the following "role capability" exists:
|
||||
| role | editingteacher |
|
||||
| moodle/grade:viewhidden | prohibit |
|
||||
And I am on "Course 1" course homepage with editing mode on
|
||||
When I navigate to "View > Single view" in the course gradebook
|
||||
And I click on "Grade items" "link" in the ".page-toggler" "css_element"
|
||||
And I click on ".gradesearchwidget" "css_element"
|
||||
And I set the field "Search items" to "Course total"
|
||||
And I click on "Course total" in the "grade" search widget
|
||||
And the field "Grade for Student1 1" matches value "210"
|
||||
And the field "Grade for Student2 2" matches value "220"
|
||||
And "Hidden" "icon" should not exist in the "Student1 1" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Student2 2" "table_row"
|
||||
And the field "Feedback for Student1 1" matches value ""
|
||||
And the field "Feedback for Student2 2" matches value ""
|
||||
And "Override for Student1 1" "checkbox" should exist in the "Student1 1" "table_row"
|
||||
And "Override for Student2 2" "checkbox" should exist in the "Student2 2" "table_row"
|
||||
And "Exclude for Student1 1" "checkbox" should exist in the "Student1 1" "table_row"
|
||||
And "Exclude for Student2 2" "checkbox" should exist in the "Student2 2" "table_row"
|
||||
|
||||
And I click on ".gradesearchwidget" "css_element"
|
||||
And I set the field "Search items" to "Manual grade"
|
||||
And I click on "Manual grade" in the "grade" search widget
|
||||
And the following should exist in the "generaltable" table:
|
||||
| User full name | Grade |
|
||||
| Student1 1 | |
|
||||
| Student2 2 | |
|
||||
And "Hidden" "icon" should not exist in the "Student1 1" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Student2 2" "table_row"
|
||||
|
||||
And I click on ".gradesearchwidget" "css_element"
|
||||
And I set the field "Search items" to "Test assignment name 3"
|
||||
And I click on "Test assignment name 3" in the "grade" search widget
|
||||
And the following should exist in the "generaltable" table:
|
||||
| User full name | Grade |
|
||||
| Student1 1 | |
|
||||
| Student2 2 | |
|
||||
And "Hidden" "icon" should not exist in the "Student1 1" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Student2 2" "table_row"
|
||||
|
||||
And I click on ".gradesearchwidget" "css_element"
|
||||
And I set the field "Search items" to "Test assignment name 2"
|
||||
And I click on "Test assignment name 2" in the "grade" search widget
|
||||
And the following should exist in the "generaltable" table:
|
||||
| User full name | Grade |
|
||||
| Student1 1 | |
|
||||
| Student2 2 | |
|
||||
And "Hidden" "icon" should not exist in the "Student1 1" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Student2 2" "table_row"
|
||||
|
||||
And I click on ".gradesearchwidget" "css_element"
|
||||
And I set the field "Search items" to "Test assignment name 1"
|
||||
And I click on "Test assignment name 1" in the "grade" search widget
|
||||
And the following should exist in the "generaltable" table:
|
||||
| User full name | Grade |
|
||||
| Student1 1 | |
|
||||
And "Hidden" "icon" should not exist in the "Student1 1" "table_row"
|
||||
And the field "Grade for Student2 2" matches value "70"
|
||||
And "Hidden" "icon" should not exist in the "Student2 2" "table_row"
|
||||
And the field "Feedback for Student2 2" matches value ""
|
||||
And "Override for Student2 2" "checkbox" should exist in the "Student2 2" "table_row"
|
||||
And "Exclude for Student2 2" "checkbox" should exist in the "Student2 2" "table_row"
|
||||
|
||||
And I click on "Users" "link" in the ".page-toggler" "css_element"
|
||||
And I set the field "Search users" to "Student1"
|
||||
And I click on "Student1" "list_item"
|
||||
And the following should exist in the "generaltable" table:
|
||||
| Grade item | Grade | Feedback |
|
||||
| Test assignment name 1 | | |
|
||||
| Test assignment name 2 | | |
|
||||
| Test assignment name 3 | | |
|
||||
| Manual grade | | |
|
||||
And "Hidden" "icon" should not exist in the "Test assignment name 1" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Test assignment name 2" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Test assignment name 3" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Manual grade" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Course total" "table_row"
|
||||
And "Override for Test assignment name 1" "checkbox" should not exist in the "Test assignment name 1" "table_row"
|
||||
And "Exclude for Test assignment name 1" "checkbox" should not exist in the "Test assignment name 1" "table_row"
|
||||
And "Override for Test assignment name 2" "checkbox" should not exist in the "Test assignment name 2" "table_row"
|
||||
And "Exclude for Test assignment name 2" "checkbox" should not exist in the "Test assignment name 2" "table_row"
|
||||
And "Override for Test assignment name 3" "checkbox" should not exist in the "Test assignment name 3" "table_row"
|
||||
And "Exclude for Test assignment name 3" "checkbox" should not exist in the "Test assignment name 3" "table_row"
|
||||
And "Override for Manual grade" "checkbox" should not exist in the "Manual grade" "table_row"
|
||||
And "Exclude for Manual grade" "checkbox" should not exist in the "Manual grade" "table_row"
|
||||
And the field "Grade for Course total" matches value "210"
|
||||
And the field "Feedback for Course total" matches value ""
|
||||
And "Override for Course total" "checkbox" should exist in the "Course total" "table_row"
|
||||
And "Exclude for Course total" "checkbox" should exist in the "Course total" "table_row"
|
||||
|
||||
And I set the field "Search users" to "Student2"
|
||||
And I click on "Student2" "list_item"
|
||||
And the following should exist in the "generaltable" table:
|
||||
| Grade item | Grade | Feedback |
|
||||
| Test assignment name 2 | | |
|
||||
| Test assignment name 3 | | |
|
||||
| Manual grade | | |
|
||||
And "Hidden" "icon" should not exist in the "Test assignment name 1" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Test assignment name 2" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Test assignment name 3" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Manual grade" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Course total" "table_row"
|
||||
And the field "Grade for Test assignment name 1" matches value "70"
|
||||
And the field "Feedback for Test assignment name 1" matches value ""
|
||||
And "Override for Test assignment name 1" "checkbox" should exist in the "Test assignment name 1" "table_row"
|
||||
And "Exclude for Test assignment name 1" "checkbox" should exist in the "Test assignment name 1" "table_row"
|
||||
And "Override for Test assignment name 2" "checkbox" should not exist in the "Test assignment name 2" "table_row"
|
||||
And "Exclude for Test assignment name 2" "checkbox" should not exist in the "Test assignment name 2" "table_row"
|
||||
And "Override for Test assignment name 3" "checkbox" should not exist in the "Test assignment name 3" "table_row"
|
||||
And "Exclude for Test assignment name 3" "checkbox" should not exist in the "Test assignment name 3" "table_row"
|
||||
And "Override for Manual grade" "checkbox" should not exist in the "Manual grade" "table_row"
|
||||
And "Exclude for Manual grade" "checkbox" should not exist in the "Manual grade" "table_row"
|
||||
And the field "Grade for Course total" matches value "220"
|
||||
And the field "Feedback for Course total" matches value ""
|
||||
And "Override for Course total" "checkbox" should exist in the "Course total" "table_row"
|
||||
Then "Exclude for Course total" "checkbox" should exist in the "Course total" "table_row"
|
||||
@@ -524,7 +524,7 @@ class user extends grade_report {
|
||||
$gradegrade->load_grade_item();
|
||||
|
||||
// Hidden Items.
|
||||
if ($gradegrade->grade_item->is_hidden()) {
|
||||
if ($gradegrade->grade_item->is_hidden() && $this->canviewhidden) {
|
||||
$hidden = ' dimmed_text';
|
||||
}
|
||||
|
||||
@@ -590,7 +590,7 @@ class user extends grade_report {
|
||||
|
||||
$itemicon = \html_writer::div(grade_helper::get_element_icon($element), 'mr-1');
|
||||
$elementtype = grade_helper::get_element_type_string($element);
|
||||
$itemtype = \html_writer::span($elementtype, 'd-block text-uppercase small dimmed_text',
|
||||
$itemtype = \html_writer::span($elementtype, 'd-block text-uppercase small ' . $hidden,
|
||||
['title' => $elementtype]);
|
||||
|
||||
if ($type == 'categoryitem' || $type == 'courseitem') {
|
||||
@@ -694,10 +694,11 @@ class user extends grade_report {
|
||||
);
|
||||
$gradeitemdata['gradehiddenbydate'] = true;
|
||||
} else if ($gradegrade->is_hidden()) {
|
||||
$data['grade']['class'] = $class.' dimmed_text';
|
||||
$data['grade']['class'] = $class;
|
||||
$data['grade']['content'] = '-';
|
||||
|
||||
if ($this->canviewhidden) {
|
||||
$data['grade']['class'] .= ' dimmed_text';
|
||||
$gradeitemdata['graderaw'] = $gradeval;
|
||||
$data['grade']['content'] = grade_format_gradevalue($gradeval,
|
||||
$gradegrade->grade_item,
|
||||
@@ -762,9 +763,10 @@ class user extends grade_report {
|
||||
$data['percentage']['class'] = $class.' gradingerror';
|
||||
$data['percentage']['content'] = get_string('error');
|
||||
} else if ($gradegrade->is_hidden()) {
|
||||
$data['percentage']['class'] = $class.' dimmed_text';
|
||||
$data['percentage']['class'] = $class;
|
||||
$data['percentage']['content'] = '-';
|
||||
if ($this->canviewhidden) {
|
||||
$data['percentage']['class'] .= ' dimmed_text';
|
||||
$data['percentage']['content'] = grade_format_gradevalue(
|
||||
$gradeval,
|
||||
$gradegrade->grade_item,
|
||||
@@ -791,8 +793,9 @@ class user extends grade_report {
|
||||
$data['lettergrade']['class'] = $class.' gradingerror';
|
||||
$data['lettergrade']['content'] = get_string('error');
|
||||
} else if ($gradegrade->is_hidden()) {
|
||||
$data['lettergrade']['class'] = $class.' dimmed_text';
|
||||
$data['lettergrade']['class'] = $class;
|
||||
if (!$this->canviewhidden) {
|
||||
$data['lettergrade']['class'] .= ' dimmed_text';
|
||||
$data['lettergrade']['content'] = '-';
|
||||
} else {
|
||||
$data['lettergrade']['content'] = grade_format_gradevalue(
|
||||
@@ -822,8 +825,11 @@ class user extends grade_report {
|
||||
$data['rank']['class'] = $class.' gradingerror';
|
||||
$data['rank']['content'] = get_string('error');
|
||||
} else if ($gradegrade->is_hidden()) {
|
||||
$data['rank']['class'] = $class.' dimmed_text';
|
||||
$data['rank']['class'] = $class;
|
||||
$data['rank']['content'] = '-';
|
||||
if ($this->canviewhidden) {
|
||||
$data['rank']['class'] .= ' dimmed_text';
|
||||
}
|
||||
} else if (is_null($gradeval)) {
|
||||
// No grade, o rank.
|
||||
$data['rank']['class'] = $class;
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
@gradereport @gradereport_user
|
||||
Feature: We don't show hidden grades for users without the 'moodle/grade:viewhidden' capability on user report
|
||||
In order to show user report in secure way
|
||||
As a teacher without the 'moodle/grade:viewhidden' capability
|
||||
I should not see hidden grades in the user report
|
||||
|
||||
Background:
|
||||
Given the following "courses" exist:
|
||||
| fullname | shortname | format |
|
||||
| Course 1 | C1 | topics |
|
||||
And the following "users" exist:
|
||||
| username | firstname | lastname | email |
|
||||
| teacher1 | Teacher | 1 | teacher1@example.com |
|
||||
| student1 | Student1 | 1 | student1@example.com |
|
||||
| student2 | Student2 | 2 | student2@example.com |
|
||||
And the following "course enrolments" exist:
|
||||
| user | course | role |
|
||||
| teacher1 | C1 | editingteacher |
|
||||
| student1 | C1 | student |
|
||||
| student2 | C1 | student |
|
||||
And the following "activities" exist:
|
||||
| activity | course | section | name | intro | assignsubmission_onlinetext_enabled | submissiondrafts |
|
||||
| assign | C1 | 1 | Test assignment name 1 | Submit your online text | 1 | 0 |
|
||||
| assign | C1 | 1 | Test assignment name 2 | submit your online text | 1 | 0 |
|
||||
| assign | C1 | 1 | Test assignment name 3 | submit your online text | 1 | 0 |
|
||||
# Hidden manual grade item.
|
||||
And the following "grade items" exist:
|
||||
| itemname | grademin | grademax | course | hidden |
|
||||
| Manual grade | 20 | 40 | C1 | 1 |
|
||||
And the following "grade grades" exist:
|
||||
| gradeitem | user | grade |
|
||||
| Test assignment name 1 | student1 | 80 |
|
||||
| Test assignment name 1 | student2 | 70 |
|
||||
| Test assignment name 2 | student1 | 90 |
|
||||
| Test assignment name 2 | student2 | 60 |
|
||||
| Test assignment name 3 | student1 | 10 |
|
||||
| Test assignment name 3 | student2 | 50 |
|
||||
| Manual grade | student1 | 30 |
|
||||
| Manual grade | student2 | 40 |
|
||||
And I log in as "admin"
|
||||
# Need to show hidden items in order to check hidden grades. Also lets show totals if they contain hidden items.
|
||||
And the following config values are set as admin:
|
||||
| grade_report_user_showhiddenitems | 2 |
|
||||
| grade_report_user_showtotalsifcontainhidden | 2 |
|
||||
And I am on "Course 1" course homepage with editing mode on
|
||||
# Hide assignment 2 activity.
|
||||
And I open "Test assignment name 2" actions menu
|
||||
And I choose "Hide" in the open action menu
|
||||
And I navigate to "View > Grader report" in the course gradebook
|
||||
# Hide grade.
|
||||
And I click on grade menu "Test assignment name 1" for user "student1"
|
||||
And I choose "Hide" in the open action menu
|
||||
# Hide assignment 3 grade item.
|
||||
And I set the following settings for grade item "Test assignment name 3" of type "gradeitem" on "grader" page:
|
||||
| Hidden | 1 |
|
||||
|
||||
@javascript
|
||||
Scenario: View user report containing hidden activities or grade items or grades with capabilities with student view
|
||||
Given I am on the "Course 1" "grades > User report > View" page logged in as "teacher1"
|
||||
When I set the field "Search users" to "Student1"
|
||||
And I click on "Student1 1" "list_item"
|
||||
And I wait until the page is ready
|
||||
|
||||
# Student View.
|
||||
And the following should exist in the "user-grade" table:
|
||||
| Grade item | Grade |
|
||||
| Test assignment name 1 | - |
|
||||
| Test assignment name 3 | - |
|
||||
| Manual grade | - |
|
||||
| Course total | 210 |
|
||||
And the following should not exist in the "user-grade" table:
|
||||
| Grade item |
|
||||
| Test assignment name 2 |
|
||||
And "Hidden" "icon" should not exist in the "Test assignment name 1" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Test assignment name 3" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Manual grade" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Course total" "table_row"
|
||||
|
||||
And I set the field "Search users" to "Student2"
|
||||
And I click on "Student2 2" "list_item"
|
||||
And I wait until the page is ready
|
||||
And the following should exist in the "user-grade" table:
|
||||
| Grade item | Grade |
|
||||
| Test assignment name 1 | 70 |
|
||||
| Test assignment name 3 | - |
|
||||
| Manual grade | - |
|
||||
| Course total | 220 |
|
||||
And the following should not exist in the "user-grade" table:
|
||||
| Grade item |
|
||||
| Test assignment name 2 |
|
||||
And "Hidden" "icon" should not exist in the "Test assignment name 1" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Test assignment name 3" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Manual grade" "table_row"
|
||||
Then "Hidden" "icon" should not exist in the "Course total" "table_row"
|
||||
|
||||
@javascript
|
||||
Scenario: View user report containing hidden activities or grade items or grades with capabilities with teacher view
|
||||
Given I am on the "Course 1" "grades > User report > View" page logged in as "teacher1"
|
||||
When I set the field "Search users" to "Student2"
|
||||
And I click on "Student2 2" "list_item"
|
||||
And I wait until the page is ready
|
||||
And I set the field "View report as" to "Myself"
|
||||
And the following should exist in the "user-grade" table:
|
||||
| Grade item | Grade |
|
||||
| Test assignment name 1 | 70 |
|
||||
| Test assignment name 2 | 60 |
|
||||
| Test assignment name 3 | 50 |
|
||||
| Manual grade | 40 |
|
||||
| Course total | 220 |
|
||||
And "Hidden" "icon" should not exist in the "Test assignment name 1" "table_row"
|
||||
And "Hidden" "icon" should exist in the "Test assignment name 2" "table_row"
|
||||
And "Hidden" "icon" should exist in the "Test assignment name 3" "table_row"
|
||||
And "Hidden" "icon" should exist in the "Manual grade" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Course total" "table_row"
|
||||
|
||||
And I set the field "Search users" to "Student1"
|
||||
And I click on "Student1 1" "list_item"
|
||||
And I wait until the page is ready
|
||||
And the following should exist in the "user-grade" table:
|
||||
| Grade item | Grade |
|
||||
| Test assignment name 1 | 80 |
|
||||
| Test assignment name 2 | 90 |
|
||||
| Test assignment name 3 | 10 |
|
||||
| Manual grade | 30 |
|
||||
| Course total | 210 |
|
||||
And "Hidden" "icon" should exist in the "Test assignment name 1" "table_row"
|
||||
And "Hidden" "icon" should exist in the "Test assignment name 2" "table_row"
|
||||
And "Hidden" "icon" should exist in the "Test assignment name 3" "table_row"
|
||||
And "Hidden" "icon" should exist in the "Manual grade" "table_row"
|
||||
Then "Hidden" "icon" should not exist in the "Course total" "table_row"
|
||||
|
||||
@javascript
|
||||
Scenario: View user report containing hidden activities or grade items or grades without capabilities with teacher view
|
||||
Given I am on the "Course 1" "grades > User report > View" page logged in as "teacher1"
|
||||
And the following "role capability" exists:
|
||||
| role | editingteacher |
|
||||
| moodle/grade:viewhidden | prohibit |
|
||||
When I set the field "Search users" to "Student1"
|
||||
And I click on "Student1 1" "list_item"
|
||||
And I wait until the page is ready
|
||||
And I set the field "View report as" to "Myself"
|
||||
And the following should exist in the "user-grade" table:
|
||||
| Grade item | Grade |
|
||||
| Test assignment name 1 | - |
|
||||
| Test assignment name 2 | - |
|
||||
| Test assignment name 3 | - |
|
||||
| Manual grade | - |
|
||||
| Course total | 210 |
|
||||
And "Hidden" "icon" should not exist in the "Test assignment name 1" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Test assignment name 2" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Test assignment name 3" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Manual grade" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Course total" "table_row"
|
||||
|
||||
And I set the field "Search users" to "Student2"
|
||||
And I click on "Student2 2" "list_item"
|
||||
And I wait until the page is ready
|
||||
And the following should exist in the "user-grade" table:
|
||||
| Grade item | Grade |
|
||||
| Test assignment name 1 | 70 |
|
||||
| Test assignment name 2 | - |
|
||||
| Test assignment name 3 | - |
|
||||
| Manual grade | - |
|
||||
| Course total | 220 |
|
||||
And "Hidden" "icon" should not exist in the "Test assignment name 1" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Test assignment name 2" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Test assignment name 3" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Manual grade" "table_row"
|
||||
Then "Hidden" "icon" should not exist in the "Course total" "table_row"
|
||||
|
||||
@javascript
|
||||
Scenario: View user report containing hidden activities or grade items or grades without capabilities with student view
|
||||
Given I am on the "Course 1" "grades > User report > View" page logged in as "teacher1"
|
||||
And the following "role capability" exists:
|
||||
| role | editingteacher |
|
||||
| moodle/grade:viewhidden | prohibit |
|
||||
When I set the field "Search users" to "Student2"
|
||||
And I click on "Student2 2" "list_item"
|
||||
And I wait until the page is ready
|
||||
And the following should exist in the "user-grade" table:
|
||||
| Grade item | Grade |
|
||||
| Test assignment name 1 | 70 |
|
||||
| Test assignment name 3 | - |
|
||||
| Manual grade | - |
|
||||
| Course total | 220 |
|
||||
And the following should not exist in the "user-grade" table:
|
||||
| Grade item |
|
||||
| Test assignment name 2 |
|
||||
And "Hidden" "icon" should not exist in the "Test assignment name 1" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Test assignment name 3" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Manual grade" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Course total" "table_row"
|
||||
|
||||
And I set the field "Search users" to "Student1"
|
||||
And I click on "Student1 1" "list_item"
|
||||
And I wait until the page is ready
|
||||
And the following should exist in the "user-grade" table:
|
||||
| Grade item | Grade |
|
||||
| Test assignment name 1 | - |
|
||||
| Test assignment name 3 | - |
|
||||
| Manual grade | - |
|
||||
| Course total | 210 |
|
||||
And the following should not exist in the "user-grade" table:
|
||||
| Grade item |
|
||||
| Test assignment name 2 |
|
||||
And "Hidden" "icon" should not exist in the "Test assignment name 1" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Test assignment name 3" "table_row"
|
||||
And "Hidden" "icon" should not exist in the "Manual grade" "table_row"
|
||||
Then "Hidden" "icon" should not exist in the "Course total" "table_row"
|
||||
@@ -145,6 +145,24 @@ class behat_grades extends behat_base {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the grade id from its grade item name and userid.
|
||||
*
|
||||
* @param int $itemid Item ID
|
||||
* @param int $userid User ID
|
||||
* @return int
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function get_grade_id(int $itemid, int $userid): int {
|
||||
global $DB;
|
||||
|
||||
if ($id = $DB->get_field('grade_grades', 'id', ['itemid' => $itemid, 'userid' => $userid])) {
|
||||
return $id;
|
||||
}
|
||||
|
||||
throw new Exception('The specified grade with id "' . $itemid . ' and userid' . $userid . '" does not exist');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the grade item id from its name.
|
||||
*
|
||||
@@ -269,4 +287,27 @@ class behat_grades extends behat_base {
|
||||
$this->execute_js_on_node($node, '{{ELEMENT}}.scrollIntoView({ block: "center", inline: "center" })');
|
||||
$this->execute("behat_general::i_click_on", [$this->escape($xpath), "xpath_element"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks on given grade menu.
|
||||
*
|
||||
* @Given /^I click on grade menu "([^"]*)" for user "([^"]*)"$/
|
||||
* @param string $itemname Item name
|
||||
* @param string $username User name
|
||||
* @throws Exception
|
||||
*/
|
||||
public function i_click_on_grade_menu(string $itemname, string $username) {
|
||||
$this->execute("behat_navigation::i_close_block_drawer_if_open");
|
||||
|
||||
$userid = $this->get_user_id_by_identifier($username);
|
||||
$itemid = $this->get_grade_item_id($itemname);
|
||||
$gradeid = $this->get_grade_id($itemid, $userid);
|
||||
|
||||
$xpath = "//table[@id='user-grades']";
|
||||
$xpath .= "//*[@data-type='grade'][@data-id='" . $gradeid . "']";
|
||||
$node = $this->get_selected_node("xpath_element", $this->escape($xpath));
|
||||
$this->execute_js_on_node($node, '{{ELEMENT}}.scrollIntoView({ block: "center", inline: "center" })');
|
||||
$this->execute("behat_general::i_click_on", [$this->escape($xpath), "xpath_element"]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+10
-14
@@ -62,26 +62,22 @@ $string['memorylimithelp'] = '<p>現在、サーバのPHPメモリ制限が {$a}
|
||||
<p>しかし、この設定が<b>すべての</b>PHPページの動作を妨げる場合もあります。ページ閲覧中にエラーが表示される場合、.htaccessファイルを削除してください。</p></li>
|
||||
</ol>';
|
||||
$string['paths'] = 'パス';
|
||||
$string['pathserrcreatedataroot'] = 'データディレクトリ ({$a->dataroot}) はインストーラで作成できません。';
|
||||
$string['pathserrcreatedataroot'] = 'データディレクトリ ({$a->dataroot}) をインストーラで作成できません。';
|
||||
$string['pathshead'] = 'パスを確認する';
|
||||
$string['pathsrodataroot'] = 'datarootディレクトリに書き込み権がありません。';
|
||||
$string['pathsroparentdataroot'] = '親ディレクトリ ({$a->parent}) に書き込み権がありません。データディレクトリ ({$a->dataroot}) はインストーラで作成できません。';
|
||||
$string['pathssubadmindir'] = 'まれに、コントロールパネルまたはその他の管理ツールにアクセスするためのURLとして/adminディレクトリを使用しているウェブホストがあります。残念ですが、これはMoodle管理ページの標準的なロケーションと衝突します。あなたはインストール時にadminディレクトリをリネームすることができます。ここに新しいディレクトリ名を入力してください。例: <br /> <br /><b>moodleadmin</b><br /> <br />
|
||||
これでMoodleでのadminへのリンクを変更します。';
|
||||
$string['pathssubdataroot'] = '<p>ユーザによってアップロードされたファイルコンテンツすべてをMoodleが保存するディレクトリです。</p>
|
||||
$string['pathsroparentdataroot'] = '親ディレクトリ ({$a->parent}) に書き込み権がありません。データディレクトリ ({$a->dataroot}) をインストーラで作成できません。';
|
||||
$string['pathssubadmindir'] = 'ごく少数のウェブホストですが、コントロールパネルまたはその他の管理ツールにアクセスするためのURLとして/adminディレクトリを使用しています。残念ですが、これはMoodle管理ページの標準的なロケーションと衝突します。あなたはインストール時にadminディレクトリをリネームできます。ここに新しいディレクトリ名を入力してください。例: <b>moodleadmin</b> これでMoodleでのadminへのリンクを変更します。';
|
||||
$string['pathssubdataroot'] = '<p>ユーザがアップロードしたファイルコンテンツすべてをMoodleが保存するディレクトリです。</p>
|
||||
<p>このディレクトリはウェブサーバユーザ (通常「nobody」または「apache」) から読み込みおよび書き込みできる必要があります。</p>
|
||||
<p>ウェブからは直接アクセスできないようにしてください。</p>
|
||||
<p>現在ディレクトリが存在しない場合、インストレーションプロセスは作成を試みます。</p';
|
||||
$string['pathssubdirroot'] = '<p>Moodleコードを含むディレクトリに関するフルパスです。</p>';
|
||||
$string['pathssubwwwroot'] = '<p>Moodleにアクセスできるフルウェブアドレスです。例えばユーザがブラウザのアドレスバーに入力してMoodleにアクセスするためのアドレスです。</p>
|
||||
|
||||
$string['pathssubdirroot'] = '<p>Moodleコードを含むディレクトリへのフルパスです。</p>';
|
||||
$string['pathssubwwwroot'] = '<p>Moodleにアクセスするための完全なウェブアドレスです。例えばユーザがブラウザのアドレスバーに入力してMoodleにアクセスするためのアドレスです。</p>
|
||||
<p>複数アドレスを使用したMoodleへのアクセスはできません。あなたのサイトに複数アドレスからアクセスできる場合、最も簡単なアドレスを選択して、すべてのアドレスにパーマネントリダイレクトを設定してください。</p>
|
||||
|
||||
<p>あなたのサイトにインターネットおよび内部ネットワーク (イントラネットと呼ばれます) からアクセスできる場合、ここではパブリックアドレスを使用してください。</p>
|
||||
|
||||
<p>現在のアドレスが正しくない場合、あなたのブラウザのURLを変更して、異なる値でインストレーションを再開してください。</p>';
|
||||
$string['pathsunsecuredataroot'] = 'dataroot ロケーションが安全ではありません。';
|
||||
$string['pathswrongadmindir'] = 'adminディレクトリがありません。';
|
||||
<p>あなたのサイトにインターネットおよび内部ネットワーク (イントラネットと呼ばれることもあります) からアクセスできる場合、ここではパブリックアドレスを使用してください。</p>
|
||||
<p>現在のアドレスが正しくない場合、あなたのブラウザのアドレスバーでURLを変更して、インストールをやり直してください。</p>';
|
||||
$string['pathsunsecuredataroot'] = 'datarootの場所が安全ではありません。';
|
||||
$string['pathswrongadmindir'] = 'adminディレクトリが存在しません。';
|
||||
$string['phpextension'] = '{$a} PHP拡張モジュール';
|
||||
$string['phpversion'] = 'PHPバージョン';
|
||||
$string['phpversionhelp'] = '<p>Moodleには少なくとも5.6.5または7.1のPHPバージョンが必要です (7.0.x にはエンジンの制限があります)。</p>
|
||||
|
||||
@@ -80,10 +80,11 @@ $string['phpversionhelp'] = '<p>Moodle wymaga wersji PHP co najmniej 5.6.5 lub 7
|
||||
<p>Aktualnie używasz wersji {$a}.</p>
|
||||
<p>Musisz zaktualizować PHP lub przenieść na host z nowszą wersją PHP.</p>';
|
||||
$string['welcomep10'] = '{$a->installername} ({$a->installerversion})';
|
||||
$string['welcomep20'] = 'Widzisz tę stronę, ponieważ z powodzeniem zainstalowałeś i uruchomiłeś <strong>{$a->packname} {$a->packversion}</strong> na swoim komputerze.';
|
||||
$string['welcomep30'] = 'Ten instalator <strong>{$a->installername}</strong> zawiera aplikacje, by stworzyć środowisko, w którym będzie działać <strong>Moodle</strong>, czyli';
|
||||
$string['welcomep20'] = 'Widzisz tę stronę, ponieważ pomyślnie zainstalowałeś i uruchomiłeś pakiet <strong>{$a->packname} {$a->packversion</strong> na swoim komputerze. Gratulacje!';
|
||||
$string['welcomep30'] = 'Ta wersja <strong>{$a->installername}</strong> zawiera aplikacje służące do tworzenia środowiska, w którym będzie działać <strong>Moodle</strong>, a mianowicie:';
|
||||
$string['welcomep40'] = 'Pakiet zawiera <strong>Moodle {$a->moodlerelease} ({$a->moodleversion})</strong>.';
|
||||
$string['welcomep50'] = 'Wszystkie aplikacje w tym pakiecie maja własne, oddzielne licencje. Kompletny pakiet <strong>{$a->installername}</strong> jest <a href="http://www.opensource.org/docs/definition_plain.html">open source</a> i jest dystrybuowany na <a href="http://www.gnu.org/copyleft/gpl.html">GPL</a> licencji.';
|
||||
$string['welcomep60'] = 'Nastepujące strony przeprowadzą cię przez instalację <strong>Moodle</strong> na twoim komputerze. Możesz zaakceptować ustawienia domyślne, lub opcjonalnie, dostosować je do swoich potrzeb.';
|
||||
$string['welcomep50'] = 'Użycie wszystkich aplikacji w tym pakiecie podlega ich odpowiednim licencjom. Kompletny pakiet <strong>{$a->installername}</strong> jest <a href="https://www.opensource.org/docs/definition_plain.html">open source</a> i jest dystrybuowany na licencji <a href="https://www.gnu.org/copyleft/gpl.html">GPL</a>.';
|
||||
$string['welcomep60'] = 'Na poniższych stronach znajdziesz kilka łatwych do wykonania kroków, aby skonfigurować i skonfigurować <strong>Moodle</strong> na swoim komputerze. Możesz zaakceptować domyślne
|
||||
ustawienia lub, opcjonalnie, zmienić je, aby odpowiadały Twoim potrzebom.';
|
||||
$string['welcomep70'] = 'Kliknij przycisk "Dalej" żeby kontynuować instalację <strong>Moodle</strong>.';
|
||||
$string['wwwroot'] = 'Adres w sieci';
|
||||
|
||||
@@ -31,7 +31,7 @@ defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
$string['clianswerno'] = 'n';
|
||||
$string['cliansweryes'] = 'y';
|
||||
$string['cliincorrectvalueerror'] = '错误,将“{$a->option}”的值设为“{$a->value}”是不正确的';
|
||||
$string['cliincorrectvalueerror'] = '错误,将"{$a->option}"的值设为"{$a->value}"是不正确的';
|
||||
$string['cliincorrectvalueretry'] = '不正确的值,请重试';
|
||||
$string['clitypevalue'] = '输入值';
|
||||
$string['clitypevaluedefault'] = '输入值,按回车使用缺省值({$a})';
|
||||
|
||||
@@ -147,6 +147,7 @@ $string['tagarea_post'] = 'Blog posts';
|
||||
$string['tagarea_user'] = 'User interests';
|
||||
$string['tagarea_course'] = 'Courses';
|
||||
$string['tagarea_course_modules'] = 'Activities and resources';
|
||||
$string['tagarea_tag'] = 'Related tags';
|
||||
$string['tagareaenabled'] = 'Enabled';
|
||||
$string['tagareaname'] = 'Name';
|
||||
$string['tagareas'] = 'Tag areas';
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -200,6 +200,7 @@ define([
|
||||
datasets: this._makeDatasetsConfig()
|
||||
},
|
||||
options: {
|
||||
locale: document.documentElement.getAttribute('lang'),
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
@@ -226,11 +227,10 @@ define([
|
||||
var axisLabels = axis.getLabels();
|
||||
|
||||
config.options.scales = config.options.scales || {};
|
||||
config.options.scales.x = config.options.scales.x || {};
|
||||
config.options.scales.x[i] = this._makeAxisConfig(axis, 'x', i);
|
||||
config.options.scales.x = this._makeAxisConfig(axis, 'x', i);
|
||||
|
||||
if (axisLabels !== null) {
|
||||
config.options.scales.x[i].ticks.callback = function(value, index) {
|
||||
config.options.scales.x.ticks.callback = function(value, index) {
|
||||
return axisLabels[index] || '';
|
||||
};
|
||||
}
|
||||
@@ -241,11 +241,10 @@ define([
|
||||
var axisLabels = axis.getLabels();
|
||||
|
||||
config.options.scales = config.options.scales || {};
|
||||
config.options.scales.y = config.options.scales.yAxes || {};
|
||||
config.options.scales.y[i] = this._makeAxisConfig(axis, 'y', i);
|
||||
config.options.scales.y = this._makeAxisConfig(axis, 'y', i);
|
||||
|
||||
if (axisLabels !== null) {
|
||||
config.options.scales.y[i].ticks.callback = function(value) {
|
||||
config.options.scales.y.ticks.callback = function(value) {
|
||||
return axisLabels[parseInt(value, 10)] || '';
|
||||
};
|
||||
}
|
||||
@@ -309,10 +308,11 @@ define([
|
||||
Output.prototype._makeTooltip = function(tooltipItem) {
|
||||
|
||||
// Get series and chart data to rebuild the tooltip and add labels.
|
||||
const formatter = new Intl.NumberFormat(this._config.options.locale);
|
||||
var series = this._chart.getSeries()[tooltipItem.datasetIndex];
|
||||
var serieLabel = series.getLabel();
|
||||
var chartData = tooltipItem.dataset.data;
|
||||
var tooltipData = chartData[tooltipItem.dataIndex];
|
||||
var tooltipData = formatter.format(chartData[tooltipItem.dataIndex]);
|
||||
|
||||
// Build default tooltip.
|
||||
var tooltip = [];
|
||||
|
||||
+16
-14
@@ -454,19 +454,26 @@ function badges_prepare_badge_for_external(stdClass $badge, stdClass $user): obj
|
||||
// Create a badge instance to be able to get the endorsement and other info.
|
||||
$badgeinstance = new badge($badge->id);
|
||||
$endorsement = $badgeinstance->get_endorsement();
|
||||
$alignments = $badgeinstance->get_alignments();
|
||||
$relatedbadges = $badgeinstance->get_related_badges();
|
||||
$alignments = [];
|
||||
foreach ($badgeinstance->get_alignments() as $alignment) {
|
||||
$alignmentobj = (object) [
|
||||
'id' => $alignment->id,
|
||||
'badgeid' => $alignment->badgeid,
|
||||
'targetName' => $alignment->targetname,
|
||||
'targetUrl' => $alignment->targeturl,
|
||||
];
|
||||
// Include only the properties visible by the user.
|
||||
if ($canconfiguredetails) {
|
||||
$alignmentobj->targetDescription = $alignment->targetdescription;
|
||||
$alignmentobj->targetFramework = $alignment->targetframework;
|
||||
$alignmentobj->targetCode = $alignment->targetcode;
|
||||
}
|
||||
$alignments[] = $alignmentobj;
|
||||
}
|
||||
|
||||
if (!$canconfiguredetails) {
|
||||
// Return only the properties visible by the user.
|
||||
if (!empty($alignments)) {
|
||||
foreach ($alignments as $alignment) {
|
||||
unset($alignment->targetdescription);
|
||||
unset($alignment->targetframework);
|
||||
unset($alignment->targetcode);
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($relatedbadges)) {
|
||||
foreach ($relatedbadges as $relatedbadge) {
|
||||
unset($relatedbadge->version);
|
||||
@@ -565,11 +572,6 @@ function badges_process_badge_image(badge $badge, $iconfile) {
|
||||
if (!empty($CFG->gdversion)) {
|
||||
process_new_icon($badge->get_context(), 'badges', 'badgeimage', $badge->id, $iconfile, true);
|
||||
@unlink($iconfile);
|
||||
|
||||
// Clean up file draft area after badge image has been saved.
|
||||
$context = context_user::instance($USER->id, MUST_EXIST);
|
||||
$fs = get_file_storage();
|
||||
$fs->delete_area_files($context->id, 'user', 'draft');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -148,6 +148,23 @@ class behat_context_helper {
|
||||
return $overrideclassname;
|
||||
}
|
||||
|
||||
try {
|
||||
$themeconfig = theme_config::load($suitename);
|
||||
} catch (Exception $e) {
|
||||
// This theme has no theme config.
|
||||
return null;
|
||||
}
|
||||
|
||||
// The theme will use all core contexts, except the one overridden by theme or its parent.
|
||||
if (isset($themeconfig->parents)) {
|
||||
foreach ($themeconfig->parents as $parent) {
|
||||
$overrideclassname = "behat_theme_{$parent}_{$classname}";
|
||||
if (self::$environment->hasContextClass($overrideclassname)) {
|
||||
return $overrideclassname;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (self::$environment->hasContextClass($classname)) {
|
||||
return $classname;
|
||||
}
|
||||
|
||||
@@ -1708,4 +1708,19 @@ EOF;
|
||||
|
||||
return $result ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare an xpath for insertion into Selenium JavaScript.
|
||||
*
|
||||
* @param string $xpath
|
||||
* @return string
|
||||
*/
|
||||
protected function prepare_xpath_for_javascript(string $xpath): string {
|
||||
$newlines = [
|
||||
"\r\n",
|
||||
"\r",
|
||||
"\n",
|
||||
];
|
||||
return str_replace($newlines, ' ', $xpath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,6 +364,7 @@ class core_grading_external extends external_api {
|
||||
'warningcode' => '1'
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($activeinstances as $activeinstance) {
|
||||
$instance = array();
|
||||
$instance['id'] = $activeinstance->get_id();
|
||||
@@ -371,17 +372,17 @@ class core_grading_external extends external_api {
|
||||
$instance['itemid'] = $activeinstance->get_data('itemid');
|
||||
$instance['rawgrade'] = $activeinstance->get_data('rawgrade');
|
||||
$instance['status'] = $activeinstance->get_data('status');
|
||||
$instance['feedback'] = $activeinstance->get_data('feedback');
|
||||
$instance['feedbackformat'] = $activeinstance->get_data('feedbackformat');
|
||||
|
||||
// Format the feedback text field.
|
||||
$formattedtext = \core_external\util::format_text($activeinstance->get_data('feedback'),
|
||||
[$instance['feedback'], $instance['feedbackformat']] = \core_external\util::format_text(
|
||||
$activeinstance->get_data('feedback'),
|
||||
$activeinstance->get_data('feedbackformat'),
|
||||
$context->id,
|
||||
$context,
|
||||
$area->component,
|
||||
'feedback',
|
||||
$params['definitionid']);
|
||||
$instance['feedback'] = $formattedtext[0];
|
||||
$instance['feedbackformat'] = $formattedtext[1];
|
||||
$params['definitionid'],
|
||||
);
|
||||
|
||||
$instance['timemodified'] = $activeinstance->get_data('timemodified');
|
||||
|
||||
if ($details != null && $getfilling != null) {
|
||||
|
||||
@@ -65,10 +65,11 @@ class h5p_clean_orphaned_records_task extends scheduled_task {
|
||||
$file->delete();
|
||||
}
|
||||
}
|
||||
$filerecords->close();
|
||||
|
||||
$DB->delete_records('h5p', ['id' => $orphanedrecord->id]);
|
||||
$DB->delete_records('h5p_contents_libraries', ['h5pid' => $orphanedrecord->id]);
|
||||
}
|
||||
|
||||
$orphanedrecords->close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,6 +244,8 @@ class manager {
|
||||
public static function queue_adhoc_task(adhoc_task $task, $checkforexisting = false) {
|
||||
global $DB;
|
||||
|
||||
$clock = \core\di::get(\core\clock::class);
|
||||
|
||||
if ($userid = $task->get_userid()) {
|
||||
// User found. Check that they are suitable.
|
||||
\core_user::require_active_user(\core_user::get_user($userid, '*', MUST_EXIST), true, true);
|
||||
@@ -252,13 +254,13 @@ class manager {
|
||||
$record = self::record_from_adhoc_task($task);
|
||||
// Schedule it immediately if nextruntime not explicitly set.
|
||||
if (!$task->get_next_run_time()) {
|
||||
$record->nextruntime = time() - 1;
|
||||
$record->nextruntime = $clock->time() - 1;
|
||||
}
|
||||
|
||||
// Check if the task is allowed to be retried or not.
|
||||
$record->attemptsavailable = $task->retry_until_success() ? $record->attemptsavailable : 1;
|
||||
// Set the time the task was created.
|
||||
$record->timecreated = time();
|
||||
$record->timecreated = $clock->time();
|
||||
|
||||
// Check if the same task is already scheduled.
|
||||
if ($checkforexisting && self::task_is_scheduled($task)) {
|
||||
@@ -543,7 +545,9 @@ class manager {
|
||||
public static function get_adhoc_tasks_summary(): array {
|
||||
global $DB;
|
||||
|
||||
$now = time();
|
||||
$clock = \core\di::get(\core\clock::class);
|
||||
|
||||
$now = $clock->time();
|
||||
$records = $DB->get_records('task_adhoc');
|
||||
$summary = [];
|
||||
foreach ($records as $r) {
|
||||
@@ -1153,6 +1157,9 @@ class manager {
|
||||
*/
|
||||
public static function adhoc_task_failed(adhoc_task $task) {
|
||||
global $DB;
|
||||
|
||||
$clock = \core\di::get(\core\clock::class);
|
||||
|
||||
// Finalise the log output.
|
||||
logmanager::finalise_log(true);
|
||||
|
||||
@@ -1180,7 +1187,7 @@ class manager {
|
||||
$task->set_timestarted();
|
||||
$task->set_hostname();
|
||||
$task->set_pid();
|
||||
$task->set_next_run_time(time() + $delay);
|
||||
$task->set_next_run_time($clock->time() + $delay);
|
||||
$task->set_fail_delay($delay);
|
||||
if ($task->get_attempts_available() > 0) {
|
||||
$task->set_attempts_available($task->get_attempts_available() - 1);
|
||||
@@ -1208,7 +1215,8 @@ class manager {
|
||||
$hostname = (string)gethostname();
|
||||
|
||||
if (empty($time)) {
|
||||
$time = time();
|
||||
$clock = \core\di::get(\core\clock::class);
|
||||
$time = $clock->time();
|
||||
}
|
||||
|
||||
$task->set_timestarted($time);
|
||||
@@ -1259,6 +1267,9 @@ class manager {
|
||||
*/
|
||||
public static function scheduled_task_failed(scheduled_task $task) {
|
||||
global $DB;
|
||||
|
||||
$clock = \core\di::get(\core\clock::class);
|
||||
|
||||
// Finalise the log output.
|
||||
logmanager::finalise_log(true);
|
||||
|
||||
@@ -1289,7 +1300,7 @@ class manager {
|
||||
$classname = self::get_canonical_class_name($task);
|
||||
|
||||
$record = $DB->get_record('task_scheduled', array('classname' => $classname));
|
||||
$record->nextruntime = time() + $delay;
|
||||
$record->nextruntime = $clock->time() + $delay;
|
||||
$record->faildelay = $delay;
|
||||
$record->timestarted = null;
|
||||
$record->hostname = null;
|
||||
@@ -1327,11 +1338,14 @@ class manager {
|
||||
*/
|
||||
public static function scheduled_task_starting(scheduled_task $task, int $time = 0) {
|
||||
global $DB;
|
||||
|
||||
$clock = \core\di::get(\core\clock::class);
|
||||
|
||||
$pid = (int)getmypid();
|
||||
$hostname = (string)gethostname();
|
||||
|
||||
if (!$time) {
|
||||
$time = time();
|
||||
$time = $clock->time();
|
||||
}
|
||||
|
||||
$task->set_timestarted($time);
|
||||
@@ -1356,6 +1370,8 @@ class manager {
|
||||
public static function scheduled_task_complete(scheduled_task $task) {
|
||||
global $DB;
|
||||
|
||||
$clock = \core\di::get(\core\clock::class);
|
||||
|
||||
// Finalise the log output.
|
||||
logmanager::finalise_log();
|
||||
$task->set_timestarted();
|
||||
@@ -1365,7 +1381,7 @@ class manager {
|
||||
$classname = self::get_canonical_class_name($task);
|
||||
$record = $DB->get_record('task_scheduled', array('classname' => $classname));
|
||||
if ($record) {
|
||||
$record->lastruntime = time();
|
||||
$record->lastruntime = $clock->time();
|
||||
$record->faildelay = 0;
|
||||
$record->nextruntime = $task->get_next_scheduled_time();
|
||||
$record->timestarted = null;
|
||||
@@ -1390,10 +1406,13 @@ class manager {
|
||||
*/
|
||||
public static function get_running_tasks($sort = ''): array {
|
||||
global $DB;
|
||||
|
||||
$clock = \core\di::get(\core\clock::class);
|
||||
|
||||
if (empty($sort)) {
|
||||
$sort = 'timestarted ASC, classname ASC';
|
||||
}
|
||||
$params = ['now1' => time(), 'now2' => time()];
|
||||
$params = ['now1' => $clock->time(), 'now2' => $clock->time()];
|
||||
|
||||
$sql = "SELECT subquery.*
|
||||
FROM (SELECT " . $DB->sql_concat("'s'", 'ts.id') . " as uniqueid,
|
||||
@@ -1428,11 +1447,13 @@ class manager {
|
||||
public static function cleanup_metadata() {
|
||||
global $DB;
|
||||
|
||||
$clock = \core\di::get(\core\clock::class);
|
||||
|
||||
$cronlockfactory = \core\lock\lock_config::get_lock_factory('cron');
|
||||
$runningtasks = self::get_running_tasks();
|
||||
|
||||
foreach ($runningtasks as $runningtask) {
|
||||
if ($runningtask->timestarted > time() - HOURSECS) {
|
||||
if ($runningtask->timestarted > $clock->time() - HOURSECS) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1494,15 +1515,18 @@ class manager {
|
||||
*/
|
||||
public static function clear_static_caches() {
|
||||
global $DB;
|
||||
|
||||
$clock = \core\di::get(\core\clock::class);
|
||||
|
||||
// Do not use get/set config here because the caches cannot be relied on.
|
||||
$record = $DB->get_record('config', array('name'=>'scheduledtaskreset'));
|
||||
if ($record) {
|
||||
$record->value = time();
|
||||
$record->value = $clock->time();
|
||||
$DB->update_record('config', $record);
|
||||
} else {
|
||||
$record = new \stdClass();
|
||||
$record->name = 'scheduledtaskreset';
|
||||
$record->value = time();
|
||||
$record->value = $clock->time();
|
||||
$DB->insert_record('config', $record);
|
||||
}
|
||||
}
|
||||
@@ -1802,12 +1826,15 @@ class manager {
|
||||
*/
|
||||
public static function clean_failed_adhoc_tasks(): void {
|
||||
global $CFG, $DB;
|
||||
|
||||
$clock = \core\di::get(\core\clock::class);
|
||||
|
||||
$difftime = !empty($CFG->task_adhoc_failed_retention) ?
|
||||
$CFG->task_adhoc_failed_retention : static::ADHOC_TASK_FAILED_RETENTION;
|
||||
$DB->delete_records_select(
|
||||
table: 'task_adhoc',
|
||||
select: 'attemptsavailable = 0 AND firststartingtime < :time',
|
||||
params: ['time' => time() - $difftime],
|
||||
params: ['time' => $clock->time() - $difftime],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-7
@@ -382,12 +382,10 @@ function users_order_by_sql(string $usertablealias = '', string $search = null,
|
||||
}
|
||||
|
||||
$exactconditions = array();
|
||||
$paramkey = 'usersortexact1';
|
||||
|
||||
$exactconditions[] = $DB->sql_fullname($tableprefix . 'firstname', $tableprefix . 'lastname') .
|
||||
' = :' . $paramkey;
|
||||
$params[$paramkey] = $search;
|
||||
$paramkey++;
|
||||
' = :usersortexact';
|
||||
$params['usersortexact'] = $search;
|
||||
|
||||
if ($customfieldmappings) {
|
||||
$fieldstocheck = array_merge([$tableprefix . 'firstname', $tableprefix . 'lastname'], array_values($customfieldmappings));
|
||||
@@ -399,9 +397,8 @@ function users_order_by_sql(string $usertablealias = '', string $search = null,
|
||||
}
|
||||
|
||||
foreach ($fieldstocheck as $key => $field) {
|
||||
$exactconditions[] = 'LOWER(' . $field . ') = LOWER(:' . $paramkey . ')';
|
||||
$params[$paramkey] = $search;
|
||||
$paramkey++;
|
||||
$exactconditions[] = 'LOWER(' . $field . ') = LOWER(:usersortfield' . $key . ')';
|
||||
$params['usersortfield' . $key] = $search;
|
||||
}
|
||||
|
||||
$sort = 'CASE WHEN ' . implode(' OR ', $exactconditions) .
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -27,6 +27,7 @@ import uploadFile from 'editor_tiny/uploader';
|
||||
import {prefetchStrings} from 'core/prefetch';
|
||||
import {getStrings} from 'core/str';
|
||||
import {component} from "./common";
|
||||
import {getFilePicker} from 'editor_tiny/options';
|
||||
import {displayFilepicker} from 'editor_tiny/utils';
|
||||
import {ImageDetails} from 'tiny_media/imagedetails';
|
||||
import {
|
||||
@@ -82,9 +83,16 @@ export class ImageInsert {
|
||||
this.currentModal.setTitle(this.langStrings.insertimage);
|
||||
if (this.canShowDropZone) {
|
||||
const dropZoneEle = document.querySelector(Selectors.IMAGE.elements.dropzoneContainer);
|
||||
|
||||
// Accepted types can be either a string or an array.
|
||||
let acceptedTypes = getFilePicker(this.editor, 'image').accepted_types;
|
||||
if (Array.isArray(acceptedTypes)) {
|
||||
acceptedTypes = acceptedTypes.join(',');
|
||||
}
|
||||
|
||||
const dropZone = new Dropzone(
|
||||
dropZoneEle,
|
||||
'image/*',
|
||||
acceptedTypes,
|
||||
files => {
|
||||
this.handleUploadedFile(files);
|
||||
}
|
||||
|
||||
+1
-1
@@ -6,6 +6,6 @@ define("core_form/collapsesections",["exports","jquery","core/pending"],(functio
|
||||
* @copyright 2021 Bas Brands
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
* @since 4.0
|
||||
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,_jquery=_interopRequireDefault(_jquery),_pending=_interopRequireDefault(_pending);const SELECTORS_FORM=".mform",SELECTORS_FORMHEADER=".fheader",SELECTORS_FORMCONTAINER="fieldset > .fcontainer",CLASSES_SHOW="show",CLASSES_COLLAPSED="collapsed",CLASSES_HIDDEN="d-none";_exports.init=collapsesections=>{const pendingPromise=new _pending.default("core_form/collapsesections"),collapsemenu=document.querySelector(collapsesections),formParent=collapsemenu.closest(SELECTORS_FORM),formContainers=formParent.querySelectorAll(SELECTORS_FORMCONTAINER);collapsemenu.addEventListener("keydown",(e=>{"Enter"!==e.key&&" "!==e.key||(e.preventDefault(),collapsemenu.click())}));let formcontainercount=0,expandedcount=0;formContainers.forEach((container=>{container.parentElement.classList.contains(CLASSES_HIDDEN)||formcontainercount++,container.classList.contains(CLASSES_SHOW)&&expandedcount++})),formcontainercount===expandedcount&&(collapsemenu.classList.remove(CLASSES_COLLAPSED),collapsemenu.setAttribute("aria-expanded",!0)),collapsemenu.addEventListener("click",(()=>{let action="hide";collapsemenu.classList.contains(CLASSES_COLLAPSED)&&(action="show"),formContainers.forEach((container=>(0,_jquery.default)(container).collapse(action)))}));const collapseElementIds=[...formParent.querySelectorAll(SELECTORS_FORMHEADER)].map(((element,index)=>(element.id=element.id||"collapseElement-".concat(index),element.id)));collapsemenu.setAttribute("aria-controls",collapseElementIds.join(" ")),(0,_jquery.default)(SELECTORS_FORMCONTAINER).on("hidden.bs.collapse",(()=>{[...formContainers].every((container=>!container.classList.contains(CLASSES_SHOW)))&&(collapsemenu.classList.add(CLASSES_COLLAPSED),collapsemenu.setAttribute("aria-expanded",!1))})),(0,_jquery.default)(SELECTORS_FORMCONTAINER).on("shown.bs.collapse",(()=>{[...formContainers].every((container=>container.classList.contains(CLASSES_SHOW)))&&(collapsemenu.classList.remove(CLASSES_COLLAPSED),collapsemenu.setAttribute("aria-expanded",!0))})),pendingPromise.resolve()}}));
|
||||
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,_jquery=_interopRequireDefault(_jquery),_pending=_interopRequireDefault(_pending);const SELECTORS_FORM=".mform",SELECTORS_FORMHEADER=".fheader",SELECTORS_FORMCONTAINER="fieldset > .fcontainer",CLASSES_SHOW="show",CLASSES_COLLAPSED="collapsed",CLASSES_HIDDEN="d-none";_exports.init=collapsesections=>{const pendingPromise=new _pending.default("core_form/collapsesections"),collapsemenu=document.querySelector(collapsesections),formParent=collapsemenu.closest(SELECTORS_FORM),formContainers=formParent.querySelectorAll(SELECTORS_FORMCONTAINER);collapsemenu.addEventListener("keydown",(e=>{"Enter"!==e.key&&" "!==e.key||(e.preventDefault(),collapsemenu.click())}));let formcontainercount=0,expandedcount=0;formContainers.forEach((container=>{container.parentElement.classList.contains(CLASSES_HIDDEN)||formcontainercount++,container.classList.contains(CLASSES_SHOW)&&expandedcount++})),formcontainercount===expandedcount&&(collapsemenu.classList.remove(CLASSES_COLLAPSED),collapsemenu.setAttribute("aria-expanded",!0)),collapsemenu.addEventListener("click",(()=>{let action="hide";if(collapsemenu.classList.contains(CLASSES_COLLAPSED)&&(action="show"),formContainers.length){const pendingPromiseToggle=new _pending.default("core_form/collapsesections:toggle-"+action);formContainers.forEach(((container,index,array)=>{(0,_jquery.default)(container).collapse(action),index===array.length-1&&pendingPromiseToggle.resolve()}))}}));const collapseElementIds=[...formParent.querySelectorAll(SELECTORS_FORMHEADER)].map(((element,index)=>(element.id=element.id||"collapseElement-".concat(index),element.id)));collapsemenu.setAttribute("aria-controls",collapseElementIds.join(" ")),(0,_jquery.default)(SELECTORS_FORMCONTAINER).on("hidden.bs.collapse",(()=>{[...formContainers].every((container=>!container.classList.contains(CLASSES_SHOW)))&&(collapsemenu.classList.add(CLASSES_COLLAPSED),collapsemenu.setAttribute("aria-expanded",!1))})),(0,_jquery.default)(SELECTORS_FORMCONTAINER).on("shown.bs.collapse",(()=>{[...formContainers].every((container=>container.classList.contains(CLASSES_SHOW)))&&(collapsemenu.classList.remove(CLASSES_COLLAPSED),collapsemenu.setAttribute("aria-expanded",!0))})),pendingPromise.resolve()}}));
|
||||
|
||||
//# sourceMappingURL=collapsesections.min.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -82,7 +82,15 @@ export const init = collapsesections => {
|
||||
action = 'show';
|
||||
}
|
||||
|
||||
formContainers.forEach(container => $(container).collapse(action));
|
||||
if (formContainers.length) {
|
||||
const pendingPromiseToggle = new Pending('core_form/collapsesections:toggle-' + action);
|
||||
formContainers.forEach((container, index, array) => {
|
||||
$(container).collapse(action);
|
||||
if (index === array.length - 1) {
|
||||
pendingPromiseToggle.resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure collapse menu button adds aria-controls attribute referring to each collapsible element.
|
||||
|
||||
+1
-2
@@ -5060,8 +5060,7 @@ function reset_course_userdata($data) {
|
||||
|
||||
// Calculate the time shift of dates.
|
||||
if (!empty($data->reset_start_date)) {
|
||||
// Time part of course startdate should be zero.
|
||||
$data->timeshift = $data->reset_start_date - usergetmidnight($data->reset_start_date_old);
|
||||
$data->timeshift = $data->reset_start_date - $data->reset_start_date_old;
|
||||
} else {
|
||||
$data->timeshift = 0;
|
||||
}
|
||||
|
||||
+1
-1
@@ -2178,7 +2178,7 @@ class moodle_page {
|
||||
global $DB;
|
||||
$category = $DB->get_record('course_categories', array('id' => $categoryid));
|
||||
if (!$category) {
|
||||
throw new moodle_exception('unknowncategory');
|
||||
throw new moodle_exception('unknowncategory', a: $categoryid);
|
||||
}
|
||||
$this->_categories[$category->id] = $category;
|
||||
$parentcategoryids = explode('/', trim($category->path, '/'));
|
||||
|
||||
+6
-7
@@ -233,13 +233,12 @@ function resourcelib_embed_pdf($fullurl, $title, $clicktoopen) {
|
||||
global $CFG, $PAGE;
|
||||
|
||||
$code = <<<EOT
|
||||
<div class="resourcecontent resourcepdf">
|
||||
<object id="resourceobject" data="$fullurl" type="application/pdf" width="800" height="600">
|
||||
<param name="src" value="$fullurl" />
|
||||
$clicktoopen
|
||||
</object>
|
||||
</div>
|
||||
EOT;
|
||||
<div class="resourcecontent resourcepdf">
|
||||
<iframe id="resourceobject" src="$fullurl" title="$title" width="800" height="600">
|
||||
$clicktoopen
|
||||
</iframe>
|
||||
</div>
|
||||
EOT;
|
||||
|
||||
// the size is hardcoded in the boject obove intentionally because it is adjusted by the following function on-the-fly
|
||||
$PAGE->requires->js_init_call('M.util.init_maximised_embed', array('resourceobject'), true);
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"showallbutton": false
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -52,7 +53,10 @@
|
||||
<button type="button" class="btn btn-link text-reset" data-filteraction="add">
|
||||
{{#pix}}t/add{{/pix}}<span class="pl-3">{{#str}}addcondition{{/str}}</span>
|
||||
</button>
|
||||
<button data-filteraction="reset" type="button" class="btn btn-secondary ml-auto mr-2">{{#str}}clearfilters{{/str}}</button>
|
||||
{{#showallbutton}}
|
||||
<button data-filteraction="showall" data-perpage="{{ perpage }}" data-status="0" type="button" class="btn btn-light ml-auto mr-2">{{#str}}showall{{/str}}</button>
|
||||
{{/showallbutton}}
|
||||
<button data-filteraction="reset" type="button" class="btn btn-secondary {{^showallbutton}}ml-auto{{/showallbutton}} mr-2">{{#str}}clearfilters{{/str}}</button>
|
||||
<button data-filteraction="apply" type="button" class="btn btn-primary">{{#str}}applyfilters{{/str}}</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"sensitiveinput": "<input type=\"password\" name=\"password\" id=\"password\">"
|
||||
}
|
||||
}}
|
||||
<div class="toggle-sensitive-wrapper {{#smallscreensonly}}small-screens-only{{/smallscreensonly}} input-group mb-3">
|
||||
<div class="toggle-sensitive-wrapper {{#smallscreensonly}}small-screens-only{{/smallscreensonly}} input-group">
|
||||
{{{sensitiveinput}}}
|
||||
<div class="input-group-append">
|
||||
<button class="toggle-sensitive-btn btn btn-secondary" type="button" {{!
|
||||
|
||||
@@ -14,16 +14,9 @@
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
/**
|
||||
* Steps definitions to open and close action menus.
|
||||
*
|
||||
* @package core
|
||||
* @category test
|
||||
* @copyright 2020 Andrew Nicols <andrew@nicols.co.uk>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
use Behat\Mink\Exception\{DriverException, ExpectationException};
|
||||
use Behat\Mink\Exception\DriverException;
|
||||
use Behat\Mink\Exception\ExpectationException;
|
||||
use Behat\Mink\Element\NodeElement;
|
||||
|
||||
// NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php.
|
||||
|
||||
@@ -38,7 +31,6 @@ require_once(__DIR__ . '/../../behat/behat_base.php');
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class behat_accessibility extends behat_base {
|
||||
|
||||
/**
|
||||
* Run the axe-core accessibility tests.
|
||||
*
|
||||
@@ -62,6 +54,37 @@ class behat_accessibility extends behat_base {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the axe-core accessibility tests for a page region.
|
||||
*
|
||||
* There are standard tags to ensure WCAG 2.1 A, WCAG 2.1 AA, and Section 508 compliance.
|
||||
* It is also possible to specify any desired optional tags.
|
||||
*
|
||||
* See {@link https://github.com/dequelabs/axe-core/blob/v4.10.0/doc/rule-descriptions.md} for the list of available tags
|
||||
*
|
||||
* @Then the :element :selector should meet accessibility standards
|
||||
* @Then the :element :selector should meet accessibility standards with :extratags extra tests
|
||||
* @Then the :element :selector should meet :standardtags accessibility standards
|
||||
* @param string $element The element to run the tests on
|
||||
* @param string $selector The selector to use to find the element
|
||||
* @param string $standardtags Comma-separated list of standard tags to run
|
||||
* @param string $extratags Comma-separated list of tags to run in addition to the standard tags
|
||||
*/
|
||||
public function run_axe_validation_for_tags_within_element(
|
||||
string $element,
|
||||
string $selector,
|
||||
string $standardtags = '',
|
||||
string $extratags = '',
|
||||
): void {
|
||||
$node = $this->get_selected_node($selector, $element);
|
||||
$this->run_axe_for_tags(
|
||||
// Turn the comma-separated string into an array of trimmed values, filtering out empty values.
|
||||
array_filter(array_map('trim', explode(',', $standardtags))),
|
||||
array_filter(array_map('trim', explode(',', $extratags))),
|
||||
$node,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the Axe tests.
|
||||
*
|
||||
@@ -70,8 +93,13 @@ class behat_accessibility extends behat_base {
|
||||
*
|
||||
* @param array $standardtags The list of standard tags to run
|
||||
* @param array $extratags The list of tags, in addition to the standard tags, to run
|
||||
* @param null|NodeElement $containerelement The element to run the tests on
|
||||
*/
|
||||
protected function run_axe_for_tags(array $standardtags = [], array $extratags = []): void {
|
||||
protected function run_axe_for_tags(
|
||||
array $standardtags = [],
|
||||
array $extratags = [],
|
||||
?NodeElement $containerelement = null,
|
||||
): void {
|
||||
if (!behat_config_manager::get_behat_run_config_value('axe')) {
|
||||
return;
|
||||
}
|
||||
@@ -86,13 +114,33 @@ class behat_accessibility extends behat_base {
|
||||
|
||||
$axeurl = (new \moodle_url('/lib/behat/axe/axe.min.js'))->out(false);
|
||||
$axeconfig = $this->get_axe_config_for_tags($standardtags, $extratags);
|
||||
$xpath = '';
|
||||
if ($containerelement) {
|
||||
$xpath = $this->prepare_xpath_for_javascript($containerelement->getXpath());
|
||||
}
|
||||
$runaxe = <<<EOF
|
||||
(axeurl => {
|
||||
const runTests = () => {
|
||||
const axeTag = document.querySelector('script[data-purpose="axe"]');
|
||||
axeTag.dataset.results = null;
|
||||
|
||||
axe.run({$axeconfig})
|
||||
const getRun = () => {
|
||||
const xpath = "{$xpath}";
|
||||
if (xpath.length) {
|
||||
const targetElements = [];
|
||||
const results = document.evaluate(xpath, document, null, XPathResult.ANY_TYPE, null);
|
||||
let targetElement = results.iterateNext();
|
||||
while (targetElement) {
|
||||
targetElements.push(targetElement);
|
||||
targetElement = results.iterateNext();
|
||||
}
|
||||
return axe.run(targetElements, {$axeconfig});
|
||||
}
|
||||
|
||||
return axe.run({$axeconfig});
|
||||
};
|
||||
|
||||
getRun()
|
||||
.then(results => {
|
||||
axeTag.dataset.results = JSON.stringify({
|
||||
violations: results.violations,
|
||||
@@ -167,12 +215,15 @@ EOF;
|
||||
}
|
||||
|
||||
$violationdata .= sprintf(
|
||||
" %.03d violations of '%s' (severity: %s)\n%s\n",
|
||||
" %.03d violations of rule '%s' found (severity: %s)\n",
|
||||
count($violation->nodes),
|
||||
$violation->description,
|
||||
$violation->id,
|
||||
$violation->impact,
|
||||
$nodedata
|
||||
);
|
||||
$violationdata .= " {$violation->help}\n";
|
||||
$violationdata .= " {$violation->description}\n";
|
||||
$violationdata .= " {$violation->helpUrl}\n";
|
||||
$violationdata .= $nodedata;
|
||||
}
|
||||
|
||||
throw new ExpectationException($violationdata, $this->getSession());
|
||||
@@ -191,9 +242,13 @@ EOF;
|
||||
if (empty($standardtags)) {
|
||||
$standardtags = [
|
||||
// Meet WCAG 2.2 Level A success criteria.
|
||||
'wcag2a',
|
||||
'wcag21a',
|
||||
'wcag22a',
|
||||
|
||||
// Meet WCAG 2.2 Level AA success criteria.
|
||||
'wcag2aa',
|
||||
'wcag21aa',
|
||||
'wcag22aa',
|
||||
|
||||
// Meet Section 508 requirements.
|
||||
|
||||
@@ -167,7 +167,7 @@ class behat_forms extends behat_base {
|
||||
|
||||
// Different try & catch as we can have expanded fieldsets with advanced fields on them.
|
||||
try {
|
||||
|
||||
$this->wait_for_pending_js();
|
||||
// Expand all fields xpath.
|
||||
$showmorexpath = "//a[normalize-space(.)='" . get_string('showmore', 'form') . "']" .
|
||||
"[contains(concat(' ', normalize-space(@class), ' '), ' moreless-toggler')]";
|
||||
@@ -177,22 +177,23 @@ class behat_forms extends behat_base {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->getSession()->getDriver() instanceof \DMore\ChromeDriver\ChromeDriver) {
|
||||
// Chrome Driver produces unique xpaths for each element.
|
||||
foreach ($showmores as $showmore) {
|
||||
$showmore->click();
|
||||
$js = <<<EOF
|
||||
require(['core/pending'], function(Pending) {
|
||||
const query = document.evaluate("{$showmorexpath}", document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
|
||||
if (query.snapshotLength > 0) {
|
||||
const pendingPromise = new Pending('showmore:expand');
|
||||
for (let i = 0, length = query.snapshotLength; i < length; ++i) {
|
||||
query.snapshotItem(i).click();
|
||||
if (i === length - 1) {
|
||||
pendingPromise.resolve();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Funny thing about this, with findAll() we specify a pattern and each element matching the pattern
|
||||
// is added to the array with of xpaths with a [0], [1]... sufix, but when we click on an element it
|
||||
// does not matches the specified xpath anymore (now is a "Show less..." link) so [1] becomes [0],
|
||||
// that's why we always click on the first XPath match, will be always the next one.
|
||||
$iterations = count($showmores);
|
||||
for ($i = 0; $i < $iterations; $i++) {
|
||||
$showmores[0]->click();
|
||||
}
|
||||
}
|
||||
});
|
||||
EOF;
|
||||
|
||||
$this->execute_script($js);
|
||||
$this->wait_for_pending_js();
|
||||
} catch (ElementNotFoundException $e) {
|
||||
// We continue with the test.
|
||||
}
|
||||
@@ -834,4 +835,79 @@ class behat_forms extends behat_base {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that the validationMessage property on a form field element includes the given text.
|
||||
*
|
||||
* @Then the :field field validation message should contain :text
|
||||
* @param string $field The css selector for the input field
|
||||
* @param string $text The text which should be found in the validation message
|
||||
*/
|
||||
public function the_field_validation_message_should_contain(string $field, string $text): void {
|
||||
|
||||
// We can't use this assertion if javascript is not running.
|
||||
$this->require_javascript();
|
||||
|
||||
// Check that the element exists.
|
||||
// This is fail and go no further, if the element does not exist.
|
||||
$node = $this->get_selected_node('field', $field);
|
||||
|
||||
// Get the validity result.
|
||||
$wdelement = $this->get_webdriver_element_from_node_element($node);
|
||||
$webdriver = $this->getSession()->getDriver()->getWebDriver();
|
||||
$message = $webdriver->executeScript("return arguments[0].validationMessage;", [$wdelement]);
|
||||
if (strpos($message, $text) === false) {
|
||||
throw new ExpectationException(
|
||||
'"' . $field . '" validation message does not contain "' . $text . '"', $this->getSession()
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that the result of calling the checkValidity API on a form field element matches the expected result.
|
||||
*
|
||||
* @Then the :field field validity check should return :result
|
||||
* @param string $field The css selector for the input field
|
||||
* @param string $expected "true" or "false"
|
||||
*/
|
||||
public function the_field_validity_check_should_return(string $field, string $expected): void {
|
||||
|
||||
// We can't use this assertion if javascript is not running.
|
||||
$this->require_javascript();
|
||||
|
||||
// Expected value can only be 'true' or 'false'.
|
||||
$expected = strtolower($expected);
|
||||
if (!in_array($expected, ['true', 'false'])) {
|
||||
throw new ExpectationException(
|
||||
'Invalid value for expected value "' . $expected . '". Should be "true" or "false".',
|
||||
$this->getSession());
|
||||
}
|
||||
|
||||
// Convert the expected result from a string to bool.
|
||||
$expected = ($expected === "true");
|
||||
|
||||
// Check that the element exists.
|
||||
// This is fail and go no further, if the element does not exist.
|
||||
$node = $this->get_selected_node('field', $field);
|
||||
|
||||
// Get the validity result.
|
||||
$wdelement = $this->get_webdriver_element_from_node_element($node);
|
||||
$webdriver = $this->getSession()->getDriver()->getWebDriver();
|
||||
$result = $webdriver->executeScript("return arguments[0].checkValidity();", [$wdelement]);
|
||||
if ($result !== $expected) {
|
||||
|
||||
// Convert booleans to strings for the exception message.
|
||||
$result = ($result) ? "true" : "false";
|
||||
$expected = ($expected) ? "true" : "false";
|
||||
|
||||
throw new ExpectationException(
|
||||
'"' . $field . '" validation check was "' . $result . '". Expected: "' .
|
||||
$expected . '"', $this->getSession()
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -997,13 +997,12 @@ class behat_general extends behat_base {
|
||||
list($preselector, $prelocator) = $this->transform_selector($preselectortype, $preelement);
|
||||
list($postselector, $postlocator) = $this->transform_selector($postselectortype, $postelement);
|
||||
|
||||
$newlines = [
|
||||
"\r\n",
|
||||
"\r",
|
||||
"\n",
|
||||
];
|
||||
$prexpath = str_replace($newlines, ' ', $this->find($preselector, $prelocator, false, $containernode)->getXpath());
|
||||
$postxpath = str_replace($newlines, ' ', $this->find($postselector, $postlocator, false, $containernode)->getXpath());
|
||||
$prexpath = $this->prepare_xpath_for_javascript(
|
||||
$this->find($preselector, $prelocator, false, $containernode)->getXpath()
|
||||
);
|
||||
$postxpath = $this->prepare_xpath_for_javascript(
|
||||
$this->find($postselector, $postlocator, false, $containernode)->getXpath()
|
||||
);
|
||||
|
||||
if ($this->running_javascript()) {
|
||||
// The xpath to do this was running really slowly on certain Chrome versions so we are using
|
||||
|
||||
+17
-18
@@ -174,12 +174,11 @@ final class datalib_test extends \advanced_testcase {
|
||||
|
||||
list($sort, $params) = users_order_by_sql('', 'search', \context_system::instance());
|
||||
$this->assert_same_sql('CASE WHEN
|
||||
' . $DB->sql_fullname() . ' = :usersortexact1 OR
|
||||
LOWER(firstname) = LOWER(:usersortexact2) OR
|
||||
LOWER(lastname) = LOWER(:usersortexact3)
|
||||
' . $DB->sql_fullname() . ' = :usersortexact OR
|
||||
LOWER(firstname) = LOWER(:usersortfield0) OR
|
||||
LOWER(lastname) = LOWER(:usersortfield1)
|
||||
THEN 0 ELSE 1 END, lastname, firstname, id', $sort);
|
||||
$this->assertEquals(array('usersortexact1' => 'search', 'usersortexact2' => 'search',
|
||||
'usersortexact3' => 'search'), $params);
|
||||
$this->assertEquals(['usersortexact' => 'search', 'usersortfield0' => 'search', 'usersortfield1' => 'search'], $params);
|
||||
}
|
||||
|
||||
public function test_users_order_by_sql_search_with_extra_fields_and_prefix(): void {
|
||||
@@ -191,14 +190,14 @@ final class datalib_test extends \advanced_testcase {
|
||||
|
||||
list($sort, $params) = users_order_by_sql('u', 'search', \context_system::instance());
|
||||
$this->assert_same_sql('CASE WHEN
|
||||
' . $DB->sql_fullname('u.firstname', 'u.lastname') . ' = :usersortexact1 OR
|
||||
LOWER(u.firstname) = LOWER(:usersortexact2) OR
|
||||
LOWER(u.lastname) = LOWER(:usersortexact3) OR
|
||||
LOWER(u.email) = LOWER(:usersortexact4) OR
|
||||
LOWER(u.idnumber) = LOWER(:usersortexact5)
|
||||
' . $DB->sql_fullname('u.firstname', 'u.lastname') . ' = :usersortexact OR
|
||||
LOWER(u.firstname) = LOWER(:usersortfield0) OR
|
||||
LOWER(u.lastname) = LOWER(:usersortfield1) OR
|
||||
LOWER(u.email) = LOWER(:usersortfield2) OR
|
||||
LOWER(u.idnumber) = LOWER(:usersortfield3)
|
||||
THEN 0 ELSE 1 END, u.lastname, u.firstname, u.id', $sort);
|
||||
$this->assertEquals(array('usersortexact1' => 'search', 'usersortexact2' => 'search',
|
||||
'usersortexact3' => 'search', 'usersortexact4' => 'search', 'usersortexact5' => 'search'), $params);
|
||||
$this->assertEquals(['usersortexact' => 'search', 'usersortfield0' => 'search',
|
||||
'usersortfield1' => 'search', 'usersortfield2' => 'search', 'usersortfield3' => 'search'], $params);
|
||||
}
|
||||
|
||||
public function test_users_order_by_sql_search_with_custom_fields(): void {
|
||||
@@ -211,13 +210,13 @@ final class datalib_test extends \advanced_testcase {
|
||||
list($sort, $params) =
|
||||
users_order_by_sql('u', 'search', \context_system::instance(), ['profile_field_customfield' => 'x.customfield']);
|
||||
$this->assert_same_sql('CASE WHEN
|
||||
' . $DB->sql_fullname('u.firstname', 'u.lastname') . ' = :usersortexact1 OR
|
||||
LOWER(u.firstname) = LOWER(:usersortexact2) OR
|
||||
LOWER(u.lastname) = LOWER(:usersortexact3) OR
|
||||
LOWER(x.customfield) = LOWER(:usersortexact4)
|
||||
' . $DB->sql_fullname('u.firstname', 'u.lastname') . ' = :usersortexact OR
|
||||
LOWER(u.firstname) = LOWER(:usersortfield0) OR
|
||||
LOWER(u.lastname) = LOWER(:usersortfield1) OR
|
||||
LOWER(x.customfield) = LOWER(:usersortfield2)
|
||||
THEN 0 ELSE 1 END, u.lastname, u.firstname, u.id', $sort);
|
||||
$this->assertEquals(array('usersortexact1' => 'search', 'usersortexact2' => 'search',
|
||||
'usersortexact3' => 'search', 'usersortexact4' => 'search'), $params);
|
||||
$this->assertEquals(['usersortexact' => 'search', 'usersortfield0' => 'search',
|
||||
'usersortfield1' => 'search', 'usersortfield2' => 'search'], $params);
|
||||
}
|
||||
|
||||
public function test_get_admin(): void {
|
||||
|
||||
@@ -274,7 +274,8 @@ final class grading_external_test extends \externallib_advanced_testcase {
|
||||
'raterid' => $USER->id,
|
||||
'itemid' => $gid,
|
||||
'status' => 1,
|
||||
'feedbackformat' => 0,
|
||||
'feedback' => 'Fabulous',
|
||||
'feedbackformat' => FORMAT_HTML,
|
||||
'timemodified' => 1
|
||||
);
|
||||
$instanceid = $DB->insert_record('grading_instances', $instance);
|
||||
@@ -297,9 +298,12 @@ final class grading_external_test extends \externallib_advanced_testcase {
|
||||
$this->assertEquals($USER->id, $result['instances'][0]['raterid']);
|
||||
$this->assertEquals($gid, $result['instances'][0]['itemid']);
|
||||
$this->assertEquals(1, $result['instances'][0]['status']);
|
||||
$this->assertEquals('Fabulous', $result['instances'][0]['feedback']);
|
||||
$this->assertEquals(FORMAT_HTML, $result['instances'][0]['feedbackformat']);
|
||||
$this->assertEquals(1, $result['instances'][0]['timemodified']);
|
||||
$this->assertEquals(1, count($result['instances'][0]['rubric']));
|
||||
$this->assertEquals(1, count($result['instances'][0]['rubric']['criteria']));
|
||||
$this->assertCount(1, $result['instances'][0]['rubric']);
|
||||
$this->assertCount(1, $result['instances'][0]['rubric']['criteria']);
|
||||
|
||||
$criteria = $result['instances'][0]['rubric']['criteria'];
|
||||
$this->assertEquals($criterionid, $criteria[0]['criterionid']);
|
||||
$this->assertEquals($levelid, $criteria[0]['levelid']);
|
||||
|
||||
@@ -58,15 +58,16 @@ final class adhoc_task_test extends \advanced_testcase {
|
||||
public function test_get_next_adhoc_task_now(): void {
|
||||
$this->resetAfterTest(true);
|
||||
|
||||
$clock = \core\di::get(\core\clock::class);
|
||||
|
||||
// Create an adhoc task.
|
||||
$task = new adhoc_test_task();
|
||||
|
||||
// Queue it.
|
||||
manager::queue_adhoc_task($task);
|
||||
|
||||
$now = time();
|
||||
// Get it from the scheduler.
|
||||
$task = manager::get_next_adhoc_task($now);
|
||||
$task = manager::get_next_adhoc_task($clock->time());
|
||||
$this->assertInstanceOf('\\core\\task\\adhoc_test_task', $task);
|
||||
$task->execute();
|
||||
manager::adhoc_task_complete($task);
|
||||
@@ -78,20 +79,21 @@ final class adhoc_task_test extends \advanced_testcase {
|
||||
public function test_get_next_adhoc_task_class(): void {
|
||||
$this->resetAfterTest(true);
|
||||
|
||||
$clock = $this->mock_clock_with_frozen();
|
||||
|
||||
// Create an adhoc task.
|
||||
$task = new \core\task\adhoc_test_task();
|
||||
|
||||
// Queue it.
|
||||
manager::queue_adhoc_task($task);
|
||||
|
||||
$now = time();
|
||||
$classname = get_class($task);
|
||||
|
||||
// The task will not be returned.
|
||||
$this->assertNull(manager::get_next_adhoc_task($now, true, "{$classname}notexists"));
|
||||
$this->assertNull(manager::get_next_adhoc_task($clock->time(), true, "{$classname}notexists"));
|
||||
|
||||
// Get it from the scheduler.
|
||||
$task = manager::get_next_adhoc_task($now, true, $classname);
|
||||
$task = manager::get_next_adhoc_task($clock->time(), true, $classname);
|
||||
$this->assertInstanceOf('\\core\\task\\adhoc_test_task', $task);
|
||||
$task->execute();
|
||||
manager::adhoc_task_complete($task);
|
||||
@@ -103,11 +105,13 @@ final class adhoc_task_test extends \advanced_testcase {
|
||||
public function test_get_next_adhoc_task_fail_retry(): void {
|
||||
$this->resetAfterTest(true);
|
||||
|
||||
$clock = $this->mock_clock_with_frozen();
|
||||
|
||||
// Create an adhoc task.
|
||||
$task = new adhoc_test_task();
|
||||
manager::queue_adhoc_task($task);
|
||||
|
||||
$now = time();
|
||||
$now = $clock->time();
|
||||
|
||||
// Get it from the scheduler, execute it, and mark it as failed.
|
||||
$task = manager::get_next_adhoc_task($now);
|
||||
@@ -119,7 +123,8 @@ final class adhoc_task_test extends \advanced_testcase {
|
||||
$this->assertNull(manager::get_next_adhoc_task($now));
|
||||
|
||||
// Should get the adhoc task (retry after delay). Fail it again.
|
||||
$task = manager::get_next_adhoc_task($now + 120);
|
||||
$clock->bump(120);
|
||||
$task = manager::get_next_adhoc_task($clock->time());
|
||||
$this->assertInstanceOf('\\core\\task\\adhoc_test_task', $task);
|
||||
$this->assertEquals($taskid, $task->get_id());
|
||||
$task->execute();
|
||||
@@ -144,7 +149,8 @@ final class adhoc_task_test extends \advanced_testcase {
|
||||
public function test_get_next_adhoc_task_maximum_fail_delay(): void {
|
||||
$this->resetAfterTest(true);
|
||||
|
||||
$now = time();
|
||||
$clock = \core\di::get(\core\clock::class);
|
||||
$now = $clock->time();
|
||||
|
||||
// Create an adhoc task.
|
||||
$task = new adhoc_test_task();
|
||||
@@ -169,7 +175,9 @@ final class adhoc_task_test extends \advanced_testcase {
|
||||
global $DB;
|
||||
$this->resetAfterTest();
|
||||
|
||||
$now = time();
|
||||
$clock = \core\di::get(\core\clock::class);
|
||||
|
||||
$now = $clock->time();
|
||||
// Create a normal adhoc task.
|
||||
$task = new adhoc_test_task();
|
||||
$taskid1 = manager::queue_adhoc_task(task: $task);
|
||||
@@ -197,7 +205,7 @@ final class adhoc_task_test extends \advanced_testcase {
|
||||
$this->assertEquals(expected: 12 - 1, actual: $attemptsavailable);
|
||||
|
||||
// Create a no-retry adhoc task.
|
||||
$now = time();
|
||||
$now = $clock->time();
|
||||
$task = new no_retry_adhoc_task();
|
||||
$taskid2 = manager::queue_adhoc_task(task: $task);
|
||||
|
||||
@@ -268,6 +276,8 @@ final class adhoc_task_test extends \advanced_testcase {
|
||||
global $DB, $CFG;
|
||||
$this->resetAfterTest();
|
||||
|
||||
$clock = \core\di::get(\core\clock::class);
|
||||
|
||||
// Create two no-retry adhoc tasks.
|
||||
$task1 = new no_retry_adhoc_task();
|
||||
$taskid1 = manager::queue_adhoc_task(task: $task1);
|
||||
@@ -317,7 +327,7 @@ final class adhoc_task_test extends \advanced_testcase {
|
||||
$DB->set_field(
|
||||
table: 'task_adhoc',
|
||||
newfield: 'firststartingtime',
|
||||
newvalue: time() - (DAYSECS * 2) - 10, // Plus 10 seconds to make sure it is older than 2 days.
|
||||
newvalue: $clock->time() - (DAYSECS * 2) - 10, // Plus 10 seconds to make sure it is older than 2 days.
|
||||
conditions: ['id' => $taskid2],
|
||||
);
|
||||
|
||||
@@ -335,7 +345,7 @@ final class adhoc_task_test extends \advanced_testcase {
|
||||
table: 'task_adhoc',
|
||||
newfield: 'firststartingtime',
|
||||
// Plus 10 seconds to make sure it is older than the retention time.
|
||||
newvalue: time() - $CFG->task_adhoc_failed_retention - 10,
|
||||
newvalue: $clock->time() - $CFG->task_adhoc_failed_retention - 10,
|
||||
conditions: ['id' => $taskid1],
|
||||
);
|
||||
|
||||
@@ -368,7 +378,9 @@ final class adhoc_task_test extends \advanced_testcase {
|
||||
global $DB;
|
||||
$this->resetAfterTest();
|
||||
|
||||
$now = time();
|
||||
$clock = \core\di::get(\core\clock::class);
|
||||
|
||||
$now = $clock->time();
|
||||
// Create an adhoc task.
|
||||
$task = new adhoc_test_task();
|
||||
// Queue it.
|
||||
@@ -378,7 +390,7 @@ final class adhoc_task_test extends \advanced_testcase {
|
||||
$DB->set_field(
|
||||
table: 'task_adhoc',
|
||||
newfield: 'timecreated',
|
||||
newvalue: time() - DAYSECS,
|
||||
newvalue: $clock->time() - DAYSECS,
|
||||
conditions: ['id' => $taskid],
|
||||
);
|
||||
|
||||
@@ -413,7 +425,9 @@ final class adhoc_task_test extends \advanced_testcase {
|
||||
public function test_get_next_adhoc_task_future(): void {
|
||||
$this->resetAfterTest(true);
|
||||
|
||||
$now = time();
|
||||
$clock = \core\di::get(\core\clock::class);
|
||||
|
||||
$now = $clock->time();
|
||||
// Create an adhoc task in future.
|
||||
$task = new adhoc_test_task();
|
||||
$task->set_next_run_time($now + 1000);
|
||||
@@ -571,10 +585,12 @@ final class adhoc_task_test extends \advanced_testcase {
|
||||
public function test_reschedule_or_queue_adhoc_task_match_no_change(): void {
|
||||
$this->resetAfterTest(true);
|
||||
|
||||
$clock = \core\di::get(\core\clock::class);
|
||||
|
||||
// Schedule adhoc task.
|
||||
$task = new adhoc_test_task();
|
||||
$task->set_custom_data(['courseid' => 10]);
|
||||
$task->set_next_run_time(time() + DAYSECS);
|
||||
$task->set_next_run_time($clock->time() + DAYSECS);
|
||||
manager::reschedule_or_queue_adhoc_task($task);
|
||||
|
||||
$before = manager::get_adhoc_tasks('core\task\adhoc_test_task');
|
||||
@@ -593,8 +609,11 @@ final class adhoc_task_test extends \advanced_testcase {
|
||||
*/
|
||||
public function test_reschedule_or_queue_adhoc_task_match_update_runtime(): void {
|
||||
$this->resetAfterTest(true);
|
||||
$initialruntime = time() + DAYSECS;
|
||||
$newruntime = time() + WEEKSECS;
|
||||
|
||||
$clock = \core\di::get(\core\clock::class);
|
||||
|
||||
$initialruntime = $clock->time() + DAYSECS;
|
||||
$newruntime = $clock->time() + WEEKSECS;
|
||||
|
||||
// Schedule adhoc task.
|
||||
$task = new adhoc_test_task();
|
||||
@@ -690,12 +709,14 @@ final class adhoc_task_test extends \advanced_testcase {
|
||||
public function test_adhoc_task_user_empty(): void {
|
||||
$this->resetAfterTest(true);
|
||||
|
||||
$clock = \core\di::get(\core\clock::class);
|
||||
|
||||
// Create an adhoc task in future.
|
||||
$task = new adhoc_test_task();
|
||||
manager::queue_adhoc_task($task);
|
||||
|
||||
// Get it back from the scheduler.
|
||||
$now = time();
|
||||
$now = $clock->time();
|
||||
$task = manager::get_next_adhoc_task($now);
|
||||
manager::adhoc_task_complete($task);
|
||||
|
||||
@@ -718,7 +739,8 @@ final class adhoc_task_test extends \advanced_testcase {
|
||||
manager::queue_adhoc_task($task);
|
||||
|
||||
// Get it back from the scheduler.
|
||||
$now = time();
|
||||
$clock = \core\di::get(\core\clock::class);
|
||||
$now = $clock->time();
|
||||
$task = manager::get_next_adhoc_task($now);
|
||||
manager::adhoc_task_complete($task);
|
||||
|
||||
@@ -732,7 +754,8 @@ final class adhoc_task_test extends \advanced_testcase {
|
||||
global $DB;
|
||||
$this->resetAfterTest(true);
|
||||
|
||||
$now = time();
|
||||
$clock = $this->mock_clock_with_frozen();
|
||||
$now = $clock->time();
|
||||
|
||||
// Create an adhoc task.
|
||||
$task = new adhoc_test_task();
|
||||
@@ -748,7 +771,7 @@ final class adhoc_task_test extends \advanced_testcase {
|
||||
$this->assertNull($firststartingtime);
|
||||
|
||||
// This will make sure that the task will be started after the $now value.
|
||||
sleep(3);
|
||||
$clock->bump(5);
|
||||
|
||||
// Get the task from the scheduler.
|
||||
$task = manager::get_next_adhoc_task(timestart: $now);
|
||||
@@ -768,8 +791,11 @@ final class adhoc_task_test extends \advanced_testcase {
|
||||
$this->assertNotNull($origintimestarted);
|
||||
$this->assertGreaterThan($now, $origintimestarted);
|
||||
|
||||
// Time travel 24 hours into the future.
|
||||
$clock->bump(DAYSECS * 3);
|
||||
$now = $clock->time();
|
||||
// Get the task from the scheduler.
|
||||
$task = manager::get_next_adhoc_task(timestart: $now + 86400);
|
||||
$task = manager::get_next_adhoc_task(timestart: $now);
|
||||
// Mark the task as starting.
|
||||
manager::adhoc_task_starting($task);
|
||||
// Execute the task.
|
||||
@@ -832,6 +858,8 @@ final class adhoc_task_test extends \advanced_testcase {
|
||||
public function test_get_next_adhoc_task_sorting(): void {
|
||||
$this->resetAfterTest(true);
|
||||
|
||||
$clock = \core\di::get(\core\clock::class);
|
||||
|
||||
// Create adhoc tasks.
|
||||
$task1 = new adhoc_test_task();
|
||||
$task1->set_next_run_time(1510000000);
|
||||
@@ -859,15 +887,15 @@ final class adhoc_task_test extends \advanced_testcase {
|
||||
manager::reschedule_or_queue_adhoc_task($task2);
|
||||
|
||||
// Confirm, that tasks are sorted by nextruntime and then by id (ascending).
|
||||
$task = manager::get_next_adhoc_task(time());
|
||||
$task = manager::get_next_adhoc_task($clock->time());
|
||||
$this->assertEquals('Task 2', $task->get_custom_data_as_string());
|
||||
manager::adhoc_task_complete($task);
|
||||
|
||||
$task = manager::get_next_adhoc_task(time());
|
||||
$task = manager::get_next_adhoc_task($clock->time());
|
||||
$this->assertEquals('Task 3', $task->get_custom_data_as_string());
|
||||
manager::adhoc_task_complete($task);
|
||||
|
||||
$task = manager::get_next_adhoc_task(time());
|
||||
$task = manager::get_next_adhoc_task($clock->time());
|
||||
$this->assertEquals('Task 1', $task->get_custom_data_as_string());
|
||||
manager::adhoc_task_complete($task);
|
||||
}
|
||||
@@ -928,6 +956,8 @@ final class adhoc_task_test extends \advanced_testcase {
|
||||
$this->resetAfterTest();
|
||||
$this->setAdminUser();
|
||||
|
||||
$clock = \core\di::get(\core\clock::class);
|
||||
|
||||
// Redirect messages.
|
||||
$messagesink = $this->redirectMessages();
|
||||
|
||||
@@ -935,7 +965,7 @@ final class adhoc_task_test extends \advanced_testcase {
|
||||
$task = new adhoc_test_task();
|
||||
manager::queue_adhoc_task($task);
|
||||
|
||||
$now = time();
|
||||
$now = $clock->time();
|
||||
|
||||
// Get it from the scheduler, execute it, and mark it as failed.
|
||||
$task = manager::get_next_adhoc_task($now);
|
||||
|
||||
@@ -2549,6 +2549,9 @@ function check_upgrade_key($upgradekeyhash) {
|
||||
if ($upgradekeyhash === null or $upgradekeyhash !== sha1($CFG->config_php_settings['upgradekey'])) {
|
||||
if (!$PAGE->headerprinted) {
|
||||
$PAGE->set_title(get_string('upgradekeyreq', 'admin'));
|
||||
$PAGE->requires->js_call_amd('core/togglesensitive', 'init', ['upgradekey']);
|
||||
|
||||
/** @var core_admin_renderer $output */
|
||||
$output = $PAGE->get_renderer('core', 'admin');
|
||||
echo $output->upgradekey_form_page(new moodle_url('/admin/index.php', array('cache' => 0)));
|
||||
die();
|
||||
|
||||
@@ -39,3 +39,35 @@ Feature: To be able to see and save user message preferences as admin
|
||||
Then "[data-processor-name='popup']" "css_element" should exist
|
||||
And "[data-processor-name='email']" "css_element" should not exist
|
||||
And "[data-processor-name='airnotifier']" "css_element" should exist
|
||||
|
||||
@javascript
|
||||
Scenario: An admin can set the default notification preferences
|
||||
Given I log in as "admin"
|
||||
And I navigate to "Messaging > Notification settings" in site administration
|
||||
And I set the field "email" to "1"
|
||||
And I press "Save changes"
|
||||
And I set the following fields to these values:
|
||||
| mod_assign_assign_notification_enabled[popup] | 1 |
|
||||
| mod_assign_assign_notification_enabled[email] | 0 |
|
||||
| mod_feedback_submission_enabled[popup] | 0 |
|
||||
| mod_feedback_submission_locked[popup] | 1 |
|
||||
| mod_feedback_submission_enabled[email] | 1 |
|
||||
| mod_feedback_submission_locked[email] | 1 |
|
||||
| mod_feedback_message_disable | 0 |
|
||||
And I press "Save changes"
|
||||
And I log in as "student1"
|
||||
And I follow "Preferences" in the user menu
|
||||
When I click on "Notification preferences" "link" in the "#page-content" "css_element"
|
||||
# Assignment web notification is enabled, email is disabled.
|
||||
Then the field "message_provider_mod_assign_assign_notification_popup" matches value "1"
|
||||
And the field "message_provider_mod_assign_assign_notification_email" matches value "0"
|
||||
# Corresponding checkboxes can be updated.
|
||||
And I click on "message_provider_mod_assign_assign_notification_popup" "checkbox"
|
||||
And I click on "message_provider_mod_assign_assign_notification_email" "checkbox"
|
||||
And the field "message_provider_mod_assign_assign_notification_popup" matches value "0"
|
||||
And the field "message_provider_mod_assign_assign_notification_email" matches value "1"
|
||||
# Feedback submission notifications are locked off for web and locked on for email.
|
||||
And "Feedback notifications" row "Web" column of "preference-table" table should contain "Locked off"
|
||||
And "Feedback notifications" row "Requires configuration Email" column of "preference-table" table should contain "Locked on"
|
||||
# Feedback reminder is disabled.
|
||||
And I should not see "Feedback reminder"
|
||||
|
||||
@@ -734,7 +734,7 @@ class pdf extends TcpdfFpdi {
|
||||
$gsexec = \escapeshellarg($CFG->pathtogs);
|
||||
$tempdstarg = \escapeshellarg($tempdst);
|
||||
$tempsrcarg = \escapeshellarg($tempsrc);
|
||||
$command = "$gsexec -q -sDEVICE=pdfwrite -dPreserveAnnots=false -dSAFER -dBATCH -dNOPAUSE "
|
||||
$command = "$gsexec -q -sDEVICE=pdfwrite -dPreserveAnnots=false -dSAFER -dBATCH -dNOPAUSE -dCompatibilityLevel=1.4 "
|
||||
. "-sOutputFile=$tempdstarg $tempsrcarg";
|
||||
|
||||
exec($command);
|
||||
|
||||
@@ -499,10 +499,6 @@ class assign {
|
||||
$nextpageparams['id'] = $this->get_course_module()->id;
|
||||
}
|
||||
|
||||
if (empty($action)) {
|
||||
$PAGE->add_body_class('limitedwidth');
|
||||
}
|
||||
|
||||
// Handle form submissions first.
|
||||
if ($action == 'savesubmission') {
|
||||
$action = 'editsubmission';
|
||||
|
||||
+2
-1
@@ -74,7 +74,8 @@ if ($mform->is_cancelled()) {
|
||||
// Make sure at least one chapter exists.
|
||||
$chapters = book_preload_chapters($book);
|
||||
if (!$chapters) {
|
||||
redirect(new moodle_url('/course/view.php', array('id' => $course->id))); // Back to course view.
|
||||
$section = $DB->get_record('course_sections', ['id' => $cm->section]);
|
||||
redirect(course_get_url($course, $section)); // Back to course view.
|
||||
}
|
||||
|
||||
if (empty($chapter->id)) {
|
||||
|
||||
@@ -11,9 +11,13 @@ Feature: Attempts review settings.
|
||||
| student2 | Student | 2 | student2@example.com |
|
||||
| teacher1 | Teacher | 1 | teacher1@example.com |
|
||||
| teacher2 | Teacher | 2 | teacher2@example.com |
|
||||
And the following config values are set as admin:
|
||||
| enrol_guest | Yes |
|
||||
And the following "courses" exist:
|
||||
| fullname | shortname | category |
|
||||
| Course 1 | C1 | 0 |
|
||||
And I am on the "Course 1" "enrolment methods" page logged in as admin
|
||||
And I click on "Enable" "link" in the "Guest access" "table_row"
|
||||
And the following "course enrolments" exist:
|
||||
| user | course | role |
|
||||
| student1 | C1 | student |
|
||||
@@ -44,6 +48,9 @@ Feature: Attempts review settings.
|
||||
|
||||
Examples:
|
||||
| user | enabletracking | reviewmode | attemptsreportlink | previewmode | attempttracking | attempttrackingsettings | viewattempts |
|
||||
| guest | 1 | 1 | not exist | not see | not see | not see | not see |
|
||||
| guest | 1 | 0 | not exist | not see | not see | not see | not see |
|
||||
| guest | 0 | 1 | not exist | not see | not see | not see | not see |
|
||||
| student1 | 1 | 1 | exist | not see | not see | not see | not see |
|
||||
| student1 | 1 | 0 | not exist | not see | not see | not see | not see |
|
||||
| student1 | 0 | 1 | not exist | not see | not see | not see | not see |
|
||||
|
||||
@@ -71,8 +71,8 @@ echo $OUTPUT->header();
|
||||
|
||||
$instance = $manager->get_instance();
|
||||
|
||||
// Only users without permission to submit can see the warning messages.
|
||||
if (!$manager->can_submit()) {
|
||||
// Only non-guest users without permission to submit can see the warning messages (typically a teacher or a content creator).
|
||||
if (!$manager->can_submit() && !isguestuser()) {
|
||||
// Show preview mode message.
|
||||
$message = get_string('previewmode', 'mod_h5pactivity');
|
||||
echo $OUTPUT->notification($message, \core\output\notification::NOTIFY_INFO, false);
|
||||
|
||||
@@ -1067,6 +1067,13 @@ class edit_renderer extends \plugin_renderer_base {
|
||||
$temp->questiontext = '';
|
||||
$temp->name = $structure->describe_random_slot($slot->id);
|
||||
$instancename = quiz_question_tostring($temp);
|
||||
if (strpos($instancename, structure::MISSING_QUESTION_CATEGORY_PLACEHOLDER) !== false) {
|
||||
$label = html_writer::span(
|
||||
get_string('missingcategory', 'mod_quiz'),
|
||||
'badge bg-danger text-white h-50'
|
||||
);
|
||||
$instancename = str_replace(structure::MISSING_QUESTION_CATEGORY_PLACEHOLDER, $label, $instancename);
|
||||
}
|
||||
|
||||
$configuretitle = get_string('configurerandomquestion', 'quiz');
|
||||
$qtype = \question_bank::get_qtype($question->qtype, false);
|
||||
|
||||
@@ -83,10 +83,10 @@ class custom_view extends \core_question\local\bank\view {
|
||||
}
|
||||
|
||||
$this->init_columns($this->wanted_columns(), $this->heading_column());
|
||||
$this->pagesize = self::DEFAULT_PAGE_SIZE;
|
||||
parent::__construct($contexts, $pageurl, $course, $cm, $params, $extraparams);
|
||||
[$this->quiz, ] = get_module_from_cmid($cm->id);
|
||||
$this->set_quiz_has_attempts(quiz_has_attempts($this->quiz->id));
|
||||
$this->pagesize = self::DEFAULT_PAGE_SIZE;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -246,9 +246,16 @@ class custom_view extends \core_question\local\bank\view {
|
||||
FROM {question_versions} v
|
||||
JOIN {question_bank_entries} be
|
||||
ON be.id = v.questionbankentryid
|
||||
WHERE be.id = qbe.id)';
|
||||
$onlyready = '((' . "qv.status = '" . question_version_status::QUESTION_STATUS_READY . "'" .'))';
|
||||
$this->sqlparams = [];
|
||||
WHERE be.id = qbe.id AND v.status <> :substatus)';
|
||||
|
||||
// An additional condition is required in the subquery to account for scenarios
|
||||
// where the latest version is hidden. This ensures we retrieve the previous
|
||||
// "Ready" version instead of the hidden latest version.
|
||||
$onlyready = '((qv.status = :status))';
|
||||
$this->sqlparams = [
|
||||
'status' => question_version_status::QUESTION_STATUS_READY,
|
||||
'substatus' => question_version_status::QUESTION_STATUS_HIDDEN,
|
||||
];
|
||||
$conditions = [];
|
||||
foreach ($this->searchconditions as $searchcondition) {
|
||||
if ($searchcondition->where()) {
|
||||
|
||||
@@ -102,8 +102,11 @@ class custom_category_condition_helper extends \qbank_managecategories\helper {
|
||||
bool $top = false, int $showallversions = 0): array {
|
||||
global $DB;
|
||||
$topwhere = $top ? '' : 'AND c.parent <> 0';
|
||||
$statuscondition = "AND qv.status = '". question_version_status::QUESTION_STATUS_READY . "' ";
|
||||
|
||||
$statuscondition = "AND qv.status = :status";
|
||||
$params = [
|
||||
'status' => question_version_status::QUESTION_STATUS_READY,
|
||||
'substatus' => question_version_status::QUESTION_STATUS_HIDDEN,
|
||||
];
|
||||
$sql = "SELECT c.*,
|
||||
(SELECT COUNT(1)
|
||||
FROM {question} q
|
||||
@@ -116,7 +119,7 @@ class custom_category_condition_helper extends \qbank_managecategories\helper {
|
||||
OR (qv.version = (SELECT MAX(v.version)
|
||||
FROM {question_versions} v
|
||||
JOIN {question_bank_entries} be ON be.id = v.questionbankentryid
|
||||
WHERE be.id = qbe.id)
|
||||
WHERE be.id = qbe.id AND v.status <> :substatus)
|
||||
)
|
||||
)
|
||||
) AS questioncount
|
||||
@@ -124,6 +127,6 @@ class custom_category_condition_helper extends \qbank_managecategories\helper {
|
||||
WHERE c.contextid IN ($contexts) $topwhere
|
||||
ORDER BY $sortorder";
|
||||
|
||||
return $DB->get_records_sql($sql);
|
||||
return $DB->get_records_sql($sql, $params);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,12 @@ use stdClass;
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class structure {
|
||||
|
||||
/**
|
||||
* Placeholder string used when a question category is missing.
|
||||
*/
|
||||
const MISSING_QUESTION_CATEGORY_PLACEHOLDER = 'missing_question_category';
|
||||
|
||||
/** @var quiz_settings the quiz this is the structure of. */
|
||||
protected $quizobj = null;
|
||||
|
||||
@@ -1759,6 +1765,13 @@ class structure {
|
||||
// Now, put the data required for each slot into $this->randomslotcategories and $this->randomslottags.
|
||||
foreach ($randomcategoriesandtags as $slotid => $catandtags) {
|
||||
$qcategoryid = $catandtags['cat']['values'];
|
||||
|
||||
// If the category does not exist, replace with a temporary placeholder.
|
||||
if (!array_key_exists($qcategoryid, $categories)) {
|
||||
$this->randomslotcategories[$slotid] = self::MISSING_QUESTION_CATEGORY_PLACEHOLDER;
|
||||
continue;
|
||||
}
|
||||
|
||||
$qcategory = $categories[$qcategoryid];
|
||||
$includesubcategories = $catandtags['cat']['includesubcategories'];
|
||||
$this->randomslotcategories[$slotid] = $this->get_used_category_description($qcategory, $includesubcategories);
|
||||
|
||||
@@ -72,6 +72,9 @@ $summary->add_item('quizname', get_string('modulename', 'quiz'), format_string($
|
||||
// Question name.
|
||||
$summary->add_item('questionname', get_string('question', 'quiz'), $attemptobj->get_question_name($slot));
|
||||
|
||||
// Error message in case of input invalid mark.
|
||||
$submiterror = false;
|
||||
|
||||
// Process any data that was submitted.
|
||||
if (data_submitted() && confirm_sesskey()) {
|
||||
if (optional_param('submit', false, PARAM_BOOL) && question_engine::is_manual_grade_in_range($attemptobj->get_uniqueid(), $slot)) {
|
||||
@@ -96,12 +99,18 @@ if (data_submitted() && confirm_sesskey()) {
|
||||
echo $output->notification(get_string('changessaved'), 'notifysuccess');
|
||||
close_window(2, true);
|
||||
die;
|
||||
} else {
|
||||
$submiterror = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Print quiz information.
|
||||
echo html_writer::div($output->render($summary), 'mb-3');
|
||||
|
||||
// Display notification if current mark is invalid.
|
||||
if ($submiterror) {
|
||||
echo $output->notification(get_string('savemanualgradingfailed', 'quiz'), \core\output\notification::NOTIFY_ERROR);
|
||||
}
|
||||
// Print the comment form.
|
||||
echo '<form method="post" class="mform" id="manualgradingform" action="' .
|
||||
$CFG->wwwroot . '/mod/quiz/comment.php">';
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
use core_question\local\bank\filter_condition_manager;
|
||||
use core_question\question_reference_manager;
|
||||
use mod_quiz\quiz_settings;
|
||||
use mod_quiz\question\bank\random_question_view;
|
||||
@@ -58,6 +59,7 @@ $setreference = $DB->get_record('question_set_references',
|
||||
['itemid' => $slot->id, 'component' => 'mod_quiz', 'questionarea' => 'slot']);
|
||||
$filterconditions = json_decode($setreference->filtercondition, true);
|
||||
$filterconditions = question_reference_manager::convert_legacy_set_reference_filter_condition($filterconditions);
|
||||
$filterconditions = filter_condition_manager::filter_invalid_values($filterconditions);
|
||||
|
||||
$params = $filterconditions;
|
||||
$params['cmid'] = $cm->id;
|
||||
@@ -132,9 +134,10 @@ if ($mform->is_cancelled()) {
|
||||
redirect($returnurl);
|
||||
}
|
||||
|
||||
$PAGE->set_title('Random question');
|
||||
$heading = get_string('randomediting', 'mod_quiz');
|
||||
$PAGE->set_title($heading);
|
||||
$PAGE->set_heading($COURSE->fullname);
|
||||
$PAGE->navbar->add('Random question');
|
||||
$PAGE->navbar->add($heading);
|
||||
|
||||
// Custom View.
|
||||
$questionbank = new random_question_view($contexts, $thispageurl, $course, $cm, $params, $extraparams);
|
||||
@@ -151,7 +154,6 @@ $PAGE->requires->js_call_amd('mod_quiz/update_random_question_filter_condition',
|
||||
|
||||
// Display a heading, question editing form.
|
||||
echo $OUTPUT->header();
|
||||
$heading = get_string('randomediting', 'mod_quiz');
|
||||
echo $OUTPUT->heading_with_help($heading, 'randomquestion', 'mod_quiz');
|
||||
echo $updateform;
|
||||
echo $OUTPUT->footer();
|
||||
|
||||
@@ -555,6 +555,7 @@ $string['maxmarks_help'] = 'The maximum mark available for each question.';
|
||||
|
||||
$string['min'] = 'Min';
|
||||
$string['minutes'] = 'Minutes';
|
||||
$string['missingcategory'] = 'Missing question category';
|
||||
$string['missingcorrectanswer'] = 'Correct answer must be specified';
|
||||
$string['missingitemtypename'] = 'Missing name';
|
||||
$string['missingquestion'] = 'This question no longer seems to exist';
|
||||
@@ -966,6 +967,7 @@ $string['saveandedit'] = 'Save changes and edit questions';
|
||||
$string['saveattemptfailed'] = 'Failed to save the current quiz attempt.';
|
||||
$string['savedfromdeletedcourse'] = 'Saved from deleted course "{$a}"';
|
||||
$string['savegrades'] = 'Save grades';
|
||||
$string['savemanualgradingfailed'] = 'Modification not saved. Please check the message below and try again.';
|
||||
$string['savemyanswers'] = 'Save my answers';
|
||||
$string['savenosubmit'] = 'Save without submitting';
|
||||
$string['saveoverrideandstay'] = 'Save and enter another override';
|
||||
|
||||
@@ -152,4 +152,616 @@ final class repeated_restore_test extends advanced_testcase {
|
||||
$this->assertEquals($questionscourse2firstimport[$slot->slot]->questionid, $slot->questionid);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a copy of a quiz to the same course, using questions that include line breaks in the text.
|
||||
*/
|
||||
public function test_restore_question_with_linebreaks(): void {
|
||||
global $USER;
|
||||
$this->resetAfterTest();
|
||||
$this->setAdminUser();
|
||||
|
||||
// Step 1: Create two courses and a user with editing teacher capabilities.
|
||||
$generator = $this->getDataGenerator();
|
||||
$course1 = $generator->create_course();
|
||||
$course2 = $generator->create_course();
|
||||
$teacher = $USER;
|
||||
$generator->enrol_user($teacher->id, $course1->id, 'editingteacher');
|
||||
$generator->enrol_user($teacher->id, $course2->id, 'editingteacher');
|
||||
|
||||
// Create a quiz with questions in the first course.
|
||||
$quiz = $this->create_test_quiz($course1);
|
||||
$coursecontext = \context_course::instance($course1->id);
|
||||
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
|
||||
|
||||
// Create a question category.
|
||||
$cat = $questiongenerator->create_question_category(['contextid' => $coursecontext->id]);
|
||||
|
||||
// Create questions and add to the quiz.
|
||||
$q1 = $questiongenerator->create_question('truefalse', null, [
|
||||
'category' => $cat->id,
|
||||
'questiontext' => ['text' => "<p>Question</p>\r\n<p>One</p>", 'format' => FORMAT_MOODLE]
|
||||
]);
|
||||
$q2 = $questiongenerator->create_question('truefalse', null, [
|
||||
'category' => $cat->id,
|
||||
'questiontext' => ['text' => "<p>Question</p>\n<p>Two</p>", 'format' => FORMAT_MOODLE]
|
||||
]);
|
||||
// Add question to quiz.
|
||||
quiz_add_quiz_question($q1->id, $quiz);
|
||||
quiz_add_quiz_question($q2->id, $quiz);
|
||||
|
||||
// Capture original question IDs for verification after import.
|
||||
$modules1 = get_fast_modinfo($course1->id)->get_instances_of('quiz');
|
||||
$module1 = reset($modules1);
|
||||
$originalslots = \mod_quiz\question\bank\qbank_helper::get_question_structure(
|
||||
$module1->instance, $module1->context);
|
||||
|
||||
$originalquestionids = [];
|
||||
foreach ($originalslots as $slot) {
|
||||
array_push($originalquestionids, intval($slot->questionid));
|
||||
}
|
||||
|
||||
$this->assertCount(2, get_questions_category($cat, false));
|
||||
|
||||
// Step 2: Backup the quiz
|
||||
$bc = new backup_controller(backup::TYPE_1ACTIVITY, $quiz->cmid, backup::FORMAT_MOODLE,
|
||||
backup::INTERACTIVE_NO, backup::MODE_IMPORT, $teacher->id);
|
||||
$backupid = $bc->get_backupid();
|
||||
$bc->execute_plan();
|
||||
$bc->destroy();
|
||||
|
||||
// Step 3: Import the backup into the same course.
|
||||
$rc = new restore_controller($backupid, $course1->id, backup::INTERACTIVE_NO, backup::MODE_IMPORT,
|
||||
$teacher->id, backup::TARGET_CURRENT_ADDING);
|
||||
$rc->execute_precheck();
|
||||
$rc->execute_plan();
|
||||
$rc->destroy();
|
||||
|
||||
// Verify the question ids from the new quiz match the first.
|
||||
$modules2 = get_fast_modinfo($course1->id)->get_instances_of('quiz');
|
||||
$this->assertCount(2, $modules2);
|
||||
$module2 = end($modules2);
|
||||
$copyslots = \mod_quiz\question\bank\qbank_helper::get_question_structure(
|
||||
$module2->instance, $module2->context);
|
||||
|
||||
foreach ($copyslots as $slot) {
|
||||
$this->assertContains(intval($slot->questionid), $originalquestionids);
|
||||
}
|
||||
|
||||
// The category should still only contain 2 question, neither question should be duplicated.
|
||||
$this->assertCount(2, get_questions_category($cat, false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a list of qtypes with valid generators in their helper class.
|
||||
*
|
||||
* This will check all installed qtypes for a test helper class, then find a defined test question which has a corresponding
|
||||
* form_data method and return it. If the helper doesn't have a form_data method for any test question, it will return a
|
||||
* null test question name for that qtype.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function get_qtype_generators(): array {
|
||||
global $CFG;
|
||||
$generators = [];
|
||||
foreach (\core\plugin_manager::instance()->get_plugins_of_type('qtype') as $qtype) {
|
||||
if ($qtype->name == 'random') {
|
||||
continue;
|
||||
}
|
||||
$helperpath = "{$CFG->dirroot}/question/type/{$qtype->name}/tests/helper.php";
|
||||
if (!file_exists($helperpath)) {
|
||||
continue;
|
||||
}
|
||||
require_once($helperpath);
|
||||
$helperclass = "qtype_{$qtype->name}_test_helper";
|
||||
if (!class_exists($helperclass)) {
|
||||
continue;
|
||||
}
|
||||
$helper = new $helperclass();
|
||||
$testquestion = null;
|
||||
foreach ($helper->get_test_questions() as $question) {
|
||||
if (method_exists($helper, "get_{$qtype->name}_question_form_data_{$question}")) {
|
||||
$testquestion = $question;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$generators[$qtype->name] = [
|
||||
'qtype' => $qtype->name,
|
||||
'testquestion' => $testquestion,
|
||||
];
|
||||
}
|
||||
return $generators;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a quiz with questions of same stamp into the same course, but different answers.
|
||||
*
|
||||
* @dataProvider get_qtype_generators
|
||||
* @param string $qtype The name of the qtype plugin to test
|
||||
* @param ?string $testquestion The test question to generate for the plugin. If null, the plugin will be skipped
|
||||
* with a message.
|
||||
*/
|
||||
public function test_restore_quiz_with_same_stamp_questions(string $qtype, ?string $testquestion): void {
|
||||
global $DB, $USER;
|
||||
if (is_null($testquestion)) {
|
||||
$this->markTestSkipped(
|
||||
"Cannot test qtype_{$qtype} as there is no test question with a form_data method in the " .
|
||||
"test helper class."
|
||||
);
|
||||
}
|
||||
$this->resetAfterTest();
|
||||
$this->setAdminUser();
|
||||
|
||||
// Create a course and a user with editing teacher capabilities.
|
||||
$generator = $this->getDataGenerator();
|
||||
$course1 = $generator->create_course();
|
||||
$teacher = $USER;
|
||||
$generator->enrol_user($teacher->id, $course1->id, 'editingteacher');
|
||||
$coursecontext = \context_course::instance($course1->id);
|
||||
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
|
||||
|
||||
// Create a question category.
|
||||
$cat = $questiongenerator->create_question_category(['contextid' => $coursecontext->id]);
|
||||
|
||||
// Create 2 quizzes with 2 questions multichoice.
|
||||
$quiz1 = $this->create_test_quiz($course1);
|
||||
$question1 = $questiongenerator->create_question($qtype, $testquestion, ['category' => $cat->id]);
|
||||
quiz_add_quiz_question($question1->id, $quiz1, 0);
|
||||
|
||||
$question2 = $questiongenerator->create_question($qtype, $testquestion, ['category' => $cat->id]);
|
||||
quiz_add_quiz_question($question2->id, $quiz1, 0);
|
||||
|
||||
// Update question2 to have the same stamp as question1.
|
||||
$DB->set_field('question', 'stamp', $question1->stamp, ['id' => $question2->id]);
|
||||
|
||||
// Change the answers of the question2 to be different to question1.
|
||||
$question2data = \question_bank::load_question_data($question2->id);
|
||||
if (!isset($question2data->options->answers) || empty($question2data->options->answers)) {
|
||||
$this->markTestSkipped(
|
||||
"Cannot test edited answers for qtype_{$qtype} as it does not use answers.",
|
||||
);
|
||||
}
|
||||
foreach ($question2data->options->answers as $answer) {
|
||||
$DB->set_field('question_answers', 'answer', 'edited', ['id' => $answer->id]);
|
||||
}
|
||||
|
||||
// Backup quiz1.
|
||||
$bc = new backup_controller(backup::TYPE_1ACTIVITY, $quiz1->cmid, backup::FORMAT_MOODLE,
|
||||
backup::INTERACTIVE_NO, backup::MODE_IMPORT, $teacher->id);
|
||||
$backupid = $bc->get_backupid();
|
||||
$bc->execute_plan();
|
||||
$bc->destroy();
|
||||
|
||||
// Restore the backup into the same course.
|
||||
$rc = new restore_controller($backupid, $course1->id, backup::INTERACTIVE_NO, backup::MODE_IMPORT,
|
||||
$teacher->id, backup::TARGET_CURRENT_ADDING);
|
||||
$rc->execute_precheck();
|
||||
$rc->execute_plan();
|
||||
$rc->destroy();
|
||||
|
||||
// Verify that the newly-restored quiz uses the same question as quiz2.
|
||||
$modules = get_fast_modinfo($course1->id)->get_instances_of('quiz');
|
||||
$this->assertCount(2, $modules);
|
||||
$quiz2structure = \mod_quiz\question\bank\qbank_helper::get_question_structure(
|
||||
$quiz1->id,
|
||||
\context_module::instance($quiz1->cmid),
|
||||
);
|
||||
$quiz2 = end($modules);
|
||||
$quiz2structure = \mod_quiz\question\bank\qbank_helper::get_question_structure($quiz2->instance, $quiz2->context);
|
||||
$this->assertEquals($quiz2structure[1]->questionid, $quiz2structure[1]->questionid);
|
||||
$this->assertEquals($quiz2structure[2]->questionid, $quiz2structure[2]->questionid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a quiz with duplicate questions (same stamp and questions) into the same course.
|
||||
*
|
||||
* This is a contrived case, but this test serves as a control for the other tests in this class, proving that the hashing
|
||||
* process will match an identical question.
|
||||
*
|
||||
* @dataProvider get_qtype_generators
|
||||
* @param string $qtype The name of the qtype plugin to test
|
||||
* @param ?string $testquestion The test question to generate for the plugin. If null, the plugin will be skipped
|
||||
* with a message.
|
||||
*/
|
||||
public function test_restore_quiz_with_duplicate_questions(string $qtype, ?string $testquestion): void {
|
||||
global $DB, $USER;
|
||||
if (is_null($testquestion)) {
|
||||
$this->markTestSkipped(
|
||||
"Cannot test qtype_{$qtype} as there is no test question with a form_data method in the " .
|
||||
"test helper class."
|
||||
);
|
||||
}
|
||||
$this->resetAfterTest();
|
||||
$this->setAdminUser();
|
||||
|
||||
// Create a course and a user with editing teacher capabilities.
|
||||
$generator = $this->getDataGenerator();
|
||||
$course1 = $generator->create_course();
|
||||
$teacher = $USER;
|
||||
$generator->enrol_user($teacher->id, $course1->id, 'editingteacher');
|
||||
$coursecontext = \context_course::instance($course1->id);
|
||||
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
|
||||
|
||||
// Create a question category.
|
||||
$cat = $questiongenerator->create_question_category(['contextid' => $coursecontext->id]);
|
||||
|
||||
// Create a quiz with 2 identical but separate questions.
|
||||
$quiz1 = $this->create_test_quiz($course1);
|
||||
$question1 = $questiongenerator->create_question($qtype, $testquestion, ['category' => $cat->id]);
|
||||
quiz_add_quiz_question($question1->id, $quiz1, 0);
|
||||
$question2 = $questiongenerator->create_question($qtype, $testquestion, ['category' => $cat->id]);
|
||||
quiz_add_quiz_question($question2->id, $quiz1, 0);
|
||||
|
||||
// Update question2 to have the same times and stamp as question1.
|
||||
$DB->update_record('question', [
|
||||
'id' => $question2->id,
|
||||
'stamp' => $question1->stamp,
|
||||
'timecreated' => $question1->timecreated,
|
||||
'timemodified' => $question1->timemodified,
|
||||
]);
|
||||
|
||||
// Backup quiz.
|
||||
$bc = new backup_controller(backup::TYPE_1ACTIVITY, $quiz1->cmid, backup::FORMAT_MOODLE,
|
||||
backup::INTERACTIVE_NO, backup::MODE_IMPORT, $teacher->id);
|
||||
$backupid = $bc->get_backupid();
|
||||
$bc->execute_plan();
|
||||
$bc->destroy();
|
||||
|
||||
// Restore the backup into the same course.
|
||||
$rc = new restore_controller($backupid, $course1->id, backup::INTERACTIVE_NO, backup::MODE_IMPORT,
|
||||
$teacher->id, backup::TARGET_CURRENT_ADDING);
|
||||
$rc->execute_precheck();
|
||||
$rc->execute_plan();
|
||||
$rc->destroy();
|
||||
|
||||
// Expect that the restored quiz will have the second question in both its slots
|
||||
// by virtue of identical stamp, version, and hash of question answer texts.
|
||||
$modules = get_fast_modinfo($course1->id)->get_instances_of('quiz');
|
||||
$this->assertCount(2, $modules);
|
||||
$quiz2 = end($modules);
|
||||
$quiz2structure = \mod_quiz\question\bank\qbank_helper::get_question_structure($quiz2->instance, $quiz2->context);
|
||||
$this->assertEquals($quiz2structure[1]->questionid, $quiz2structure[2]->questionid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a quiz with questions that have the same stamp but different text.
|
||||
*
|
||||
* @dataProvider get_qtype_generators
|
||||
* @param string $qtype The name of the qtype plugin to test
|
||||
* @param ?string $testquestion The test question to generate for the plugin. If null, the plugin will be skipped
|
||||
* with a message.
|
||||
*/
|
||||
public function test_restore_quiz_with_edited_questions(string $qtype, ?string $testquestion): void {
|
||||
global $DB, $USER;
|
||||
if (is_null($testquestion)) {
|
||||
$this->markTestSkipped(
|
||||
"Cannot test qtype_{$qtype} as there is no test question with a form_data method in the " .
|
||||
"test helper class."
|
||||
);
|
||||
}
|
||||
$this->resetAfterTest();
|
||||
$this->setAdminUser();
|
||||
|
||||
// Create a course and a user with editing teacher capabilities.
|
||||
$generator = $this->getDataGenerator();
|
||||
$course1 = $generator->create_course();
|
||||
$teacher = $USER;
|
||||
$generator->enrol_user($teacher->id, $course1->id, 'editingteacher');
|
||||
$coursecontext = \context_course::instance($course1->id);
|
||||
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
|
||||
|
||||
// Create a question category.
|
||||
$cat = $questiongenerator->create_question_category(['contextid' => $coursecontext->id]);
|
||||
|
||||
// Create a quiz with 2 identical but separate questions.
|
||||
$quiz1 = $this->create_test_quiz($course1);
|
||||
$question1 = $questiongenerator->create_question($qtype, $testquestion, ['category' => $cat->id]);
|
||||
quiz_add_quiz_question($question1->id, $quiz1);
|
||||
$question2 = $questiongenerator->create_question($qtype, $testquestion, ['category' => $cat->id]);
|
||||
// Edit question 2 to have the same stamp and times as question1, but different text.
|
||||
$DB->update_record('question', [
|
||||
'id' => $question2->id,
|
||||
'questiontext' => 'edited',
|
||||
'stamp' => $question1->stamp,
|
||||
'timecreated' => $question1->timecreated,
|
||||
'timemodified' => $question1->timemodified,
|
||||
]);
|
||||
quiz_add_quiz_question($question2->id, $quiz1);
|
||||
|
||||
// Backup quiz.
|
||||
$bc = new backup_controller(backup::TYPE_1ACTIVITY, $quiz1->cmid, backup::FORMAT_MOODLE,
|
||||
backup::INTERACTIVE_NO, backup::MODE_IMPORT, $teacher->id);
|
||||
$backupid = $bc->get_backupid();
|
||||
$bc->execute_plan();
|
||||
$bc->destroy();
|
||||
|
||||
// Restore the backup into the same course.
|
||||
$rc = new restore_controller($backupid, $course1->id, backup::INTERACTIVE_NO, backup::MODE_IMPORT,
|
||||
$teacher->id, backup::TARGET_CURRENT_ADDING);
|
||||
$rc->execute_precheck();
|
||||
$rc->execute_plan();
|
||||
$rc->destroy();
|
||||
|
||||
// The quiz should contain both questions, as they have different text.
|
||||
$modules = get_fast_modinfo($course1->id)->get_instances_of('quiz');
|
||||
$this->assertCount(2, $modules);
|
||||
$quiz2 = end($modules);
|
||||
$quiz2structure = \mod_quiz\question\bank\qbank_helper::get_question_structure($quiz2->instance, $quiz2->context);
|
||||
$this->assertEquals($quiz2structure[1]->questionid, $question1->id);
|
||||
$this->assertEquals($quiz2structure[2]->questionid, $question2->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a course to another course having questions with the same stamp in a shared question bank context category.
|
||||
*
|
||||
* @dataProvider get_qtype_generators
|
||||
* @param string $qtype The name of the qtype plugin to test
|
||||
* @param ?string $testquestion The test question to generate for the plugin. If null, the plugin will be skipped
|
||||
* with a message.
|
||||
*/
|
||||
public function test_restore_course_with_same_stamp_questions(string $qtype, ?string $testquestion): void {
|
||||
global $DB, $USER;
|
||||
if (is_null($testquestion)) {
|
||||
$this->markTestSkipped(
|
||||
"Cannot test qtype_{$qtype} as there is no test question with a form_data method in the " .
|
||||
"test helper class."
|
||||
);
|
||||
}
|
||||
$this->resetAfterTest();
|
||||
$this->setAdminUser();
|
||||
|
||||
// Create three courses and a user with editing teacher capabilities.
|
||||
$generator = $this->getDataGenerator();
|
||||
$course1 = $generator->create_course();
|
||||
$course2 = $generator->create_course();
|
||||
$teacher = $USER;
|
||||
$generator->enrol_user($teacher->id, $course1->id, 'editingteacher');
|
||||
$generator->enrol_user($teacher->id, $course2->id, 'editingteacher');
|
||||
|
||||
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
|
||||
|
||||
// Create a question category.
|
||||
$systemcontext = \context_system::instance();
|
||||
$cat = $questiongenerator->create_question_category(['contextid' => $systemcontext->id]);
|
||||
|
||||
// Create quiz with question.
|
||||
$quiz1 = $this->create_test_quiz($course1);
|
||||
$question1 = $questiongenerator->create_question($qtype, $testquestion, ['category' => $cat->id]);
|
||||
quiz_add_quiz_question($question1->id, $quiz1, 0);
|
||||
|
||||
$quiz2 = $this->create_test_quiz($course1);
|
||||
$question2 = $questiongenerator->create_question($qtype, $testquestion, ['category' => $cat->id]);
|
||||
quiz_add_quiz_question($question2->id, $quiz2, 0);
|
||||
|
||||
// Update question2 to have the same stamp as question1.
|
||||
$DB->set_field('question', 'stamp', $question1->stamp, ['id' => $question2->id]);
|
||||
|
||||
// Change the answers of the question2 to be different to question1.
|
||||
$question2data = \question_bank::load_question_data($question2->id);
|
||||
if (!isset($question2data->options->answers) || empty($question2data->options->answers)) {
|
||||
$this->markTestSkipped(
|
||||
"Cannot test edited answers for qtype_{$qtype} as it does not use answers.",
|
||||
);
|
||||
}
|
||||
foreach ($question2data->options->answers as $answer) {
|
||||
$answer->answer = 'New answer ' . $answer->id;
|
||||
$DB->update_record('question_answers', $answer);
|
||||
}
|
||||
|
||||
// Backup course1.
|
||||
$bc = new backup_controller(backup::TYPE_1COURSE, $course1->id, backup::FORMAT_MOODLE,
|
||||
backup::INTERACTIVE_NO, backup::MODE_IMPORT, $teacher->id);
|
||||
$backupid = $bc->get_backupid();
|
||||
$bc->execute_plan();
|
||||
$bc->destroy();
|
||||
|
||||
// Restore the backup, adding to course2.
|
||||
$rc = new restore_controller($backupid, $course2->id, backup::INTERACTIVE_NO, backup::MODE_IMPORT,
|
||||
$teacher->id, backup::TARGET_CURRENT_ADDING);
|
||||
$rc->execute_precheck();
|
||||
$rc->execute_plan();
|
||||
$rc->destroy();
|
||||
|
||||
// Verify that the newly-restored course's quizzes use the same questions as their counterparts of course1.
|
||||
$modules = get_fast_modinfo($course2->id)->get_instances_of('quiz');
|
||||
$course1structure = \mod_quiz\question\bank\qbank_helper::get_question_structure(
|
||||
$quiz1->id, \context_module::instance($quiz1->cmid));
|
||||
$course2quiz1 = array_shift($modules);
|
||||
$course2structure = \mod_quiz\question\bank\qbank_helper::get_question_structure(
|
||||
$course2quiz1->instance, $course2quiz1->context);
|
||||
$this->assertEquals($course1structure[1]->questionid, $course2structure[1]->questionid);
|
||||
|
||||
$course1structure = \mod_quiz\question\bank\qbank_helper::get_question_structure(
|
||||
$quiz2->id, \context_module::instance($quiz2->cmid));
|
||||
$course2quiz2 = array_shift($modules);
|
||||
$course2structure = \mod_quiz\question\bank\qbank_helper::get_question_structure(
|
||||
$course2quiz2->instance, $course2quiz2->context);
|
||||
$this->assertEquals($course1structure[1]->questionid, $course2structure[1]->questionid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a quiz with questions of same stamp into the same course, but different hints.
|
||||
*
|
||||
* @dataProvider get_qtype_generators
|
||||
* @param string $qtype The name of the qtype plugin to test
|
||||
* @param ?string $testquestion The test question to generate for the plugin. If null, the plugin will be skipped
|
||||
* with a message.
|
||||
*/
|
||||
public function test_restore_quiz_with_same_stamp_questions_edited_hints(string $qtype, ?string $testquestion): void {
|
||||
global $DB, $USER;
|
||||
if (is_null($testquestion)) {
|
||||
$this->markTestSkipped(
|
||||
"Cannot test qtype_{$qtype} as there is no test question with a form_data method in the " .
|
||||
"test helper class."
|
||||
);
|
||||
}
|
||||
$this->resetAfterTest();
|
||||
$this->setAdminUser();
|
||||
|
||||
// Create a course and a user with editing teacher capabilities.
|
||||
$generator = $this->getDataGenerator();
|
||||
$course1 = $generator->create_course();
|
||||
$teacher = $USER;
|
||||
$generator->enrol_user($teacher->id, $course1->id, 'editingteacher');
|
||||
$coursecontext = \context_course::instance($course1->id);
|
||||
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
|
||||
|
||||
// Create a question category.
|
||||
$cat = $questiongenerator->create_question_category(['contextid' => $coursecontext->id]);
|
||||
|
||||
// Create 2 questions multichoice.
|
||||
$quiz1 = $this->create_test_quiz($course1);
|
||||
$question1 = $questiongenerator->create_question($qtype, $testquestion, ['category' => $cat->id]);
|
||||
quiz_add_quiz_question($question1->id, $quiz1, 0);
|
||||
|
||||
$question2 = $questiongenerator->create_question($qtype, $testquestion, ['category' => $cat->id]);
|
||||
quiz_add_quiz_question($question2->id, $quiz1, 0);
|
||||
|
||||
// Update question2 to have the same stamp as question1.
|
||||
$DB->set_field('question', 'stamp', $question1->stamp, ['id' => $question2->id]);
|
||||
|
||||
// Change the hints of the question2 to be different to question1.
|
||||
$hints = $DB->get_records('question_hints', ['questionid' => $question2->id]);
|
||||
if (empty($hints)) {
|
||||
$this->markTestSkipped(
|
||||
"Cannot test edited hints for qtype_{$qtype} as test question {$testquestion} does not use hints.",
|
||||
);
|
||||
}
|
||||
foreach ($hints as $hint) {
|
||||
$DB->set_field('question_hints', 'hint', "{$hint->hint} edited", ['id' => $hint->id]);
|
||||
}
|
||||
|
||||
// Backup quiz1.
|
||||
$bc = new backup_controller(backup::TYPE_1ACTIVITY, $quiz1->cmid, backup::FORMAT_MOODLE,
|
||||
backup::INTERACTIVE_NO, backup::MODE_IMPORT, $teacher->id);
|
||||
$backupid = $bc->get_backupid();
|
||||
$bc->execute_plan();
|
||||
$bc->destroy();
|
||||
|
||||
// Restore the backup into the same course.
|
||||
$rc = new restore_controller($backupid, $course1->id, backup::INTERACTIVE_NO, backup::MODE_IMPORT,
|
||||
$teacher->id, backup::TARGET_CURRENT_ADDING);
|
||||
$rc->execute_precheck();
|
||||
$rc->execute_plan();
|
||||
$rc->destroy();
|
||||
|
||||
// Verify that the newly-restored quiz uses the same question as quiz2.
|
||||
$modules = get_fast_modinfo($course1->id)->get_instances_of('quiz');
|
||||
$this->assertCount(2, $modules);
|
||||
$quiz1structure = \mod_quiz\question\bank\qbank_helper::get_question_structure(
|
||||
$quiz1->id,
|
||||
\context_module::instance($quiz1->cmid),
|
||||
);
|
||||
$quiz2 = end($modules);
|
||||
$quiz2structure = \mod_quiz\question\bank\qbank_helper::get_question_structure($quiz2->instance, $quiz2->context);
|
||||
$this->assertEquals($quiz1structure[1]->questionid, $quiz2structure[1]->questionid);
|
||||
$this->assertEquals($quiz1structure[2]->questionid, $quiz2structure[2]->questionid);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a set of options fields and new values.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function get_edited_option_fields(): array {
|
||||
return [
|
||||
'single' => [
|
||||
'single',
|
||||
'0',
|
||||
],
|
||||
'shuffleanswers' => [
|
||||
'shuffleanswers',
|
||||
'0',
|
||||
],
|
||||
'answernumbering' => [
|
||||
'answernumbering',
|
||||
'ABCD',
|
||||
],
|
||||
'shownumcorrect' => [
|
||||
'shownumcorrect',
|
||||
'0',
|
||||
],
|
||||
'showstandardinstruction' => [
|
||||
'showstandardinstruction',
|
||||
'1',
|
||||
],
|
||||
'correctfeedback' => [
|
||||
'correctfeedback',
|
||||
'edited',
|
||||
],
|
||||
'partiallycorrectfeedback' => [
|
||||
'partiallycorrectfeedback',
|
||||
'edited',
|
||||
],
|
||||
'incorrectfeedback' => [
|
||||
'incorrectfeedback',
|
||||
'edited',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a quiz with questions of same stamp into the same course, but different qtype-specific options.
|
||||
*
|
||||
* @dataProvider get_edited_option_fields
|
||||
* @param string $field The answer field to edit
|
||||
* @param string $value The value to set
|
||||
*/
|
||||
public function test_restore_quiz_with_same_stamp_questions_edited_options(string $field, string $value): void {
|
||||
global $DB, $USER;
|
||||
$this->resetAfterTest();
|
||||
$this->setAdminUser();
|
||||
|
||||
// Create a course and a user with editing teacher capabilities.
|
||||
$generator = $this->getDataGenerator();
|
||||
$course1 = $generator->create_course();
|
||||
$teacher = $USER;
|
||||
$generator->enrol_user($teacher->id, $course1->id, 'editingteacher');
|
||||
$coursecontext = \context_course::instance($course1->id);
|
||||
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
|
||||
|
||||
// Create a question category.
|
||||
$cat = $questiongenerator->create_question_category(['contextid' => $coursecontext->id]);
|
||||
|
||||
// A quiz with 2 multichoice questions.
|
||||
$quiz1 = $this->create_test_quiz($course1);
|
||||
$question1 = $questiongenerator->create_question('multichoice', 'one_of_four', ['category' => $cat->id]);
|
||||
quiz_add_quiz_question($question1->id, $quiz1, 0);
|
||||
|
||||
$question2 = $questiongenerator->create_question('multichoice', 'one_of_four', ['category' => $cat->id]);
|
||||
quiz_add_quiz_question($question2->id, $quiz1, 0);
|
||||
|
||||
// Update question2 to have the same stamp as question1.
|
||||
$DB->set_field('question', 'stamp', $question1->stamp, ['id' => $question2->id]);
|
||||
|
||||
// Change the options of question2 to be different to question1.
|
||||
$DB->set_field('qtype_multichoice_options', $field, $value, ['questionid' => $question2->id]);
|
||||
|
||||
// Backup quiz.
|
||||
$bc = new backup_controller(backup::TYPE_1ACTIVITY, $quiz1->cmid, backup::FORMAT_MOODLE,
|
||||
backup::INTERACTIVE_NO, backup::MODE_IMPORT, $teacher->id);
|
||||
$backupid = $bc->get_backupid();
|
||||
$bc->execute_plan();
|
||||
$bc->destroy();
|
||||
|
||||
// Restore the backup into the same course.
|
||||
$rc = new restore_controller($backupid, $course1->id, backup::INTERACTIVE_NO, backup::MODE_IMPORT,
|
||||
$teacher->id, backup::TARGET_CURRENT_ADDING);
|
||||
$rc->execute_precheck();
|
||||
$rc->execute_plan();
|
||||
$rc->destroy();
|
||||
|
||||
// Verify that the newly-restored quiz questions match their quiz1 counterparts.
|
||||
$modules = get_fast_modinfo($course1->id)->get_instances_of('quiz');
|
||||
$this->assertCount(2, $modules);
|
||||
$quiz1structure = \mod_quiz\question\bank\qbank_helper::get_question_structure(
|
||||
$quiz1->id,
|
||||
\context_module::instance($quiz1->cmid),
|
||||
);
|
||||
$quiz2 = end($modules);
|
||||
$quiz2structure = \mod_quiz\question\bank\qbank_helper::get_question_structure($quiz2->instance, $quiz2->context);
|
||||
$this->assertEquals($quiz1structure[1]->questionid, $quiz2structure[1]->questionid);
|
||||
$this->assertEquals($quiz1structure[2]->questionid, $quiz2structure[2]->questionid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,3 +71,18 @@ Feature: Moving a question to another category should not affect random question
|
||||
And I should see "I was edited" in the "Used category new" "list_item"
|
||||
And I am on the "Quiz 1" "mod_quiz > Edit" page
|
||||
And I should see "Random (Used category new) based on filter condition" on quiz page "1"
|
||||
|
||||
@javascript
|
||||
Scenario: A random question with an invalid category should still be editable
|
||||
Given I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher1"
|
||||
When I open the "last" add to quiz menu
|
||||
And I follow "a random question"
|
||||
And I apply question bank filter "Category" with value "Used category"
|
||||
And I press "Add random question"
|
||||
And I should see "Random (Used category) based on filter condition" on quiz page "1"
|
||||
And I am on the "Course 1" "core_question > course question categories" page
|
||||
And I click on "Delete" "link" in the "Used category" "list_item"
|
||||
And I press "Save in category"
|
||||
And I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher1"
|
||||
Then I should not see "Random (Used category) based on filter condition" on quiz page "1"
|
||||
And I should see "Missing question category" on quiz page "1"
|
||||
|
||||
@@ -31,7 +31,7 @@ require_once($CFG->dirroot . '/question/editlib.php');
|
||||
* @category test
|
||||
* @copyright 2018 the Open University
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
* @covers \core_question\local\bank\view
|
||||
* @covers \mod_quiz\question\bank\custom_view
|
||||
*/
|
||||
final class quiz_question_bank_view_test extends \advanced_testcase {
|
||||
|
||||
@@ -82,6 +82,59 @@ final class quiz_question_bank_view_test extends \advanced_testcase {
|
||||
$this->assertFalse($cache->has($questiondata->id));
|
||||
}
|
||||
|
||||
public function test_viewing_question_bank_should_not_load_hidden_question(): void {
|
||||
$this->resetAfterTest();
|
||||
$this->setAdminUser();
|
||||
$generator = $this->getDataGenerator();
|
||||
/** @var core_question_generator $questiongenerator */
|
||||
$questiongenerator = $generator->get_plugin_generator('core_question');
|
||||
|
||||
// Create a course and a quiz.
|
||||
$course = $generator->create_course();
|
||||
$quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]);
|
||||
$context = \context_module::instance($quiz->cmid);
|
||||
$cm = get_coursemodule_from_instance('quiz', $quiz->id);
|
||||
|
||||
// Create a question in the default category.
|
||||
$contexts = new question_edit_contexts($context);
|
||||
question_make_default_categories($contexts->all());
|
||||
$cat = question_get_default_category($context->id);
|
||||
$question = $questiongenerator->create_question('numerical', null,
|
||||
['name' => 'Example question', 'category' => $cat->id]);
|
||||
|
||||
// Create another version.
|
||||
$newversion = $questiongenerator->update_question($question, null, ['name' => 'This is the latest version']);
|
||||
|
||||
// Add them to the quiz.
|
||||
quiz_add_quiz_question($newversion->id, $quiz);
|
||||
// Generate the view.
|
||||
$params = [
|
||||
'qpage' => 0,
|
||||
'qperpage' => 20,
|
||||
'cat' => $cat->id . ',' . $context->id,
|
||||
'recurse' => false,
|
||||
'showhidden' => false,
|
||||
'qbshowtext' => false,
|
||||
'tabname' => 'editq',
|
||||
];
|
||||
$extraparams = ['cmid' => $cm->id];
|
||||
$view = new custom_view($contexts, new \moodle_url('/'), $course, $cm, $params, $extraparams);
|
||||
ob_start();
|
||||
$view->display();
|
||||
$html = ob_get_clean();
|
||||
// Verify the output should included the latest version.
|
||||
$this->assertStringContainsString('This is the latest version', $html);
|
||||
$this->assertStringNotContainsString('Example question', $html);
|
||||
// Delete the latest version.
|
||||
question_delete_question($newversion->id);
|
||||
// Verify the output should display the old version with status ready.
|
||||
ob_start();
|
||||
$view->display();
|
||||
$html = ob_get_clean();
|
||||
$this->assertStringContainsString('Example question', $html);
|
||||
$this->assertStringNotContainsString('This is the latest version', $html);
|
||||
}
|
||||
|
||||
public function test_viewing_question_bank_when_paging_out_of_limit(): void {
|
||||
$this->resetAfterTest();
|
||||
$this->setAdminUser();
|
||||
|
||||
@@ -473,7 +473,7 @@ function scorm_insert_track($userid, $scormid, $scoid, $attemptornumber, $elemen
|
||||
if (!empty($tracktest)) {
|
||||
if ($tracktest->value == "incomplete") {
|
||||
$v = new stdClass();
|
||||
$v->id = $track->valueid;
|
||||
$v->id = $tracktest->valueid;
|
||||
$v->value = "completed";
|
||||
$DB->update_record('scorm_scoes_value', $v);
|
||||
}
|
||||
|
||||
@@ -41,4 +41,10 @@ $preview = optional_param('preview', null, PARAM_ALPHANUM);
|
||||
// The repository may have to export the file to an offline format.
|
||||
$offline = optional_param('offline', 0, PARAM_BOOL);
|
||||
$embed = optional_param('embed', 0, PARAM_BOOL);
|
||||
|
||||
// We need to set the page URL here because there are cases where file_pluginfile may display an
|
||||
// error directly on the page instead of throwing an exception, and this will generate a server
|
||||
// error if $PAGE->url is not defined.
|
||||
$PAGE->set_url(new \moodle_url($FULLME));
|
||||
|
||||
file_pluginfile($relativepath, $forcedownload, $preview, $offline, $embed);
|
||||
|
||||
Vendored
+2
-2
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -26,6 +26,9 @@ import Notification from 'core/notification';
|
||||
import Selectors from 'core/datafilter/selectors';
|
||||
import Templates from 'core/templates';
|
||||
import Fragment from 'core/fragment';
|
||||
import {get_strings as getStrings} from 'core/str';
|
||||
import {getString} from 'core/str';
|
||||
import {addIconToContainerRemoveOnCompletion} from 'core/loadingicon';
|
||||
|
||||
/**
|
||||
* Initialise the question bank filter on the element with the given id.
|
||||
@@ -42,7 +45,7 @@ import Fragment from 'core/fragment';
|
||||
* @param {Object} pagevars JSON-encoded parameters from passed from the view, including filters and jointype.
|
||||
* @param {Object} extraparams JSON-encoded additional parameters specific to this view class, used for re-rendering the view.
|
||||
*/
|
||||
export const init = (
|
||||
export const init = async(
|
||||
filterRegionId,
|
||||
defaultcourseid,
|
||||
defaultcategoryid,
|
||||
@@ -66,10 +69,21 @@ export const init = (
|
||||
MENU_ACTIONS: '.menu-action',
|
||||
EDIT_SWITCH: '.editmode-switch-form input[name=setmode]',
|
||||
EDIT_SWITCH_URL: '.editmode-switch-form input[name=pageurl]',
|
||||
CATEGORY_VALIDATION_INPUT: 'div[data-filter-type="category"] div.form-autocomplete-input input',
|
||||
QUESTION_BANK_WINDOW: '.questionbankwindow',
|
||||
SHOW_ALL_LINK: '[data-filteraction="showall"]',
|
||||
};
|
||||
|
||||
const filterSet = document.querySelector(`#${filterRegionId}`);
|
||||
|
||||
const [
|
||||
showAllText,
|
||||
showPerPageText,
|
||||
] = await Promise.all([
|
||||
getString('showall', 'core', ''),
|
||||
getString('showperpage', 'core', extraparams.defaultqperpage),
|
||||
]);
|
||||
|
||||
const viewData = {
|
||||
extraparams: JSON.stringify(extraparams),
|
||||
cmid,
|
||||
@@ -97,6 +111,27 @@ export const init = (
|
||||
* @param {Promise} pendingPromise pending promise
|
||||
*/
|
||||
const applyFilter = (filterdata, pendingPromise) => {
|
||||
|
||||
// MDL-84578 - This is a simple fix for older stable branches, which does not require
|
||||
// backporting loads of functionality to validate filters properly.
|
||||
let categoryid = parseInt(filterdata.category.values[0]);
|
||||
let categorynode = document.querySelector(SELECTORS.CATEGORY_VALIDATION_INPUT);
|
||||
categorynode.setCustomValidity('');
|
||||
if (isNaN(categoryid) || categoryid <= 0) {
|
||||
getStrings([
|
||||
{
|
||||
key: 'error:category',
|
||||
component: 'qbank_managecategories',
|
||||
},
|
||||
]).then((strings) => {
|
||||
categorynode.setCustomValidity(strings[0]);
|
||||
categorynode.reportValidity();
|
||||
return strings;
|
||||
}).catch(Notification.exception);
|
||||
pendingPromise.resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// Reload the questions based on the specified filters. If no filters are provided,
|
||||
// use the default category filter condition.
|
||||
if (filterdata) {
|
||||
@@ -115,10 +150,15 @@ export const init = (
|
||||
// Load questions for first page.
|
||||
viewData.filter = JSON.stringify(filterdata);
|
||||
viewData.sortdata = JSON.stringify(sortData);
|
||||
|
||||
const questionscontainer = document.querySelector(SELECTORS.QUESTION_CONTAINER_ID);
|
||||
// Clear the contents of the element, then append the loading icon.
|
||||
questionscontainer.innerHTML = '';
|
||||
addIconToContainerRemoveOnCompletion(questionscontainer, pendingPromise);
|
||||
|
||||
Fragment.loadFragment(component, callback, contextId, viewData)
|
||||
// Render questions for first page and pagination.
|
||||
.then((questionhtml, jsfooter) => {
|
||||
const questionscontainer = document.querySelector(SELECTORS.QUESTION_CONTAINER_ID);
|
||||
if (questionhtml === undefined) {
|
||||
questionhtml = '';
|
||||
}
|
||||
@@ -180,10 +220,11 @@ export const init = (
|
||||
};
|
||||
|
||||
// Add listeners for the sorting, paging and clear actions.
|
||||
document.addEventListener('click', e => {
|
||||
document.querySelector(SELECTORS.QUESTION_BANK_WINDOW).addEventListener('click', e => {
|
||||
const sortableLink = e.target.closest(SELECTORS.SORT_LINK);
|
||||
const paginationLink = e.target.closest(SELECTORS.PAGINATION_LINK);
|
||||
const clearLink = e.target.closest(Selectors.filterset.actions.resetFilters);
|
||||
const showallLink = e.target.closest(SELECTORS.SHOW_ALL_LINK);
|
||||
if (sortableLink) {
|
||||
e.preventDefault();
|
||||
const oldSort = sortData;
|
||||
@@ -209,6 +250,23 @@ export const init = (
|
||||
if (clearLink) {
|
||||
cleanUrlParams();
|
||||
}
|
||||
if (showallLink) {
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
// Toggle between showing all and going back to the original qperpage.
|
||||
if (Number(showallLink.dataset.status) === 0) {
|
||||
viewData.qperpage = extraparams.maxqperpage;
|
||||
showallLink.dataset.status = 1;
|
||||
showallLink.innerText = showPerPageText;
|
||||
} else {
|
||||
viewData.qperpage = extraparams.defaultqperpage;
|
||||
showallLink.dataset.status = 0;
|
||||
showallLink.innerText = showAllText;
|
||||
}
|
||||
viewData.qpage = 0;
|
||||
coreFilter.updateTableFromFilter();
|
||||
}
|
||||
});
|
||||
|
||||
// Run apply filter at page load.
|
||||
|
||||
@@ -97,18 +97,9 @@ class question_history_view extends view {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default sort for question data.
|
||||
* @return array
|
||||
*/
|
||||
#[\Override]
|
||||
protected function default_sort(): array {
|
||||
$defaultsort = [];
|
||||
if (class_exists('\\qbank_viewcreator\\creator_name_column')) {
|
||||
$sort = 'qbank_viewcreator\creator_name_column-timecreated';
|
||||
}
|
||||
$defaultsort[$sort] = 1;
|
||||
|
||||
return $defaultsort;
|
||||
return ['qbank_history__version_number_column' => 1];
|
||||
}
|
||||
|
||||
protected function build_query(): void {
|
||||
|
||||
@@ -44,4 +44,9 @@ class version_number_column extends column_base {
|
||||
return ['pr-3'];
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function is_sortable() {
|
||||
return 'qv.version';
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@qbank @qbank_history
|
||||
@qbank @qbank_history @javascript
|
||||
Feature: Use the qbank plugin manager page for version column
|
||||
In order to check the plugin behaviour with enable and disable
|
||||
|
||||
@@ -10,11 +10,16 @@ Feature: Use the qbank plugin manager page for version column
|
||||
| activity | name | course | idnumber |
|
||||
| quiz | Test quiz | C1 | quiz1 |
|
||||
And the following "question categories" exist:
|
||||
| contextlevel | reference | name |
|
||||
| Course | C1 | Test questions |
|
||||
| contextlevel | reference | name |
|
||||
| Activity module | quiz1 | Test questions |
|
||||
And the following "questions" exist:
|
||||
| questioncategory | qtype | name | questiontext |
|
||||
| Test questions | truefalse | First question | Answer the first question |
|
||||
| questioncategory | qtype | name |
|
||||
| Test questions | truefalse | First Question |
|
||||
And the following "core_question > updated questions" exist:
|
||||
| questioncategory | question | name |
|
||||
| Test questions | First Question | First Edit |
|
||||
| Test questions | First Question | Edit the Second |
|
||||
| Test questions | First Question | The Final Edit |
|
||||
|
||||
Scenario: Enable/disable version column from the base view
|
||||
Given I log in as "admin"
|
||||
@@ -22,8 +27,26 @@ Feature: Use the qbank plugin manager page for version column
|
||||
And I should see "Question history"
|
||||
When I click on "Disable" "link" in the "Question history" "table_row"
|
||||
And I am on the "Test quiz" "mod_quiz > question bank" page
|
||||
And I apply question bank filter "Category" with value "Test questions"
|
||||
Then I should not see "Version" in the "region-main" "region"
|
||||
And I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration
|
||||
And I click on "Enable" "link" in the "Question history" "table_row"
|
||||
And I am on the "Test quiz" "mod_quiz > question bank" page
|
||||
And I apply question bank filter "Category" with value "Test questions"
|
||||
And I should see "Version" in the "region-main" "region"
|
||||
|
||||
Scenario: Sort the question versions
|
||||
Given I am on the "Test quiz" "mod_quiz > question bank" page logged in as "admin"
|
||||
And I apply question bank filter "Category" with value "Test questions"
|
||||
When I choose "History" action for "The Final Edit" in the question bank
|
||||
Then "Edit the Second" "checkbox" should appear before "The Final Edit" "checkbox"
|
||||
And "First Edit" "checkbox" should appear before "Edit the Second" "checkbox"
|
||||
And "First Question" "checkbox" should appear before "First Edit" "checkbox"
|
||||
And I follow "Sort by Version ascending"
|
||||
And "Edit the Second" "checkbox" should appear before "The Final Edit" "checkbox"
|
||||
And "First Edit" "checkbox" should appear before "Edit the Second" "checkbox"
|
||||
And "First Question" "checkbox" should appear before "First Edit" "checkbox"
|
||||
And I follow "Sort by Version descending"
|
||||
And "The Final Edit" "checkbox" should appear before "Edit the Second" "checkbox"
|
||||
And "Edit the Second" "checkbox" should appear before "First Edit" "checkbox"
|
||||
And "First Edit" "checkbox" should appear before "First Question" "checkbox"
|
||||
|
||||
@@ -336,4 +336,35 @@ class category_condition extends condition {
|
||||
public function is_required(): bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function filter_invalid_values(array $filterconditions): array {
|
||||
|
||||
global $DB;
|
||||
|
||||
$defaultcatid = explode(',', $filterconditions['cat'])[0];
|
||||
|
||||
[$insql, $inparams] = $DB->get_in_or_equal($filterconditions['filter']['category']['values']);
|
||||
$categories = $DB->get_records_select('question_categories', "id {$insql}",
|
||||
$inparams, null, 'id');
|
||||
$categoryids = array_keys($categories);
|
||||
|
||||
foreach ($filterconditions['filter']['category']['values'] as $key => $catid) {
|
||||
|
||||
// Check that the category still exists, and if not, remove it from the conditions.
|
||||
if (!in_array($catid, $categoryids)) {
|
||||
unset($filterconditions['filter']['category']['values'][$key]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// If we now don't have any valid categories, use the default loaded from the page.
|
||||
if (count($filterconditions['filter']['category']['values']) === 0) {
|
||||
$filterconditions['filter']['category']['values'] = [$defaultcatid];
|
||||
}
|
||||
|
||||
return $filterconditions;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -257,9 +257,11 @@ class helper {
|
||||
bool $top = false, int $showallversions = 0): array {
|
||||
global $DB;
|
||||
$topwhere = $top ? '' : 'AND c.parent <> 0';
|
||||
$statuscondition = "AND (qv.status = '". question_version_status::QUESTION_STATUS_READY . "' " .
|
||||
" OR qv.status = '" . question_version_status::QUESTION_STATUS_DRAFT . "' )";
|
||||
|
||||
$statuscondition = "AND qv.status <> :status";
|
||||
$params = [
|
||||
'status' => question_version_status::QUESTION_STATUS_HIDDEN,
|
||||
'substatus' => question_version_status::QUESTION_STATUS_HIDDEN,
|
||||
];
|
||||
$sql = "SELECT c.*,
|
||||
(SELECT COUNT(1)
|
||||
FROM {question} q
|
||||
@@ -272,7 +274,7 @@ class helper {
|
||||
OR (qv.version = (SELECT MAX(v.version)
|
||||
FROM {question_versions} v
|
||||
JOIN {question_bank_entries} be ON be.id = v.questionbankentryid
|
||||
WHERE be.id = qbe.id)
|
||||
WHERE be.id = qbe.id AND v.status <> :substatus)
|
||||
)
|
||||
)
|
||||
) AS questioncount
|
||||
@@ -280,7 +282,7 @@ class helper {
|
||||
WHERE c.contextid IN ($contexts) $topwhere
|
||||
ORDER BY $sortorder";
|
||||
|
||||
return $DB->get_records_sql($sql);
|
||||
return $DB->get_records_sql($sql, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
$string['error:category'] = 'You must select a valid category';
|
||||
$string['pluginname'] = 'Manage categories';
|
||||
$string['privacy:preference:includesubcategories_filter_default'] = 'Indicates whether or not the "Also show questions from subcategories" filter option is enabled by default when the user views the question bank.';
|
||||
$string['questionsubcategoriesdisplayed'] = 'When selecting categories in the question bank, the "Also show questions from subcategories" option is used and are automatically included in the filter results.';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user