Compare commits

..

130 Commits

Author SHA1 Message Date
Jun Pataleta cbd60e5a9b Moodle release 4.4.7 2025-03-15 20:19:52 +08:00
Ilya Tregubov 6f24f29cde MDL-81945 gradereport_singleview: Fix hidden grade handling.
Seems to become broken after IR refactoring.
2025-03-14 15:21:15 +00:00
Ilya Tregubov 3ba2ebb887 MDL-81945 gradereport_user: Don't reveal visibility by color.
Seems like dimmed_text class was added without proper check so
user can guess if item is hidden.
2025-03-14 15:21:15 +00:00
Ilya Tregubov 501fc68186 MDL-81945 gradereport_user: Behat tests for user report. 2025-03-14 15:21:15 +00:00
Ilya Tregubov 42eab03367 MDL-81945 gradereport_singleview: Behat tests for singleview report. 2025-03-14 15:21:15 +00:00
Ilya Tregubov 9bbcab02b8 MDL-81945 gradereport_grader: Behat tests for grader report 2025-03-14 15:21:15 +00:00
Ilya Tregubov 0dad8a8369 MDL-81945 core_grades: create Behat step for grades action menu. 2025-03-14 15:21:15 +00:00
Ilya Tregubov 1387393a3b MDL-81945 gradereport_singleview: Fix hidden grades handling.
If a user is lacking moodle/grade:viewhidden he should not be able
to view or modify grades.
2025-03-14 15:21:15 +00:00
Sara Arjona 5919d99222 weekly release 4.4.6+ 2025-03-14 16:14:06 +01:00
Sara Arjona a86c05ee7c Merge branch 'MDL-83541-404-behat' of https://github.com/HuongNV13/moodle into MOODLE_404_STABLE 2025-03-14 11:14:44 +01:00
Huong Nguyen d58329f1e1 MDL-83541 qtype_ddimageortext: Fix Behat failure 2025-03-14 16:59:04 +07:00
Huong Nguyen 1fcd367669 MDL-83541 qtype_numerical: Fix incorrect test hints 2025-03-14 09:21:33 +01:00
Huong Nguyen d4f1cb1078 Merge branch 'MDL-83541-404' of https://github.com/HuongNV13/moodle into MOODLE_404_STABLE 2025-03-14 11:59:45 +07:00
Huong Nguyen ba75747f2e MDL-83541 mod_quiz: Fix PHPUnit failure 2025-03-14 10:31:33 +07:00
Mark Johnson 2de34d5c0e MDL-83541 qtypes: Define excluded hash fields and test hints 2025-03-14 10:31:26 +07:00
Mark Johnson 85d85a6a25 MDL-83541 question/quiz: restoring questions with identical stamps
Historically it was possible, through a series of question restores,
moves and edits, to end up with multiple questions in the same
category with the same stamp, but differences in other question
or answer fields. This, combined with changes in versions, led
to errors when restoring or duplicating quizzes using these questions.

While recent changes have made it impossible to create this situation
in current Moodle versions, as any edits will create a new question
version with a new stamp, this situation may exist on long-standing
Moodle sites which have been upgraded since pre-4.0.

