Compare commits
286 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac802ad804 | |||
| a379081818 | |||
| 550afb2a72 | |||
| e99b368c60 | |||
| 70d026f0f7 | |||
| 833f55cf11 | |||
| f36fd442e8 | |||
| 60564c02ab | |||
| b39f3bb7f9 | |||
| 31a3e0eaa9 | |||
| aac49cd591 | |||
| 557432b764 | |||
| 8683b4a049 | |||
| 49b29449af | |||
| a80b692453 | |||
| 5100441bba | |||
| ff5c46a912 | |||
| 860feade62 | |||
| e193924db6 | |||
| 364a762fa7 | |||
| fa810327f7 | |||
| b25ed2333f | |||
| 6803fd0988 | |||
| 03341aea9f | |||
| a7c5fad12d | |||
| 3132cc9d4f | |||
| 2eb32285d7 | |||
| 274d5ccc62 | |||
| a105edc53f | |||
| 582f2e673a | |||
| e9e6e0921d | |||
| 0a580ed98d | |||
| bb13942ace | |||
| 3c681534da | |||
| 036fc9ef9a | |||
| 03a901b1fb | |||
| 6390af2f48 | |||
| 078308b04a | |||
| c708bb044e | |||
| e001ebc339 | |||
| f9a69ed13c | |||
| f1f1738ca8 | |||
| 8bdec47d8e | |||
| dc662df705 | |||
| d5f21323ce | |||
| 608266dace | |||
| f8349fb154 | |||
| 3408c2366d | |||
| 6d9979bd1b | |||
| 9360d678e5 | |||
| 5182889f53 | |||
| 918cc4f6c4 | |||
| 6862165efa | |||
| 052cf0a509 | |||
| 9556da6637 | |||
| 5af16f7c62 | |||
| 223e070e9c | |||
| 3e49f34657 | |||
| a73abacee5 | |||
| 604a1e0cdd | |||
| 50e44173c8 | |||
| 8084242767 | |||
| e05e7093c9 | |||
| ebf862e0d3 | |||
| c94a1593e3 | |||
| d5cad3ef39 | |||
| 65da222994 | |||
| 3382df5406 | |||
| f9280e786b | |||
| 8d4df26b01 | |||
| 884a9b436c | |||
| bada40d4a4 | |||
| b0130619d6 | |||
| 207f222e80 | |||
| 8e959ef40d | |||
| 846ddd3979 | |||
| 5370fa4d1d | |||
| b23182d82d | |||
| 8c49fdeb1d | |||
| 37ec33f56c | |||
| e4c3310295 | |||
| 915c263266 | |||
| 0f332fb0ff | |||
| dd8a1647c3 | |||
| b56a9953cf | |||
| 5cb2949d50 | |||
| 75ad63dd97 | |||
| 2448dbd55f | |||
| 0222576f3f | |||
| 002e281598 | |||
| 17f2636bc2 | |||
| cff7c05eb8 | |||
| 3f9e294f21 | |||
| e80b3cf70a | |||
| 86de0a0c8d | |||
| 59d0bb149d | |||
| 7b93ccde37 | |||
| 96dd901bea | |||
| 316507507b | |||
| e11305428c | |||
| 489eb64b4a | |||
| 555eef58af | |||
| 2e011c622f | |||
| a686188165 | |||
| 880314b565 | |||
| 638de36275 | |||
| bc72e1c107 | |||
| 23a8fdc812 | |||
| 6511e183f5 | |||
| 775858e46a | |||
| ff9fb97d72 | |||
| c00b4eda45 | |||
| ca12b96b77 | |||
| 2bc458c4f9 | |||
| 6945ab9db5 | |||
| 3768fa2928 | |||
| ad9a1630a1 | |||
| 3252c42592 | |||
| f41ee92e8e | |||
| 77937f2359 | |||
| e3ccaace4a | |||
| b612105230 | |||
| 157a8eb014 | |||
| 7d620f8fdc | |||
| ba75b60c85 | |||
| d70681c2f5 | |||
| 1bc6eba524 | |||
| 6200a07627 | |||
| ccdacb8443 | |||
| a84be34f83 | |||
| 5ec89b95ec | |||
| 189be87f2e | |||
| 61868b9510 | |||
| 13317f2598 | |||
| 54dfeb3d11 | |||
| 908784bca9 | |||
| 28c9a52455 | |||
| 86ee7eef07 | |||
| b4ed92a701 | |||
| 2ae416b6f5 | |||
| c5faeaee4c | |||
| d3c9ccba3c | |||
| 72c6f8882c | |||
| ec07626137 | |||
| 28f156895a | |||
| 7adddedf4f | |||
| 6ba03f474a | |||
| 44fbef243d | |||
| 7c19447345 | |||
| 5e946c1f4f | |||
| f793334a44 | |||
| 4c47af3dee | |||
| 3ab3c72aa7 | |||
| efee80655a | |||
| a4da1b6b4e | |||
| 36186e9af7 | |||
| 37c54215d1 | |||
| 92788c9c31 | |||
| 42cb48db5c | |||
| 3bb83daec9 | |||
| 34db25536e | |||
| ab57c200c5 | |||
| 3b72da69af | |||
| 358dd1dc29 | |||
| 3f436bb599 | |||
| d7431b36a2 | |||
| 159a155062 | |||
| 0c0b4b69c9 | |||
| a4b47235d6 | |||
| 90e25dfc45 | |||
| 3c7933178d | |||
| 738fb91dec | |||
| badca8a618 | |||
| 0fa3a9d6aa | |||
| 8b0edbf074 | |||
| 27db562110 | |||
| de4bcb1b4c | |||
| 47ce3408a1 | |||
| 1e2abd1466 | |||
| 1e5e225a86 | |||
| 774796cf77 | |||
| 9e72cc3998 | |||
| 928b6719dc | |||
| 1c4022b77d | |||
| 158bff3dcb | |||
| 8642510801 | |||
| 36a0b0572f | |||
| 0fd0462bc6 | |||
| 362c9de6ad | |||
| 8d8941166c | |||
| 0d16e46646 | |||
| 4d04135186 | |||
| cb4e4c8d45 | |||
| bf58e69516 | |||
| cf51659f5a | |||
| 4a7f57ee8e | |||
| 1cc0453ccd | |||
| 9f589703a6 | |||
| ed4f0da1c9 | |||
| 923eb86a8c | |||
| 5886683a33 | |||
| 5ef0a36d3d | |||
| 07e4f55804 | |||
| dda4552e79 | |||
| c04a71b431 | |||
| 120beb8aaf | |||
| d08fd3c2af | |||
| 2b94d55524 | |||
| 057c314edc | |||
| d4690ea232 | |||
| 88763ab043 | |||
| 547ea9cfbf | |||
| cfaccbf953 | |||
| 11d0802ca6 | |||
| 1cd17816c5 | |||
| 8bcf6f016d | |||
| bd39c5df46 | |||
| 85f08eccd7 | |||
| e35c3f18e5 | |||
| 700ffe79a3 | |||
| 0bfc7f5abd | |||
| 39a16ed55a | |||
| 7414e469b8 | |||
| a55b9cfc70 | |||
| f4187d49da | |||
| cacdd7e63a | |||
| 08d6834166 | |||
| 740c3dafcc | |||
| b50f58ec0d | |||
| 3fd9808fc6 | |||
| 9155d32f36 | |||
| e6d7fcb2e0 | |||
| c5b69126d7 | |||
| 0f0904243d | |||
| 95d96c926b | |||
| e39521934c | |||
| c629f23da8 | |||
| 3f5b13cd49 | |||
| 019e03af94 | |||
| 8e477788f8 | |||
| b32b09d584 | |||
| 1a1033c601 | |||
| fcde530bc4 | |||
| 8117d73b98 | |||
| 6ca58162c6 | |||
| 7b348fa333 | |||
| a6587c7986 | |||
| 19b193784b | |||
| 6f44b00e42 | |||
| 90eb487eec | |||
| 2d111226fe | |||
| 832acc2639 | |||
| 7eb5190921 | |||
| 2e1e274ed4 | |||
| a217af7ae5 | |||
| 1e893d4b21 | |||
| de2ab7ccb7 | |||
| f6a31e8fdc | |||
| 7cf1291cc2 | |||
| f380071cb5 | |||
| 3055d89c46 | |||
| 4ef04a9d94 | |||
| a633683bb3 | |||
| 0c4b848a48 | |||
| 010daf38da | |||
| 038963316f | |||
| 94a34a82f7 | |||
| d8f2f5e6a0 | |||
| bdcc11503d | |||
| 35ca47e57a | |||
| 840c045bc1 | |||
| 7ca1574c28 | |||
| bae9d77604 | |||
| 91bdc56a5d | |||
| 00cd643c33 | |||
| 73118e777f | |||
| eb50526c35 | |||
| 8e952904e8 | |||
| 0df4a0d4ce | |||
| 56e76851cc | |||
| 6fd62e9bfc | |||
| 8d07409ff8 | |||
| 7c6aaf9d4b | |||
| e06a6ee9c8 | |||
| 92a8d0e9c0 | |||
| 26a16e4e27 |
@@ -51,7 +51,7 @@ jobs:
|
||||
chunk=$(((($count % $chunks)) + 1))
|
||||
echo "$testname $testfile" >> ./chunk_$chunk.txt
|
||||
done < <(grep "function test_" "${testfile}" | sed -r "s/^.*function (test_[a-zA-Z0-9_]+).*/\1/")
|
||||
done < <(find . -name "*_test.php")
|
||||
done < <(find . -name "*_test.php" -not -path "*/fixtures/*")
|
||||
# Generate the matrix to run tests.
|
||||
echo "matrix=$(ls -1 chunk_*.txt | jq -R -s -c 'split("\n")[:-1]')" >> $GITHUB_OUTPUT
|
||||
echo "$count individual tests collected in $chunks files"
|
||||
|
||||
@@ -43,8 +43,8 @@ module.exports = (grunt) => {
|
||||
|
||||
const componentData = fetchComponentData().components;
|
||||
for (const [thisPath, component] of Object.entries(componentData)) {
|
||||
jsconfigData.compilerOptions.paths[`${component}/*`] = [`public/${thisPath}/amd/src/*`];
|
||||
jsconfigData.include.push(`public/${thisPath}/amd/src/**/*`);
|
||||
jsconfigData.compilerOptions.paths[`${component}/*`] = [`${thisPath}/amd/src/*`];
|
||||
jsconfigData.include.push(`${thisPath}/amd/src/**/*`);
|
||||
}
|
||||
|
||||
grunt.file.write('jsconfig.json', JSON.stringify(jsconfigData, null, " ") + "\n");
|
||||
|
||||
@@ -6,6 +6,51 @@ More detailed information on key changes can be found in the [Developer update n
|
||||
|
||||
The format of this change log follows the advice given at [Keep a CHANGELOG](https://keepachangelog.com).
|
||||
|
||||
## 5.1.2
|
||||
|
||||
### core
|
||||
|
||||
#### Added
|
||||
|
||||
- There is a new Behat `toast_message` named selector to more easily assert the presence of Toast messages on the page
|
||||
|
||||
For more information see [MDL-87443](https://tracker.moodle.org/browse/MDL-87443)
|
||||
|
||||
#### Changed
|
||||
|
||||
- `\core\output\core_renderer::confirm()`'s `$displayoptions` parameter now also accepts a `headinglevel` option that developers can use to specify the heading level of the confirmation's heading. If not specified, the confirmation heading will be rendered in an `h4` tag.
|
||||
|
||||
For more information see [MDL-87694](https://tracker.moodle.org/browse/MDL-87694)
|
||||
|
||||
### core_customfield
|
||||
|
||||
#### Added
|
||||
|
||||
- Added new `\core_customfield\api::is_shortname_unique(...)` method to determine whether a shortname is available for use inside a given handler
|
||||
|
||||
For more information see [MDL-87059](https://tracker.moodle.org/browse/MDL-87059)
|
||||
|
||||
### core_question
|
||||
|
||||
#### Fixed
|
||||
|
||||
- In order to prevent re-use of question version numbers after a version is deleted, the `nextversion` column was added to `question_bank_entries`. This serves as a counter incremented each time a version is created.
|
||||
Do not query this field directly. Instead use `core_question\versions::get_next_version()` to read the value, which will initialise it based on the existing versions if it is not set yet. By default, it will increment the version number automatically, unless you pass `increment: false`. Because of this, it is advisable to call it inside a transaction, that is only committed after the version number is used in a `question_versions` record.
|
||||
|
||||
For more information see [MDL-86798](https://tracker.moodle.org/browse/MDL-86798)
|
||||
|
||||
### customfield_number
|
||||
|
||||
#### Changed
|
||||
|
||||
- In order to fully support shared custom field categories, additional component/area/itemid parameters have been added to the following:
|
||||
|
||||
* The `customfield_number_recalculate_value` external method
|
||||
* The abstract `\customfield_number\provider_base::recalculate()` method
|
||||
* The `\customfield_number\task\recalculate` helpers for queueing task instances
|
||||
|
||||
For more information see [MDL-87714](https://tracker.moodle.org/browse/MDL-87714)
|
||||
|
||||
## 5.1.1
|
||||
|
||||
### core
|
||||
|
||||
@@ -125,11 +125,6 @@ if ($options['list']) {
|
||||
exit(0);
|
||||
}
|
||||
|
||||
if (moodle_needs_upgrading()) {
|
||||
mtrace("Moodle upgrade pending, cannot manage tasks.");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if ($disable = $options['disable']) {
|
||||
if (!$task = \core\task\manager::get_scheduled_task($disable)) {
|
||||
mtrace("Task '$disable' not found");
|
||||
@@ -157,6 +152,11 @@ if ($disable = $options['disable']) {
|
||||
exit(1);
|
||||
}
|
||||
} else if ($execute = $options['execute']) {
|
||||
if (moodle_needs_upgrading()) {
|
||||
mtrace("Moodle upgrade pending, cannot execute tasks.");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if (!$task = \core\task\manager::get_scheduled_task($execute)) {
|
||||
mtrace("Task '$execute' not found");
|
||||
exit(1);
|
||||
|
||||
+7
-1
@@ -2,8 +2,11 @@
|
||||
"name": "moodle/moodle",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"description": "Moodle - the world's open source learning platform",
|
||||
"type": "project",
|
||||
"type": "moodle-core",
|
||||
"homepage": "https://moodle.org",
|
||||
"provide": {
|
||||
"moodle/lms": "5.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^11",
|
||||
"mikey179/vfsstream": "1.6.*",
|
||||
@@ -26,6 +29,9 @@
|
||||
"core_phpunit\\": "public/lib/phpunit/classes/"
|
||||
}
|
||||
},
|
||||
"extra": {
|
||||
"haspublicdir": true
|
||||
},
|
||||
"minimum-stability": "dev",
|
||||
"prefer-stable": true,
|
||||
"require": {
|
||||
|
||||
Generated
+2
-2
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "6dea3d23fae938df252056489715c946",
|
||||
"content-hash": "dbc988c9feb49514ee1ac7b206929260",
|
||||
"packages": [],
|
||||
"packages-dev": [
|
||||
{
|
||||
@@ -4701,5 +4701,5 @@
|
||||
"ext-sodium": "*"
|
||||
},
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.6.0"
|
||||
"plugin-api-version": "2.9.0"
|
||||
}
|
||||
|
||||
@@ -119,8 +119,8 @@ foreach ($settingspage->children as $childpage) {
|
||||
$outputhtml .= html_writer::tag('div', '<!-- -->', array('class' => 'clearer'));
|
||||
$outputhtml .= $setting->output_html($data);
|
||||
if ($childpage->has_dependencies()) {
|
||||
$opts = ['dependencies' => $childpage->get_dependencies_for_javascript()];
|
||||
$PAGE->requires->js_call_amd('core/showhidesettings', 'init', [$opts]);
|
||||
$context = ['dependencies' => json_encode($childpage->get_dependencies_for_javascript())];
|
||||
echo $OUTPUT->render_from_template('core_admin/settings_showhide', $context);
|
||||
}
|
||||
}
|
||||
$outputhtml .= html_writer::end_tag('fieldset');
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<?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_admin\admin;
|
||||
|
||||
use admin_setting;
|
||||
|
||||
/**
|
||||
* Render a notification as part of other admin settings.
|
||||
*
|
||||
* @package core_admin
|
||||
* @subpackage admin
|
||||
* @copyright 2025 Matt Porritt <matt.porritt@moodle.com>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class admin_setting_notification extends admin_setting {
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param string $name The name of the setting.
|
||||
* @param string $notification The notification to display.
|
||||
* @param string $type The type of notification.
|
||||
* @param bool $cancelable Whether the notification can be canceled.
|
||||
*/
|
||||
public function __construct(
|
||||
string $name,
|
||||
/** @var string The notification to display. */
|
||||
protected string $notification,
|
||||
/** @var string The type of notification. */
|
||||
protected string $type = 'info',
|
||||
/** @var bool Whether the notification can be canceled. */
|
||||
protected bool $cancelable = false
|
||||
) {
|
||||
$this->nosave = true;
|
||||
|
||||
parent::__construct($name, '', '', '');
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function get_setting(): bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function get_defaultsetting(): bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function write_setting($data): string {
|
||||
// Do not write any setting.
|
||||
return '';
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function output_html($data, $query = ''): string {
|
||||
global $OUTPUT;
|
||||
|
||||
return $OUTPUT->notification($this->notification, $this->type, $this->cancelable);
|
||||
}
|
||||
}
|
||||
@@ -990,6 +990,13 @@ $result = new environment_results('custom_checks');
|
||||
$result = check_xmlrpc_usage($result);
|
||||
$xmlrpcwarning = !is_null($result) ? get_string($result->getFeedbackStr(), 'admin') : '';
|
||||
|
||||
// Check whether MoodleNet features are enabled and warn about removal.
|
||||
$moodlenetwarning = '';
|
||||
$moodlenetenabled = get_config('tool_moodlenet', 'enablemoodlenet');
|
||||
if ($moodlenetenabled) {
|
||||
$moodlenetwarning = get_string('moodlenetremovalwarning', 'admin');
|
||||
}
|
||||
|
||||
admin_externalpage_setup('adminnotifications');
|
||||
|
||||
$output = $PAGE->get_renderer('core', 'admin');
|
||||
@@ -1016,5 +1023,6 @@ echo $output->admin_notifications_page(
|
||||
$showcampaigncontent,
|
||||
$showfeedbackencouragement,
|
||||
$servicesandsupportcontent,
|
||||
$xmlrpcwarning
|
||||
$xmlrpcwarning,
|
||||
$moodlenetwarning
|
||||
);
|
||||
|
||||
@@ -279,6 +279,7 @@ class core_admin_renderer extends plugin_renderer_base {
|
||||
* @param bool $showfeedbackencouragement Whether the feedback encouragement content should be displayed or not.
|
||||
* @param bool $showservicesandsupport Whether the services and support content should be displayed or not.
|
||||
* @param string $xmlrpcwarning XML-RPC deprecation warning message.
|
||||
* @param string $moodlenetwarning MoodleNet removal warning message.
|
||||
*
|
||||
* @return string HTML to output.
|
||||
*/
|
||||
@@ -288,7 +289,7 @@ class core_admin_renderer extends plugin_renderer_base {
|
||||
$themedesignermode = false, $devlibdir = false, $mobileconfigured = false,
|
||||
$overridetossl = false, $invalidforgottenpasswordurl = false, $croninfrequent = false,
|
||||
$showcampaigncontent = false, bool $showfeedbackencouragement = false, bool $showservicesandsupport = false,
|
||||
$xmlrpcwarning = '') {
|
||||
$xmlrpcwarning = '', $moodlenetwarning = '') {
|
||||
|
||||
global $CFG;
|
||||
$output = '';
|
||||
@@ -313,6 +314,7 @@ class core_admin_renderer extends plugin_renderer_base {
|
||||
$output .= $this->mobile_configuration_warning($mobileconfigured);
|
||||
$output .= $this->forgotten_password_url_warning($invalidforgottenpasswordurl);
|
||||
$output .= $this->mnet_deprecation_warning($xmlrpcwarning);
|
||||
$output .= $this->moodlenet_removal_warning($moodlenetwarning);
|
||||
$output .= $this->userfeedback_encouragement($showfeedbackencouragement);
|
||||
$output .= $this->services_and_support_content($showservicesandsupport);
|
||||
$output .= $this->campaign_content($showcampaigncontent);
|
||||
@@ -2326,6 +2328,20 @@ class core_admin_renderer extends plugin_renderer_base {
|
||||
return $this->warning($xmlrpcwarning);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display a warning about the removal of MoodleNet integration.
|
||||
*
|
||||
* @param string $moodlenetwarning The warning message
|
||||
* @return string HTML to output.
|
||||
*/
|
||||
protected function moodlenet_removal_warning($moodlenetwarning) {
|
||||
if (empty($moodlenetwarning)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $this->warning($moodlenetwarning);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the theme selector list.
|
||||
*
|
||||
|
||||
@@ -165,10 +165,8 @@ if (empty($SITE->fullname)) {
|
||||
$PAGE->requires->js_call_amd('core_form/changechecker', 'watchFormById', ['adminsettings']);
|
||||
|
||||
if ($settingspage->has_dependencies()) {
|
||||
$opts = [
|
||||
'dependencies' => $settingspage->get_dependencies_for_javascript()
|
||||
];
|
||||
$PAGE->requires->js_call_amd('core/showhidesettings', 'init', [$opts]);
|
||||
$context = ['dependencies' => json_encode($settingspage->get_dependencies_for_javascript())];
|
||||
echo $OUTPUT->render_from_template('core_admin/settings_showhide', $context);
|
||||
}
|
||||
|
||||
echo $OUTPUT->footer();
|
||||
|
||||
@@ -32,19 +32,29 @@ if ($hassiteconfig) {
|
||||
$providers->add(new admin_setting_heading('availableproviders',
|
||||
get_string('availableproviders', 'core_ai'),
|
||||
get_string('availableproviders_desc', 'core_ai')));
|
||||
// Add call to action to add a new provider.
|
||||
$providers->add(new \core_admin\admin\admin_setting_template_render(
|
||||
name: 'addnewprovider',
|
||||
templatename: 'core_ai/admin_add_provider',
|
||||
context: ['addnewproviderurl' => new moodle_url('/ai/configure.php')]
|
||||
));
|
||||
|
||||
$providers->add(new \core_ai\admin\admin_setting_provider_manager(
|
||||
if (!empty(core_plugin_manager::instance()->get_plugins_of_type("aiprovider"))) {
|
||||
// Add call to action to add a new provider.
|
||||
$providers->add(new \core_admin\admin\admin_setting_template_render(
|
||||
name: 'addnewprovider',
|
||||
templatename: 'core_ai/admin_add_provider',
|
||||
context: ['addnewproviderurl' => new moodle_url('/ai/configure.php')]
|
||||
));
|
||||
|
||||
$providers->add(new \core_ai\admin\admin_setting_provider_manager(
|
||||
'aiprovider',
|
||||
\core_ai\table\aiprovider_management_table::class,
|
||||
'manageaiproviders',
|
||||
new lang_string('manageaiproviders', 'core_ai'),
|
||||
));
|
||||
));
|
||||
} else {
|
||||
$providers->add(new \core_admin\admin\admin_setting_notification(
|
||||
name:'noproviderplugins',
|
||||
notification: get_string('noproviderplugins', 'core_ai'),
|
||||
type: 'danger'
|
||||
));
|
||||
}
|
||||
|
||||
$ADMIN->add('ai', $providers);
|
||||
|
||||
// Add settings page for AI placement settings.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
{{!
|
||||
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/>.
|
||||
}}
|
||||
{{!
|
||||
@template core_admin/settings_showhide
|
||||
|
||||
Passes the necessary data to show/hide dependant settings.
|
||||
|
||||
Context variables required for this template:
|
||||
* dependencies - JSON-serialized data structure describing the dependencies between settings
|
||||
|
||||
Example context (json):
|
||||
{
|
||||
"dependencies": {
|
||||
"s__setting1":{
|
||||
"eq":[
|
||||
[
|
||||
"s__setting0"
|
||||
]
|
||||
]
|
||||
},
|
||||
"s__setting2":{
|
||||
"eq":[
|
||||
[
|
||||
"s__setting1"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
<span id="settings-showhide-{{uniqid}}" data-dependencies="{{dependencies}}"></span>
|
||||
|
||||
{{#js}}
|
||||
require(['core/showhidesettings'], function(ShowHideSettings) {
|
||||
ShowHideSettings.init("settings-showhide-{{uniqid}}");
|
||||
});
|
||||
{{/js}}
|
||||
@@ -0,0 +1,100 @@
|
||||
@tool @tool_dataprivacy
|
||||
Feature: Authorized users can request others personal data
|
||||
In order to export or access another users data
|
||||
As a designated role
|
||||
I need the correct permissions
|
||||
|
||||
Background:
|
||||
Given the following "users" exist:
|
||||
| username | firstname | lastname | email |
|
||||
| user1 | User1 | One | user1@example.com |
|
||||
| user2 | User2 | Two | user2@example.com |
|
||||
| officer1 | Officer1 | One | officer1@example.com |
|
||||
# Create Privacy Officer Role.
|
||||
And the following "role" exists:
|
||||
| shortname | privacyofficer |
|
||||
| name | Privacy Officer |
|
||||
| context_system | 1 |
|
||||
| tool/dataprivacy:managedataregistry | allow |
|
||||
| tool/dataprivacy:managedatarequests | allow |
|
||||
| tool/dataprivacy:makedatarequestsforchildren | allow |
|
||||
| moodle/site:configview | allow |
|
||||
| moodle/category:viewhiddencategories | allow |
|
||||
| moodle/course:viewhiddencourses | allow |
|
||||
| moodle/course:viewhiddenactivities | allow |
|
||||
| moodle/course:view | allow |
|
||||
# Create Parent Role.
|
||||
And the following "role" exists:
|
||||
| shortname | parentrole |
|
||||
| name | Parent Role |
|
||||
| context_user | 1 |
|
||||
| moodle/user:viewdetails | allow |
|
||||
| moodle/user:viewalldetails | allow |
|
||||
| moodle/user:readuserblogs | allow |
|
||||
| moodle/user:readuserposts | allow |
|
||||
| moodle/user:viewuseractivitiesreport | allow |
|
||||
| moodle/user:editprofile | allow |
|
||||
| tool/policy:acceptbehalf | allow |
|
||||
| tool/dataprivacy:makedatarequestsforchildren | allow |
|
||||
# Add permission to allow parent to make requests on behalf of child user.
|
||||
And the following config values are set as admin:
|
||||
| contactdataprotectionofficer | 1 | tool_dataprivacy |
|
||||
And I log in as "admin"
|
||||
|
||||
@javascript
|
||||
Scenario: Privacy officer can request for other user's personal data
|
||||
Given I navigate to "Users > Permissions > Assign system roles" in site administration
|
||||
# Assign Privacy Officer role to officer1.
|
||||
And I follow "Privacy Officer"
|
||||
And I set the field "addselect_searchtext" to "Officer1"
|
||||
And I set the field "addselect" to "Officer1 One (officer1@example.com)"
|
||||
And I press "Add"
|
||||
# Navigate to home in order to navigate properly to Privacy settings.
|
||||
And I am on site homepage
|
||||
# Select Privacy officer in the Orivacy officer role mapping setting.
|
||||
And I navigate to "Users > Privacy and policies > Privacy settings" in site administration
|
||||
And I click on "Privacy Officer" "checkbox"
|
||||
And I press "Save changes"
|
||||
And I log in as "officer1"
|
||||
And I navigate to "Users > Privacy and policies > Data requests" in site administration
|
||||
# Create a new request as the designated privacy officer.
|
||||
When I follow "New request"
|
||||
And I set the field "User" to "User1 One"
|
||||
And I set the field "Comment" to "User One data"
|
||||
And I press "Save changes"
|
||||
# Confirm that the new data request is successfully created for selected user with status "Awaiting approval".
|
||||
Then the following should exist in the "generaltable" table:
|
||||
| Type | User | Requested by | Status | Message |
|
||||
| Export | User1 One | Officer1 One | Awaiting approval | User One data |
|
||||
|
||||
@javascript
|
||||
Scenario: Parent user can request data on behalf of child user
|
||||
Given I navigate to "Users > Accounts > Browse list of users" in site administration
|
||||
And I follow "User1 One"
|
||||
And I click on "Preferences" "link" in the ".profile_tree" "css_element"
|
||||
# Assign user2 as parent for user1.
|
||||
And I follow "Assign roles relative to this user"
|
||||
And I follow "Parent"
|
||||
And I set the field "Potential users" to "User2 Two (user2@example.com)"
|
||||
And I click on "Add" "button" in the "#page-content" "css_element"
|
||||
And I log in as "user2"
|
||||
And I follow "Profile" in the user menu
|
||||
And I follow "Data requests"
|
||||
# As parent, create a data request for a child user.
|
||||
And I follow "New request"
|
||||
And I click on "User" "field"
|
||||
When I type "User1 One"
|
||||
# Confirm that only the parent's child users can be searched and selected.
|
||||
Then I should see "User1 One"
|
||||
And I type "User2 Two"
|
||||
And I should see "No suggestions"
|
||||
And I type "Officer1 One"
|
||||
And I should see "No suggestions"
|
||||
And I set the field "Search" to "User1"
|
||||
And I set the field "Comment" to "This is a comment"
|
||||
And I press "Save changes"
|
||||
# Confirm that data request was successfully made by parent on behalf of child user.
|
||||
And I should see "Your request has been submitted to the privacy officer"
|
||||
And the following should exist in the "generaltable" table:
|
||||
| Type | Requested by | Status | Message |
|
||||
| Export all of my personal data (User1 One) | User2 Two | Awaiting approval | This is a comment |
|
||||
@@ -38,6 +38,9 @@ $string['defaultmoodlenet_desc'] = 'The URL of the MoodleNet instance available
|
||||
$string['defaultmoodlenetname'] = "MoodleNet instance name";
|
||||
$string['defaultmoodlenetnamevalue'] = 'MoodleNet Central';
|
||||
$string['defaultmoodlenetname_desc'] = 'The name of the MoodleNet instance available via the activity chooser.';
|
||||
$string['removalwarning_feature'] = 'If you need to continue using MoodleNet, contact your site administrator about setting up a self-hosted MoodleNet instance.';
|
||||
$string['removalwarning_service'] = 'After the date, you will no longer be able to browse or add content from MoodleNet Central.';
|
||||
$string['removalwarning_title'] = 'The MoodleNet service will be shut down on 20 April 2026.';
|
||||
$string['enablemoodlenet'] = 'Enable MoodleNet integration (inbound)';
|
||||
$string['enablemoodlenet_desc'] = 'If enabled, a user with the capability to create and manage activities can browse MoodleNet via the activity chooser and import MoodleNet resources into their course. In addition, a user with the capability to restore backups can select a backup file on MoodleNet and restore it into Moodle.';
|
||||
$string['errorduringdownload'] = 'An error occurred while downloading the file: {$a}';
|
||||
|
||||
@@ -27,8 +27,16 @@
|
||||
<div class="content text-start mb-5 px-5 py-4" data-region="chooser-option-summary-content-container">
|
||||
<div data-region="moodle-net">
|
||||
<div class="overlay-icon-container z-index-1 d-none" data-region="spinner"></div>
|
||||
<img class="w-25 mb-4" aria-hidden="true" src="{{{img}}}">
|
||||
<img class="w-25 mb-4" alt="" aria-hidden="true" src="{{{img}}}">
|
||||
<p>{{#str}} instancedescription, tool_moodlenet {{/str}}</p>
|
||||
|
||||
{{! Removal warning - always visible when MoodleNet integration is enabled }}
|
||||
<div class="alert alert-warning w-75 mx-auto mb-3" role="alert">
|
||||
<strong>{{#str}}removalwarning_title, tool_moodlenet{{/str}}</strong>
|
||||
<p class="mb-1">{{#str}}removalwarning_service, tool_moodlenet{{/str}}</p>
|
||||
<p class="mb-0">{{#str}}removalwarning_feature, tool_moodlenet{{/str}}</p>
|
||||
</div>
|
||||
|
||||
<p class="w-75 mx-auto mb-1 mt-5">{{#str}} connectandbrowse, tool_moodlenet {{/str}}</p>
|
||||
<a class="btn btn-secondary d-block w-75 mx-auto mb-4"
|
||||
data-action="browse"
|
||||
|
||||
@@ -61,7 +61,8 @@ Feature: Basic OAuth2 functionality
|
||||
And I should see "device_authorization_endpoint"
|
||||
And I navigate to "Server > OAuth 2 services" in site administration
|
||||
And I click on "Configure user field mappings" "link" in the "Testing service" "table_row"
|
||||
And I should see "firstname" in the "givenname" "table_row"
|
||||
And I should see "firstname" in the "given_name" "table_row"
|
||||
And I should see "lastname" in the "family_name" "table_row"
|
||||
And I should see "idnumber" in the "sub" "table_row"
|
||||
And I should see "email" in the "email" "table_row"
|
||||
And I should see "lang" in the "locale" "table_row"
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
@tool @tool_uploaduser @_file_upload
|
||||
Feature: Admin can suspend user course enrolment via CSV upload
|
||||
In order to manage enrolments in bulk
|
||||
As an administrator
|
||||
I need to be able to enrol and suspend users using CSV upload
|
||||
|
||||
Background:
|
||||
Given the following "courses" exist:
|
||||
| fullname | shortname |
|
||||
| Course 1 | C1 |
|
||||
| Course 2 | C2 |
|
||||
| Course 3 | C3 |
|
||||
|
||||
@javascript
|
||||
Scenario: Admin uploads enrol and suspend CSVs and verifies enrolment status
|
||||
Given I log in as "admin"
|
||||
And I navigate to "Users > Accounts > Upload users" in site administration
|
||||
When I upload "lib/tests/fixtures/QA_user_enrol.txt" file to "File" filemanager
|
||||
And I press "Upload users"
|
||||
And I press "Upload users"
|
||||
And I press "Continue"
|
||||
And I upload "lib/tests/fixtures/QA_user_suspend.txt" file to "File" filemanager
|
||||
And I press "Upload users"
|
||||
And I set the field "Upload type" to "Update existing users only"
|
||||
And I press "Upload users"
|
||||
And I press "Continue"
|
||||
And I am on the "Course 1" "enrolled users" page
|
||||
Then the following should exist in the "participants" table:
|
||||
| First name | Status |
|
||||
| Learner One | Active |
|
||||
| Learner Two | Active |
|
||||
And I am on the "Course 2" "enrolled users" page
|
||||
And the following should exist in the "participants" table:
|
||||
| First name | Status |
|
||||
| Learner One | Active |
|
||||
| Learner Two | Suspended |
|
||||
And I am on the "Course 3" "enrolled users" page
|
||||
And the following should exist in the "participants" table:
|
||||
| First name | Status |
|
||||
| Learner One | Suspended |
|
||||
| Learner Two | Active |
|
||||
@@ -72,6 +72,12 @@ $PAGE->set_pagelayout('admin');
|
||||
$PAGE->set_title($title);
|
||||
$PAGE->set_heading($title);
|
||||
|
||||
// Explode if there are no provider plugins installed.
|
||||
$plugins = core_plugin_manager::instance()->get_plugins_of_type('aiprovider');
|
||||
if (empty($plugins)) {
|
||||
throw new moodle_exception('noproviderplugins', 'core_ai');
|
||||
}
|
||||
|
||||
// Provider instance form processing.
|
||||
$mform = new \core_ai\form\ai_provider_form(customdata: $data);
|
||||
if ($mform->is_cancelled()) {
|
||||
|
||||
@@ -197,9 +197,7 @@ class api {
|
||||
|
||||
$data->link = $confirmationurl->out(false);
|
||||
$message = get_string('confirmlinkedloginemail', 'auth_oauth2', $data);
|
||||
|
||||
$data->link = $confirmationurl->out();
|
||||
$messagehtml = text_to_html(get_string('confirmlinkedloginemail', 'auth_oauth2', $data), false, false, true);
|
||||
$messagehtml = text_to_html(get_string('confirmlinkedloginemail', 'auth_oauth2', $data), false, false);
|
||||
|
||||
$user->mailformat = 1; // Always send HTML version as well.
|
||||
|
||||
@@ -339,9 +337,7 @@ class api {
|
||||
|
||||
$data->link = $confirmationurl->out(false);
|
||||
$message = get_string('confirmaccountemail', 'auth_oauth2', $data);
|
||||
|
||||
$data->link = $confirmationurl->out();
|
||||
$messagehtml = text_to_html(get_string('confirmaccountemail', 'auth_oauth2', $data), false, false, true);
|
||||
$messagehtml = text_to_html(get_string('confirmaccountemail', 'auth_oauth2', $data), false, false);
|
||||
|
||||
$user->mailformat = 1; // Always send HTML version as well.
|
||||
|
||||
|
||||
@@ -467,18 +467,31 @@ class auth extends \auth_plugin_base {
|
||||
$mappeduser = get_complete_user_data('id', $linkedlogin->get('userid'));
|
||||
|
||||
if ($mappeduser && $mappeduser->suspended) {
|
||||
$failurereason = AUTH_LOGIN_SUSPENDED;
|
||||
$event = \core\event\user_login_failed::create([
|
||||
'userid' => $mappeduser->id,
|
||||
'other' => [
|
||||
'username' => $userinfo['username'],
|
||||
'reason' => $failurereason
|
||||
]
|
||||
]);
|
||||
$event->trigger();
|
||||
$SESSION->loginerrormsg = get_string('invalidlogin');
|
||||
$client->log_out();
|
||||
redirect(new moodle_url('/login/index.php'));
|
||||
// Check if there's another user with the same email that is not suspended.
|
||||
$moodleuser = \core_user::get_user_by_email($userinfo['email'], '*', null, IGNORE_MULTIPLE);
|
||||
if ($moodleuser->id == $mappeduser->id) {
|
||||
$failurereason = AUTH_LOGIN_SUSPENDED;
|
||||
$event = \core\event\user_login_failed::create([
|
||||
'userid' => $mappeduser->id,
|
||||
'other' => [
|
||||
'username' => $userinfo['username'],
|
||||
'reason' => $failurereason,
|
||||
],
|
||||
]);
|
||||
$event->trigger();
|
||||
$SESSION->loginerrormsg = get_string('invalidlogin');
|
||||
$client->log_out();
|
||||
redirect(new moodle_url('/login/index.php'));
|
||||
} else if ($moodleuser && !$moodleuser->suspended) {
|
||||
// Update the OAuth2 linked login to point to the active user account.
|
||||
$linkedlogin->set('userid', $moodleuser->id);
|
||||
$linkedlogin->set('timemodified', time());
|
||||
$linkedlogin->update();
|
||||
|
||||
// Update user fields and continue with login.
|
||||
$userinfo = $this->update_user($userinfo, $moodleuser);
|
||||
$userwasmapped = true;
|
||||
}
|
||||
} else if ($mappeduser && ($mappeduser->confirmed || !$issuer->get('requireconfirmation'))) {
|
||||
// Update user fields.
|
||||
$userinfo = $this->update_user($userinfo, $mappeduser);
|
||||
@@ -508,7 +521,6 @@ class auth extends \auth_plugin_base {
|
||||
redirect(new moodle_url('/login/index.php'));
|
||||
}
|
||||
|
||||
|
||||
if (!$issuer->is_valid_login_domain($oauthemail)) {
|
||||
// Trigger login failed event.
|
||||
$failurereason = AUTH_LOGIN_UNAUTHORISED;
|
||||
@@ -524,8 +536,24 @@ class auth extends \auth_plugin_base {
|
||||
|
||||
if (!$userwasmapped) {
|
||||
// No defined mapping - we need to see if there is an existing account with the same email.
|
||||
$moodleuser = \core_user::get_user_by_email($userinfo['email'], '*', null, IGNORE_MULTIPLE);
|
||||
|
||||
// Ensure we don't link a login for a suspended user.
|
||||
if (!empty($moodleuser) && $moodleuser->suspended) {
|
||||
$failurereason = AUTH_LOGIN_SUSPENDED;
|
||||
$event = \core\event\user_login_failed::create([
|
||||
'userid' => $moodleuser->id,
|
||||
'other' => [
|
||||
'username' => $userinfo['email'],
|
||||
'reason' => $failurereason,
|
||||
],
|
||||
]);
|
||||
$event->trigger();
|
||||
$SESSION->loginerrormsg = get_string('invalidlogin');
|
||||
$client->log_out();
|
||||
redirect(new moodle_url('/login/index.php'));
|
||||
}
|
||||
|
||||
$moodleuser = \core_user::get_user_by_email($userinfo['email']);
|
||||
if (!empty($moodleuser)) {
|
||||
if ($issuer->get('requireconfirmation')) {
|
||||
$PAGE->set_url('/auth/oauth2/confirm-link-login.php');
|
||||
|
||||
@@ -27,19 +27,14 @@ $string['auth_oauth2description'] = 'OAuth 2 standards based authentication';
|
||||
$string['auth_oauth2settings'] = 'OAuth 2 authentication settings.';
|
||||
$string['confirmaccountemail'] = 'Hi {$a->firstname},
|
||||
|
||||
A new account has been requested at \'{$a->sitename}\'
|
||||
using your email address.
|
||||
A new account has been requested at \'{$a->sitename}\' using your email address.
|
||||
|
||||
To confirm your new account, please go to this web address:
|
||||
To confirm your new account, please click the link below:
|
||||
|
||||
{$a->link}
|
||||
<a href="{$a->link}">Confirm your account</a>
|
||||
|
||||
In most mail programs, this should appear as a blue link
|
||||
which you can just click on. If that doesn\'t work,
|
||||
then cut and paste the address into the address
|
||||
line at the top of your web browser window.
|
||||
|
||||
If you need help, please contact the site administrator,
|
||||
If you need help, please contact the site administrator.
|
||||
{$a->admin}
|
||||
|
||||
If you did not do this, someone else could be trying to compromise your account.
|
||||
@@ -53,16 +48,12 @@ A request has been made to link the {$a->issuername} login
|
||||
{$a->linkedemail} to your account at \'{$a->sitename}\'
|
||||
using your email address.
|
||||
|
||||
To confirm this request and link these logins, please go to this web address:
|
||||
To confirm this request and link these logins, please click the link below:
|
||||
|
||||
{$a->link}
|
||||
<a href="{$a->link}">Link your accounts</a>
|
||||
|
||||
In most mail programs, this should appear as a blue link
|
||||
which you can just click on. If that doesn\'t work,
|
||||
then cut and paste the address into the address
|
||||
line at the top of your web browser window.
|
||||
|
||||
If you need help, please contact the site administrator,
|
||||
If you need help, please contact the site administrator.
|
||||
{$a->admin}
|
||||
|
||||
If you did not do this, someone else could be trying to compromise your account.
|
||||
|
||||
+8
-8
@@ -32,7 +32,7 @@ Feature: Confirm that availability_completion works with previous activity setti
|
||||
And I expand all fieldsets
|
||||
And I click on "Add restriction..." "button"
|
||||
And I click on "Activity completion" "button" in the "Add restriction..." "dialogue"
|
||||
And I click on "Displayed if student doesn't meet this condition • Click to hide" "link"
|
||||
And I click on "Item name displayed with access restriction information if student doesn't meet this condition • Click to hide" "link"
|
||||
And I set the field "Activity or resource" to "Previous activity with completion"
|
||||
And I press "Save and return to course"
|
||||
Then I should see "Not available unless: The previous activity with completion" in the "region-main" "region"
|
||||
@@ -58,7 +58,7 @@ Feature: Confirm that availability_completion works with previous activity setti
|
||||
And I expand all fieldsets
|
||||
And I click on "Add restriction..." "button"
|
||||
And I click on "Activity completion" "button" in the "Add restriction..." "dialogue"
|
||||
And I click on "Displayed if student doesn't meet this condition • Click to hide" "link"
|
||||
And I click on "Item name displayed with access restriction information if student doesn't meet this condition • Click to hide" "link"
|
||||
And I set the field "Activity or resource" to "Previous activity with completion"
|
||||
And I press "Save and return to course"
|
||||
Then I should see "Not available unless: The previous activity with completion" in the "region-main" "region"
|
||||
@@ -83,7 +83,7 @@ Feature: Confirm that availability_completion works with previous activity setti
|
||||
And I expand all fieldsets
|
||||
And I click on "Add restriction..." "button"
|
||||
And I click on "Activity completion" "button" in the "Add restriction..." "dialogue"
|
||||
And I click on "Displayed if student doesn't meet this condition • Click to hide" "link"
|
||||
And I click on "Item name displayed with access restriction information if student doesn't meet this condition • Click to hide" "link"
|
||||
And I set the field "Activity or resource" to "Previous activity with completion"
|
||||
And I press "Save and return to course"
|
||||
Then I should see "Not available unless: The previous activity with completion" in the "region-main" "region"
|
||||
@@ -112,7 +112,7 @@ Feature: Confirm that availability_completion works with previous activity setti
|
||||
And I expand all fieldsets
|
||||
And I click on "Add restriction..." "button"
|
||||
And I click on "Activity completion" "button" in the "Add restriction..." "dialogue"
|
||||
And I click on "Displayed if student doesn't meet this condition • Click to hide" "link"
|
||||
And I click on "Item name displayed with access restriction information if student doesn't meet this condition • Click to hide" "link"
|
||||
And I set the field "Activity or resource" to "Previous activity with completion"
|
||||
And I press "Save changes"
|
||||
Then I should see "Not available unless: The previous activity with completion" in the "region-main" "region"
|
||||
@@ -138,7 +138,7 @@ Feature: Confirm that availability_completion works with previous activity setti
|
||||
And I expand all fieldsets
|
||||
And I click on "Add restriction..." "button"
|
||||
And I click on "Activity completion" "button" in the "Add restriction..." "dialogue"
|
||||
And I click on "Displayed if student doesn't meet this condition • Click to hide" "link"
|
||||
And I click on "Item name displayed with access restriction information if student doesn't meet this condition • Click to hide" "link"
|
||||
Then the "Activity or resource" select box should not contain "Previous activity with completion"
|
||||
|
||||
# Set Page2 restriction to Previous Activity with completion and delete Page1.
|
||||
@@ -148,7 +148,7 @@ Feature: Confirm that availability_completion works with previous activity setti
|
||||
And I expand all fieldsets
|
||||
And I click on "Add restriction..." "button"
|
||||
And I click on "Activity completion" "button" in the "Add restriction..." "dialogue"
|
||||
And I click on "Displayed if student doesn't meet this condition • Click to hide" "link"
|
||||
And I click on "Item name displayed with access restriction information if student doesn't meet this condition • Click to hide" "link"
|
||||
And I set the field "Activity or resource" to "Previous activity with completion"
|
||||
And I press "Save and return to course"
|
||||
Then I should see "Not available unless: The previous activity with completion" in the "region-main" "region"
|
||||
@@ -170,7 +170,7 @@ Feature: Confirm that availability_completion works with previous activity setti
|
||||
And I expand all fieldsets
|
||||
And I click on "Add restriction..." "button"
|
||||
And I click on "Activity completion" "button" in the "Add restriction..." "dialogue"
|
||||
And I click on "Displayed if student doesn't meet this condition • Click to hide" "link"
|
||||
And I click on "Item name displayed with access restriction information if student doesn't meet this condition • Click to hide" "link"
|
||||
And I set the field "Activity or resource" to "Previous activity with completion"
|
||||
And I press "Save changes"
|
||||
Then I should see "Not available unless: The previous activity with completion" in the "region-main" "region"
|
||||
@@ -184,7 +184,7 @@ Feature: Confirm that availability_completion works with previous activity setti
|
||||
And I expand all fieldsets
|
||||
And I click on "Add restriction..." "button"
|
||||
And I click on "Activity completion" "button" in the "Add restriction..." "dialogue"
|
||||
And I click on "Displayed if student doesn't meet this condition • Click to hide" "link"
|
||||
And I click on "Item name displayed with access restriction information if student doesn't meet this condition • Click to hide" "link"
|
||||
And I set the field "Activity or resource" to "Previous activity with completion"
|
||||
And I press "Save changes"
|
||||
And I should see "Not available unless: The previous activity with completion" in the "region-main" "region"
|
||||
|
||||
@@ -180,4 +180,4 @@ Feature: Display availability for activities and sections
|
||||
And I should not see "Date" in the "Restrict access" "fieldset"
|
||||
And I press "Add restriction..."
|
||||
And I click on "Grade" "button" in the "Add restriction..." "dialogue"
|
||||
And the "alt" attribute of ".availability-item .availability-eye img" "css_element" should contain "Displayed if student"
|
||||
And the "alt" attribute of ".availability-item .availability-eye img" "css_element" should contain "Item name displayed"
|
||||
|
||||
@@ -89,13 +89,13 @@ Feature: edit_availability
|
||||
And I should see "Date" in the "Restrict access" "fieldset"
|
||||
And ".availability-item .availability-eye img" "css_element" should be visible
|
||||
And ".availability-item .availability-delete img" "css_element" should be visible
|
||||
And the "alt" attribute of ".availability-item .availability-eye img" "css_element" should contain "Displayed if student"
|
||||
And the "alt" attribute of ".availability-item .availability-eye img" "css_element" should contain "Item name displayed"
|
||||
|
||||
# Toggle the eye icon.
|
||||
When I click on ".availability-item .availability-eye img" "css_element"
|
||||
Then the "alt" attribute of ".availability-item .availability-eye img" "css_element" should contain "Hidden entirely"
|
||||
When I click on ".availability-item .availability-eye img" "css_element"
|
||||
Then the "alt" attribute of ".availability-item .availability-eye img" "css_element" should contain "Displayed if student"
|
||||
Then the "alt" attribute of ".availability-item .availability-eye img" "css_element" should contain "Item name displayed"
|
||||
|
||||
# Click the delete button.
|
||||
When I click on ".availability-item .availability-delete img" "css_element"
|
||||
|
||||
@@ -2649,6 +2649,7 @@ class backup_questions_structure_step extends backup_structure_step {
|
||||
'questioncategoryid',
|
||||
'idnumber',
|
||||
'ownerid',
|
||||
'nextversion',
|
||||
]);
|
||||
|
||||
$questionversions = new backup_nested_element('question_version');
|
||||
@@ -2725,7 +2726,7 @@ class backup_questions_structure_step extends backup_structure_step {
|
||||
WHERE bi.backupid = ?
|
||||
AND bi.itemname = 'question_categoryfinal'", [backup::VAR_BACKUPID]);
|
||||
|
||||
// Add all question bank entries from "complete" categories, plus annotated question bank entires
|
||||
// Add all question bank entries from "complete" categories, plus annotated question bank entires and their children
|
||||
// from "partial" categories.
|
||||
$questionbankentry->set_source_sql(
|
||||
"
|
||||
@@ -2737,8 +2738,11 @@ class backup_questions_structure_step extends backup_structure_step {
|
||||
UNION
|
||||
SELECT qbe.*
|
||||
FROM {question_bank_entries} qbe
|
||||
JOIN {question_versions} qv ON qv.questionbankentryid = qbe.id
|
||||
JOIN {question} q ON q.id = qv.questionid
|
||||
LEFT JOIN {question_versions} parentqv ON parentqv.questionid = q.parent
|
||||
JOIN {question_category_partial_temp} qcp ON qcp.itemid = qbe.questioncategoryid
|
||||
JOIN {backup_ids_temp} biq ON biq.itemid = qbe.id
|
||||
JOIN {backup_ids_temp} biq ON biq.itemid = qbe.id OR biq.itemid = parentqv.questionbankentryid
|
||||
WHERE qcp.itemid = ?
|
||||
AND qcp.backupid = ?
|
||||
AND biq.backupid = ?
|
||||
|
||||
@@ -27,6 +27,9 @@
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
use core_question\local\bank\question_version_status;
|
||||
use core_question\versions;
|
||||
|
||||
/**
|
||||
* delete old directories and conditionally create backup_temp_ids table
|
||||
*/
|
||||
@@ -5289,6 +5292,15 @@ class restore_create_categories_and_questions extends restore_structure_step {
|
||||
$this->set_mapping('question_bank_entry', $this->latestqbe->oldid, $this->latestqbe->newid);
|
||||
}
|
||||
|
||||
if (
|
||||
($data->qtype === 'random')
|
||||
&& ($this->latestversion->status == \core_question\local\bank\question_version_status::QUESTION_STATUS_HIDDEN)
|
||||
) {
|
||||
// Ensure that this newly created question is considered by
|
||||
// \qtype_random\task\remove_unused_questions.
|
||||
$this->latestversion->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_DRAFT;
|
||||
}
|
||||
|
||||
// Now store the question.
|
||||
$newitemid = $DB->insert_record('question', $data);
|
||||
$this->set_mapping('question', $oldid, $newitemid);
|
||||
@@ -5301,9 +5313,55 @@ class restore_create_categories_and_questions extends restore_structure_step {
|
||||
$oldqvid = $this->latestversion->id;
|
||||
$this->latestversion->questionbankentryid = $this->latestqbe->newid;
|
||||
$this->latestversion->questionid = $newitemid;
|
||||
// In case the backed up version was deleted and a new one created in its place, increase the version numbers of
|
||||
// conflicting versions to make room for this one.
|
||||
$transaction = $DB->start_delegated_transaction();
|
||||
if (
|
||||
$DB->record_exists(
|
||||
'question_versions',
|
||||
[
|
||||
'questionbankentryid' => $this->latestversion->questionbankentryid,
|
||||
'version' => $this->latestversion->version,
|
||||
],
|
||||
)
|
||||
) {
|
||||
// We'll update each higher version and any references one-at-a-time, starting with the highest, to avoid
|
||||
// creating a duplicate questionbankentryid-version combination in question_versions.
|
||||
$moveversions = $DB->get_records_select(
|
||||
'question_versions',
|
||||
'questionbankentryid = :questionbankentryid AND version >= :oldversion',
|
||||
[
|
||||
'questionbankentryid' => $this->latestversion->questionbankentryid,
|
||||
'oldversion' => $this->latestversion->version,
|
||||
],
|
||||
'version DESC',
|
||||
);
|
||||
foreach ($moveversions as $moveversion) {
|
||||
$DB->set_field(
|
||||
'question_versions',
|
||||
'version',
|
||||
$moveversion->version + 1,
|
||||
[
|
||||
'questionbankentryid' => $moveversion->questionbankentryid,
|
||||
'version' => $moveversion->version,
|
||||
]
|
||||
);
|
||||
$DB->set_field(
|
||||
'question_references',
|
||||
'version',
|
||||
$moveversion->version + 1,
|
||||
[
|
||||
'questionbankentryid' => $moveversion->questionbankentryid,
|
||||
'version' => $moveversion->version,
|
||||
]
|
||||
);
|
||||
}
|
||||
// Ensure the nextversion value has been initialised, and increment it to account for the additional version.
|
||||
versions::get_next_version($this->latestversion->questionbankentryid);
|
||||
}
|
||||
$newqvid = $DB->insert_record('question_versions', $this->latestversion);
|
||||
$this->set_mapping('question_versions', $oldqvid, $newqvid);
|
||||
|
||||
$transaction->allow_commit();
|
||||
} else {
|
||||
// By performing this set_mapping() we make get_old/new_parentid() to work for all the
|
||||
// children elements of the 'question' one (so qtype plugins will know the question they belong to).
|
||||
@@ -5311,6 +5369,18 @@ class restore_create_categories_and_questions extends restore_structure_step {
|
||||
|
||||
// Also create the question_bank_entry and version mappings, if required.
|
||||
$newquestionversion = $DB->get_record('question_versions', ['questionid' => $questionmapping->newitemid]);
|
||||
// Restore the version to ready state if it has been hidden.
|
||||
if (
|
||||
$newquestionversion->status == question_version_status::QUESTION_STATUS_HIDDEN
|
||||
&& $this->latestversion->status == question_version_status::QUESTION_STATUS_READY
|
||||
) {
|
||||
$DB->set_field(
|
||||
'question_versions',
|
||||
'status',
|
||||
question_version_status::QUESTION_STATUS_READY,
|
||||
['questionid' => $questionmapping->newitemid],
|
||||
);
|
||||
}
|
||||
$this->set_mapping('question_versions', $this->latestversion->id, $newquestionversion->id);
|
||||
if (empty($this->latestqbe->newid)) {
|
||||
$this->latestqbe->oldid = $this->latestqbe->id;
|
||||
@@ -5485,7 +5555,11 @@ class restore_move_module_questions_categories extends restore_execution_step {
|
||||
// but if that context still exists on the site and the user has access then point question references
|
||||
// to the originals.
|
||||
$originalcontext = context::instance_by_id($contextid, IGNORE_MISSING);
|
||||
if ($originalcontext && has_capability('mod/qbank:view', $originalcontext)) {
|
||||
if (
|
||||
$this->task->is_samesite()
|
||||
&& $originalcontext
|
||||
&& has_capability('mod/qbank:view', $originalcontext)
|
||||
) {
|
||||
$originalquestions = get_questions_category(question_get_top_category($contextid), false);
|
||||
$targetcoursecontext = context_course::instance($this->get_courseid());
|
||||
foreach ($originalquestions as $originalquestion) {
|
||||
|
||||
@@ -32,6 +32,14 @@ use core_backup\hook\fixtures\copy_course_hook_callbacks;
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
final class copy_course_hook_test extends advanced_testcase {
|
||||
/**
|
||||
* Load required test libraries
|
||||
*/
|
||||
public static function setUpBeforeClass(): void {
|
||||
global $CFG;
|
||||
require_once("{$CFG->dirroot}/backup/util/includes/backup_includes.php");
|
||||
parent::setUpBeforeClass();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the hook.
|
||||
|
||||
@@ -464,6 +464,39 @@ abstract class restore_dbops {
|
||||
$xmlparser->process();
|
||||
}
|
||||
|
||||
/**
|
||||
* Store ids associated with any activity in the backup that supports FEATURE_PUBLISHES_QUESTIONS.
|
||||
*
|
||||
* @param string $restoreid The restore ID.
|
||||
* @param string $activitiespath The path to the `activities` folder in the backup being restored.
|
||||
*/
|
||||
public static function load_questionbanks_to_tempids(string $restoreid, string $activitiespath): void {
|
||||
if (!is_dir($activitiespath)) {
|
||||
return;
|
||||
}
|
||||
// Get modules that publish questions.
|
||||
$qmodules = array_filter(
|
||||
array_keys(core\component::get_all_plugins_list('mod')),
|
||||
fn($module) => plugin_supports('mod', $module, FEATURE_PUBLISHES_QUESTIONS),
|
||||
);
|
||||
foreach (scandir($activitiespath) as $activitydir) {
|
||||
[$modname] = explode('_', $activitydir);
|
||||
if (!in_array($modname, $qmodules)) {
|
||||
continue;
|
||||
}
|
||||
$activityfile = "{$activitiespath}/{$activitydir}/{$modname}.xml";
|
||||
if (!file_exists($activityfile)) { // Shouldn't happen ever, but...
|
||||
throw new backup_helper_exception('missing_moodle_backup_xml_file', $activityfile);
|
||||
}
|
||||
// Parse each activity's file, storing the relevant data in the database.
|
||||
$xmlparser = new progressive_parser();
|
||||
$xmlparser->set_file($activityfile);
|
||||
$xmlprocessor = new restore_questionbanks_parser_processor($restoreid);
|
||||
$xmlparser->set_processor($xmlprocessor);
|
||||
$xmlparser->process();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all the included categories and questions, deciding the action to perform
|
||||
* for each one (mapping / creation) and returning one array of problems in case
|
||||
@@ -609,9 +642,18 @@ abstract class restore_dbops {
|
||||
$topcats = 0;
|
||||
// get categories in context (bank)
|
||||
$categories = self::restore_get_question_categories($restoreid, $contextid, $contextlevel);
|
||||
|
||||
// cache permissions if $targetcontext is found
|
||||
if ($targetcontext = self::restore_find_best_target_context($categories, $courseid, $contextlevel)) {
|
||||
if (
|
||||
$contextlevel == \core\context\module::LEVEL
|
||||
&& self::get_backup_ids_record($restoreid, 'questionbank', $contextid)
|
||||
) {
|
||||
// Don't look for an existing module context, we have the original context in the backup,
|
||||
// so we'll put the categories in the course context for now and move them once the activity is restored.
|
||||
$targetcontext = core\context\course::instance($courseid);
|
||||
} else {
|
||||
$targetcontext = self::restore_find_best_target_context($categories, $courseid, $contextlevel);
|
||||
}
|
||||
if ($targetcontext) {
|
||||
// Cache permissions if $targetcontext is found.
|
||||
$canmanagecategory = has_capability('moodle/question:managecategory', $targetcontext, $userid);
|
||||
$canadd = has_capability('moodle/question:add', $targetcontext, $userid);
|
||||
}
|
||||
|
||||
@@ -173,6 +173,7 @@ abstract class restore_prechecks_helper {
|
||||
$progress->progress($majorstep++);
|
||||
|
||||
// Check we are able to restore and the categories and questions
|
||||
restore_dbops::load_questionbanks_to_tempids($restoreid, $controller->get_plan()->get_basepath() . '/activities');
|
||||
$file = $controller->get_plan()->get_basepath() . '/questions.xml';
|
||||
restore_dbops::load_categories_and_questions_to_tempids($restoreid, $file);
|
||||
if ($problems = restore_dbops::precheck_categories_and_questions($restoreid, $courseid, $userid, $samesite)) {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<?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/>.
|
||||
|
||||
defined('MOODLE_INTERNAL' || die());
|
||||
|
||||
require_once($CFG->dirroot . '/backup/util/xml/parser/processors/grouped_parser_processor.class.php');
|
||||
|
||||
/**
|
||||
* Parse and store activity data for activities that publish questions.
|
||||
*
|
||||
* @package core
|
||||
* @copyright 2025 onwards Catalyst IT EU {@link https://catalyst-eu.net}
|
||||
* @author Mark Johnson <mark.johnson@catalyst-eu.net>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class restore_questionbanks_parser_processor extends grouped_parser_processor {
|
||||
/**
|
||||
* Store the restore ID and register paths.
|
||||
*
|
||||
* @param string $restoreid ID of the backup being restored.
|
||||
*/
|
||||
public function __construct(
|
||||
/** @var string ID of the backup being restored */
|
||||
protected string $restoreid,
|
||||
) {
|
||||
parent::__construct();
|
||||
$this->add_path('/activity');
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function dispatch_chunk($data): void {
|
||||
// Recieved one chunk, store the context ID as that's what we will match question categories against.
|
||||
$itemid = $data['tags']['contextid'];
|
||||
restore_dbops::set_backup_ids_record($this->restoreid, 'questionbank', $itemid);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function notify_path_start($path) {
|
||||
// Nothing to do.
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function notify_path_end($path) {
|
||||
// Nothing to do.
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ require_once($CFG->dirroot . '/backup/util/helper/backup_anonymizer_helper.class
|
||||
require_once($CFG->dirroot . '/backup/util/helper/backup_file_manager.class.php');
|
||||
require_once($CFG->dirroot . '/backup/util/helper/copy_helper.class.php');
|
||||
require_once($CFG->dirroot . '/backup/util/helper/restore_prechecks_helper.class.php');
|
||||
require_once($CFG->dirroot . '/backup/util/helper/restore_questionbanks_parser_processor.php');
|
||||
require_once($CFG->dirroot . '/backup/util/helper/restore_moodlexml_parser_processor.class.php');
|
||||
require_once($CFG->dirroot . '/backup/util/helper/restore_inforef_parser_processor.class.php');
|
||||
require_once($CFG->dirroot . '/backup/util/helper/restore_users_parser_processor.class.php');
|
||||
|
||||
@@ -104,6 +104,9 @@ class block_completionstatus extends block_base {
|
||||
// Flag to set if current completion data is inconsistent with what is stored in the database.
|
||||
$pending_update = false;
|
||||
|
||||
// Get activities visible to the user that have completion enabled.
|
||||
$visibleactivities = $info->get_user_activities_with_completion($USER->id);
|
||||
|
||||
// Loop through course criteria.
|
||||
foreach ($completions as $completion) {
|
||||
$criteria = $completion->get_criteria();
|
||||
@@ -115,6 +118,11 @@ class block_completionstatus extends block_base {
|
||||
|
||||
// Activities are a special case, so cache them and leave them till last.
|
||||
if ($criteria->criteriatype == COMPLETION_CRITERIA_TYPE_ACTIVITY) {
|
||||
// Only include activities that are visible to the user.
|
||||
if (!isset($visibleactivities[$criteria->moduleinstance])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$activities[$criteria->moduleinstance] = $complete;
|
||||
|
||||
if ($complete) {
|
||||
|
||||
@@ -119,10 +119,21 @@ $pendingupdate = false;
|
||||
// Load criteria to display.
|
||||
$completions = $info->get_completions($user->id);
|
||||
|
||||
// Get activities visible to the user that have completion enabled.
|
||||
$visibleactivities = $info->get_user_activities_with_completion($user->id);
|
||||
|
||||
// Loop through course criteria.
|
||||
foreach ($completions as $completion) {
|
||||
$criteria = $completion->get_criteria();
|
||||
|
||||
// Skip display of activity completion criteria for activities the user cannot see.
|
||||
if (
|
||||
$criteria->criteriatype == COMPLETION_CRITERIA_TYPE_ACTIVITY &&
|
||||
!isset($visibleactivities[$criteria->moduleinstance])
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$pendingupdate && $criteria->is_pending($completion)) {
|
||||
$pendingupdate = true;
|
||||
}
|
||||
|
||||
+274
-11
@@ -6,9 +6,10 @@ Feature: Enable Block Completion in a course using activity completion
|
||||
|
||||
Background:
|
||||
Given the following "users" exist:
|
||||
| username | firstname | lastname | email | idnumber |
|
||||
| teacher1 | Teacher | 1 | teacher1@example.com | T1 |
|
||||
| student1 | Student | 1 | student1@example.com | S1 |
|
||||
| username | firstname | lastname | email | idnumber |
|
||||
| teacher1 | Teacher | 1 | teacher1@example.com | T1 |
|
||||
| student1 | Student | 1 | student1@example.com | S1 |
|
||||
| student2 | Student | 2 | student2@example.com | S2 |
|
||||
And the following "courses" exist:
|
||||
| fullname | shortname | category | enablecompletion |
|
||||
| Course 1 | C1 | 0 | 1 |
|
||||
@@ -16,6 +17,7 @@ Feature: Enable Block Completion in a course using activity completion
|
||||
| user | course | role |
|
||||
| teacher1 | C1 | editingteacher |
|
||||
| student1 | C1 | student |
|
||||
| student2 | C1 | student |
|
||||
And the following "activities" exist:
|
||||
| activity | course | idnumber | name | gradepass | completion | completionview | completionusegrade | completionpassgrade |
|
||||
| page | C1 | page1 | Test page name | | 2 | 1 | 0 | 0 |
|
||||
@@ -23,10 +25,12 @@ Feature: Enable Block Completion in a course using activity completion
|
||||
And the following "blocks" exist:
|
||||
| blockname | contextlevel | reference | pagetypepattern | defaultregion |
|
||||
| completionstatus | Course | C1 | course-view-* | side-pre |
|
||||
And I am on the "Course 1" course page logged in as teacher1
|
||||
And I change window size to "large"
|
||||
And I turn editing mode on
|
||||
|
||||
Scenario: Completion status block when student has not started any activities
|
||||
Given I am on the "Course 1" course page logged in as teacher1
|
||||
And I navigate to "Course completion" in current page administration
|
||||
Given I navigate to "Course completion" in current page administration
|
||||
And I expand all fieldsets
|
||||
And I set the following fields to these values:
|
||||
| Test page name | 1 |
|
||||
@@ -36,8 +40,7 @@ Feature: Enable Block Completion in a course using activity completion
|
||||
And I should see "0 of 1" in the "Activity completion" "table_row"
|
||||
|
||||
Scenario: Completion status block when student has completed a page
|
||||
Given I am on the "Course 1" course page logged in as teacher1
|
||||
And I navigate to "Course completion" in current page administration
|
||||
Given I navigate to "Course completion" in current page administration
|
||||
And I expand all fieldsets
|
||||
And I set the following fields to these values:
|
||||
| Test page name | 1 |
|
||||
@@ -50,8 +53,7 @@ Feature: Enable Block Completion in a course using activity completion
|
||||
And I should see "Yes" in the "Activity completion" "table_row"
|
||||
|
||||
Scenario: Completion status block with items with passing grade
|
||||
Given I am on the "Course 1" course page logged in as teacher1
|
||||
And I navigate to "Course completion" in current page administration
|
||||
Given I navigate to "Course completion" in current page administration
|
||||
And I expand all fieldsets
|
||||
And I set the following fields to these values:
|
||||
| Test assign name | 1 |
|
||||
@@ -69,8 +71,7 @@ Feature: Enable Block Completion in a course using activity completion
|
||||
And I should see "Yes" in the "Activity completion" "table_row"
|
||||
|
||||
Scenario: Completion status block with items with failing grade
|
||||
Given I am on the "Course 1" course page logged in as teacher1
|
||||
And the following "grade grades" exist:
|
||||
Given the following "grade grades" exist:
|
||||
| gradeitem | user | grade |
|
||||
| Test assign name | student1 | 49 |
|
||||
And I navigate to "Course completion" in current page administration
|
||||
@@ -86,3 +87,265 @@ Feature: Enable Block Completion in a course using activity completion
|
||||
And I follow "More details"
|
||||
And I should see "Achieving grade, Achieving passing grade" in the "Activity completion" "table_row"
|
||||
And I should see "No" in the "Activity completion" "table_row"
|
||||
|
||||
@javascript
|
||||
Scenario: Student visibility respects combined activity and section restrictions with progressive completion
|
||||
Given the following "activities" exist:
|
||||
| activity | name | intro | course | idnumber | section | visible | completion | completionview |
|
||||
| page | task A | page description | C1 | page1 | 0 | 1 | 2 | 1 |
|
||||
| page | task B | page description | C1 | page2 | 1 | 1 | 2 | 1 |
|
||||
| assign | task C | assignment description | C1 | assign1 | 1 | 1 | 2 | 1 |
|
||||
And I navigate to "Course completion" in current page administration
|
||||
And I expand all fieldsets
|
||||
And I set the following fields to these values:
|
||||
| Page - task A | 1 |
|
||||
| Page - task B | 1 |
|
||||
| Assignment - task C | 1 |
|
||||
And I press "Save changes"
|
||||
# Add conditionally visible restriction (open eye) to section 1 requiring task A completion.
|
||||
And I turn editing mode on
|
||||
And I edit the section "1"
|
||||
And I expand all fieldsets
|
||||
And I click on "Add restriction..." "button"
|
||||
And I click on "Activity completion" "button" in the "Add restriction..." "dialogue"
|
||||
And I set the field "Activity or resource" to "task A"
|
||||
And I press "Save changes"
|
||||
# Add conditionally hidden restriction (closed eye) to task C requiring task A completion.
|
||||
And I am on the "task C" "assign activity editing" page
|
||||
And I expand all fieldsets
|
||||
And I click on "Add restriction..." "button"
|
||||
And I click on "Activity completion" "button" in the "Add restriction..." "dialogue"
|
||||
And I click on "Item name displayed with access restriction information if student doesn't meet this condition • Click to hide" "link"
|
||||
And I set the field "Activity or resource" to "task A"
|
||||
And I press "Save and return to course"
|
||||
And I log out
|
||||
# Initial state: Only unrestricted visible activities appear.
|
||||
When I am on the "Course 1" course page logged in as student1
|
||||
Then I should see "Status: Not yet started"
|
||||
And I should see "0 of 1" in the "Activity completion" "table_row"
|
||||
And I follow "More details"
|
||||
And I should see "task A" in the "criteriastatus" "table"
|
||||
And I should not see "task B" in the "criteriastatus" "table"
|
||||
And I should not see "task C" in the "criteriastatus" "table"
|
||||
# After completing task A: Section 2 activities become visible.
|
||||
And I click on "task A" "link"
|
||||
And I am on the "Course 1" course page logged in as student1
|
||||
And I should see "Status: In progress"
|
||||
And I should see "1 of 3" in the "Activity completion" "table_row"
|
||||
And I follow "More details"
|
||||
And I should see "task A" in the "criteriastatus" "table"
|
||||
And I should see "task B" in the "criteriastatus" "table"
|
||||
And I should see "task C" in the "criteriastatus" "table"
|
||||
|
||||
@javascript
|
||||
Scenario: Student completion view shows only accessible activities considering all activity restrictions
|
||||
Given the following "activities" exist:
|
||||
| activity | name | intro | course | idnumber | section | visible | completion | completionview |
|
||||
| page | task A | page description | C1 | page1 | 0 | 1 | 2 | 1 |
|
||||
| page | task B | page description | C1 | assign1 | 1 | 1 | 2 | 1 |
|
||||
| assign | task C | assignment description | C1 | assign2 | 1 | 1 | 2 | 1 |
|
||||
And I navigate to "Course completion" in current page administration
|
||||
And I expand all fieldsets
|
||||
# Set completion of the activities.
|
||||
And I set the following fields to these values:
|
||||
| Page - task A | 1 |
|
||||
| Page - task B | 1 |
|
||||
| Assignment - task C | 1 |
|
||||
And I press "Save changes"
|
||||
# Add conditionally visible restriction (open eye) to "task B".
|
||||
And I am on the "task B" "page activity editing" page
|
||||
And I expand all fieldsets
|
||||
And I click on "Add restriction..." "button"
|
||||
And I click on "Activity completion" "button" in the "Add restriction..." "dialogue"
|
||||
And I set the field "Activity or resource" to "task A"
|
||||
And I press "Save and return to course"
|
||||
# Add conditionally hidden restriction (closed eye) to "task C".
|
||||
And I am on the "task C" "assign activity editing" page
|
||||
And I expand all fieldsets
|
||||
And I click on "Add restriction..." "button"
|
||||
And I click on "Activity completion" "button" in the "Add restriction..." "dialogue"
|
||||
And I click on "Item name displayed with access restriction information if student doesn't meet this condition • Click to hide" "link"
|
||||
And I set the field "Activity or resource" to "task A"
|
||||
And I press "Save and return to course"
|
||||
When I am on the "Course 1" course page logged in as student1
|
||||
And I should see "Status: Not yet started"
|
||||
And I should see "0 of 2" in the "Activity completion" "table_row"
|
||||
And I follow "More details"
|
||||
Then I should see "task A" in the "criteriastatus" "table"
|
||||
And I should see "task B" in the "criteriastatus" "table"
|
||||
And I should not see "task C" in the "criteriastatus" "table"
|
||||
And I click on "task A" "link"
|
||||
# Complete task A to make task C visible.
|
||||
And I am on the "Course 1" course page logged in as student1
|
||||
And I should see "Status: In progress"
|
||||
And I should see "1 of 3" in the "Activity completion" "table_row"
|
||||
And I follow "More details"
|
||||
And I should see "task A" in the "criteriastatus" "table"
|
||||
And I should see "task B" in the "criteriastatus" "table"
|
||||
And I should see "task C" in the "criteriastatus" "table"
|
||||
|
||||
@javascript
|
||||
Scenario: Hidden activities do not appear in the completion status block
|
||||
Given the following "activities" exist:
|
||||
| activity | name | intro | course | idnumber | section | visible | completion | completionview |
|
||||
| page | task A | page description | C1 | page1 | 0 | 1 | 2 | 1 |
|
||||
| page | task B | page description | C1 | page2 | 0 | 0 | 2 | 1 |
|
||||
And I navigate to "Course completion" in current page administration
|
||||
And I expand all fieldsets
|
||||
# Set completion of the activities.
|
||||
And I set the following fields to these values:
|
||||
| Page - task A | 1 |
|
||||
| Page - task B | 1 |
|
||||
And I press "Save changes"
|
||||
When I am on the "Course 1" course page logged in as student1
|
||||
And I should see "Status: Not yet started"
|
||||
And I should see "0 of 1" in the "Activity completion" "table_row"
|
||||
And I follow "More details"
|
||||
And I should see "task A" in the "criteriastatus" "table"
|
||||
Then I should not see "task B" in the "criteriastatus" "table"
|
||||
|
||||
@javascript
|
||||
Scenario: Activities in the hidden section do not appear in the completion status block
|
||||
Given the following "activities" exist:
|
||||
| activity | name | intro | course | idnumber | section | visible | completion | completionview |
|
||||
| page | task A | page description | C1 | page1 | 0 | 1 | 2 | 1 |
|
||||
| page | task B | page description | C1 | page2 | 1 | 1 | 2 | 1 |
|
||||
| assign | task C | page description | C1 | page3 | 2 | 1 | 2 | 1 |
|
||||
| assign | task D | page description | C1 | page4 | 3 | 1 | 2 | 1 |
|
||||
And I navigate to "Course completion" in current page administration
|
||||
And I expand all fieldsets
|
||||
# Set completion of the activities.
|
||||
And I set the following fields to these values:
|
||||
| Page - task A | 1 |
|
||||
| Page - task B | 1 |
|
||||
| Assignment - task C | 1 |
|
||||
| Assignment - task D | 1 |
|
||||
And I press "Save changes"
|
||||
# Hide section 1 to make sure book activities are not visible to the student.
|
||||
And I hide section "1"
|
||||
# Add conditionally visible restriction to section 2.
|
||||
And I edit the section "2"
|
||||
And I expand all fieldsets
|
||||
And I click on "Add restriction..." "button"
|
||||
And I click on "Activity completion" "button" in the "Add restriction..." "dialogue"
|
||||
And I set the field "Activity or resource" to "task A"
|
||||
And I press "Save changes"
|
||||
# Add conditionally hidden restriction to section 3.
|
||||
And I am on the "Course 1" course page
|
||||
And I edit the section "3"
|
||||
And I expand all fieldsets
|
||||
And I click on "Add restriction..." "button"
|
||||
And I click on "Activity completion" "button" in the "Add restriction..." "dialogue"
|
||||
And I set the field "Activity or resource" to "task A"
|
||||
And I click on "Item name displayed with access restriction information if student doesn't meet this condition • Click to hide" "link"
|
||||
And I press "Save changes"
|
||||
And I log out
|
||||
When I am on the "Course 1" course page logged in as student1
|
||||
And I should see "Status: Not yet started"
|
||||
And I should see "0 of 1" in the "Activity completion" "table_row"
|
||||
And I follow "More details"
|
||||
Then I should see "task A" in the "criteriastatus" "table"
|
||||
And I should not see "task B" in the "criteriastatus" "table"
|
||||
And I should not see "task C" in the "criteriastatus" "table"
|
||||
And I should not see "task D" in the "criteriastatus" "table"
|
||||
# Complete task A to make other activities visible.
|
||||
And I click on "task A" "link"
|
||||
And I am on the "Course 1" course page logged in as student1
|
||||
And I should see "Status: In progress"
|
||||
And I should see "1 of 3" in the "Activity completion" "table_row"
|
||||
And I follow "More details"
|
||||
And I should see "task A" in the "criteriastatus" "table"
|
||||
And I should not see "task B" in the "criteriastatus" "table"
|
||||
And I should see "task C" in the "criteriastatus" "table"
|
||||
And I should see "task D" in the "criteriastatus" "table"
|
||||
|
||||
@javascript
|
||||
Scenario: Activities with disabled completion tracking are omitted from the completion view
|
||||
Given the following "activities" exist:
|
||||
| activity | name | intro | course | idnumber | section | visible | completion | completionview |
|
||||
| page | task A | page description | C1 | page1 | 0 | 1 | 2 | 1 |
|
||||
| page | task B | page description | C1 | page2 | 1 | 1 | 2 | 1 |
|
||||
And I navigate to "Course completion" in current page administration
|
||||
And I expand all fieldsets
|
||||
# Set completion of the activities.
|
||||
And I set the following fields to these values:
|
||||
| Page - task A | 1 |
|
||||
| Page - task B | 1 |
|
||||
And I press "Save changes"
|
||||
# Disable completion tracking for "task A".
|
||||
And I am on the "task A" "page activity editing" page
|
||||
And I expand all fieldsets
|
||||
And I set the following fields to these values:
|
||||
| completion | 0 |
|
||||
And I press "Save and return to course"
|
||||
When I am on the "Course 1" course page logged in as student1
|
||||
And I should see "Status: Not yet started"
|
||||
And I should see "0 of 1" in the "Activity completion" "table_row"
|
||||
And I follow "More details"
|
||||
Then I should not see "task A" in the "criteriastatus" "table"
|
||||
And I should see "task B" in the "criteriastatus" "table"
|
||||
|
||||
@javascript
|
||||
Scenario: Activities with group or grouping restrictions are omitted from the completion view
|
||||
Given the following "activities" exist:
|
||||
| activity | name | intro | course | idnumber | section | visible | completion | completionview |
|
||||
| page | task A | page description | C1 | page1 | 0 | 1 | 2 | 1 |
|
||||
| page | task B | page description | C1 | page2 | 1 | 1 | 2 | 1 |
|
||||
| page | task C | page description | C1 | page3 | 2 | 1 | 2 | 1 |
|
||||
And I navigate to "Course completion" in current page administration
|
||||
And I expand all fieldsets
|
||||
# Set completion of the activities.
|
||||
And I set the following fields to these values:
|
||||
| Page - task A | 1 |
|
||||
| Page - task B | 1 |
|
||||
| Page - task C | 1 |
|
||||
And I press "Save changes"
|
||||
# Add groups and groupings.
|
||||
And the following "groups" exist:
|
||||
| name | course | idnumber |
|
||||
| G1 | C1 | GI1 |
|
||||
| G2 | C1 | GI2 |
|
||||
And the following "groupings" exist:
|
||||
| name | course | idnumber |
|
||||
| Grouping 1 | C1 | GG1 |
|
||||
| Grouping 2 | C1 | GG2 |
|
||||
And the following "grouping groups" exist:
|
||||
| grouping | group |
|
||||
| GG1 | GI1 |
|
||||
| GG2 | GI2 |
|
||||
# Add students to groups.
|
||||
And the following "group members" exist:
|
||||
| user | group |
|
||||
| student1 | GI1 |
|
||||
| student2 | GI2 |
|
||||
# Add group restriction to "task B".
|
||||
And I am on the "task B" "page activity editing" page
|
||||
And I expand all fieldsets
|
||||
And I click on "Add restriction..." "button"
|
||||
And I click on "Group" "button" in the "Add restriction..." "dialogue"
|
||||
And I set the field "Group" to "G1"
|
||||
And I click on "Save and return to course" "button"
|
||||
# Add Grouping and 'Activity or resource' restriction to "task C".
|
||||
And I am on the "task C" "page activity editing" page
|
||||
And I expand all fieldsets
|
||||
And I click on "Add restriction..." "button"
|
||||
And I click on "Grouping" "button" in the "Add restriction..." "dialogue"
|
||||
And I set the field "Grouping" to "Grouping 2"
|
||||
And I click on "Add restriction..." "button"
|
||||
And I click on "Activity completion" "button" in the "Add restriction..." "dialogue"
|
||||
And I set the field "Activity or resource" to "task A"
|
||||
And I click on "Save and return to course" "button"
|
||||
When I am on the "Course 1" course page logged in as student1
|
||||
And I should see "Status: Not yet started"
|
||||
And I should see "0 of 2" in the "Activity completion" "table_row"
|
||||
And I follow "More details"
|
||||
Then I should see "task A" in the "criteriastatus" "table"
|
||||
And I should see "task B" in the "criteriastatus" "table"
|
||||
And I should not see "task C" in the "criteriastatus" "table"
|
||||
And I am on the "Course 1" course page logged in as student2
|
||||
And I should see "Status: Not yet started"
|
||||
And I should see "0 of 2" in the "Activity completion" "table_row"
|
||||
And I follow "More details"
|
||||
And I should see "task A" in the "criteriastatus" "table"
|
||||
And I should not see "task B" in the "criteriastatus" "table"
|
||||
And I should see "task C" in the "criteriastatus" "table"
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
{}
|
||||
}}
|
||||
|
||||
<div id="block-myoverview-{{uniqid}}" class="block-myoverview block-cards" data-region="myoverview" role="navigation">
|
||||
<div id="block-myoverview-{{uniqid}}" class="block-myoverview block-cards" data-region="myoverview">
|
||||
<div role="search" data-region="filter" class="d-flex align-items-center my-2" aria-label="{{#str}} aria:controls, block_myoverview {{/str}}">
|
||||
<div class="d-flex flex-wrap gap-1 align-items-center w-100">
|
||||
{{> block_myoverview/nav-grouping-selector }}
|
||||
|
||||
@@ -44,14 +44,14 @@
|
||||
}
|
||||
}}
|
||||
|
||||
<div class="block-myoverview block-cards" data-region="myoverview" role="navigation">
|
||||
<div class="block-myoverview block-cards" data-region="myoverview">
|
||||
<div class="container-fluid p-0">
|
||||
<div class="text-xs-center text-center mt-3" data-region="empty-message">
|
||||
<img class="empty-placeholder-image-lg mt-1"
|
||||
src="{{nocoursesimg}}"
|
||||
alt=""
|
||||
>
|
||||
<h5 class="h5 mt-3 mb-0">{{{ title }}}</h5>
|
||||
<h4 class="h4 mt-3 mb-0">{{{ title }}}</h4>
|
||||
<p class="mt-3 mb-0">{{{ intro }}}</p>
|
||||
|
||||
<div class="mt-5 mb-0 whitebutton" id="action_bar">
|
||||
|
||||
@@ -314,6 +314,6 @@ Feature: The my overview block allows users to easily access their courses
|
||||
Then I should not see "Category 1" in the "Course overview" "block"
|
||||
|
||||
@accessibility
|
||||
Scenario: The dashboard page must have sufficient colour contrast
|
||||
Scenario: The My courses page must meet accessibility standards
|
||||
When I am on the "My courses" page logged in as "student1"
|
||||
Then the page should meet "wcag143" accessibility standards
|
||||
Then the page should meet accessibility standards with "best-practice" extra tests
|
||||
|
||||
@@ -88,4 +88,4 @@ Feature: Zero state on my overview block
|
||||
@javascript @accessibility
|
||||
Scenario: Evaluate the accessibility of the My courses (zero state)
|
||||
When I am on the "My courses" page logged in as "manager"
|
||||
Then the page should meet accessibility standards
|
||||
Then the page should meet accessibility standards with "best-practice" extra tests
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
require_once($CFG->dirroot . '/course/lib.php');
|
||||
require_once($CFG->dirroot. '/course/format/lib.php');
|
||||
|
||||
/**
|
||||
|
||||
@@ -70,7 +70,7 @@ class event_exporter_base extends exporter {
|
||||
|
||||
$data = new \stdClass();
|
||||
$data->id = $event->get_id();
|
||||
$data->name = $event->get_name();
|
||||
$data->name = clean_string($event->get_name());
|
||||
$data->description = file_rewrite_pluginfile_urls(
|
||||
$event->get_description()->get_value(),
|
||||
'pluginfile.php',
|
||||
|
||||
@@ -15,11 +15,11 @@ Feature: Perform basic calendar functionality
|
||||
| name | category | idnumber |
|
||||
| CatA | 0 | cata |
|
||||
And the following "courses" exist:
|
||||
| fullname | shortname | format | category |
|
||||
| Course 1 | C1 | topics | cata |
|
||||
| Course 2 | C2 | topics | cata |
|
||||
| Course 3 | C3 | topics | cata |
|
||||
| Course 4 | C4 | topics | cata |
|
||||
| fullname | shortname | format | category | startdate |
|
||||
| Course 1 | C1 | topics | cata | ##first day of last month## |
|
||||
| Course 2 | C2 | topics | cata | ##first day of last month## |
|
||||
| Course 3 | C3 | topics | cata | ##first day of last month## |
|
||||
| Course 4 | C4 | topics | cata | ##first day of last month## |
|
||||
And the following "course enrolments" exist:
|
||||
| user | course | role |
|
||||
| student1 | C1 | student |
|
||||
@@ -58,20 +58,24 @@ Feature: Perform basic calendar functionality
|
||||
Scenario: Create a course event
|
||||
Given I log in as "teacher1"
|
||||
And I create a calendar event with form data:
|
||||
| Type of event | course |
|
||||
| Course | Course 1 |
|
||||
| Event title | Really awesome event! |
|
||||
| Description | Come join this awesome event, sucka! |
|
||||
| Type of event | course |
|
||||
| Course | Course 1 |
|
||||
| timestart[day] | 1 |
|
||||
| Event title | Really awesome event! & < > " ' |
|
||||
| Description | Come join this awesome event, sucka! |
|
||||
And I log out
|
||||
When I am on the "Course 1" course page logged in as student1
|
||||
And I follow "Course calendar"
|
||||
And I click on "Really awesome event!" "link"
|
||||
And "Course 1" "link" should exist in the "Really awesome event!" "dialogue"
|
||||
And I click on "Close" "button" in the "Really awesome event!" "dialogue"
|
||||
And I hover over day "1" of this month in the full calendar page
|
||||
And I should see "Really awesome event! & < > \" '"
|
||||
And I click on "Really awesome event! & < > \" '" "link"
|
||||
And I should see "Really awesome event! & < > \" '" in the ".modal-title" "css_element"
|
||||
And I should see "Course 1" in the ".modal-body" "css_element"
|
||||
And I click on "Close" "button" in the ".modal-content" "css_element"
|
||||
And I log out
|
||||
And I log in as "student2"
|
||||
And I follow "Full calendar"
|
||||
Then I should not see "Really awesome event!"
|
||||
Then I should not see "Really awesome event! & < > \" '"
|
||||
|
||||
@javascript
|
||||
Scenario: Create a group event
|
||||
|
||||
+1
-1
@@ -28,4 +28,4 @@ if (!file_exists($configfile)) {
|
||||
die;
|
||||
}
|
||||
|
||||
require($configfile);
|
||||
require_once($configfile);
|
||||
|
||||
+8
-5
@@ -53,21 +53,24 @@ class course_image implements data_source_interface {
|
||||
* Loads the data for the key provided ready formatted for caching.
|
||||
*
|
||||
* @param string|int $key The key to load.
|
||||
* @return string|bool Returns course image url as a string or false if the image is not exist
|
||||
* @return string|null The course image URL path, or false if the image does not exist.
|
||||
*/
|
||||
public function load_for_cache($key) {
|
||||
// We should use get_course() instead of get_fast_modinfo() for better performance.
|
||||
$course = get_course($key);
|
||||
return $this->get_image_url_from_overview_files($course);
|
||||
$url = $this->get_image_url_from_overview_files($course);
|
||||
|
||||
// Return only the path.
|
||||
return $url?->out_as_local_url();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns image URL from course overview files.
|
||||
*
|
||||
* @param \stdClass $course Course object.
|
||||
* @return null|string Image URL or null if it's not exists.
|
||||
* @return null|moodle_url Image URL or null if the image does not exist.
|
||||
*/
|
||||
protected function get_image_url_from_overview_files(\stdClass $course): ?string {
|
||||
protected function get_image_url_from_overview_files(\stdClass $course): moodle_url|null {
|
||||
$courseinlist = new core_course_list_element($course);
|
||||
foreach ($courseinlist->get_course_overviewfiles() as $file) {
|
||||
if ($file->is_valid_image()) {
|
||||
@@ -78,7 +81,7 @@ class course_image implements data_source_interface {
|
||||
null,
|
||||
$file->get_filepath(),
|
||||
$file->get_filename()
|
||||
)->out();
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3263,6 +3263,7 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
|
||||
ON uc.roleid = rc.roleid
|
||||
AND ( $eqpaths OR $likechild )
|
||||
WHERE (ra.id IS NOT NULL OR uc.upath IS NOT NULL)
|
||||
ORDER BY cc.sortorder, cc.id
|
||||
";
|
||||
|
||||
$params = [
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
namespace core_course;
|
||||
|
||||
use core\{clock, di};
|
||||
use core\context;
|
||||
use core\context\course as context_course;
|
||||
use core\context\coursecat as context_coursecat;
|
||||
@@ -324,6 +325,7 @@ class course_request {
|
||||
$data->newsitems = $courseconfig->newsitems;
|
||||
$data->showgrades = $courseconfig->showgrades;
|
||||
$data->showreports = $courseconfig->showreports;
|
||||
$data->showactivitydates = $courseconfig->showactivitydates;
|
||||
$data->maxbytes = $courseconfig->maxbytes;
|
||||
$data->groupmode = $courseconfig->groupmode;
|
||||
$data->groupmodeforce = $courseconfig->groupmodeforce;
|
||||
@@ -332,9 +334,9 @@ class course_request {
|
||||
$data->lang = $courseconfig->lang;
|
||||
$data->enablecompletion = $courseconfig->enablecompletion;
|
||||
$data->numsections = $courseconfig->numsections;
|
||||
$data->startdate = usergetmidnight(time());
|
||||
$data->startdate = usergetmidnight(di::get(clock::class)->time());
|
||||
if ($courseconfig->courseenddateenabled) {
|
||||
$data->enddate = usergetmidnight(time()) + $courseconfig->courseduration;
|
||||
$data->enddate = $data->startdate + $courseconfig->courseduration;
|
||||
}
|
||||
|
||||
[$data->fullname, $data->shortname] = restore_dbops::calculate_course_names(0, $data->fullname, $data->shortname);
|
||||
|
||||
@@ -186,10 +186,11 @@ class course_summary_exporter extends \core\external\exporter {
|
||||
$image = \cache::make('core', 'course_image')->get($course->id);
|
||||
|
||||
if (is_null($image)) {
|
||||
$image = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
return $image;
|
||||
$url = new moodle_url($image);
|
||||
return $url->out();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -440,7 +440,7 @@ class course_edit_form extends moodleform {
|
||||
// When two elements we need a group.
|
||||
$buttonarray = array();
|
||||
$classarray = array('class' => 'form-submit');
|
||||
if ($returnto !== 0) {
|
||||
if (!empty($returnto)) {
|
||||
$buttonarray[] = &$mform->createElement('submit', 'saveandreturn', get_string('savechangesandreturn'), $classarray);
|
||||
}
|
||||
$buttonarray[] = &$mform->createElement('submit', 'saveanddisplay', get_string('savechangesanddisplay'), $classarray);
|
||||
|
||||
@@ -39,7 +39,18 @@ class editsection_form extends moodleform {
|
||||
$mform->setDefault('name', $sectioninfo->name);
|
||||
$mform->addRule('name', get_string('maximumchars', '', 1333), 'maxlength', 1333, 'client');
|
||||
|
||||
/// Prepare course and the editor
|
||||
// Prepare course and the editor.
|
||||
|
||||
if ($sectioninfo->is_delegated()) {
|
||||
// Show a warning that subsection descriptions will be discontinued.
|
||||
$warning = $OUTPUT->notification(
|
||||
message: get_string('subsectiondescriptionwarning', 'course'),
|
||||
type: \core\output\notification::NOTIFY_WARNING,
|
||||
closebutton: false,
|
||||
titleicon: 'i/circleinfo',
|
||||
);
|
||||
$mform->addElement('static', 'subsectionwarning', '', $warning);
|
||||
}
|
||||
|
||||
$mform->addElement('editor', 'summary_editor', get_string('description'), null, $this->_customdata['editoroptions']);
|
||||
$mform->setType('summary_editor', PARAM_RAW);
|
||||
|
||||
@@ -8,6 +8,6 @@ define("core_courseformat/local/courseeditor/exporter",["exports"],(function(_ex
|
||||
* @copyright 2021 Ferran Recio <ferran@moodle.com>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class{constructor(reactive){this.reactive=reactive,this.COMPLETIONS=["incomplete","complete","complete","fail"]}course(state){var _state$course$highlig;const data={sections:[],editmode:this.reactive.isEditing,highlighted:null!==(_state$course$highlig=state.course.highlighted)&&void 0!==_state$course$highlig?_state$course$highlig:""};return this.listedSectionIds(state).forEach((sectionid=>{var _state$section$get;const sectioninfo=null!==(_state$section$get=state.section.get(sectionid))&&void 0!==_state$section$get?_state$section$get:{},section=this.section(state,sectioninfo);data.sections.push(section)})),data.hassections=0!=data.sections.length,data}listedSectionIds(state){var _state$course$section;return(null!==(_state$course$section=state.course.sectionlist)&&void 0!==_state$course$section?_state$course$section:[]).filter((sectionid=>{var _state$section$get2;return null===(null!==(_state$section$get2=state.section.get(sectionid))&&void 0!==_state$section$get2?_state$section$get2:{}).component}))}section(state,sectioninfo){var _state$course$highlig2,_sectioninfo$cmlist;const section={...sectioninfo,highlighted:null!==(_state$course$highlig2=state.course.highlighted)&&void 0!==_state$course$highlig2?_state$course$highlig2:"",cms:[]};return(null!==(_sectioninfo$cmlist=sectioninfo.cmlist)&&void 0!==_sectioninfo$cmlist?_sectioninfo$cmlist:[]).forEach((cmid=>{const cminfo=state.cm.get(cmid),cm=this.cm(state,cminfo);section.cms.push(cm)})),section.hascms=0!=section.cms.length,section}cm(state,cminfo){const cm={...cminfo,isactive:!1,sectioninfo:!1};if(cminfo.hasdelegatedsection){const sectioninfo=state.section.get(cminfo.delegatesectionid);cm.sectioninfo=this.section(state,sectioninfo)}return cm}cmDraggableData(state,cmid){const cminfo=state.cm.get(cmid);if(!cminfo)return null;let nextcmid;const section=state.section.get(cminfo.sectionid),currentindex=null==section?void 0:section.cmlist.indexOf(cminfo.id);return void 0!==currentindex&&(nextcmid=null==section?void 0:section.cmlist[currentindex+1]),{type:"cm",id:cminfo.id,name:cminfo.name,sectionid:cminfo.sectionid,hasdelegatedsection:cminfo.hasdelegatedsection,nextcmid:nextcmid}}sectionDraggableData(state,sectionid){const sectioninfo=state.section.get(sectionid);return sectioninfo?{type:"section",id:sectioninfo.id,name:sectioninfo.name,number:sectioninfo.number}:null}fileDraggableData(state,dataTransfer){var _dataTransfer$files;const files=[];return(null===(_dataTransfer$files=dataTransfer.files)||void 0===_dataTransfer$files?void 0:_dataTransfer$files.length)>0&&dataTransfer.files.forEach((file=>{files.push(file)})),{type:"files",files:files}}cmCompletion(state,cminfo){const data={statename:"",state:"NaN"};if(void 0!==cminfo.completionstate){var _this$COMPLETIONS$cmi;data.state=cminfo.completionstate,data.hasstate=!0;let statename=null!==(_this$COMPLETIONS$cmi=this.COMPLETIONS[cminfo.completionstate])&&void 0!==_this$COMPLETIONS$cmi?_this$COMPLETIONS$cmi:"NaN";void 0!==cminfo.isoverallcomplete&&!0===cminfo.isoverallcomplete&&(statename="complete"),data["is".concat(statename)]=!0}return data}allItemsArray(state){var _state$course$section2;const items=[];return(null!==(_state$course$section2=state.course.sectionlist)&&void 0!==_state$course$section2?_state$course$section2:[]).forEach((sectionid=>{var _sectioninfo$cmlist2;const sectioninfo=state.section.get(sectionid);if(null!==sectioninfo.component)return;items.push({type:"section",id:sectioninfo.id,url:sectioninfo.sectionurl});(null!==(_sectioninfo$cmlist2=sectioninfo.cmlist)&&void 0!==_sectioninfo$cmlist2?_sectioninfo$cmlist2:[]).forEach((cmid=>{const cmInfo=state.cm.get(cmid);items.push(...this.cmItemsArray(state,cmInfo))}))})),items}cmItemsArray(state,cmInfo){if(cmInfo.hasdelegatedsection){var _delegatedsection$cml;const items=[],delegatedsection=state.section.get(cmInfo.delegatesectionid);items.push({type:"section",id:delegatedsection.id,url:delegatedsection.sectionurl});return(null!==(_delegatedsection$cml=delegatedsection.cmlist)&&void 0!==_delegatedsection$cml?_delegatedsection$cml:[]).forEach((cmid=>{const cmInfo=state.cm.get(cmid);items.push({type:"cm",id:cmInfo.id,url:cmInfo.url})})),items}return[{type:"cm",id:cmInfo.id,url:cmInfo.url}]}canUseStealth(state,cmIds){return cmIds.some((cmId=>{var _cminfo$allowstealth;const cminfo=state.cm.get(cmId);return null!==(_cminfo$allowstealth=null==cminfo?void 0:cminfo.allowstealth)&&void 0!==_cminfo$allowstealth&&_cminfo$allowstealth}))}},_exports.default}));
|
||||
class{constructor(reactive){this.reactive=reactive,this.COMPLETIONS=["incomplete","complete","complete","fail"]}course(state){var _state$course$highlig;const data={sections:[],editmode:this.reactive.isEditing,highlighted:null!==(_state$course$highlig=state.course.highlighted)&&void 0!==_state$course$highlig?_state$course$highlig:""};return this.listedSectionIds(state).forEach((sectionid=>{var _state$section$get;const sectioninfo=null!==(_state$section$get=state.section.get(sectionid))&&void 0!==_state$section$get?_state$section$get:{},section=this.section(state,sectioninfo);data.sections.push(section)})),data.hassections=0!=data.sections.length,data}listedSectionIds(state){var _state$course$section;return(null!==(_state$course$section=state.course.sectionlist)&&void 0!==_state$course$section?_state$course$section:[]).filter((sectionid=>{var _state$section$get2;return null===(null!==(_state$section$get2=state.section.get(sectionid))&&void 0!==_state$section$get2?_state$section$get2:{}).component}))}section(state,sectioninfo){var _state$course$highlig2,_sectioninfo$cmlist;const section={...sectioninfo,highlighted:null!==(_state$course$highlig2=state.course.highlighted)&&void 0!==_state$course$highlig2?_state$course$highlig2:"",cms:[]};return(null!==(_sectioninfo$cmlist=sectioninfo.cmlist)&&void 0!==_sectioninfo$cmlist?_sectioninfo$cmlist:[]).forEach((cmid=>{const cminfo=state.cm.get(cmid),cm=this.cm(state,cminfo);section.cms.push(cm)})),section.hascms=0!=section.cms.length,section}cm(state,cminfo){const cm={...cminfo,isactive:!1,sectioninfo:!1};if(cminfo.hasdelegatedsection){var _state$section$get3;const sectioninfo=null!==(_state$section$get3=state.section.get(cminfo.delegatesectionid))&&void 0!==_state$section$get3?_state$section$get3:{};cm.sectioninfo=this.section(state,sectioninfo)}return cm}cmDraggableData(state,cmid){const cminfo=state.cm.get(cmid);if(!cminfo)return null;let nextcmid;const section=state.section.get(cminfo.sectionid),currentindex=null==section?void 0:section.cmlist.indexOf(cminfo.id);return void 0!==currentindex&&(nextcmid=null==section?void 0:section.cmlist[currentindex+1]),{type:"cm",id:cminfo.id,name:cminfo.name,sectionid:cminfo.sectionid,hasdelegatedsection:cminfo.hasdelegatedsection,nextcmid:nextcmid}}sectionDraggableData(state,sectionid){const sectioninfo=state.section.get(sectionid);return sectioninfo?{type:"section",id:sectioninfo.id,name:sectioninfo.name,number:sectioninfo.number}:null}fileDraggableData(state,dataTransfer){var _dataTransfer$files;const files=[];return(null===(_dataTransfer$files=dataTransfer.files)||void 0===_dataTransfer$files?void 0:_dataTransfer$files.length)>0&&dataTransfer.files.forEach((file=>{files.push(file)})),{type:"files",files:files}}cmCompletion(state,cminfo){const data={statename:"",state:"NaN"};if(void 0!==cminfo.completionstate){var _this$COMPLETIONS$cmi;data.state=cminfo.completionstate,data.hasstate=!0;let statename=null!==(_this$COMPLETIONS$cmi=this.COMPLETIONS[cminfo.completionstate])&&void 0!==_this$COMPLETIONS$cmi?_this$COMPLETIONS$cmi:"NaN";void 0!==cminfo.isoverallcomplete&&!0===cminfo.isoverallcomplete&&(statename="complete"),data["is".concat(statename)]=!0}return data}allItemsArray(state){var _state$course$section2;const items=[];return(null!==(_state$course$section2=state.course.sectionlist)&&void 0!==_state$course$section2?_state$course$section2:[]).forEach((sectionid=>{var _sectioninfo$cmlist2;const sectioninfo=state.section.get(sectionid);if(null!==sectioninfo.component)return;items.push({type:"section",id:sectioninfo.id,url:sectioninfo.sectionurl});(null!==(_sectioninfo$cmlist2=sectioninfo.cmlist)&&void 0!==_sectioninfo$cmlist2?_sectioninfo$cmlist2:[]).forEach((cmid=>{const cmInfo=state.cm.get(cmid);items.push(...this.cmItemsArray(state,cmInfo))}))})),items}cmItemsArray(state,cmInfo){if(cmInfo.hasdelegatedsection){const items=[],delegatedsection=state.section.get(cmInfo.delegatesectionid);if(delegatedsection){var _delegatedsection$cml;items.push({type:"section",id:delegatedsection.id,url:delegatedsection.sectionurl});(null!==(_delegatedsection$cml=delegatedsection.cmlist)&&void 0!==_delegatedsection$cml?_delegatedsection$cml:[]).forEach((cmid=>{const cmInfo=state.cm.get(cmid);items.push({type:"cm",id:cmInfo.id,url:cmInfo.url})}))}return items}return[{type:"cm",id:cmInfo.id,url:cmInfo.url}]}canUseStealth(state,cmIds){return cmIds.some((cmId=>{var _cminfo$allowstealth;const cminfo=state.cm.get(cmId);return null!==(_cminfo$allowstealth=null==cminfo?void 0:cminfo.allowstealth)&&void 0!==_cminfo$allowstealth&&_cminfo$allowstealth}))}},_exports.default}));
|
||||
|
||||
//# sourceMappingURL=exporter.min.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -8,6 +8,6 @@ define("core_courseformat/local/courseindex/section",["exports","core_courseform
|
||||
* @class core_courseformat/local/courseindex/section
|
||||
* @copyright 2021 Ferran Recio <ferran@moodle.com>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_sectiontitle=_interopRequireDefault(_sectiontitle),_dndsection=_interopRequireDefault(_dndsection),_log=_interopRequireDefault(_log);class Component extends _dndsection.default{create(){this.name="courseindex_section",this.selectors={SECTION:"[data-for='section']",SECTION_ITEM:"[data-for='section_item']",SECTION_TITLE:"[data-for='section_title']",CM_LAST:'[data-for="cm"]:last-child',DND_ALLOWED:"[data-courseindexdndallowed='true']"},this.classes={SECTIONHIDDEN:"dimmed",SECTIONCURRENT:"current",LOCKED:"editinprogress",RESTRICTIONS:"restrictions",PAGEITEM:"pageitem",OVERLAYBORDERS:"overlay-preview-borders"},this.id=this.element.dataset.id,this.isPageItem=!1}static init(target,selectors){let element=document.querySelector(target);return element||(_log.default.debug("Init component with id is deprecated, use a query selector instead."),element=document.getElementById(target)),new this({element:element,selectors:selectors})}stateReady(state){var _section$sectionurl;this.configState(state);const sectionItem=this.getElement(this.selectors.SECTION_ITEM);if(this.reactive.isEditing&&this.reactive.supportComponents&&document.querySelector(this.selectors.DND_ALLOWED)){const titleitem=new _sectiontitle.default({...this,element:sectionItem,fullregion:this.element});this.configDragDrop(titleitem)}const section=state.section.get(this.id);window.location.href==(null===(_section$sectionurl=section.sectionurl)||void 0===_section$sectionurl?void 0:_section$sectionurl.replace(/&/g,"&"))&&(this.reactive.dispatch("setPageItem","section",this.id),sectionItem.scrollIntoView())}getWatchers(){return[{watch:"section[".concat(this.id,"]:deleted"),handler:this.remove},{watch:"section[".concat(this.id,"]:updated"),handler:this._refreshSection},{watch:"course.pageItem:updated",handler:this._refreshPageItem}]}getLastCm(){return this.getElement(this.selectors.CM_LAST)}_refreshSection(_ref){var _element$hasrestricti,_element$dragging,_element$locked;let{element:element}=_ref;const sectionItem=this.getElement(this.selectors.SECTION_ITEM);sectionItem.classList.toggle(this.classes.SECTIONHIDDEN,!element.visible),sectionItem.classList.toggle(this.classes.RESTRICTIONS,null!==(_element$hasrestricti=element.hasrestrictions)&&void 0!==_element$hasrestricti&&_element$hasrestricti),this.element.classList.toggle(this.classes.SECTIONCURRENT,element.current),this.element.classList.toggle(this.classes.DRAGGING,null!==(_element$dragging=element.dragging)&&void 0!==_element$dragging&&_element$dragging),this.element.classList.toggle(this.classes.LOCKED,null!==(_element$locked=element.locked)&&void 0!==_element$locked&&_element$locked),this.locked=element.locked,this.getElement(this.selectors.SECTION_TITLE).innerHTML=element.title}_refreshPageItem(_ref2){var _element$pageItem,_this$pageItem;let{element:element,state:state}=_ref2;if(!element.pageItem)return;const containsPageItem=this._isPageItemInThisSection(element.pageItem);if(!containsPageItem||this._isParentSectionIndexCollapsed(state))return this.pageItem=!1,void this.getElement(this.selectors.SECTION_ITEM).classList.remove(this.classes.PAGEITEM);!state.section.get(this.id).indexcollapsed||null!==(_element$pageItem=element.pageItem)&&void 0!==_element$pageItem&&_element$pageItem.isStatic?this.pageItem="section"==element.pageItem.type&&element.pageItem.id==this.id:this.pageItem=containsPageItem;this.getElement(this.selectors.SECTION_ITEM).classList.toggle(this.classes.PAGEITEM,null!==(_this$pageItem=this.pageItem)&&void 0!==_this$pageItem&&_this$pageItem),this.pageItem&&!this.reactive.isEditing&&this.element.scrollIntoView({block:"nearest"})}_isPageItemInThisSection(pageItem){if(pageItem.sectionId==this.id)return!0;return!!this.element.querySelector("".concat(this.selectors.SECTION,'[data-id="').concat(pageItem.sectionId,'"]'))}_isParentSectionIndexCollapsed(state){const parentElement=this.element.parentElement.closest(this.selectors.SECTION);if(!parentElement||!parentElement.dataset.id)return null;return!!state.section.get(parentElement.dataset.id).indexcollapsed}async addOverlay(){this.element.classList.add(this.classes.OVERLAYBORDERS)}removeOverlay(){this.element.classList.remove(this.classes.OVERLAYBORDERS)}}return _exports.default=Component,_exports.default}));
|
||||
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_sectiontitle=_interopRequireDefault(_sectiontitle),_dndsection=_interopRequireDefault(_dndsection),_log=_interopRequireDefault(_log);class Component extends _dndsection.default{create(){this.name="courseindex_section",this.selectors={SECTION:"[data-for='section']",SECTION_ITEM:"[data-for='section_item']",SECTION_TITLE:"[data-for='section_title']",CM_LAST:'[data-for="cm"]:last-child',DND_ALLOWED:"[data-courseindexdndallowed='true']"},this.classes={SECTIONHIDDEN:"dimmed",SECTIONCURRENT:"current",LOCKED:"editinprogress",RESTRICTIONS:"restrictions",PAGEITEM:"pageitem",OVERLAYBORDERS:"overlay-preview-borders"},this.id=this.element.dataset.id,this.isPageItem=!1}static init(target,selectors){let element=document.querySelector(target);return element||(_log.default.debug("Init component with id is deprecated, use a query selector instead."),element=document.getElementById(target)),new this({element:element,selectors:selectors})}stateReady(state){var _section$sectionurl;this.configState(state);const sectionItem=this.getElement(this.selectors.SECTION_ITEM);if(this.reactive.isEditing&&this.reactive.supportComponents&&document.querySelector(this.selectors.DND_ALLOWED)){const titleitem=new _sectiontitle.default({...this,element:sectionItem,fullregion:this.element});this.configDragDrop(titleitem)}const section=state.section.get(this.id);window.location.href==(null==section||null===(_section$sectionurl=section.sectionurl)||void 0===_section$sectionurl?void 0:_section$sectionurl.replace(/&/g,"&"))&&(this.reactive.dispatch("setPageItem","section",this.id),sectionItem.scrollIntoView())}getWatchers(){return[{watch:"section[".concat(this.id,"]:deleted"),handler:this.remove},{watch:"section[".concat(this.id,"]:updated"),handler:this._refreshSection},{watch:"course.pageItem:updated",handler:this._refreshPageItem}]}getLastCm(){return this.getElement(this.selectors.CM_LAST)}_refreshSection(_ref){var _element$hasrestricti,_element$dragging,_element$locked;let{element:element}=_ref;const sectionItem=this.getElement(this.selectors.SECTION_ITEM);sectionItem.classList.toggle(this.classes.SECTIONHIDDEN,!element.visible),sectionItem.classList.toggle(this.classes.RESTRICTIONS,null!==(_element$hasrestricti=element.hasrestrictions)&&void 0!==_element$hasrestricti&&_element$hasrestricti),this.element.classList.toggle(this.classes.SECTIONCURRENT,element.current),this.element.classList.toggle(this.classes.DRAGGING,null!==(_element$dragging=element.dragging)&&void 0!==_element$dragging&&_element$dragging),this.element.classList.toggle(this.classes.LOCKED,null!==(_element$locked=element.locked)&&void 0!==_element$locked&&_element$locked),this.locked=element.locked,this.getElement(this.selectors.SECTION_TITLE).innerHTML=element.title}_refreshPageItem(_ref2){var _element$pageItem,_this$pageItem;let{element:element,state:state}=_ref2;if(!element.pageItem)return;const containsPageItem=this._isPageItemInThisSection(element.pageItem);if(!containsPageItem||this._isParentSectionIndexCollapsed(state))return this.pageItem=!1,void this.getElement(this.selectors.SECTION_ITEM).classList.remove(this.classes.PAGEITEM);!state.section.get(this.id).indexcollapsed||null!==(_element$pageItem=element.pageItem)&&void 0!==_element$pageItem&&_element$pageItem.isStatic?this.pageItem="section"==element.pageItem.type&&element.pageItem.id==this.id:this.pageItem=containsPageItem;this.getElement(this.selectors.SECTION_ITEM).classList.toggle(this.classes.PAGEITEM,null!==(_this$pageItem=this.pageItem)&&void 0!==_this$pageItem&&_this$pageItem),this.pageItem&&!this.reactive.isEditing&&this.element.scrollIntoView({block:"nearest"})}_isPageItemInThisSection(pageItem){if(pageItem.sectionId==this.id)return!0;return!!this.element.querySelector("".concat(this.selectors.SECTION,'[data-id="').concat(pageItem.sectionId,'"]'))}_isParentSectionIndexCollapsed(state){const parentElement=this.element.parentElement.closest(this.selectors.SECTION);if(!parentElement||!parentElement.dataset.id)return null;return!!state.section.get(parentElement.dataset.id).indexcollapsed}async addOverlay(){this.element.classList.add(this.classes.OVERLAYBORDERS)}removeOverlay(){this.element.classList.remove(this.classes.OVERLAYBORDERS)}}return _exports.default=Component,_exports.default}));
|
||||
|
||||
//# sourceMappingURL=section.min.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -124,7 +124,7 @@ export default class {
|
||||
sectioninfo: false, // Init to false to prevent mustache recursion loops.
|
||||
};
|
||||
if (cminfo.hasdelegatedsection) {
|
||||
const sectioninfo = state.section.get(cminfo.delegatesectionid);
|
||||
const sectioninfo = state.section.get(cminfo.delegatesectionid) ?? {};
|
||||
cm.sectioninfo = this.section(state, sectioninfo);
|
||||
}
|
||||
return cm;
|
||||
@@ -279,20 +279,22 @@ export default class {
|
||||
if (cmInfo.hasdelegatedsection) {
|
||||
const items = [];
|
||||
const delegatedsection = state.section.get(cmInfo.delegatesectionid);
|
||||
items.push({
|
||||
type: 'section',
|
||||
id: delegatedsection.id,
|
||||
url: delegatedsection.sectionurl
|
||||
});
|
||||
const delegatedCmlist = delegatedsection.cmlist ?? [];
|
||||
delegatedCmlist.forEach(cmid => {
|
||||
const cmInfo = state.cm.get(cmid);
|
||||
if (delegatedsection) {
|
||||
items.push({
|
||||
type: 'cm',
|
||||
id: cmInfo.id,
|
||||
url: cmInfo.url
|
||||
type: 'section',
|
||||
id: delegatedsection.id,
|
||||
url: delegatedsection.sectionurl
|
||||
});
|
||||
});
|
||||
const delegatedCmlist = delegatedsection.cmlist ?? [];
|
||||
delegatedCmlist.forEach(cmid => {
|
||||
const cmInfo = state.cm.get(cmid);
|
||||
items.push({
|
||||
type: 'cm',
|
||||
id: cmInfo.id,
|
||||
url: cmInfo.url
|
||||
});
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ export default class Component extends DndSection {
|
||||
// Check if the current url is the section url.
|
||||
const section = state.section.get(this.id);
|
||||
// Sections urls may be null.
|
||||
if (window.location.href == section.sectionurl?.replace(/&/g, "&")) {
|
||||
if (window.location.href == section?.sectionurl?.replace(/&/g, "&")) {
|
||||
this.reactive.dispatch('setPageItem', 'section', this.id);
|
||||
sectionItem.scrollIntoView();
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ class header implements named_templatable, renderable {
|
||||
} else {
|
||||
if (is_null($format->get_sectionid()) || $format->get_sectionid() != $section->id) {
|
||||
// All sections are displayed.
|
||||
if (!$data->editing) {
|
||||
if (!$data->editing && $section->uservisible) {
|
||||
$data->title = $output->section_title($section, $course);
|
||||
} else {
|
||||
$data->title = $output->section_title_without_link($section, $course);
|
||||
|
||||
@@ -83,7 +83,6 @@ class section implements renderable {
|
||||
'rawtitle' => $section->name,
|
||||
'cmlist' => [],
|
||||
'visible' => !empty($section->visible),
|
||||
'sectionurl' => course_get_url($course, $section->section, ['navigation' => true])?->out(false),
|
||||
'current' => $format->is_section_current($section),
|
||||
'indexcollapsed' => $indexcollapsed,
|
||||
'contentcollapsed' => $contentcollapsed,
|
||||
@@ -94,6 +93,10 @@ class section implements renderable {
|
||||
'parentsectionid' => $section->get_component_instance()?->get_parent_section()?->id,
|
||||
];
|
||||
|
||||
if ($section->uservisible) {
|
||||
$data->sectionurl = course_get_url($course, $section->section, ['navigation' => true])?->out(false);
|
||||
}
|
||||
|
||||
if (empty($modinfo->sections[$section->section])) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
@@ -73,7 +73,8 @@
|
||||
class="collapse-list-link icons-collapse-expand collapsed"
|
||||
>
|
||||
<span class="collapsed-icon icon-no-margin me-1" data-bs-toggle="tooltip" title="{{#str}} expand, core {{/str}}">
|
||||
{{#pix}} t/collapsedchevron, core {{/pix}}
|
||||
<span class="dir-rtl-hide">{{#pix}} t/collapsedchevron, core {{/pix}}</span>
|
||||
<span class="dir-ltr-hide">{{#pix}} t/collapsedchevron_rtl, core {{/pix}}</span>
|
||||
<span class="visually-hidden">{{#str}} expand, core {{/str}}</span>
|
||||
</span>
|
||||
<span class="expanded-icon icon-no-margin me-1" data-bs-toggle="tooltip" title="{{#str}} collapse, core {{/str}}">
|
||||
|
||||
@@ -1529,7 +1529,7 @@ final class stateactions_test extends \advanced_testcase {
|
||||
$modinfo = get_fast_modinfo($course); // Get refreshed version.
|
||||
|
||||
$allsections = $modinfo->get_listed_section_info_all();
|
||||
$cmidstoref = array_flip($references);
|
||||
$cmidstoref = array_flip(array_filter($references, fn($key) => str_starts_with($key, 'cm'), ARRAY_FILTER_USE_KEY));
|
||||
foreach ($allsections as $sectioninfo) {
|
||||
$sectionkey = 'section' . $sectioninfo->sectionnum;
|
||||
$coursetree[$sectionkey] = [];
|
||||
|
||||
@@ -17,6 +17,7 @@ Feature: Restrict activities availability
|
||||
And the following "activities" exist:
|
||||
| activity | course | name |
|
||||
| assign | C1 | Test assign name |
|
||||
And I enable "bigbluebuttonbn" "mod" plugin
|
||||
|
||||
Scenario: Activities can be added with the default permissions
|
||||
Given I log in as "teacher1"
|
||||
@@ -27,17 +28,34 @@ Feature: Restrict activities availability
|
||||
And I should see "Test assign name"
|
||||
|
||||
@javascript @skip_chrome_zerosize
|
||||
Scenario: Activities can not be added when the admin restricts the permissions
|
||||
Scenario Outline: Activities can not be added when the admin restricts the permissions
|
||||
Given the following "role capability" exists:
|
||||
| role | editingteacher |
|
||||
| mod/assign:addinstance | prohibit |
|
||||
And I log in as "admin"
|
||||
And I am on the "Course 1" "permissions" page
|
||||
And I override the system permissions of "Teacher" role with:
|
||||
| mod/glossary:addinstance | Prohibit |
|
||||
And I log out
|
||||
| role | editingteacher |
|
||||
| <activityrole> | prohibit |
|
||||
And I log in as "teacher1"
|
||||
When I am on "Course 1" course homepage with editing mode on
|
||||
And I open the activity chooser
|
||||
Then "Add a new Assignment" "link" should not exist in the "Add an activity or resource" "dialogue"
|
||||
Then "Add a new Glossary" "link" should not exist in the "Add an activity or resource" "dialogue"
|
||||
Then "Add a new <activitytype>" "link" should not exist in the "Add an activity or resource" "dialogue"
|
||||
|
||||
Examples:
|
||||
| activityrole | activitytype |
|
||||
| mod/assign:addinstance | Assignment |
|
||||
| mod/bigbluebuttonbn:addinstance | BigBlueButton |
|
||||
| mod/book:addinstance | Book |
|
||||
| mod/choice:addinstance | Choice |
|
||||
| mod/data:addinstance | Database |
|
||||
| mod/feedback:addinstance | Feedback |
|
||||
| mod/folder:addinstance | Folder |
|
||||
| mod/forum:addinstance | Forum |
|
||||
| mod/glossary:addinstance | Glossary |
|
||||
| mod/h5pactivity:addinstance | H5P |
|
||||
| mod/imscp:addinstance | IMS content package |
|
||||
| mod/label:addinstance | Text and media area |
|
||||
| mod/lesson:addinstance | Lesson |
|
||||
| mod/page:addinstance | Page |
|
||||
| mod/quiz:addinstance | Quiz |
|
||||
| mod/resource:addinstance | File |
|
||||
| mod/scorm:addinstance | SCORM package |
|
||||
| mod/url:addinstance | URL |
|
||||
| mod/wiki:addinstance | Wiki |
|
||||
| mod/workshop:addinstance | Workshop |
|
||||
|
||||
@@ -77,9 +77,12 @@ final class course_request_test extends \advanced_testcase {
|
||||
|
||||
public function test_approve_request(): void {
|
||||
global $DB;
|
||||
|
||||
$this->resetAfterTest(true);
|
||||
$this->preventResetByRollback();
|
||||
|
||||
$this->mock_clock_with_frozen(1622502000);
|
||||
|
||||
$defaultcategory = $DB->get_field_select('course_categories', "MIN(id)", "parent=0");
|
||||
set_config('enablecourserequests', 1);
|
||||
set_config('lockrequestcategory', 1);
|
||||
@@ -116,6 +119,7 @@ final class course_request_test extends \advanced_testcase {
|
||||
$id = $cr->approve();
|
||||
$this->assertCount(1, $sink->get_messages_by_component_and_type('core', 'courserequestapproved'));
|
||||
$sink->close();
|
||||
|
||||
$course = $DB->get_record('course', ['id' => $id]);
|
||||
$this->assertEquals($data->fullname, $course->fullname);
|
||||
$this->assertEquals($data->shortname, $course->shortname);
|
||||
@@ -136,8 +140,49 @@ final class course_request_test extends \advanced_testcase {
|
||||
$id = $cr->approve();
|
||||
$this->assertCount(1, $sink->get_messages_by_component_and_type('core', 'courserequestapproved'));
|
||||
$sink->close();
|
||||
|
||||
$course = $DB->get_record('course', ['id' => $id]);
|
||||
$this->assertEquals($data->category, $course->category);
|
||||
|
||||
// Test with default course settings.
|
||||
set_config('format', 'weeks', 'moodlecourse');
|
||||
set_config('newsitems', 2, 'moodlecourse');
|
||||
set_config('showgrades', false, 'moodlecourse');
|
||||
set_config('showreports', true, 'moodlecourse');
|
||||
set_config('showactivitydates', 0, 'moodlecourse');
|
||||
set_config('maxbytes', 10240, 'moodlecourse');
|
||||
set_config('groupmode', SEPARATEGROUPS, 'moodlecourse');
|
||||
set_config('groupmodeforce', true, 'moodlecourse');
|
||||
set_config('visible', false, 'moodlecourse');
|
||||
set_config('lang', 'es', 'moodlecourse');
|
||||
set_config('enablecompletion', false, 'moodlecourse');
|
||||
set_config('courseenddateenabled', true, 'moodlecourse');
|
||||
set_config('courseduration', WEEKSECS * 4, 'moodlecourse');
|
||||
|
||||
$data->shortname .= ' 3rd';
|
||||
$this->setUser($requester);
|
||||
$cr = course_request::create($data);
|
||||
$this->setAdminUser();
|
||||
$sink = $this->redirectMessages();
|
||||
$id = $cr->approve();
|
||||
$this->assertCount(1, $sink->get_messages_by_component_and_type('core', 'courserequestapproved'));
|
||||
$sink->close();
|
||||
|
||||
$course = $DB->get_record('course', ['id' => $id]);
|
||||
|
||||
$this->assertEquals('weeks', $course->format);
|
||||
$this->assertEquals(2, $course->newsitems);
|
||||
$this->assertEquals(0, $course->showgrades);
|
||||
$this->assertEquals(1, $course->showreports);
|
||||
$this->assertEquals(0, $course->showactivitydates);
|
||||
$this->assertEquals(10240, $course->maxbytes);
|
||||
$this->assertEquals(SEPARATEGROUPS, $course->groupmode);
|
||||
$this->assertEquals(1, $course->groupmodeforce);
|
||||
$this->assertEquals(0, $course->visible);
|
||||
$this->assertEquals('es', $course->lang);
|
||||
$this->assertEquals(0, $course->enablecompletion);
|
||||
$this->assertEquals(1622476800, $course->startdate);
|
||||
$this->assertEquals($course->startdate + (WEEKSECS * 4), $course->enddate);
|
||||
}
|
||||
|
||||
public function test_reject_request(): void {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# core_customfield (subsystem) Upgrade notes
|
||||
|
||||
## 5.1.2
|
||||
|
||||
### Added
|
||||
|
||||
- Added new `\core_customfield\api::is_shortname_unique(...)` method to determine whether a shortname is available for use inside a given handler
|
||||
|
||||
For more information see [MDL-87059](https://tracker.moodle.org/browse/MDL-87059)
|
||||
|
||||
## 5.1
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -487,4 +487,60 @@ class api {
|
||||
];
|
||||
return shared::record_exists_select($sql, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given shortname is unique for the handler's component-area-itemid combination and shared fields.
|
||||
*
|
||||
* @param handler $handler
|
||||
* @param string $shortname
|
||||
* @param int $fieldid
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_shortname_unique(handler $handler, string $shortname, int $fieldid): bool {
|
||||
global $DB;
|
||||
|
||||
if ($handler->get_component() === 'core_customfield' && $handler->get_area() === 'shared') {
|
||||
// If it's a shared field, just check the shortname is unique among all fields.
|
||||
$params = [
|
||||
'shortname' => $shortname,
|
||||
'fieldid' => $fieldid,
|
||||
];
|
||||
return !$DB->record_exists_select('customfield_field', 'shortname = :shortname AND id <> :fieldid', $params);
|
||||
} else {
|
||||
// Check the shortname is unique for this component-area-itemid combination and shared fields.
|
||||
$query = "
|
||||
SELECT 1
|
||||
FROM {customfield_field} f
|
||||
JOIN {customfield_category} c ON c.id = f.categoryid
|
||||
WHERE f.shortname = :shortname
|
||||
AND (
|
||||
(
|
||||
f.id <> :fieldid
|
||||
AND c.component = :component
|
||||
AND c.area = :area
|
||||
AND c.itemid = :itemid
|
||||
)
|
||||
OR
|
||||
(
|
||||
c.component = :sharedcomponent
|
||||
AND c.area = :sharedarea
|
||||
AND c.itemid = :shareditemid
|
||||
)
|
||||
)
|
||||
";
|
||||
|
||||
$params = [
|
||||
'shortname' => $shortname,
|
||||
'fieldid' => $fieldid,
|
||||
'component' => $handler->get_component(),
|
||||
'area' => $handler->get_area(),
|
||||
'itemid' => $handler->get_itemid(),
|
||||
'sharedcomponent' => 'core_customfield',
|
||||
'sharedarea' => 'shared',
|
||||
'shareditemid' => 0,
|
||||
];
|
||||
|
||||
return !$DB->record_exists_sql($query, $params);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,15 +118,11 @@ class field_config_form extends \core_form\dynamic_form {
|
||||
$field = $this->get_field();
|
||||
$handler = $field->get_handler();
|
||||
|
||||
// Check the shortname is specified and is unique for this component-area-itemid combination.
|
||||
// Check the shortname is specified and is unique for this component-area-itemid combination and shared fields.
|
||||
if (!preg_match('/^[a-z0-9_]+$/', $data['shortname'])) {
|
||||
// Check allowed pattern (numbers, letters and underscore).
|
||||
$errors['shortname'] = get_string('invalidshortnameerror', 'core_customfield');
|
||||
} else if ($DB->record_exists_sql('SELECT 1 FROM {customfield_field} f ' .
|
||||
'JOIN {customfield_category} c ON c.id = f.categoryid ' .
|
||||
'WHERE f.shortname = ? AND f.id <> ? AND c.component = ? AND c.area = ? AND c.itemid = ?',
|
||||
[$data['shortname'], $data['id'],
|
||||
$handler->get_component(), $handler->get_area(), $handler->get_itemid()])) {
|
||||
} else if (!\core_customfield\api::is_shortname_unique($handler, $data['shortname'], $field->get('id'))) {
|
||||
$errors['shortname'] = get_string('formfieldcheckshortname', 'core_customfield');
|
||||
}
|
||||
|
||||
|
||||
@@ -447,7 +447,13 @@ abstract class handler {
|
||||
* In the last case data_controller::get_value() and export_value() functions will return default values.
|
||||
*/
|
||||
public function get_instances_data(array $instanceids, bool $returnall = false): array {
|
||||
$result = api::get_instances_fields_data($this->get_fields(), $instanceids);
|
||||
$result = api::get_instances_fields_data(
|
||||
fields: $this->get_fields(),
|
||||
instanceids: $instanceids,
|
||||
component: $this->get_component(),
|
||||
area: $this->get_area(),
|
||||
itemid: $this->get_itemid(),
|
||||
);
|
||||
|
||||
if (!$returnall) {
|
||||
// Filter only by visible fields (list of visible fields may be different for each instance).
|
||||
@@ -706,6 +712,7 @@ abstract class handler {
|
||||
}
|
||||
$data->instance_form_save($instance);
|
||||
}
|
||||
$this->clear_configuration_cache();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# customfield_number Upgrade notes
|
||||
|
||||
## 5.1.2
|
||||
|
||||
### Changed
|
||||
|
||||
- In order to fully support shared custom field categories, additional component/area/itemid parameters have been added to the following:
|
||||
|
||||
* The `customfield_number_recalculate_value` external method
|
||||
* The abstract `\customfield_number\provider_base::recalculate()` method
|
||||
* The `\customfield_number\task\recalculate` helpers for queueing task instances
|
||||
|
||||
For more information see [MDL-87714](https://tracker.moodle.org/browse/MDL-87714)
|
||||
|
||||
## 4.5
|
||||
|
||||
### Added
|
||||
|
||||
@@ -6,6 +6,6 @@ define("customfield_number/recalculate",["exports","core/ajax","core/notificatio
|
||||
* @author 2024 Marina Glancy
|
||||
* @copyright 2024 Moodle Pty Ltd <support@moodle.com>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=function(){if(initialised)return;document.addEventListener("click",(e=>{const target=e.target.closest(SELECTORS_wrapper+" "+SELECTORS_link);if(!target)return;const el=target.closest(SELECTORS_wrapper).querySelector(SELECTORS_value);if(!el)return;e.preventDefault();const{fieldid:fieldid,instanceid:instanceid}=target.dataset,pendingPromise=new _pending.default("recalculate_customfield_number");(0,_loadingicon.addIconToContainer)(el).then((()=>_ajax.default.call([{methodname:"customfield_number_recalculate_value",args:{fieldid:fieldid,instanceid:instanceid}}])[0])).then((data=>(el.innerHTML=data.value,pendingPromise.resolve()))).catch(_notification.default.exception)})),initialised=!0},_ajax=_interopRequireDefault(_ajax),_notification=_interopRequireDefault(_notification),_pending=_interopRequireDefault(_pending);const SELECTORS_wrapper='[data-fieldtype="wrapper"]',SELECTORS_value='[data-fieldtype="value"]',SELECTORS_link='[data-fieldtype="link"]';let initialised=!1}));
|
||||
*/Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=function(){if(initialised)return;document.addEventListener("click",(e=>{const target=e.target.closest(SELECTORS_wrapper+" "+SELECTORS_link);if(!target)return;const el=target.closest(SELECTORS_wrapper).querySelector(SELECTORS_value);if(!el)return;e.preventDefault();const{fieldid:fieldid,instanceid:instanceid,component:component,area:area,itemid:itemid}=target.dataset,pendingPromise=new _pending.default("recalculate_customfield_number");(0,_loadingicon.addIconToContainer)(el).then((()=>_ajax.default.call([{methodname:"customfield_number_recalculate_value",args:{fieldid:fieldid,instanceid:instanceid,component:component,area:area,itemid:itemid}}])[0])).then((data=>(el.innerHTML=data.value,pendingPromise.resolve()))).catch(_notification.default.exception)})),initialised=!0},_ajax=_interopRequireDefault(_ajax),_notification=_interopRequireDefault(_notification),_pending=_interopRequireDefault(_pending);const SELECTORS_wrapper='[data-fieldtype="wrapper"]',SELECTORS_value='[data-fieldtype="value"]',SELECTORS_link='[data-fieldtype="link"]';let initialised=!1}));
|
||||
|
||||
//# sourceMappingURL=recalculate.min.js.map
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"recalculate.min.js","sources":["../src/recalculate.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see <http://www.gnu.org/licenses/>.\n\n/**\n * Allows to recalculate a single value on demand\n *\n * @module customfield_number/recalculate\n * @author 2024 Marina Glancy\n * @copyright 2024 Moodle Pty Ltd <support@moodle.com>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Ajax from 'core/ajax';\nimport Notification from 'core/notification';\nimport {addIconToContainer} from 'core/loadingicon';\nimport Pending from 'core/pending';\n\nconst SELECTORS = {\n wrapper: '[data-fieldtype=\"wrapper\"]',\n value: '[data-fieldtype=\"value\"]',\n link: '[data-fieldtype=\"link\"]',\n};\n\nlet initialised = false;\n\n/**\n * Init\n */\nexport function init() {\n if (initialised) {\n return;\n }\n\n document.addEventListener('click', (e) => {\n const target = e.target.closest(SELECTORS.wrapper + \" \" + SELECTORS.link);\n if (!target) {\n return;\n }\n const el = target.closest(SELECTORS.wrapper).querySelector(SELECTORS.value);\n if (!el) {\n return;\n }\n e.preventDefault();\n\n const {fieldid, instanceid} = target.dataset;\n\n const pendingPromise = new Pending('recalculate_customfield_number');\n addIconToContainer(el).then(() => {\n return Ajax.call([{\n methodname: 'customfield_number_recalculate_value',\n args: {fieldid, instanceid}\n }])[0];\n }).then((data) => {\n el.innerHTML = data.value;\n return pendingPromise.resolve();\n }).catch(Notification.exception);\n });\n\n initialised = true;\n}\n"],"names":["initialised","document","addEventListener","e","target","closest","SELECTORS","el","querySelector","preventDefault","fieldid","instanceid","dataset","pendingPromise","Pending","then","Ajax","call","methodname","args","data","innerHTML","value","resolve","catch","Notification","exception"],"mappings":";;;;;;;;yFAyCQA,mBAIJC,SAASC,iBAAiB,SAAUC,UAC1BC,OAASD,EAAEC,OAAOC,QAAQC,kBAAoB,IAAMA,oBACrDF,oBAGCG,GAAKH,OAAOC,QAAQC,mBAAmBE,cAAcF,qBACtDC,UAGLJ,EAAEM,uBAEIC,QAACA,QAADC,WAAUA,YAAcP,OAAOQ,QAE/BC,eAAiB,IAAIC,iBAAQ,sEAChBP,IAAIQ,MAAK,IACjBC,cAAKC,KAAK,CAAC,CACdC,WAAY,uCACZC,KAAM,CAACT,QAAAA,QAASC,WAAAA,eAChB,KACLI,MAAMK,OACLb,GAAGc,UAAYD,KAAKE,MACbT,eAAeU,aACvBC,MAAMC,sBAAaC,cAG1B1B,aAAc,2IAzCZM,kBACO,6BADPA,gBAEK,2BAFLA,eAGI,8BAGNN,aAAc"}
|
||||
{"version":3,"file":"recalculate.min.js","sources":["../src/recalculate.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see <http://www.gnu.org/licenses/>.\n\n/**\n * Allows to recalculate a single value on demand\n *\n * @module customfield_number/recalculate\n * @author 2024 Marina Glancy\n * @copyright 2024 Moodle Pty Ltd <support@moodle.com>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Ajax from 'core/ajax';\nimport Notification from 'core/notification';\nimport {addIconToContainer} from 'core/loadingicon';\nimport Pending from 'core/pending';\n\nconst SELECTORS = {\n wrapper: '[data-fieldtype=\"wrapper\"]',\n value: '[data-fieldtype=\"value\"]',\n link: '[data-fieldtype=\"link\"]',\n};\n\nlet initialised = false;\n\n/**\n * Init\n */\nexport function init() {\n if (initialised) {\n return;\n }\n\n document.addEventListener('click', (e) => {\n const target = e.target.closest(SELECTORS.wrapper + \" \" + SELECTORS.link);\n if (!target) {\n return;\n }\n const el = target.closest(SELECTORS.wrapper).querySelector(SELECTORS.value);\n if (!el) {\n return;\n }\n e.preventDefault();\n\n const {fieldid, instanceid, component, area, itemid} = target.dataset;\n\n const pendingPromise = new Pending('recalculate_customfield_number');\n addIconToContainer(el).then(() => {\n return Ajax.call([{\n methodname: 'customfield_number_recalculate_value',\n args: {fieldid, instanceid, component, area, itemid}\n }])[0];\n }).then((data) => {\n el.innerHTML = data.value;\n return pendingPromise.resolve();\n }).catch(Notification.exception);\n });\n\n initialised = true;\n}\n"],"names":["initialised","document","addEventListener","e","target","closest","SELECTORS","el","querySelector","preventDefault","fieldid","instanceid","component","area","itemid","dataset","pendingPromise","Pending","then","Ajax","call","methodname","args","data","innerHTML","value","resolve","catch","Notification","exception"],"mappings":";;;;;;;;yFAyCQA,mBAIJC,SAASC,iBAAiB,SAAUC,UAC1BC,OAASD,EAAEC,OAAOC,QAAQC,kBAAoB,IAAMA,oBACrDF,oBAGCG,GAAKH,OAAOC,QAAQC,mBAAmBE,cAAcF,qBACtDC,UAGLJ,EAAEM,uBAEIC,QAACA,QAADC,WAAUA,WAAVC,UAAsBA,UAAtBC,KAAiCA,KAAjCC,OAAuCA,QAAUV,OAAOW,QAExDC,eAAiB,IAAIC,iBAAQ,sEAChBV,IAAIW,MAAK,IACjBC,cAAKC,KAAK,CAAC,CACdC,WAAY,uCACZC,KAAM,CAACZ,QAAAA,QAASC,WAAAA,WAAYC,UAAAA,UAAWC,KAAAA,KAAMC,OAAAA,WAC7C,KACLI,MAAMK,OACLhB,GAAGiB,UAAYD,KAAKE,MACbT,eAAeU,aACvBC,MAAMC,sBAAaC,cAG1B7B,aAAc,2IAzCZM,kBACO,6BADPA,gBAEK,2BAFLA,eAGI,8BAGNN,aAAc"}
|
||||
@@ -54,13 +54,13 @@ export function init() {
|
||||
}
|
||||
e.preventDefault();
|
||||
|
||||
const {fieldid, instanceid} = target.dataset;
|
||||
const {fieldid, instanceid, component, area, itemid} = target.dataset;
|
||||
|
||||
const pendingPromise = new Pending('recalculate_customfield_number');
|
||||
addIconToContainer(el).then(() => {
|
||||
return Ajax.call([{
|
||||
methodname: 'customfield_number_recalculate_value',
|
||||
args: {fieldid, instanceid}
|
||||
args: {fieldid, instanceid, component, area, itemid}
|
||||
}])[0];
|
||||
}).then((data) => {
|
||||
el.innerHTML = data.value;
|
||||
|
||||
@@ -52,7 +52,14 @@ class data_controller extends \core_customfield\data_controller {
|
||||
$field = $this->get_field();
|
||||
if (!$field->is_editable()) {
|
||||
$instanceid = (int)$this->get('instanceid');
|
||||
$data = ['value' => $this->export_value(), 'fieldid' => $field->get('id'), 'instanceid' => $instanceid];
|
||||
$data = [
|
||||
'value' => $this->export_value(),
|
||||
'fieldid' => $field->get('id'),
|
||||
'instanceid' => $instanceid,
|
||||
'component' => $this->data->get('component'),
|
||||
'area' => $this->data->get('area'),
|
||||
'itemid' => $this->data->get('itemid'),
|
||||
];
|
||||
$value = $OUTPUT->render_from_template('customfield_number/staticvalue', $data);
|
||||
$mform->addElement('static', $elementname . '_static', $this->get_field()->get_formatted_name(),
|
||||
$value);
|
||||
|
||||
@@ -19,6 +19,7 @@ declare(strict_types=1);
|
||||
namespace customfield_number\external;
|
||||
|
||||
use core\exception\invalid_parameter_exception;
|
||||
use core_customfield\handler;
|
||||
use core_external\external_function_parameters;
|
||||
use core_external\external_single_structure;
|
||||
use core_external\external_api;
|
||||
@@ -44,6 +45,9 @@ class recalculate extends external_api {
|
||||
return new external_function_parameters([
|
||||
'fieldid' => new external_value(PARAM_INT, 'Field id', VALUE_REQUIRED),
|
||||
'instanceid' => new external_value(PARAM_INT, 'Instance id', VALUE_REQUIRED),
|
||||
'component' => new external_value(PARAM_COMPONENT, 'Component related to instance id', VALUE_DEFAULT, 'core_course'),
|
||||
'area' => new external_value(PARAM_AREA, 'Area related to instance id', VALUE_DEFAULT, 'course'),
|
||||
'itemid' => new external_value(PARAM_INT, 'Item id', VALUE_DEFAULT, 0),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -52,16 +56,31 @@ class recalculate extends external_api {
|
||||
*
|
||||
* @param int $fieldid
|
||||
* @param int $instanceid
|
||||
* @param string $component
|
||||
* @param string $area
|
||||
* @param int $itemid
|
||||
* @return array
|
||||
*/
|
||||
public static function execute(int $fieldid, int $instanceid): array {
|
||||
public static function execute(
|
||||
int $fieldid,
|
||||
int $instanceid,
|
||||
string $component = 'core_course',
|
||||
string $area = 'course',
|
||||
int $itemid = 0,
|
||||
): array {
|
||||
// Parameter validation.
|
||||
[
|
||||
'fieldid' => $fieldid,
|
||||
'instanceid' => $instanceid,
|
||||
'component' => $component,
|
||||
'area' => $area,
|
||||
'itemid' => $itemid,
|
||||
] = self::validate_parameters(self::execute_parameters(), [
|
||||
'fieldid' => $fieldid,
|
||||
'instanceid' => $instanceid,
|
||||
'component' => $component,
|
||||
'area' => $area,
|
||||
'itemid' => $itemid,
|
||||
]);
|
||||
|
||||
// Access validation.
|
||||
@@ -70,19 +89,25 @@ class recalculate extends external_api {
|
||||
|
||||
$field = \core_customfield\field_controller::create($fieldid);
|
||||
$provider = provider_base::instance($field);
|
||||
if (!$provider) {
|
||||
if (!$provider || !$provider->is_available()) {
|
||||
throw new invalid_parameter_exception('Invalid parameter');
|
||||
}
|
||||
|
||||
$handler = $field->get_handler();
|
||||
$handler = handler::get_handler($component, $area, $itemid);
|
||||
if (!$handler->can_edit($field, $instanceid)) {
|
||||
throw new \moodle_exception('nopermissions', '', '', get_string('update'));
|
||||
}
|
||||
|
||||
$provider->recalculate($instanceid);
|
||||
$provider->recalculate($instanceid, $component, $area, $itemid);
|
||||
|
||||
$data = $handler->get_instance_fields_data(
|
||||
[$fieldid => $field], $instanceid)[$fieldid];
|
||||
$datas = \core_customfield\api::get_instance_fields_data(
|
||||
fields: [$fieldid => $field],
|
||||
instanceid: $instanceid,
|
||||
component: $component,
|
||||
area: $area,
|
||||
itemid: $itemid,
|
||||
);
|
||||
$data = $datas[$fieldid];
|
||||
|
||||
return ['value' => $data->export_value()];
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ declare(strict_types=1);
|
||||
namespace customfield_number\local\numberproviders;
|
||||
|
||||
use context_course;
|
||||
use core\exception\coding_exception;
|
||||
use core_plugin_manager;
|
||||
use customfield_number\data_controller;
|
||||
use customfield_number\provider_base;
|
||||
@@ -80,9 +81,21 @@ class nofactivities extends provider_base {
|
||||
* Recalculate the number of activities in the course.
|
||||
*
|
||||
* @param int|null $instanceid
|
||||
* @param string $component
|
||||
* @param string $area
|
||||
* @param int $itemid
|
||||
*/
|
||||
public function recalculate(?int $instanceid = null): void {
|
||||
public function recalculate(
|
||||
?int $instanceid = null,
|
||||
string $component = 'core_course',
|
||||
string $area = 'course',
|
||||
int $itemid = 0,
|
||||
): void {
|
||||
global $DB;
|
||||
if ($component !== 'core_course' || $area !== 'course') {
|
||||
throw new coding_exception('Can only calculate number of activities for courses');
|
||||
}
|
||||
|
||||
$types = $this->field->get_configdata_property('activitytypes');
|
||||
$displaywhenzero = $this->field->get_configdata_property('displaywhenzero');
|
||||
|
||||
|
||||
@@ -64,8 +64,16 @@ abstract class provider_base {
|
||||
* Recalculate field value.
|
||||
*
|
||||
* @param int|null $instanceid
|
||||
* @param string $component
|
||||
* @param string $area
|
||||
* @param int $itemid
|
||||
*/
|
||||
public function recalculate(?int $instanceid = null): void {
|
||||
public function recalculate(
|
||||
?int $instanceid = null,
|
||||
string $component = 'core_course',
|
||||
string $area = 'course',
|
||||
int $itemid = 0,
|
||||
): void {
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -67,7 +67,7 @@ class cron extends scheduled_task {
|
||||
$field = field_controller::create(0, $row, $category);
|
||||
if ($provider = provider_base::instance($field)) {
|
||||
if ($provider->is_available()) {
|
||||
$provider->recalculate();
|
||||
$provider->recalculate(null, $cat->component, $cat->area, $cat->itemid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
|
||||
namespace customfield_number\task;
|
||||
|
||||
use coding_exception;
|
||||
use core\task\adhoc_task;
|
||||
use core_customfield\field_controller;
|
||||
use customfield_number\provider_base;
|
||||
@@ -58,9 +57,15 @@ class recalculate extends adhoc_task {
|
||||
|
||||
// Schedule recalculate for each field, checking component, area and the presense of provider.
|
||||
$instanceid = clean_param($customdata->instanceid ?? null, PARAM_INT);
|
||||
$component = clean_param($customdata->component ?? 'core_course', PARAM_COMPONENT);
|
||||
$area = clean_param($customdata->area ?? 'course', PARAM_AREA);
|
||||
$itemid = clean_param($customdata->itemid ?? 0, PARAM_INT);
|
||||
foreach ($fields as $field) {
|
||||
if ($this->field_is_scheduled($field) && ($provider = provider_base::instance($field))) {
|
||||
$provider->recalculate($instanceid ?: null);
|
||||
if ($this->field_is_scheduled($field)) {
|
||||
$provider = provider_base::instance($field);
|
||||
if ($provider?->is_available()) {
|
||||
$provider->recalculate($instanceid ?: null, $component, $area, $itemid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,11 +92,26 @@ class recalculate extends adhoc_task {
|
||||
*
|
||||
* @param int $fieldid in of the custom field
|
||||
* @param int|null $instanceid if specified, only recalculates for the given instance id
|
||||
* @param string|null $component the component related to the instance
|
||||
* @param string|null $area the area related to the instance
|
||||
* @param int $itemid
|
||||
* @return void
|
||||
*/
|
||||
public static function schedule_for_field(int $fieldid, ?int $instanceid = null) {
|
||||
public static function schedule_for_field(
|
||||
int $fieldid,
|
||||
?int $instanceid = null,
|
||||
?string $component = null,
|
||||
?string $area = null,
|
||||
int $itemid = 0,
|
||||
) {
|
||||
$task = new static();
|
||||
$task->set_custom_data(['fieldid' => $fieldid, 'instanceid' => $instanceid]);
|
||||
$task->set_custom_data([
|
||||
'fieldid' => $fieldid,
|
||||
'instanceid' => $instanceid,
|
||||
'component' => $component,
|
||||
'area' => $area,
|
||||
'itemid' => $itemid,
|
||||
]);
|
||||
\core\task\manager::queue_adhoc_task($task, true);
|
||||
}
|
||||
|
||||
@@ -102,16 +122,23 @@ class recalculate extends adhoc_task {
|
||||
* @param string|null $component
|
||||
* @param string|null $area
|
||||
* @param int|null $instanceid
|
||||
* @param int $itemid
|
||||
* @return void
|
||||
*/
|
||||
public static function schedule_for_fieldtype(string $fieldtype, ?string $component = null, ?string $area = null,
|
||||
?int $instanceid = null) {
|
||||
public static function schedule_for_fieldtype(
|
||||
string $fieldtype,
|
||||
?string $component = null,
|
||||
?string $area = null,
|
||||
?int $instanceid = null,
|
||||
int $itemid = 0,
|
||||
) {
|
||||
$task = new static();
|
||||
$task->set_custom_data([
|
||||
'fieldtype' => $fieldtype,
|
||||
'component' => $component,
|
||||
'area' => $area,
|
||||
'instanceid' => $instanceid,
|
||||
'itemid' => $itemid,
|
||||
]);
|
||||
\core\task\manager::queue_adhoc_task($task, true);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,10 @@
|
||||
{
|
||||
"value": 5,
|
||||
"instanceid": 4,
|
||||
"fieldid": 5
|
||||
"fieldid": 5,
|
||||
"component": "core_course",
|
||||
"area": "course",
|
||||
"itemid": 0
|
||||
}
|
||||
}}
|
||||
<div class="customfield_number-recalculate-wrapper d-flex mt-2" data-fieldtype="wrapper">
|
||||
@@ -28,7 +31,9 @@
|
||||
{{#instanceid}}
|
||||
<div class="customfield_number-recalculate-link ms-2">
|
||||
<a href="#" data-customfield_number-action="recalculate" data-fieldtype="link"
|
||||
data-fieldid="{{fieldid}}" data-instanceid="{{instanceid}}">{{#pix}}t/reload, core{{/pix}}{{#str}} update, core {{/str}}</a>
|
||||
data-fieldid="{{fieldid}}" data-instanceid="{{instanceid}}"
|
||||
data-component="{{component}}" data-area="{{area}}" data-itemid="{{itemid}}"
|
||||
>{{#pix}}t/reload, core{{/pix}}{{#str}} update, core {{/str}}</a>
|
||||
</div>
|
||||
{{/instanceid}}
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,7 @@ namespace core_customfield;
|
||||
/**
|
||||
* Functional test for class \core_customfield\api
|
||||
*
|
||||
* @covers \core_customfield\api
|
||||
* @package core_customfield
|
||||
* @category test
|
||||
* @copyright 2018 Toni Barbera <toni@moodle.com>
|
||||
@@ -271,4 +272,58 @@ final class api_test extends \advanced_testcase {
|
||||
$this->assertCount(6, $DB->get_records_select(\core_customfield\field::TABLE, 'id '.$sql, $p));
|
||||
$this->assertCount(6, $DB->get_records_select(\core_customfield\data::TABLE, 'fieldid '.$sql, $p));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test for function api::is_shortname_unique()
|
||||
*/
|
||||
public function test_is_shortname_unique(): void {
|
||||
$this->resetAfterTest();
|
||||
$this->setAdminUser();
|
||||
|
||||
/** @var \core_customfield_generator $generator */
|
||||
$generator = $this->getDataGenerator()->get_plugin_generator('core_customfield');
|
||||
|
||||
// Create shared category and fields.
|
||||
$sharedcategory = $generator->create_category(['component' => 'core_customfield', 'area' => 'shared']);
|
||||
$generator->create_field([
|
||||
'categoryid' => $sharedcategory->get('id'),
|
||||
'name' => 'My shared field',
|
||||
'shortname' => 'mysharedfield',
|
||||
'type' => 'text',
|
||||
]);
|
||||
$sharedfield2 = $generator->create_field([
|
||||
'categoryid' => $sharedcategory->get('id'),
|
||||
'name' => 'My shared field 2',
|
||||
'shortname' => 'mysharedfield2',
|
||||
'type' => 'text',
|
||||
]);
|
||||
|
||||
// Create a course category and fields.
|
||||
$coursecategory = $generator->create_category(['component' => 'core_course', 'area' => 'course']);
|
||||
$generator->create_field([
|
||||
'categoryid' => $coursecategory->get('id'),
|
||||
'name' => 'My course field',
|
||||
'shortname' => 'mycoursefield',
|
||||
'type' => 'text',
|
||||
]);
|
||||
$coursefield2 = $generator->create_field([
|
||||
'categoryid' => $coursecategory->get('id'),
|
||||
'name' => 'My course field 2',
|
||||
'shortname' => 'mycoursefield2',
|
||||
'type' => 'text',
|
||||
]);
|
||||
|
||||
$coursehandler = \core_course\customfield\course_handler::create();
|
||||
$sharedhandler = \core_customfield\customfield\shared_handler::create();
|
||||
|
||||
$this->assertTrue(api::is_shortname_unique($coursehandler, 'otherfield', $coursefield2->get('id')));
|
||||
$this->assertTrue(api::is_shortname_unique($coursehandler, 'mycoursefield2', $coursefield2->get('id')));
|
||||
$this->assertFalse(api::is_shortname_unique($coursehandler, 'mycoursefield', $coursefield2->get('id')));
|
||||
$this->assertFalse(api::is_shortname_unique($coursehandler, 'mysharedfield', $coursefield2->get('id')));
|
||||
|
||||
$this->assertTrue(api::is_shortname_unique($sharedhandler, 'otherfield', $sharedfield2->get('id')));
|
||||
$this->assertTrue(api::is_shortname_unique($sharedhandler, 'mysharedfield2', $sharedfield2->get('id')));
|
||||
$this->assertFalse(api::is_shortname_unique($sharedhandler, 'mycoursefield', $sharedfield2->get('id')));
|
||||
$this->assertFalse(api::is_shortname_unique($sharedhandler, 'mysharedfield', $sharedfield2->get('id')));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,9 @@ Feature: Teachers can edit course custom fields
|
||||
|
||||
Background:
|
||||
Given the following "custom field categories" exist:
|
||||
| name | component | area | itemid |
|
||||
| Category for test | core_course | course | 0 |
|
||||
| name | component | area | itemid |
|
||||
| Category for test | core_course | course | 0 |
|
||||
| Shared category | core_customfield | shared | 0 |
|
||||
And the following "custom fields" exist:
|
||||
| name | category | type | shortname | description | configdata |
|
||||
| Field 1 | Category for test | text | f1 | d1 | |
|
||||
@@ -15,6 +16,7 @@ Feature: Teachers can edit course custom fields
|
||||
| Field 3 | Category for test | checkbox | f3 | d3 | |
|
||||
| Field 4 | Category for test | date | f4 | d4 | |
|
||||
| Field 5 | Category for test | select | f5 | d5 | {"options":"a\nb\nc"} |
|
||||
| Field 6 | Shared category | text | shf1 | shd1 | |
|
||||
And the following "users" exist:
|
||||
| username | firstname | lastname | email |
|
||||
| teacher1 | Teacher | 1 | teacher1@example.com |
|
||||
@@ -110,5 +112,9 @@ Feature: Teachers can edit course custom fields
|
||||
And I set the field "Short name" to "f1"
|
||||
And I click on "Save changes" "button" in the "Adding a new Short text" "dialogue"
|
||||
Then I should see "Short name already exists" in the "Short name" "form_row"
|
||||
# Let's try to use a shortname that exists in the Shared custom field.
|
||||
And I set the field "Short name" to "shf1"
|
||||
And I click on "Save changes" "button" in the "Adding a new Short text" "dialogue"
|
||||
Then I should see "Short name already exists" in the "Short name" "form_row"
|
||||
And I click on "Cancel" "button" in the "Adding a new Short text" "dialogue"
|
||||
And I log out
|
||||
|
||||
@@ -15,6 +15,37 @@ Feature: Create shared categories and fields
|
||||
And I wait until the page is ready
|
||||
And I wait until "Other fields" "text" does not exist
|
||||
|
||||
Scenario: Shared custom field short name must be unique across all instance fields
|
||||
Given the following "custom field categories" exist:
|
||||
| name | component | area | itemid |
|
||||
| Category for course | core_course | course | 0 |
|
||||
| Category for cohort | core_cohort | cohort | 0 |
|
||||
| Shared category | core_customfield | shared | 0 |
|
||||
And the following "custom fields" exist:
|
||||
| name | category | type | shortname | description |
|
||||
| Field 1 | Category for course | text | f1 | d1 |
|
||||
| Field 2 | Category for cohort | text | f2 | d2 |
|
||||
| Field 3 | Shared category | text | shf1 | shd1 |
|
||||
When I log in as "admin"
|
||||
And I navigate to "Custom fields > Shared custom fields" in site administration
|
||||
And I click on "Add a new custom field" "link"
|
||||
And I click on "Short text" "link"
|
||||
And I set the following fields to these values:
|
||||
| Name | Test field |
|
||||
| Short name | shf1 |
|
||||
And I click on "Save changes" "button" in the "Adding a new Short text" "dialogue"
|
||||
Then I should see "Short name already exists" in the "Short name" "form_row"
|
||||
And I set the field "Short name" to "f1"
|
||||
And I click on "Save changes" "button" in the "Adding a new Short text" "dialogue"
|
||||
Then I should see "Short name already exists" in the "Short name" "form_row"
|
||||
And I set the field "Short name" to "f2"
|
||||
And I click on "Save changes" "button" in the "Adding a new Short text" "dialogue"
|
||||
Then I should see "Short name already exists" in the "Short name" "form_row"
|
||||
And I set the field "Short name" to "f3"
|
||||
And I click on "Save changes" "button" in the "Adding a new Short text" "dialogue"
|
||||
And I should see "Add a new category"
|
||||
And I should see "f3"
|
||||
|
||||
Scenario: Shared customfields are displayed in other entities
|
||||
Given the following "custom field categories" exist:
|
||||
| name | component | area | itemid |
|
||||
|
||||
@@ -48,4 +48,140 @@ final class handler_test extends advanced_testcase {
|
||||
$this->expectExceptionMessage('Unable to find handler for custom fields for component core_blimey and area test');
|
||||
handler::get_handler('core_blimey', 'test');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test getting instances data
|
||||
*/
|
||||
public function test_get_instances_data(): void {
|
||||
global $DB;
|
||||
$this->resetAfterTest();
|
||||
$this->setAdminUser();
|
||||
|
||||
$sharedcustomfieldcategory = $this->getDataGenerator()->create_custom_field_category([
|
||||
'component' => 'core_customfield',
|
||||
'area' => 'shared',
|
||||
]);
|
||||
$sharedfield = $this->getDataGenerator()->create_custom_field([
|
||||
'categoryid' => $sharedcustomfieldcategory->get('id'),
|
||||
'name' => 'Color',
|
||||
'type' => 'text',
|
||||
'shortname' => 'color',
|
||||
]);
|
||||
|
||||
// Enable the shared custom field category for both courses and cohorts.
|
||||
$shared = new shared(0, (object)[
|
||||
'categoryid' => $sharedcustomfieldcategory->get('id'),
|
||||
'component' => 'core_course',
|
||||
'area' => 'course',
|
||||
'itemid' => 0,
|
||||
]);
|
||||
$shared->create();
|
||||
$shared = new shared(0, (object)[
|
||||
'categoryid' => $sharedcustomfieldcategory->get('id'),
|
||||
'component' => 'core_cohort',
|
||||
'area' => 'cohort',
|
||||
'itemid' => 0,
|
||||
]);
|
||||
$shared->create();
|
||||
|
||||
$course1 = $this->getDataGenerator()->create_course();
|
||||
$course2 = $this->getDataGenerator()->create_course();
|
||||
$sharedid1 = $course1->id;
|
||||
$sharedid2 = $course2->id;
|
||||
|
||||
// Manually insert cohorts in the database to have both courses and cohorts using the same IDs.
|
||||
$now = time();
|
||||
$DB->insert_record_raw('cohort', [
|
||||
'id' => $sharedid1,
|
||||
'contextid' => 1,
|
||||
'name' => 'Cohort 1',
|
||||
'descriptionformat' => 0,
|
||||
'visible' => 1,
|
||||
'component' => '',
|
||||
'timecreated' => $now,
|
||||
'timemodified' => $now,
|
||||
]);
|
||||
$DB->insert_record_raw('cohort', [
|
||||
'id' => $sharedid2,
|
||||
'contextid' => 1,
|
||||
'name' => 'Cohort 2',
|
||||
'descriptionformat' => 0,
|
||||
'visible' => 1,
|
||||
'component' => '',
|
||||
'timecreated' => $now,
|
||||
'timemodified' => $now,
|
||||
]);
|
||||
|
||||
$fieldid = $sharedfield->get('id');
|
||||
|
||||
// Manually insert customfield_data records as well.
|
||||
$DB->insert_record_raw('customfield_data', [
|
||||
'fieldid' => $fieldid,
|
||||
'instanceid' => $sharedid1,
|
||||
'charvalue' => 'orange',
|
||||
'value' => 'orange',
|
||||
'valueformat' => 0,
|
||||
'valuetrust' => 0,
|
||||
'timecreated' => $now,
|
||||
'timemodified' => $now,
|
||||
'component' => 'core_course',
|
||||
'area' => 'course',
|
||||
'itemid' => 0,
|
||||
]);
|
||||
$DB->insert_record_raw('customfield_data', [
|
||||
'fieldid' => $fieldid,
|
||||
'instanceid' => $sharedid2,
|
||||
'charvalue' => 'red',
|
||||
'value' => 'red',
|
||||
'valueformat' => 0,
|
||||
'valuetrust' => 0,
|
||||
'timecreated' => $now,
|
||||
'timemodified' => $now,
|
||||
'component' => 'core_course',
|
||||
'area' => 'course',
|
||||
'itemid' => 0,
|
||||
]);
|
||||
$DB->insert_record_raw('customfield_data', [
|
||||
'fieldid' => $fieldid,
|
||||
'instanceid' => $sharedid1,
|
||||
'charvalue' => 'blue',
|
||||
'value' => 'blue',
|
||||
'valueformat' => 0,
|
||||
'valuetrust' => 0,
|
||||
'timecreated' => $now,
|
||||
'timemodified' => $now,
|
||||
'component' => 'core_cohort',
|
||||
'area' => 'cohort',
|
||||
'itemid' => 0,
|
||||
]);
|
||||
$DB->insert_record_raw('customfield_data', [
|
||||
'fieldid' => $fieldid,
|
||||
'instanceid' => $sharedid2,
|
||||
'charvalue' => 'green',
|
||||
'value' => 'green',
|
||||
'valueformat' => 0,
|
||||
'valuetrust' => 0,
|
||||
'timecreated' => $now,
|
||||
'timemodified' => $now,
|
||||
'component' => 'core_cohort',
|
||||
'area' => 'cohort',
|
||||
'itemid' => 0,
|
||||
]);
|
||||
|
||||
// Verify courses retrieve their own custom field values despite sharing IDs with cohorts.
|
||||
$coursehandler = handler::get_handler('core_course', 'course');
|
||||
$coursedata = $coursehandler->get_instances_data([$sharedid1, $sharedid2], true);
|
||||
$this->assertArrayHasKey($sharedid1, $coursedata);
|
||||
$this->assertArrayHasKey($sharedid2, $coursedata);
|
||||
$this->assertEquals('orange', $coursedata[$sharedid1][$fieldid]->get_value());
|
||||
$this->assertEquals('red', $coursedata[$sharedid2][$fieldid]->get_value());
|
||||
|
||||
// Verify cohorts retrieve their own custom field values despite sharing IDs with courses.
|
||||
$cohorthandler = handler::get_handler('core_cohort', 'cohort');
|
||||
$cohortdata = $cohorthandler->get_instances_data([$sharedid1, $sharedid2], true);
|
||||
$this->assertArrayHasKey($sharedid1, $cohortdata);
|
||||
$this->assertArrayHasKey($sharedid2, $cohortdata);
|
||||
$this->assertEquals('blue', $cohortdata[$sharedid1][$fieldid]->get_value());
|
||||
$this->assertEquals('green', $cohortdata[$sharedid2][$fieldid]->get_value());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,15 +58,19 @@ function xmldb_enrol_database_upgrade($oldversion) {
|
||||
|
||||
// Migrate enrolments where possible.
|
||||
// First, get the user enrolments that can be migrated.
|
||||
// Only select the earliest (MIN id) user_enrolments per user to avoid
|
||||
// duplicate key violations when a user has multiple enrolments across
|
||||
// duplicate database enrol instances.
|
||||
$migrateusers = $DB->get_records_sql(
|
||||
"SELECT ue.id
|
||||
"SELECT MIN(ue.id) AS id
|
||||
FROM {user_enrolments} ue
|
||||
WHERE ue.enrolid $insql
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM {user_enrolments} ue2
|
||||
WHERE ue2.userid = ue.userid
|
||||
AND ue2.enrolid = :idtokeep)",
|
||||
AND ue2.enrolid = :idtokeep)
|
||||
GROUP BY ue.userid",
|
||||
array_merge($inparams, ['idtokeep' => $idtokeep]),
|
||||
);
|
||||
|
||||
|
||||
@@ -122,6 +122,7 @@ class sync_tool_grades extends adhoc_task {
|
||||
mtrace("Skipping - Invalid grade $mtracecontent.");
|
||||
continue;
|
||||
}
|
||||
$grade = floatval($grade); // Grade must be sent as a numeric value, not a string.
|
||||
|
||||
if (empty($grademax)) {
|
||||
mtrace("Skipping - Invalid grademax $mtracecontent.");
|
||||
|
||||
@@ -368,7 +368,7 @@ class course_enrolment_table extends html_table implements renderable {
|
||||
$this->sort = optional_param(self::SORTVAR, self::DEFAULTSORT, PARAM_ALPHANUM);
|
||||
$this->sortdirection = optional_param(self::SORTDIRECTIONVAR, self::DEFAULTSORTDIRECTION, PARAM_ALPHA);
|
||||
|
||||
$this->attributes = array('class' => 'userenrolment table-striped');
|
||||
$this->attributes = ['class' => 'userenrolment table table-striped table-hover generaltable'];
|
||||
if (!in_array($this->sort, self::$sortablefields)) {
|
||||
$this->sort = self::DEFAULTSORT;
|
||||
}
|
||||
@@ -654,7 +654,7 @@ class course_enrolment_other_users_table extends course_enrolment_table {
|
||||
*/
|
||||
public function __construct(course_enrolment_manager $manager) {
|
||||
parent::__construct($manager);
|
||||
$this->attributes = array('class'=>'userenrolment otheruserenrolment');
|
||||
$this->attributes['class'] .= ' otheruserenrolment';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -143,7 +143,7 @@
|
||||
|
||||
// Run convert on document (.ps to .gif/.png) or run dvisvgm (.ps to .svg).
|
||||
if ($background) {
|
||||
$bg_opt = "-transparent \"$background\""; // Makes transparent background
|
||||
$bg_opt = '-transparent ' . escapeshellarg($background); // Makes transparent background
|
||||
} else {
|
||||
$bg_opt = "";
|
||||
}
|
||||
|
||||
+181
-2
@@ -26,10 +26,22 @@
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
// Default timeout in seconds for mimetex command execution.
|
||||
defined('FILTER_TEX_MIMETEX_TIMEOUT') || define('FILTER_TEX_MIMETEX_TIMEOUT', 5);
|
||||
|
||||
/**
|
||||
* Check if the current operating system is Windows.
|
||||
*
|
||||
* @return bool True if running on Windows, false otherwise.
|
||||
*/
|
||||
function filter_tex_is_windows(): bool {
|
||||
return (PHP_OS == "WINNT") || (PHP_OS == "WIN32") || (PHP_OS == "Windows");
|
||||
}
|
||||
|
||||
function filter_tex_get_executable($debug=false) {
|
||||
global $CFG;
|
||||
|
||||
if ((PHP_OS == "WINNT") || (PHP_OS == "WIN32") || (PHP_OS == "Windows")) {
|
||||
if (filter_tex_is_windows()) {
|
||||
return "$CFG->dirroot/filter/tex/mimetex.exe";
|
||||
}
|
||||
|
||||
@@ -134,7 +146,7 @@ function filter_tex_get_cmd($pathname, $texexp) {
|
||||
$texexp = escapeshellarg($texexp);
|
||||
$executable = filter_tex_get_executable(false);
|
||||
|
||||
if ((PHP_OS == "WINNT") || (PHP_OS == "WIN32") || (PHP_OS == "Windows")) {
|
||||
if (filter_tex_is_windows()) {
|
||||
$executable = str_replace(' ', '^ ', $executable);
|
||||
return "$executable ++ -e \"$pathname\" -- $texexp";
|
||||
|
||||
@@ -143,6 +155,173 @@ function filter_tex_get_cmd($pathname, $texexp) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run mimetex command with a timeout on Windows.
|
||||
*
|
||||
* @param string $cmd Command string to execute.
|
||||
* @param int $timeoutmicros Timeout in microseconds.
|
||||
* @return array Array with keys: code, timedout, status, errors.
|
||||
*/
|
||||
function filter_tex_exec_windows(string $cmd, int $timeoutmicros): array {
|
||||
// Create temporary file for stderr.
|
||||
$temperr = tempnam(sys_get_temp_dir(), 'err_');
|
||||
|
||||
$descriptors = [
|
||||
0 => ['file', 'NUL', 'r'], // STDIN.
|
||||
1 => ['file', 'NUL', 'w'], // STDOUT.
|
||||
2 => ['file', $temperr, 'w'], // STDERR.
|
||||
];
|
||||
|
||||
$process = proc_open($cmd, $descriptors, $pipes);
|
||||
if (!is_resource($process)) {
|
||||
unlink($temperr);
|
||||
return [
|
||||
'code' => 127, // Command not found.
|
||||
'timedout' => false,
|
||||
'status' => [],
|
||||
'errors' => '',
|
||||
];
|
||||
}
|
||||
|
||||
$timedout = false;
|
||||
while ($timeoutmicros > 0) {
|
||||
$start = microtime(true);
|
||||
$status = proc_get_status($process);
|
||||
|
||||
if (!$status['running']) {
|
||||
break;
|
||||
}
|
||||
|
||||
$timeoutmicros -= (microtime(true) - $start) * 1000000;
|
||||
if ($timeoutmicros <= 0) {
|
||||
$timedout = true;
|
||||
$pid = (int)($status['pid'] ?? 0);
|
||||
exec('taskkill /F /T /PID ' . $pid . ' 2>NUL');
|
||||
break;
|
||||
}
|
||||
|
||||
usleep(50000); // Sleep for 50ms.
|
||||
}
|
||||
|
||||
$status = proc_get_status($process);
|
||||
$code = proc_close($process);
|
||||
|
||||
// Capture stderr from temp file.
|
||||
$errors = file_get_contents($temperr);
|
||||
unlink($temperr);
|
||||
|
||||
return [
|
||||
'code' => $code,
|
||||
'timedout' => $timedout,
|
||||
'status' => $status,
|
||||
'errors' => $errors,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Run mimetex command with a timeout on Unix-like systems.
|
||||
*
|
||||
* @param string $cmd Command string to execute.
|
||||
* @param int $timeoutmicros Timeout in microseconds.
|
||||
* @return array Array with keys: code, timedout, status, errors.
|
||||
*/
|
||||
function filter_tex_exec_unix(string $cmd, int $timeoutmicros): array {
|
||||
// File descriptors passed to the process.
|
||||
$descriptors = [
|
||||
0 => ['pipe', 'r'], // STDIN.
|
||||
1 => ['pipe', 'w'], // STDOUT.
|
||||
2 => ['pipe', 'w'], // STDERR.
|
||||
];
|
||||
|
||||
$process = proc_open('exec ' . $cmd, $descriptors, $pipes);
|
||||
if (!is_resource($process)) {
|
||||
return [
|
||||
'code' => 127, // Command not found.
|
||||
'timedout' => false,
|
||||
'status' => [],
|
||||
'errors' => '',
|
||||
];
|
||||
}
|
||||
|
||||
fclose($pipes[0]);
|
||||
stream_set_blocking($pipes[1], false);
|
||||
stream_set_blocking($pipes[2], false);
|
||||
|
||||
$errors = '';
|
||||
$timedout = false;
|
||||
while ($timeoutmicros > 0) {
|
||||
$start = microtime(true);
|
||||
|
||||
$read = [$pipes[1], $pipes[2]];
|
||||
$other = [];
|
||||
stream_select($read, $other, $other, 0, (int)$timeoutmicros);
|
||||
|
||||
$status = proc_get_status($process);
|
||||
|
||||
stream_get_contents($pipes[1]); // Discard stdout to prevent pipe blocking.
|
||||
$errors .= stream_get_contents($pipes[2]);
|
||||
|
||||
if (!$status['running']) {
|
||||
break;
|
||||
}
|
||||
|
||||
$timeoutmicros -= (microtime(true) - $start) * 1000000;
|
||||
if ($timeoutmicros <= 0) {
|
||||
$timedout = true;
|
||||
proc_terminate($process);
|
||||
break;
|
||||
}
|
||||
|
||||
usleep(50000); // Sleep for 50ms.
|
||||
}
|
||||
|
||||
// Read any remaining data from pipes.
|
||||
stream_get_contents($pipes[1]); // Discard remaining stdout.
|
||||
$errors .= stream_get_contents($pipes[2]);
|
||||
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
|
||||
$status = proc_get_status($process);
|
||||
$code = proc_close($process);
|
||||
|
||||
return [
|
||||
'code' => $code,
|
||||
'timedout' => $timedout,
|
||||
'status' => $status,
|
||||
'errors' => $errors,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Run mimetex command with a timeout.
|
||||
*
|
||||
* @param string $cmd Command string to execute.
|
||||
* @param int|null &$code Exit code (passed by reference, set by function).
|
||||
* @return void
|
||||
*/
|
||||
function filter_tex_exec(string $cmd, ?int &$code): void {
|
||||
$timeoutmicros = FILTER_TEX_MIMETEX_TIMEOUT * 1000000;
|
||||
|
||||
if (filter_tex_is_windows()) {
|
||||
$result = filter_tex_exec_windows($cmd, $timeoutmicros);
|
||||
} else {
|
||||
$result = filter_tex_exec_unix($cmd, $timeoutmicros);
|
||||
}
|
||||
|
||||
if ($result['errors']) {
|
||||
debugging('filter_tex_exec errors: ' . $result['errors'], DEBUG_DEVELOPER);
|
||||
}
|
||||
|
||||
if ($result['timedout']) {
|
||||
$code = 124;
|
||||
} else if ($result['code'] === -1 && isset($result['status']['exitcode']) && $result['status']['exitcode'] !== -1) {
|
||||
$code = $result['status']['exitcode'];
|
||||
} else {
|
||||
$code = $result['code'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge all caches when settings changed.
|
||||
*/
|
||||
|
||||
@@ -18,7 +18,6 @@ define('NO_MOODLE_COOKIES', true); // Because it interferes with caching
|
||||
require_once($CFG->dirroot.'/filter/tex/latex.php');
|
||||
|
||||
$cmd = ''; // Initialise these variables
|
||||
$status = '';
|
||||
|
||||
$relativepath = get_file_argument();
|
||||
|
||||
@@ -58,7 +57,7 @@ define('NO_MOODLE_COOKIES', true); // Because it interferes with caching
|
||||
$texexp = preg_replace('!\r\n?!', ' ', $texexp);
|
||||
$texexp = '\Large '.$texexp;
|
||||
$cmd = filter_tex_get_cmd($pathname, $texexp);
|
||||
system($cmd, $status);
|
||||
filter_tex_exec($cmd, $status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ if ($ADMIN->fulltree) {
|
||||
$items[] = new admin_setting_heading('filter_tex/latexheading', get_string('latexsettings', 'filter_tex'), '');
|
||||
$items[] = new admin_setting_configtextarea('filter_tex/latexpreamble', get_string('latexpreamble','filter_tex'),
|
||||
'', "\\usepackage[latin1]{inputenc}\n\\usepackage{amsmath}\n\\usepackage{amsfonts}\n\\RequirePackage{amsmath,amssymb,latexsym}\n");
|
||||
$items[] = new admin_setting_configtext('filter_tex/latexbackground', get_string('backgroundcolour', 'admin'), '', '#FFFFFF');
|
||||
$items[] = new admin_setting_configcolourpicker('filter_tex/latexbackground', get_string('backgroundcolour', 'admin'), '', '#FFFFFF');
|
||||
$items[] = new admin_setting_configtext('filter_tex/density', get_string('density', 'admin'), '', '120', PARAM_INT);
|
||||
|
||||
$default_filter_tex_pathlatex = '';
|
||||
|
||||
@@ -153,7 +153,7 @@
|
||||
$texexp = '\Large '.$texexp;
|
||||
$commandpath = filter_tex_get_executable(true);
|
||||
$cmd = filter_tex_get_cmd($pathname, $texexp);
|
||||
system($cmd, $status);
|
||||
filter_tex_exec($cmd, $status);
|
||||
|
||||
if ($return) {
|
||||
return $image;
|
||||
@@ -172,6 +172,10 @@
|
||||
echo "Status corresponds to bus error<br />\n";
|
||||
} else if ($status == 22) {
|
||||
echo "Status corresponds to abnormal termination<br />\n";
|
||||
} else if ($status == 124) {
|
||||
echo "Status corresponds to timeout<br />\n";
|
||||
} else if ($status == 127) {
|
||||
echo "Status corresponds to command not found<br />\n";
|
||||
}
|
||||
if (file_exists($commandpath)) {
|
||||
echo "File size of mimetex executable $commandpath is " . filesize($commandpath) . "<br />";
|
||||
|
||||
@@ -97,6 +97,55 @@ class behat_gradingform_guide extends behat_base {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits an existing marking guide with the provided data.
|
||||
*
|
||||
* This method edits the marking guide of the marking guide definition
|
||||
* form; the provided TableNode should contain one row for
|
||||
* each field and each cell of the row should contain:
|
||||
* | Field name | New value |
|
||||
* | shortname | Updated Grade criterion |
|
||||
*
|
||||
* @When /^I edit the marking guide criterion "([^"]*)" with the following values:$/
|
||||
* @param string $criterionname
|
||||
* @param TableNode $fields
|
||||
*/
|
||||
public function i_edit_the_marking_guide_criterion_with_the_following_values(string $criterionname, TableNode $fields) {
|
||||
if ($fieldvalues = $fields->getHash()) {
|
||||
$criterionid = 0;
|
||||
$locator = "//tr[contains(@class, 'criterion')]//div[@class='criterionname']"
|
||||
. "//span[@class='textvalue'][text()='$criterionname']/ancestor::tr";
|
||||
if ($criterionrow = $this->find('xpath', $locator)) {
|
||||
$criterionid = str_replace('guide-criteria-', '', $criterionrow->getAttribute('id'));
|
||||
}
|
||||
|
||||
if ($criterionid) {
|
||||
$criterionroot = 'guide[criteria]' . '[' . $criterionid . ']';
|
||||
|
||||
foreach ($fieldvalues as $fieldvalue) {
|
||||
// Make sure the fieldvalue array has 2 elements.
|
||||
if (count($fieldvalue) != 2) {
|
||||
throw new ExpectationException(
|
||||
'The field definition should contain field name and new value. ' .
|
||||
'Please follow this format: | Field name | New value |',
|
||||
$this->getSession()
|
||||
);
|
||||
}
|
||||
|
||||
$fieldname = $fieldvalue['Field name'];
|
||||
$newvalue = $fieldvalue['New value'];
|
||||
|
||||
$this->set_guide_field_value($criterionroot . "[$fieldname]", $newvalue);
|
||||
}
|
||||
} else {
|
||||
throw new ExpectationException(
|
||||
'Criterion with name "' . $criterionname . '" not found.',
|
||||
$this->getSession()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the marking guide with the provided data, following marking guide's definition grid cells.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
@gradingform @gradingform_guide
|
||||
Feature: Editing a marking guide already used for grading updates regrade state and student visible grades
|
||||
In order to update marking guide details
|
||||
As a teacher
|
||||
I need to be able to grade a submission
|
||||
|
||||
Background:
|
||||
Given the following "users" exist:
|
||||
| username | firstname | lastname | email |
|
||||
| teacher1 | Teacher | One | teacher1@example.com |
|
||||
| student1 | Student | One | student1@example.com |
|
||||
And the following "courses" exist:
|
||||
| fullname | shortname |
|
||||
| Course 1 | C1 |
|
||||
And the following "course enrolments" exist:
|
||||
| user | course | role |
|
||||
| teacher1 | C1 | editingteacher |
|
||||
| student1 | C1 | student |
|
||||
And the following "activities" exist:
|
||||
| activity | course | name | advancedgradingmethod_submissions | assignsubmission_onlinetext_enabled |
|
||||
| assign | C1 | Assign 1 | guide | 1 |
|
||||
And the following "mod_assign > submissions" exist:
|
||||
| assign | user | onlinetext |
|
||||
| Assign 1 | student1 | Assign 1 submission |
|
||||
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 criterion A | Grade 1 description for students | Grade 1 description for markers | 70 |
|
||||
| Grade criterion B | Grade 2 description for students | Grade 2 description for markers | 30 |
|
||||
And I press "Save marking guide and make it ready"
|
||||
And I navigate to "Assignment" in current page administration
|
||||
And I go to "Student One" "Assign 1" activity advanced grading page
|
||||
And I grade by filling the marking guide with:
|
||||
| Grade criterion A | 25 | Needs improvement |
|
||||
| Grade criterion B | 20 | Excellent! |
|
||||
And I press "Save changes"
|
||||
And I am on "Course 1" course homepage
|
||||
And I go to "Assign 1" advanced grading definition page
|
||||
And I edit the marking guide criterion "Grade criterion A" with the following values:
|
||||
| Field name | New value |
|
||||
| shortname | Updated Grade criterion A |
|
||||
| description | Updated description for students |
|
||||
| descriptionmarkers | Updated description for markers |
|
||||
| maxscore | 60 |
|
||||
And I press "Save"
|
||||
|
||||
@javascript
|
||||
Scenario: Teacher edits a used marking guide and mark it for regrade and student sees breakdown only after teacher regrades
|
||||
# Set the marking guide to be "Mark for regrade".
|
||||
Given I set the field "menuguideregrade" to "Mark for regrade"
|
||||
And I should see "You are about to save changes to a marking guide that has already been used for grading. Please indicate if existing grades need to be reviewed. If you set this then the marking guide will be hidden from students until their item is regraded."
|
||||
And I click on "Continue" "button"
|
||||
And I am on the "Assign 1" "assign activity" page logged in as student1
|
||||
# Student should not see the grade breakdown as the activity is marked for regrade.
|
||||
And I should not see "Grade breakdown"
|
||||
When I am on the "Assign 1" "assign activity" page logged in as teacher1
|
||||
And I go to "Student One" "Assign 1" activity advanced grading page
|
||||
And I grade by filling the marking guide with:
|
||||
| Updated Grade criterion A | 50 | I changed my mind |
|
||||
| Grade criterion B | 30 | It's all good now |
|
||||
And I press "Save changes"
|
||||
And I am on the "Assign 1" "assign activity" page logged in as student1
|
||||
# Now the student should see the updated grade breakdown.
|
||||
Then I should see "Grade breakdown"
|
||||
And I should see the marking guide information displayed as:
|
||||
| criteria | description | remark | maxscore | criteriascore |
|
||||
| Updated Grade criterion A | Updated description for students | I changed my mind | 60 | 50 / 60 |
|
||||
| Grade criterion B | Grade 2 description for students | It's all good now | 30 | 30 / 30 |
|
||||
|
||||
@javascript
|
||||
Scenario: Teacher edits a used marking guide does not mark for regrade existing student grades remain visible and unchanged
|
||||
# Set the marking guide to be "Do not mark for regrade".
|
||||
Given I set the field "menuguideregrade" to "Do not mark for regrade"
|
||||
And I should see "You are about to save changes to a marking guide that has already been used for grading. Please indicate if existing grades need to be reviewed. If you set this then the marking guide will be hidden from students until their item is regraded."
|
||||
And I click on "Continue" "button"
|
||||
When I am on the "Assign 1" "assign activity" page logged in as student1
|
||||
# Student should see the updated grade breakdown as the activity was not marked for regrading.
|
||||
Then I should see "Grade breakdown"
|
||||
And I should see the marking guide information displayed as:
|
||||
| criteria | description | remark | maxscore | criteriascore |
|
||||
| Updated Grade criterion A | Updated description for students | Needs improvement | 60 | 25 / 60 |
|
||||
| Grade criterion B | Grade 2 description for students | Excellent! | 30 | 20 / 30 |
|
||||
@@ -1297,7 +1297,7 @@ class grade_report_grader extends grade_report {
|
||||
$html = '';
|
||||
|
||||
$fulltable = new html_table();
|
||||
$fulltable->attributes['class'] = 'gradereport-grader-table d-none';
|
||||
$fulltable->attributes['class'] = 'table gradereport-grader-table d-none';
|
||||
$fulltable->id = 'user-grades';
|
||||
$fulltable->caption = get_string('summarygrader', 'gradereport_grader');
|
||||
$fulltable->captionhide = true;
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"category": "Hitchhikers grade category"
|
||||
}
|
||||
}}
|
||||
<li class="w-100 result-row form-check mb-1">
|
||||
<li class="w-100 result-row form-check flex-shrink-0 mb-1">
|
||||
<input class="form-check-input" data-collapse="{{name}}" type="checkbox" value="" id="check-collapse-{{name}}">
|
||||
<label for="check-collapse-{{name}}" class="selected-option-info d-block pe-3 text-truncate form-check-label">
|
||||
<span class="selected-option-text w-100 p-0">
|
||||
|
||||
@@ -88,7 +88,7 @@ abstract class screen {
|
||||
protected $items;
|
||||
|
||||
/** @var int Maximum number of students that can be shown on one page */
|
||||
protected static $maxperpage = 5000;
|
||||
protected static $maxperpage = 1000;
|
||||
|
||||
/**
|
||||
* List of allowed values for 'perpage' setting
|
||||
@@ -129,7 +129,7 @@ abstract class screen {
|
||||
|
||||
$cache = \cache::make_from_params(\cache_store::MODE_SESSION, 'gradereport_singleview', 'perpage');
|
||||
$perpage = optional_param('perpage', null, PARAM_INT);
|
||||
if (!in_array($perpage, self::$validperpage) && ($perpage !== 0)) {
|
||||
if ((!in_array($perpage, self::$validperpage) && $perpage != self::$maxperpage) && ($perpage !== 0)) {
|
||||
// Get from cache.
|
||||
$perpage = $cache->get(get_class($this));
|
||||
} else {
|
||||
@@ -435,7 +435,7 @@ abstract class screen {
|
||||
sort($pagingoptions);
|
||||
$pagingoptions = array_combine($pagingoptions, $pagingoptions);
|
||||
if ($numusers > self::$maxperpage) {
|
||||
$pagingoptions['0'] = self::$maxperpage;
|
||||
$pagingoptions[self::$maxperpage] = self::$maxperpage;
|
||||
} else {
|
||||
$pagingoptions['0'] = get_string('all');
|
||||
}
|
||||
@@ -455,6 +455,6 @@ abstract class screen {
|
||||
// The number of students per page is always limited even if it is claimed to be unlimited.
|
||||
$this->perpage = $this->perpage ?: self::$maxperpage;
|
||||
$perpagedata['pagingbar'] = $this->pager();
|
||||
return $OUTPUT->render_from_template('gradereport_singleview/perpage', $perpagedata);;
|
||||
return $OUTPUT->render_from_template('gradereport_singleview/perpage', $perpagedata);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,10 @@ require_once($CFG->dirroot.'/lib/gradelib.php');
|
||||
require_once($CFG->dirroot.'/grade/lib.php');
|
||||
require_once($CFG->dirroot.'/grade/report/lib.php');
|
||||
|
||||
// This report may require a lot of memory and time on large courses.
|
||||
raise_memory_limit(MEMORY_HUGE);
|
||||
set_time_limit(120);
|
||||
|
||||
$courseid = required_param('id', PARAM_INT);
|
||||
$groupid = optional_param('group', null, PARAM_INT);
|
||||
$reset = optional_param('reset', 0, PARAM_BOOL);
|
||||
|
||||
@@ -75,6 +75,61 @@ Feature: Singleview report pagination
|
||||
And I should see "2" in the ".stickyfooter .pagination" "css_element"
|
||||
And I should not see "3" in the ".stickyfooter .pagination" "css_element"
|
||||
|
||||
Scenario: Teachers can view all students on singleview report
|
||||
Given "150" "users" exist with the following data:
|
||||
| username | student[count] |
|
||||
| firstname | Student |
|
||||
| lastname | [count] |
|
||||
| email | student[count]@example.com |
|
||||
And "150" "course enrolments" exist with the following data:
|
||||
| user | student[count] |
|
||||
| course | C1 |
|
||||
| role |student |
|
||||
When I am on the "Course 1" "grades > Grader report > View" page logged in as "teacher1"
|
||||
And I click on grade item menu "Test assignment one" of type "gradeitem" on "grader" page
|
||||
And I choose "Single view for this item" in the open action menu
|
||||
And I set the field "perpage" to "All"
|
||||
# There is also 1 header row.
|
||||
Then I should see "151" node occurrences of type "tr" in the "singleview-grades" "table"
|
||||
And ".stickyfooter .pagination" "css_element" should not exist
|
||||
|
||||
Scenario: Max perpage is 1000 and All is not available
|
||||
Given "1100" "users" exist with the following data:
|
||||
| username | student[count] |
|
||||
| firstname | Student |
|
||||
| lastname | [count] |
|
||||
| email | student[count]@example.com |
|
||||
And "1100" "course enrolments" exist with the following data:
|
||||
| user | student[count] |
|
||||
| course | C1 |
|
||||
| role | student |
|
||||
When I am on the "Course 1" "grades > Grader report > View" page logged in as "teacher1"
|
||||
And I click on grade item menu "Test assignment one" of type "gradeitem" on "grader" page
|
||||
And I choose "Single view for this item" in the open action menu
|
||||
Then the "perpage" select box should contain "1000"
|
||||
And the "perpage" select box should not contain "All"
|
||||
|
||||
Scenario: Page loads correctly with Max perpage limit
|
||||
Given "1100" "users" exist with the following data:
|
||||
| username | student[count] |
|
||||
| firstname | Student |
|
||||
| lastname | [count] |
|
||||
| email | student[count]@example.com |
|
||||
And "1100" "course enrolments" exist with the following data:
|
||||
| user | student[count] |
|
||||
| course | C1 |
|
||||
| role | student |
|
||||
When I am on the "Course 1" "grades > Grader report > View" page logged in as "teacher1"
|
||||
And I click on grade item menu "Test assignment one" of type "gradeitem" on "grader" page
|
||||
And I choose "Single view for this item" in the open action menu
|
||||
And I set the field "perpage" to "1000"
|
||||
Then I should see "1001" node occurrences of type "tr" in the "singleview-grades" "table"
|
||||
And I click on "2" "link" in the ".stickyfooter .pagination" "css_element"
|
||||
And I should see "101" node occurrences of type "tr" in the "singleview-grades" "table"
|
||||
And I should see "1" in the ".stickyfooter .pagination" "css_element"
|
||||
And I should see "2" in the ".stickyfooter .pagination" "css_element"
|
||||
And I should not see "3" in the ".stickyfooter .pagination" "css_element"
|
||||
|
||||
@javascript
|
||||
Scenario: The pagination bar is only displayed when there is more than one page on singleview report
|
||||
Given "21" "users" exist with the following data:
|
||||
|
||||
@@ -63,10 +63,10 @@
|
||||
<li data-for="sectionnode" role="treeitem" aria-owns="movemodalcollapse{{id}}" data-type="category" data-id="{{id}}" data-selected="false" aria-expanded="true" data-collapsible="true">
|
||||
<div class="collapse-list-item d-flex" id="movemodalsection{{id}}">
|
||||
<a role="button" href="#movemodalcollapse{{id}}" aria-controls="movemodalcollapse{{id}}" class="collapse-list-link btn" tabindex="-1">
|
||||
<span class="expanded-icon text-nowrap" data-bs-toggle="tooltip" title="{{#str}} collapse, core {{/str}}">
|
||||
<span class="expanded-icon icon-size-3 text-nowrap" data-bs-toggle="tooltip" data-bs-placement="right" title="{{#str}} collapse, core {{/str}}">
|
||||
{{#pix}} t/expandedchevron, core, {{#str}} collapse, core {{/str}} {{/pix}}
|
||||
</span>
|
||||
<span class="collapsed-icon text-nowrap" data-bs-toggle="tooltip" title="{{#str}} expand, core {{/str}}">
|
||||
<span class="collapsed-icon icon-size-3 text-nowrap" data-bs-toggle="tooltip" data-bs-placement="right" title="{{#str}} expand, core {{/str}}">
|
||||
<span class="dir-rtl-hide">{{#pix}} t/collapsedchevron, core, {{#str}} expand, core {{/str}} {{/pix}}</span>
|
||||
<span class="dir-ltr-hide">{{#pix}} t/collapsedchevron_rtl, core, {{#str}} expand, core {{/str}} {{/pix}}</span>
|
||||
</span>
|
||||
|
||||
@@ -250,7 +250,8 @@ Feature: We can use calculated grade totals
|
||||
| Aggregation | Natural |
|
||||
| Include outcomes in aggregation | 1 |
|
||||
| Exclude empty grades | 0 |
|
||||
And I change window size to "large"
|
||||
# Change window size to ultra-wide to avoid 'element-click-intercepted' failures.
|
||||
And I change window size to "5120x2160"
|
||||
And I navigate to "View > Grader report" in the course gradebook
|
||||
And I give the grade "Excellent" to the user "Student 1" for the grade item "Test outcome item one"
|
||||
And I press "Save changes"
|
||||
@@ -305,7 +306,8 @@ Feature: We can use calculated grade totals
|
||||
And I set the following settings for grade item "Test outcome item one" of type "gradeitem" on "setup" page:
|
||||
| Weight adjusted | 1 |
|
||||
| aggregationcoef2 | 100 |
|
||||
And I change window size to "large"
|
||||
# Change window size to ultra-wide to avoid 'element-click-intercepted' failures.
|
||||
And I change window size to "5120x2160"
|
||||
And I navigate to "View > Grader report" in the course gradebook
|
||||
And I give the grade "Excellent" to the user "Student 1" for the grade item "Test outcome item one"
|
||||
And I press "Save changes"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@core @core_grades @javascript
|
||||
@core @core_grades
|
||||
Feature: We can enter in grades and view reports from the gradebook
|
||||
In order to check the expected results are displayed
|
||||
As a teacher
|
||||
@@ -46,6 +46,7 @@ Feature: We can enter in grades and view reports from the gradebook
|
||||
And I give the grade "90.00" to the user "Student 1" for the grade item "Test assignment name 2"
|
||||
And I press "Save changes"
|
||||
|
||||
@javascript
|
||||
Scenario: Grade a grade item and ensure the results display correctly in the gradebook
|
||||
When I navigate to "View > User report" in the course gradebook
|
||||
And the "Gradebook navigation menu" select menu should contain "Grader report"
|
||||
@@ -68,6 +69,7 @@ Feature: We can enter in grades and view reports from the gradebook
|
||||
And "Course 1" row "Grade" column of "overview-grade" table should contain "170.00"
|
||||
And "Course 1" row "Grade" column of "overview-grade" table should not contain "90.00"
|
||||
|
||||
@javascript
|
||||
Scenario: We can add a weighting to a grade item and it is displayed properly in the user report
|
||||
When I navigate to "Setup > Gradebook setup" in the course gradebook
|
||||
And I set the following settings for grade item "Course 1" of type "course" on "setup" page:
|
||||
@@ -92,3 +94,8 @@ Feature: We can enter in grades and view reports from the gradebook
|
||||
| Test assignment name 1 | 0.72% | 0.72% |
|
||||
| Test assignment name 2 | 1.00% | 1.00% |
|
||||
| Course total | 1.00% | 1.00% |
|
||||
|
||||
Scenario: User not enrolled in any course should not see the grades report
|
||||
Given I log in as "admin"
|
||||
When I follow "Grades" in the user menu
|
||||
Then I should see "You are not enrolled in, nor teaching any courses on this site."
|
||||
|
||||
@@ -30,11 +30,11 @@
|
||||
}}
|
||||
{{<core/local/comboboxsearch/resultitem }}
|
||||
{{$content}}
|
||||
<div class="pe-2 ps-1 w-25">
|
||||
<div class="pe-3 ps-1 flex-shrink-0">
|
||||
<img class="rounded-circle mx-auto img-fluid" src="{{groupimageurl}}" loading="lazy" alt=""/>
|
||||
</div>
|
||||
<div class="pe-3 w-75">
|
||||
<span class="d-block w-100 p-0 text-truncate">
|
||||
<div class="pe-3">
|
||||
<span class="d-block w-100 p-0 text-wrap text-break">
|
||||
{{name}}
|
||||
</span>
|
||||
{{^participation}}
|
||||
|
||||
@@ -69,6 +69,8 @@ $string['pathsunsecuredataroot'] = 'موضع مجلد البيانات الرئ
|
||||
$string['pathswrongadmindir'] = 'مجلد المشرف غير موجود';
|
||||
$string['phpextension'] = 'إمتداد PHP {$a}';
|
||||
$string['phpversion'] = 'إصدار PHP';
|
||||
$string['webserverconfigproblemdescription'] = 'إن مخدمك ليس مهيئًا لمنع الوصول إلى الملفات الواقعة خارج المجلد /public. يرجى الرجوع إلى <a href="https://moodledev.io/docs/5.1/guides/restructure">https://moodledev.io/docs/5.1/guides/restructure</a> للاطلاع على تفاصيل كيفية تهيئة مخدمك. بمجرد إعادة تهيئته، ترجى <a href="{$a}">زيارة مجلد الويب الأساسي</a>.';
|
||||
$string['webservernotconfigured'] = 'مخدم الويب غير مهيأ';
|
||||
$string['welcomep10'] = '{$a->installername} ({$a->installerversion})';
|
||||
$string['welcomep20'] = 'أنت تشاهد هذه الصفحة لأنك تمكنت بنجاح من تنصيب وإطلاق
|
||||
حزمة <strong>{$a->packname} {$a->packversion}</strong> في حاسبتك. تهانينا!';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user