This change performs a much wider-ranging comparison of restored
existing questions, generating a hash of all the data for a question in
a backup file, and a corresponding hash for each question in the target
category, to decide if a restored question matches a question already in
the database.
2025-03-14 10:31:22 +07:00
Mark Johnson eda944f427 MDL-83541 qtype_calculated: Add missing field to backups 2025-03-14 10:31:17 +07:00
Mark Johnson 5df88228a5 MDL-83541 qtype_ddimageortext: Fix test hint text
Ensure that hint text is unique to workaround MDL-84261 for
backup/restore testing.
2025-03-14 10:31:10 +07:00
Sara Arjona f35f246d19 Merge branch 'MDL-83634-404' of https://github.com/junpataleta/moodle into MOODLE_404_STABLE 2025-03-13 16:28:23 +01:00
Sara Arjona d00f94ff44 Merge branch 'MDL-84624-M404_close-recordset-in-core-task-h5p_clean_orphaned_records_task-execute' of https://github.com/ziegenberg/moodle into MOODLE_404_STABLE 2025-03-13 13:20:35 +01:00
Sara Arjona 25a13c0394 Merge branch '83136-fix-unknowncategory-message-404' of https://github.com/DSI-Universite-Rennes2/moodle into MOODLE_404_STABLE 2025-03-13 12:38:52 +01:00
Sara Arjona 159976d1ef Merge branch 'MDL-77646-404' of https://github.com/lucaboesch/moodle into MOODLE_404_STABLE 2025-03-13 12:33:52 +01:00
Jun Pataleta fa36c75597 Merge branch 'MDL-80386_404' of https://github.com/timhunt/moodle into MOODLE_404_STABLE 2025-03-13 12:32:38 +08:00
Jun Pataleta cf1e71be2b Merge branch 'MDL-65233-404-no-midnight' of https://github.com/sim642/moodle into MOODLE_404_STABLE 2025-03-13 12:21:39 +08:00
Huong Nguyen 0a50f4c904 Merge branch 'MDL-84640-404' of https://github.com/andelacruz/moodle into MOODLE_404_STABLE 2025-03-12 16:32:28 +07:00
Angelia Dela Cruz 683c62f3c6 MDL-84640 analytics: Behat for obtaining prediction models insights 2025-03-12 16:48:48 +08:00
Jun Pataleta 633ddeb157 MDL-83634 tool_brickfield: Test the image alt text check's severity 2025-03-12 13:22:12 +08:00
Jun Pataleta 3c5accff73 MDL-83634 tool_brickfield: Increase the alt text limit to 750 characters
This is consistent with the alt-text length limit of 750 characters in
Moodle's editor.
2025-03-12 13:22:12 +08:00
Jun Pataleta d0afa76de6 MDL-83634 tool_brickfield: Downgrade image alt text check's severity
Given that
* There is no formally defined limit for alt text length,
* Current versions of screen readers can read alt texts longer than
125 characters,
* Accessibility checkers like axe DevTools, WAVE, etc., do not raise
errors or warnings about long image alt texts.

This patch:
- Downgrades the default severity for the `img_alt_is_too_long`
check from `BA_TEST_SEVERE` to `BA_TEST_SUGGESTION`.
- Updates the `checkdesc:imgaltistoolong` lang string to be more of a
reminder/suggestion rather than an error.
2025-03-12 13:22:11 +08:00
Huong Nguyen bc2f8102ce Merge branch 'MDL-84578_404' of https://github.com/cwarwicker/moodle into MOODLE_404_STABLE 2025-03-12 10:39:47 +07:00
Conn Warwicker 532ef50ed6 MDL-80386 core_question: Add "show all" button to filters.
This adds the "Show All" button to the question bank filters
to allow users to select all questions easily.

It also adds new behat tests to test the functionality.
2025-03-11 18:13:23 +00:00
Sara Arjona 8f4cb8c4d5 Merge branch 'MDL-84801-404' of https://github.com/HuongNV13/moodle into MOODLE_404_STABLE 2025-03-11 17:26:29 +01:00
Derek Henderson 1fc38904e1 MDL-77646 lib: Use iframe instead of object to display pdf. 2025-03-11 13:52:51 +01:00
Sara Arjona 4df87b3130 weekly release 4.4.6+ 2025-03-11 13:02:30 +01:00
Sara Arjona 551568aca2 Merge branch 'install_404_STABLE' of https://git.in.moodle.com/amosbot/moodle-install into MOODLE_404_STABLE 2025-03-11 13:02:29 +01:00
Huong Nguyen 3576ec553c MDL-84801 Behat: Fix Expand all fieldsets and advanced elements failure 2025-03-11 09:22:00 +07:00
AMOS bot 7b8510441f Automatically generated installer lang files 2025-03-11 00:07:52 +00:00
Conn Warwicker e7d7d84aa3 MDL-84578 qbank_managecategories: Add category validation.
- This adds simple logic check to filter.js to stop user
submitting an invalid category.
- This adds new behat steps to check this validation.
- This adds a new behat test to test this in managecategories.
2025-03-10 14:05:38 +00:00
Sara Arjona ec9b50b135 Merge branch 'MDL-84313-404' of https://github.com/dpalou/moodle into MOODLE_404_STABLE 2025-03-10 11:35:53 +01:00
Paul Holden aa4fce5f6a Merge branch 'MDL-84620-M404_close-recordset-in-tool_brickfield-manager-check_scheduled_areas' of https://github.com/ziegenberg/moodle into MOODLE_404_STABLE 2025-03-10 10:29:40 +00:00
Daniel Ziegenberg 6bf326fb9f MDL-84620 accessibility: close recordset when it's not needed anymore
Signed-off-by: Daniel Ziegenberg <daniel@ziegenberg.at>
2025-05-06 06:41:35 +02:00
Sara Arjona 482834493e Merge branch 'MDL-84256-404' of https://github.com/laurentdavid/moodle into MOODLE_404_STABLE 2025-03-10 11:16:50 +01:00
Sara Arjona a4bd8cec97 Merge branch 'MDL-84398-404' of https://github.com/pedrojordao/moodle into MOODLE_404_STABLE 2025-03-10 11:12:16 +01:00
AMOS bot fa63ee1a8a Automatically generated installer lang files 2025-03-10 00:07:40 +00:00
Jun Pataleta f252b16672 weekly release 4.4.6+ 2025-03-08 11:44:32 +08:00
Laurent David 405a99a70b MDL-84256 core_course: Fix course icon in course index page
* In RTL language the icon should follow the course name intead
of being upfront
2025-03-07 11:16:43 +01:00
Andrew Nicols 1b50220d96 Merge branch 'MDL-81623-404' of https://github.com/paulholden/moodle into MOODLE_404_STABLE 2025-03-07 09:25:43 +08:00
Andrew Nicols ce9d97bd40 Merge branch 'MDL-84729-404' of https://github.com/HuongNV13/moodle into MOODLE_404_STABLE 2025-03-07 09:09:33 +08:00
Andrew Nicols 2c2aa043c9 Merge branch 'MDL-84532-404' of https://github.com/mihailges/moodle into MOODLE_404_STABLE 2025-03-07 09:08:32 +08:00
Sara Arjona 5556f401e0 Merge branch 'MDL-83720-404' of https://github.com/paulholden/moodle into MOODLE_404_STABLE 2025-03-06 14:56:02 +01:00
Andrew Nicols 5e2a70cb74 Merge branch 'MDL-84723-404' of https://github.com/lameze/moodle into MOODLE_404_STABLE 2025-03-06 11:50:55 +08:00
Simey Lameze 6a2092db12 MDL-84723 behat: final improvements and fixes to the test
This commit does few things:
- Remove unnecessary grade field to assign generator.
- Remove unnecessary comment lines.
- Update the warning message assertion to use the no-break space unicode character.
2025-03-06 11:24:05 +08:00
Angelia Dela Cruz 31f09682df MDL-84723 gradingform: Behat for maximum grade mismatch in marking guide 2025-03-06 11:24:05 +08:00
Andrew Nicols 02e0fdb3fe Merge branch 'MDL-84295-404' of https://github.com/laurentdavid/moodle into MOODLE_404_STABLE 2025-03-06 09:20:09 +08:00
Andrew Nicols 244166e01d Merge branch 'MDL-75442-404' of https://github.com/StudiUM/moodle into MOODLE_404_STABLE 2025-03-06 09:12:08 +08:00
Laurent David 5ad933815c MDL-84295 core_theme: Fix margin for activity description list
* Make sure lists in activity descriptions are displayed
identically on course page and activity pages
2025-03-05 10:13:10 +01:00
Andrew Nicols c111e4fe87 Merge branch 'MDL-82170-404' of https://github.com/paulholden/moodle into MOODLE_404_STABLE 2025-03-05 14:40:33 +08:00
Huong Nguyen 847068d11d Merge branch 'MDL-84425-404' of https://github.com/andelacruz/moodle into MOODLE_404_STABLE 2025-03-05 10:51:26 +07:00
Angelia Dela Cruz b577e8d621 MDL-84425 theme_boost: Behat for course section selector unavailable 2025-03-05 11:18:09 +08:00
Daniel Ziegenberg 98ad4352a9 MDL-84624 h5p: close recordset when it's not needed anymore
Signed-off-by: Daniel Ziegenberg <daniel@ziegenberg.at>
2025-03-04 19:35:48 +01:00
Sara Arjona df2723c6a8 weekly release 4.4.6+ 2025-03-04 16:13:47 +01:00
Sara Arjona 201f222789 Merge branch 'install_404_STABLE' of https://git.in.moodle.com/amosbot/moodle-install into MOODLE_404_STABLE 2025-03-04 16:13:45 +01:00
Huong Nguyen 6c4bf9098e MDL-84729 core: Use core\clock for Tasks
This will help to solve the random failure on "test_adhoc_task_get_first_starting_time"
2025-03-04 16:37:23 +07:00
Huong Nguyen 05682343be Merge branch 'MDL-84595-404' of https://github.com/andrewnicols/moodle into MOODLE_404_STABLE 2025-03-04 10:40:24 +07:00
AMOS bot afdc24fc07 Automatically generated installer lang files 2025-03-02 00:08:12 +00:00
Pedro Jordao 4f1662e90a MDL-84398 libraries: Configure locale property for ChartJs
ChartJs has a property called locale that makes possible to format scale
numbers according to a language. This way we ensure that decimal places
will be displayed correctly in the chart labels and tooltips.
2025-02-27 21:43:31 -03:00
Paul Holden 9e41cd6609 MDL-81623 admin: sensitive toggle when prompting for upgrade key. 2025-02-27 21:14:07 +00:00
Sara Arjona b132ff1ed8 weekly release 4.4.6+ 2025-02-27 16:54:53 +01:00
Huong Nguyen 377d56c835 Merge branch 'MDL-80351-404' of https://github.com/pedrojordao/moodle into MOODLE_404_STABLE 2025-02-27 10:13:37 +07:00
Simey Lameze 12bf1da9e5 MDL-84424 behat: add template_competencies generator and improve test 2025-02-27 07:50:03 +08:00
Angelia Dela Cruz 52fbae2907 MDL-84424 tool_lp: admin can create learning plans from template 2025-02-27 07:48:19 +08:00
Sara Arjona 5eaaf5e016 Merge branch 'MDL-84609-404' of https://github.com/lameze/moodle into MOODLE_404_STABLE 2025-02-26 16:19:53 +01:00
Sara Arjona 045db899be Merge branch 'MDL-84584-404' of https://github.com/sh-csg/moodle into MOODLE_404_STABLE 2025-02-26 08:04:15 +01:00
Simey Lameze 16c9172c63 MDL-84609 behat: add required entities and fix failures
This commit also fix some typos and misaligned pipes around the
changed steps.

Signed-off-by: Simey Lameze <simey@moodle.com>
2025-02-25 11:51:14 +08:00
Angelia Dela Cruz 6b12fa32e1 MDL-84609 tool: Replaced tool_lp generators with standard generators 2025-02-25 11:51:14 +08:00
Van Binh Dang b9ce55d66d MDL-75442 quiz: Highlight error message when manually grading 2025-02-24 14:29:44 -05:00
Mihail Geshoski cdce19d0d0 MDL-84532 qtype_ordering: Fix failing query in mssql on upgrade
The SELECT statement can only include columns that are part of the
GROUP BY clause. To resolve this issue, instead of including all columns
('*'), only the 'id' column is now included in the SELECT statement,
while still preserving the existing GROUP BY statement. This column is
the only one needed to identify the relevant qtype_ordering_options
record that requires updating in the next step.
2025-02-24 18:02:06 +08:00
sam marshall 168ca6fdef MDL-83674 Files: pluginfile.php debugging if user not allowed course 2025-02-21 14:12:29 +00:00
Julien Boulen f063ef682f MDL-83136 course: Add missing value on unknowncategory exception message 2025-02-21 09:25:18 +01:00
Huong Nguyen e06475d8ae weekly release 4.4.6+ 2025-02-21 10:15:12 +07:00
Andrew Nicols f0199e8038 MDL-84595 tool_behat: Allow accessibility tests to run for a single Node 2025-02-20 22:28:03 +08:00
Jun Pataleta cca044ce15 Merge branch 'MDL-84448-404' of https://github.com/lameze/moodle into MOODLE_404_STABLE 2025-02-20 12:10:04 +08:00
Simey Lameze a780d14710 MDL-84448 behat: return null if theme has no config 2025-02-20 12:00:42 +08:00
Amanda Doughty 3af76654fd MDL-84448 lib: Fixed behat context check for grandchild themes 2025-02-20 12:00:42 +08:00
Jun Pataleta de8176c553 Merge branch 'MDL-84592-404' of https://github.com/andrewnicols/moodle into MOODLE_404_STABLE 2025-02-20 11:45:39 +08:00
Huong Nguyen c6e0071808 Merge branch 'MDL-84528-404' of https://github.com/paulholden/moodle into MOODLE_404_STABLE 2025-02-20 10:16:54 +07:00
Jun Pataleta 04b0132b3a Merge branch 'MDL-84590-404' of https://github.com/andrewnicols/moodle into MOODLE_404_STABLE 2025-02-20 10:49:58 +08:00
Andrew Nicols 6e3cb19969 MDL-84592 tool_behat: Improve accessibility failure information 2025-02-20 10:26:30 +08:00
Andrew Nicols 8ca7f282bc MDL-84590 tool_behat: Axe tests must use all wcag rules
Rules are not additive. That is to say that the wcag22a tag does not
include wcag21a or wcag2a. They all need to be specified.
2025-02-20 09:59:48 +08:00
Stefan Hanauska 33e58ab61a MDL-84584 badges: Don't delete all draft files 2025-02-19 12:16:26 +01:00
Sara Arjona 1d40ca9de8 Merge branch 'MDL-83784_404' of https://github.com/cwarwicker/moodle into MOODLE_404_STABLE 2025-02-19 12:00:29 +01:00
Huong Nguyen c0f24543fc MDL-84341 qbank_history: Behat cleanup 2025-02-18 20:41:00 +07:00
Huong Nguyen fe0c832dcd Merge branch 'MDL-84341_404' of https://github.com/cwarwicker/moodle into MOODLE_404_STABLE 2025-02-18 20:40:42 +07:00
Sara Arjona b19296b629 Merge branch '84348-hide-h5p-preview-message-404' of https://github.com/DSI-Universite-Rennes2/moodle into MOODLE_404_STABLE 2025-02-18 13:12:19 +01:00
Conn Warwicker 6a34598550 MDL-84341 qbank_history: Fix version ordering
Previously the version ordering was done on timecreated which
wasn't always correct. This now uses the version number instead.

Also set the default sorting on the history page to be version.

Also backports a change to the behat question generator to let
us set the value of fields on updated questions in the step.
2025-02-17 11:46:48 +00:00
Jun Pataleta 9508120293 weekly release 4.4.6+ 2025-02-14 09:53:52 +08:00
Jun Pataleta 0891666ec7 Merge branch 'install_404_STABLE' of https://git.in.moodle.com/amosbot/moodle-install into MOODLE_404_STABLE 2025-02-14 09:53:49 +08:00
Conn Warwicker 17c3e8ecc8 MDL-83784 mod_quiz: Fix quiz unusable if question category missing.
Random questions set to question categories which no longer exist
was throwing an error making it impossible to use or edit the quiz
to fix it. This will now allow the user to view the questions and
edit the quiz in order to fix the problem of the missing category.
2025-02-13 15:48:22 +00:00
Paul Holden c6a31786f7 MDL-84528 tag: define missing language string for related tags area. 2025-02-13 15:04:01 +00:00
Pedro Jordao eb0342fa4f MDL-80351 libraries: ChartJS axis options being ignored
When chartJs was updated to version 3.8.0, chart axis were now accessed
as a object property instead of an array element. This solution aims to
update the way we set the axis properties, aiming to display the labels
and other properties.
2025-02-13 02:25:49 -03:00
Huong Nguyen da47f4dafb Merge branch 'MDL-83579-404' of https://github.com/paulholden/moodle into MOODLE_404_STABLE 2025-02-13 11:11:47 +07:00
Jun Pataleta 8cc95faa8a Merge branch 'mdl-83306_404' of https://github.com/james-cnz/moodle into MOODLE_404_STABLE 2025-02-13 11:54:59 +08:00
Jun Pataleta d5ba48ab32 Merge branch 'MDL-84102-404' of https://github.com/andelacruz/moodle into MOODLE_404_STABLE 2025-02-13 11:48:59 +08:00
Huong Nguyen d7c719f597 Merge branch 'MDL-83645-404' of https://github.com/paulholden/moodle into MOODLE_404_STABLE 2025-02-13 10:43:09 +07:00
Huong Nguyen 1145b31059 Merge branch 'MDL-69142-404' of https://github.com/paulholden/moodle into MOODLE_404_STABLE 2025-02-13 10:33:46 +07:00
Huong Nguyen 90ebc61746 Merge branch 'MDL-79265-404' of https://github.com/NashTechOpenUniversity/moodle into MOODLE_404_STABLE 2025-02-13 09:15:54 +07:00
Simmo Saan 650efc4ff7 MDL-65233 course: remove usergetmidnight from reset time shift 2025-02-12 21:56:35 +02:00
Simmo Saan 51f425ec29 MDL-65233 course: add reset time shift test with non-midnight hour 2025-02-12 21:56:35 +02:00
Angelia Dela Cruz bec6456309 MDL-84102 message: Behat for setting default notification preferences 2025-02-12 15:35:21 +08:00
Huong Nguyen 3a9fd31020 Merge branch 'MDL-83452-addbodyclass-404' of https://github.com/leonstr/moodle into MOODLE_404_STABLE 2025-02-12 11:31:13 +07:00
Huong Nguyen f7f8b14c3e Merge branch 'MOODLE_404_MDL-82601' of https://github.com/danielneis/moodle into MOODLE_404_STABLE 2025-02-11 11:46:45 +07:00
Huong Nguyen 298a3f5107 Merge branch 'MDL-82440-MOODLE_404_STABLE' of https://github.com/praxisdigital/moodle into MOODLE_404_STABLE 2025-02-11 11:39:36 +07:00
Huong Nguyen 70efbd07af Merge branch 'MDL-84414-404' of https://github.com/andelacruz/moodle into MOODLE_404_STABLE 2025-02-11 11:17:44 +07:00
Angelia Dela Cruz 5b90cc9653 MDL-84414 gradingform_guide: Behat test to set marking guide as ready 2025-02-11 11:02:45 +08:00
AMOS bot b0423acc58 Automatically generated installer lang files 2025-02-11 00:09:19 +00:00
Paul Holden c275ca43c9 MDL-69142 tool_cohortroles: fix table ordering by role name. 2025-02-10 12:34:09 +00:00
Sara Arjona 746a17f9b0 Merge branch 'MDL-67462-404' of https://github.com/albertgasset/moodle into MOODLE_404_STABLE 2025-02-10 09:38:49 +01:00
Albert Gasset eb18a07be2 MDL-67462 badges: Improve tests of external functions 2025-02-07 09:13:05 +01:00
Albert Gasset e0930fce1e MDL-67462 badges: Fix badge alignments returned in external functions 2025-02-07 09:13:05 +01:00
Hieu Nguyen Dang 488a48e5f1 MDL-79265 question bank: fix which questions are listed with hidden
This fixes the bug where if the latest version of the question is hidden,
the question disappears, even if older versions exist that are still visible.
2025-02-06 17:21:15 +07:00
Paul Holden ddf3665d9c MDL-82170 user: replace increment operator on string type variable. 2025-02-05 20:10:33 +00:00
Julien Boulen 0f75ce3ebf MDL-84348 h5pactivity: Hide preview mode warning for guest users 2025-01-28 12:00:05 +01:00
Paul Holden 0df63ca7da MDL-83720 tag: correct description formatting component/filearea. 2025-01-24 12:52:22 +00:00
Dani Palou 1a3e762198 MDL-84313 pluginfile: Allow fetching by chunks in cross-site requests
Without these headers, the range headers are blocked so only the first chunk of the file can be retrieved.
2025-01-23 15:54:18 +01:00
Paul Holden 5b8a7b7d73 MDL-83579 tiny_media: pass filepicker accepted types to dropzone. 2025-01-16 17:37:59 +00:00
Daniel Neis Araujo 402ea0f8d2 MDL-82601 scorm: score not being stored when force complete is used 2025-01-13 09:56:54 -03:00
Paul Holden 56a109d2c3 MDL-83645 grade: fix text formatting in external grading form method. 2025-01-09 14:52:08 +00:00
Frederik Milling Pytlick 0d8b41f727 MDL-82440 assignfeedback_editpdf: Force ghostscript pdf's to pdf v1.4 2024-12-09 15:46:28 +01:00
James C ee87a1bcac MDL-83306 book: Book section return fix 2024-12-09 10:18:08 +13:00
Leon Stringer 9567305f70 MDL-83452 mod_assign: Remove add_body_class
Remove extraneous $PAGE->add_body_class('limitedwidth'), unneeded since
MDL-74031 added a call to this.
2024-10-14 08:44:14 +01:00
146 changed files with 4332 additions and 966 deletions
+2 -1
View File
@@ -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 |
@@ -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.';
@@ -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
+26 -26
View File
@@ -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"
+28 -28
View File
@@ -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;
}
}
+44 -12
View File
@@ -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
+287
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
}
+1 -1
View File
@@ -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)) {
+1 -1
View File
@@ -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;
+10 -1
View File
@@ -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"
+12 -6
View File
@@ -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"
+41
View File
@@ -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
View File
@@ -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>
+5 -4
View File
@@ -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 stworz ś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';
+1 -1
View File
@@ -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})';
+1
View File
@@ -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';
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+7 -7
View File
@@ -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
View File
@@ -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;
}
+15
View File
@@ -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);
}
}
+8 -7
View File
@@ -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();
}
}
+40 -13
View File
@@ -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
View File
@@ -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
View File
@@ -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
+9 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+6 -2
View File
@@ -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" {{!
+71 -16
View File
@@ -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.
+91 -15
View File
@@ -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()
);
}
}
}
+6 -7
View File
@@ -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
View File
@@ -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 {
+7 -3
View File
@@ -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']);
+57 -27
View File
@@ -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);
+3
View File
@@ -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"
+1 -1
View File
@@ -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);
-4
View File
@@ -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
View File
@@ -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 |
+2 -2
View File
@@ -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);
+11 -4
View File
@@ -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);
}
}
+13
View File
@@ -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);
+9
View File
@@ -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">';
+5 -3
View File
@@ -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();
+2
View File
@@ -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();
+1 -1
View File
@@ -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);
}
+6
View File
@@ -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);
+2 -2
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+61 -3
View File
@@ -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