{{#str}} instancedescription, tool_moodlenet {{/str}}
+ + {{! Removal warning - always visible when MoodleNet integration is enabled }} +{{#str}}removalwarning_service, tool_moodlenet{{/str}}
+{{#str}}removalwarning_feature, tool_moodlenet{{/str}}
+{{#str}} connectandbrowse, tool_moodlenet {{/str}}
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" diff --git a/admin/tool/replace/lang/en/tool_replace.php b/admin/tool/replace/lang/en/tool_replace.php index 55e960c54778f..a4d97d4bb9c8d 100644 --- a/admin/tool/replace/lang/en/tool_replace.php +++ b/admin/tool/replace/lang/en/tool_replace.php @@ -34,10 +34,10 @@ $string['notimplemented'] = 'Sorry, this feature is not implemented in your database driver.'; $string['notsupported'] = 'This script should be considered experimental. Changes made cannot be reverted, thus a complete backup should be made before running the script!'; $string['pageheader'] = 'Search and replace text throughout the whole database'; -$string['pluginname'] = 'DB search and replace'; +$string['pluginname'] = 'Database search and replace'; $string['replacewith'] = 'Replace with this string'; $string['replacewithhelp'] = 'usually new server URL'; $string['searchwholedb'] = 'Search whole database for'; $string['searchwholedbhelp'] = 'usually previous server URL'; $string['shortenoversized'] = 'Shorten result if necessary'; -$string['privacy:metadata'] = 'The DB search and replace plugin does not store any personal data.'; +$string['privacy:metadata'] = 'The Database search and replace plugin does not store any personal data.'; diff --git a/admin/tool/task/classes/check/adhocqueue.php b/admin/tool/task/classes/check/adhocqueue.php index 3787cd9e2dc14..36bec83dba3db 100644 --- a/admin/tool/task/classes/check/adhocqueue.php +++ b/admin/tool/task/classes/check/adhocqueue.php @@ -22,7 +22,11 @@ use moodle_url; /** - * Ad hoc queue checks + * Adhoc queue check. + * + * This alerts when the queue has old tasks in it which indicates that tasks + * are not being processed fast enough and more processess need to be added + * to manage the load. A large queue by itself is fine. * * @package tool_task * @copyright 2020 Brendan Heywood (brendan@catalyst-au.net) @@ -40,7 +44,10 @@ public function get_result(): result { $stats = $DB->get_record_sql(' SELECT count(*) cnt, MAX(? - nextruntime) age - FROM {task_adhoc}', [time()]); + FROM {task_adhoc} + WHERE attemptsavailable > 0 OR attemptsavailable IS NULL', + [time()] + ); $status = result::OK; $summary = get_string('adhocempty', 'tool_task'); diff --git a/admin/tool/task/lang/en/tool_task.php b/admin/tool/task/lang/en/tool_task.php index 91d1164d3ef4d..efa721412e742 100644 --- a/admin/tool/task/lang/en/tool_task.php +++ b/admin/tool/task/lang/en/tool_task.php @@ -55,7 +55,7 @@ $string['defaultx'] = 'Default: {$a}'; $string['deleteadhoctask'] = 'Delete ad hoc task {$a}'; $string['disabled'] = 'Disabled'; -$string['disabled_help'] = 'Disabled scheduled tasks are not executed from cron, however they can still be executed manually via the CLI tool.'; +$string['disabled_help'] = 'Disabled scheduled tasks are not executed from cron, however they can still be executed manually via the Command Line Interface (CLI) tool.'; $string['edittaskschedule'] = 'Edit task schedule: {$a}'; $string['enablerunnow'] = 'Allow \'Run now\' for scheduled tasks'; $string['enablerunnow_desc'] = 'Allows administrators to run a single scheduled task immediately, rather than waiting for it to run as scheduled. The feature requires \'Path to PHP CLI\' (pathtophp) to be set in System paths. The task runs on the web server, so you may wish to disable this feature to avoid potential performance issues.'; diff --git a/admin/tool/task/lib.php b/admin/tool/task/lib.php index c1813db730f86..980d4ffd1ff34 100644 --- a/admin/tool/task/lib.php +++ b/admin/tool/task/lib.php @@ -49,7 +49,7 @@ function tool_task_mtrace_wrapper(string $message, string $eol = ''): void { // We autolink urls and emails here but can't use format_text as it does // more than we need and has side effects which are not useful in this context. - $urlpattern = '/(http|https|ftp|ftps)\:\/\/[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(\/\S*)?/'; + $urlpattern = '~\b(?:https?|ftps?)://[a-z0-9-]+(?:\.[a-z0-9-]+)*(?::\d+)?(?:/[^\s<]*)?~i'; $message = preg_replace_callback($urlpattern, function($matches) { $url = $matches[0]; return html_writer::link($url, $url, ['target' => '_blank']); diff --git a/admin/tool/task/renderer.php b/admin/tool/task/renderer.php index fd9a325e2ab95..86c5f95a9742d 100644 --- a/admin/tool/task/renderer.php +++ b/admin/tool/task/renderer.php @@ -58,7 +58,7 @@ public function adhoc_tasks_summary_table(array $summary): string { get_string('nextruntime', 'tool_task'), ]; - $table->attributes['class'] = 'admintable generaltable table table-hover'; + $table->attributes['class'] = 'admintable generaltable table table-striped table-hover'; $table->colclasses = []; // For each task entry (row) show action buttons/logs link depending on the user permissions. @@ -388,7 +388,7 @@ public function scheduled_tasks_table($tasks, $lastchanged = '') { get_string('default', 'tool_task'), ]; - $table->attributes['class'] = 'admintable generaltable table table-hover'; + $table->attributes['class'] = 'admintable generaltable table table-striped table-hover'; $table->colclasses = []; if (!$showloglink) { diff --git a/admin/tool/task/tests/adhoc_queue_test.php b/admin/tool/task/tests/adhoc_queue_test.php new file mode 100644 index 0000000000000..64dcc3e330589 --- /dev/null +++ b/admin/tool/task/tests/adhoc_queue_test.php @@ -0,0 +1,79 @@ +. + +namespace tool_task\check; + +use core\check\result; + +/** + * Tests for the adhocqueue class. + * + * @package tool_task + * @copyright 2025 Brendan HeywoodSome missing indexes have been found in your DB. Here are their details and the needed SQL statements to be executed with your favourite SQL interface to create all of them. Remember to backup your data first!
-After doing that, it\'s highly recommended to execute this utility again to check that no more missing indexes are found.
'; -$string['yeswrongdefaultsfound'] = 'Some inconsistent defaults have been found in your DB. Here are their details and the needed SQL statements to be executed with your favourite SQL interface to fix them all. Remember to backup your data first!
-After doing that, it\'s highly recommended to execute this utility again to check that no more inconsistent defaults are found.
'; -$string['yeswrongintsfound'] = 'Some wrong integers have been found in your DB. Here are their details and the needed SQL statements to be executed with your favourite SQL interface to fix them. Remember to backup your data first!
-After fixing them, it is highly recommended to execute this utility again to check that no more wrong integers are found.
'; +$string['yesmissingindexesfound'] = 'Some missing indexes have been found in your database. Here are their details and the needed SQL statements to be executed in your favourite SQL interface to create them. Remember to back up your data first!
+It\'s highly recommended to execute this utility again to check that no more missing indexes are found.
'; +$string['yeswrongdefaultsfound'] = 'Some inconsistent defaults have been found in your database. Here are their details and the needed SQL statements to be executed in your favourite SQL interface to fix them. Remember to backup your data first!
+It\'s highly recommended to execute this utility again to check that no more inconsistent defaults are found.
'; +$string['yeswrongintsfound'] = 'Some incorrect integers have been found in your database. Here are their details and the needed SQL statements to be executed in your favourite SQL interface to fix them. Remember to backup your data first!
+It is highly recommended to execute this utility again to check that no more incorrect integers are found.
'; $string['privacy:metadata'] = 'The XMLDB editor plugin does not store any personal data.'; diff --git a/admin/upgradesettings.php b/admin/upgradesettings.php index eecf91abbca8a..ae19b36eeff1d 100644 --- a/admin/upgradesettings.php +++ b/admin/upgradesettings.php @@ -35,8 +35,6 @@ $newsettingshtml = implode($newsettings); unset($newsettings); -$focus = ''; - if (empty($adminroot->errors) and $newsettingshtml === '') { // there must be either redirect without message or continue button or else upgrade would be sometimes broken if ($return == 'site') { @@ -48,12 +46,12 @@ if (!empty($adminroot->errors)) { $firsterror = reset($adminroot->errors); - $focus = $firsterror->id; + $PAGE->set_focuscontrol($firsterror->id); } // and finally, if we get here, then there are new settings and we have to print a form // to modify them -echo $OUTPUT->header($focus); +echo $OUTPUT->header(); if (!empty($SITE->fullname) and !empty($SITE->shortname)) { echo $OUTPUT->box(get_string('upgradesettingsintro','admin'), 'generalbox'); diff --git a/admin/webservice/forms.php b/admin/webservice/forms.php index 4f15173f35c2f..4f5d837c380c5 100644 --- a/admin/webservice/forms.php +++ b/admin/webservice/forms.php @@ -196,8 +196,12 @@ function definition() { //we add the descriptions to the functions foreach ($functions as $functionid => $functionname) { //retrieve full function information (including the description) - $function = \core_external\external_api::external_function_info($functionname); - if (empty($function->deprecated)) { + try { + $function = \core_external\external_api::external_function_info($functionname); + } catch (Throwable $exception) { + $function = null; + } + if ($function !== null && empty($function->deprecated)) { $functions[$functionid] = $function->name . ':' . $function->description; } else { // Exclude the deprecated ones. diff --git a/ai/amd/build/helper.min.js b/ai/amd/build/helper.min.js index cf0c839a2eac8..dd52a6af2d3b6 100644 --- a/ai/amd/build/helper.min.js +++ b/ai/amd/build/helper.min.js @@ -6,6 +6,6 @@ define("core_ai/helper",["exports"],(function(_exports){Object.defineProperty(_e * @copyright 2024 Huong Nguyen".concat(textWithBreaks,"
")}static replaceMarkdown(text){return text.replace(/\*\*(.*?)\*\*/g,"$1")}static formatResponse(text){let formattedText=this.replaceLineBreaks(text);return formattedText=this.replaceMarkdown(formattedText),formattedText}},_exports.default})); +class{static replaceLineBreaks(text){const textWithBreaks=text.replace(/(\r\n|\n|".concat(textWithBreaks,"
")}static replaceMarkdown(text){return text.replace(/\*\*(.*?)\*\*/g,"$1")}static formatResponse(text){let formattedText=this.replaceLineBreaks(text);return formattedText=this.replaceMarkdown(formattedText),formattedText}},_exports.default})); //# sourceMappingURL=helper.min.js.map \ No newline at end of file diff --git a/ai/amd/build/helper.min.js.map b/ai/amd/build/helper.min.js.map index d5beb92088569..967f069fa72c2 100644 --- a/ai/amd/build/helper.min.js.map +++ b/ai/amd/build/helper.min.js.map @@ -1 +1 @@ -{"version":3,"file":"helper.min.js","sources":["../src/helper.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, seefor paragraphs.\n * This is to handle the difference in response from the AI to what is expected by the editor.\n *\n * @param {String} text The text to replace.\n * @returns {String}\n */\n static replaceLineBreaks(text) {\n // Replace double line breaks with
for paragraphs\n const textWithParagraphs = text.replace(/\\n{2,}|\\r\\n/g, '
');\n\n // Replace remaining single line breaks with
tags\n const textWithBreaks = textWithParagraphs.replace(/\\n/g, '
');\n\n // Add opening and closing
tags to wrap the entire content\n return `
${textWithBreaks}
`;\n }\n\n /**\n * Replace markdown formatting.\n * Even when asked not to, AI models will sometimes return markdown.\n *\n * @param {String} text The text to replace.\n * @returns {String}\n */\n static replaceMarkdown(text) {\n // Replace markdown bold formatting HTML equivalent.\n const textWithMarkdown = text.replace(/\\*\\*(.*?)\\*\\*/g, '$1');\n\n return textWithMarkdown;\n }\n\n /**\n * Format the response provided by the AI model.\n *\n * @param {String} text The text to format.\n * @returns {String}\n */\n static formatResponse(text) {\n let formattedText = this.replaceLineBreaks(text) ;\n formattedText = this.replaceMarkdown(formattedText);\n\n return formattedText;\n }\n}\n"],"names":["text","textWithBreaks","replace","formattedText","this","replaceLineBreaks","replaceMarkdown"],"mappings":";;;;;;;;+BA8B6BA,YAKfC,eAHqBD,KAAKE,QAAQ,eAAgB,cAGdA,QAAQ,MAAO,4BAG5CD,8CAUMD,aAEMA,KAAKE,QAAQ,iBAAkB,6CAWtCF,UACdG,cAAgBC,KAAKC,kBAAkBL,aAC3CG,cAAgBC,KAAKE,gBAAgBH,eAE9BA"} \ No newline at end of file +{"version":3,"file":"helper.min.js","sources":["../src/helper.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 for paragraphs.\n * This is to handle the difference in response from the AI to what is expected by the editor.\n *\n * @param {String} text The text to replace.\n * @returns {String}\n */\n static replaceLineBreaks(text) {\n // Normalise double line breaks\n const textWithParagraphs = text.replace(/(\\r\\n|\\n|
){2,}/g, '\\n');\n\n // Replace remaining single line breaks with
tags\n const textWithBreaks = textWithParagraphs.replace(/\\n/g, '
');\n\n // Add opening and closing
tags to wrap the entire content\n return `
${textWithBreaks}
`;\n }\n\n /**\n * Replace markdown formatting.\n * Even when asked not to, AI models will sometimes return markdown.\n *\n * @param {String} text The text to replace.\n * @returns {String}\n */\n static replaceMarkdown(text) {\n // Replace markdown bold formatting HTML equivalent.\n const textWithMarkdown = text.replace(/\\*\\*(.*?)\\*\\*/g, '$1');\n\n return textWithMarkdown;\n }\n\n /**\n * Format the response provided by the AI model.\n *\n * @param {String} text The text to format.\n * @returns {String}\n */\n static formatResponse(text) {\n let formattedText = this.replaceLineBreaks(text) ;\n formattedText = this.replaceMarkdown(formattedText);\n\n return formattedText;\n }\n}\n"],"names":["text","textWithBreaks","replace","formattedText","this","replaceLineBreaks","replaceMarkdown"],"mappings":";;;;;;;;+BA8B6BA,YAKfC,eAHqBD,KAAKE,QAAQ,4BAA6B,MAG3BA,QAAQ,MAAO,iCAG5CD,8CAUMD,aAEMA,KAAKE,QAAQ,iBAAkB,6CAWtCF,UACdG,cAAgBC,KAAKC,kBAAkBL,aAC3CG,cAAgBC,KAAKE,gBAAgBH,eAE9BA"} \ No newline at end of file diff --git a/ai/amd/src/helper.js b/ai/amd/src/helper.js index 64389a62e75cb..f7e5c45c49b92 100644 --- a/ai/amd/src/helper.js +++ b/ai/amd/src/helper.js @@ -29,11 +29,11 @@ export default class AIHelper { * @returns {String} */ static replaceLineBreaks(text) { - // Replace double line breaks with for paragraphs
- const textWithParagraphs = text.replace(/\n{2,}|\r\n/g, '
');
+ // Normalise double line breaks
+ const textWithParagraphs = text.replace(/(\r\n|\n|
){2,}/g, '\n');
// Replace remaining single line breaks with
tags
- const textWithBreaks = textWithParagraphs.replace(/\n/g, '
');
+ const textWithBreaks = textWithParagraphs.replace(/\n/g, '
');
// Add opening and closing
tags to wrap the entire content return `
${textWithBreaks}
`; diff --git a/ai/configure.php b/ai/configure.php index 50b787a6f761c..9f9e8141f7ec3 100644 --- a/ai/configure.php +++ b/ai/configure.php @@ -72,6 +72,12 @@ $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()) { diff --git a/ai/placement/courseassist/classes/external/explain_text.php b/ai/placement/courseassist/classes/external/explain_text.php index 136d2db2c4c65..abb12c205b74c 100644 --- a/ai/placement/courseassist/classes/external/explain_text.php +++ b/ai/placement/courseassist/classes/external/explain_text.php @@ -92,10 +92,11 @@ public static function execute( // Send the action to the AI manager. $manager = \core\di::get(\core_ai\manager::class); $response = $manager->process_action($action); + $generatedcontent = $response->get_response_data()['generatedcontent'] ?? ''; // Return the response. return [ 'success' => $response->get_success(), - 'generatedcontent' => $response->get_response_data()['generatedcontent'] ?? '', + 'generatedcontent' => \core_external\util::format_text($generatedcontent, FORMAT_PLAIN, $contextid)[0], 'finishreason' => $response->get_response_data()['finishreason'] ?? '', 'errorcode' => $response->get_errorcode(), 'error' => $response->get_errormessage(), diff --git a/ai/placement/courseassist/classes/external/summarise_text.php b/ai/placement/courseassist/classes/external/summarise_text.php index b0397fa5a1516..00200abfac901 100644 --- a/ai/placement/courseassist/classes/external/summarise_text.php +++ b/ai/placement/courseassist/classes/external/summarise_text.php @@ -92,10 +92,11 @@ public static function execute( // Send the action to the AI manager. $manager = \core\di::get(\core_ai\manager::class); $response = $manager->process_action($action); + $generatedcontent = $response->get_response_data()['generatedcontent'] ?? ''; // Return the response. return [ 'success' => $response->get_success(), - 'generatedcontent' => $response->get_response_data()['generatedcontent'] ?? '', + 'generatedcontent' => \core_external\util::format_text($generatedcontent, FORMAT_PLAIN, $contextid)[0], 'finishreason' => $response->get_response_data()['finishreason'] ?? '', 'errorcode' => $response->get_errorcode(), 'error' => $response->get_errormessage(), diff --git a/ai/provider/ollama/classes/abstract_processor.php b/ai/provider/ollama/classes/abstract_processor.php index 1127d6eb0de35..2f6637ed23211 100644 --- a/ai/provider/ollama/classes/abstract_processor.php +++ b/ai/provider/ollama/classes/abstract_processor.php @@ -152,7 +152,7 @@ protected function handle_api_error(ResponseInterface $response): array { $responsearr['errormessage'] = $response->getReasonPhrase(); } else { $bodyobj = json_decode($response->getBody()->getContents()); - $responsearr['errormessage'] = $bodyobj->error->message; + $responsearr['errormessage'] = $bodyobj->error; } return $responsearr; diff --git a/ai/provider/ollama/classes/provider.php b/ai/provider/ollama/classes/provider.php index ef17f88282930..4284681cca740 100644 --- a/ai/provider/ollama/classes/provider.php +++ b/ai/provider/ollama/classes/provider.php @@ -55,7 +55,7 @@ public static function get_action_settings( #[\Override] public function add_authentication_headers(RequestInterface $request): RequestInterface { - if (empty($this->config['basicauthenabled'])) { + if (empty($this->config['enablebasicauth'])) { return $request; } else { // Add the Authorization header for basic auth. diff --git a/ai/provider/ollama/tests/process_explain_text_test.php b/ai/provider/ollama/tests/process_explain_text_test.php index f0f6df51575cd..cbea44b583f9e 100644 --- a/ai/provider/ollama/tests/process_explain_text_test.php +++ b/ai/provider/ollama/tests/process_explain_text_test.php @@ -151,12 +151,21 @@ public function test_handle_api_error(): void { $responses = [ 500 => new Response(500, ['Content-Type' => 'application/json']), 503 => new Response(503, ['Content-Type' => 'application/json']), - 401 => new Response(401, ['Content-Type' => 'application/json'], - '{"error": {"message": "Invalid Authentication"}}'), - 404 => new Response(404, ['Content-Type' => 'application/json'], - '{"error": {"message": "You must be a member of an organization to use the API"}}'), - 429 => new Response(429, ['Content-Type' => 'application/json'], - '{"error": {"message": "Rate limit reached for requests"}}'), + 401 => new Response( + 401, + ['Content-Type' => 'application/json'], + json_encode(['error' => 'Invalid Authentication']), + ), + 404 => new Response( + 404, + ['Content-Type' => 'application/json'], + json_encode(['error' => 'You must be a member of an organization to use the API']), + ), + 429 => new Response( + 429, + ['Content-Type' => 'application/json'], + json_encode(['error' => 'Rate limit reached for requests']), + ), ]; $processor = new process_explain_text($this->provider, $this->action); @@ -316,7 +325,7 @@ public function test_process_error(): void { $mock->append(new Response( 401, ['Content-Type' => 'application/json'], - json_encode(['error' => ['message' => 'Invalid Authentication']]), + json_encode(['error' => 'Invalid Authentication']), )); $processor = new process_explain_text($this->provider, $this->action); diff --git a/ai/provider/ollama/tests/process_generate_text_test.php b/ai/provider/ollama/tests/process_generate_text_test.php index 5e3956f64304b..836b81ab93f0d 100644 --- a/ai/provider/ollama/tests/process_generate_text_test.php +++ b/ai/provider/ollama/tests/process_generate_text_test.php @@ -153,17 +153,17 @@ public function test_handle_api_error(): void { 401 => new Response( 401, ['Content-Type' => 'application/json'], - json_encode(['error' => ['message' => 'Invalid Authentication']]), + json_encode(['error' => 'Invalid Authentication']), ), 404 => new Response( 404, ['Content-Type' => 'application/json'], - json_encode(['error' => ['message' => 'You must be a member of an organization to use the API']]), + json_encode(['error' => 'You must be a member of an organization to use the API']), ), 429 => new Response( 429, ['Content-Type' => 'application/json'], - json_encode(['error' => ['message' => 'Rate limit reached for requests']]), + json_encode(['error' => 'Rate limit reached for requests']), ), ]; @@ -325,7 +325,7 @@ public function test_process_error(): void { $mock->append(new Response( 401, ['Content-Type' => 'application/json'], - json_encode(['error' => ['message' => 'Invalid Authentication']]), + json_encode(['error' => 'Invalid Authentication']), )); $processor = new process_generate_text($this->provider, $this->action); diff --git a/ai/provider/ollama/tests/process_summarise_text_test.php b/ai/provider/ollama/tests/process_summarise_text_test.php index c7f0cd1fa34ef..e1377f7e5b9ae 100644 --- a/ai/provider/ollama/tests/process_summarise_text_test.php +++ b/ai/provider/ollama/tests/process_summarise_text_test.php @@ -151,12 +151,21 @@ public function test_handle_api_error(): void { $responses = [ 500 => new Response(500, ['Content-Type' => 'application/json']), 503 => new Response(503, ['Content-Type' => 'application/json']), - 401 => new Response(401, ['Content-Type' => 'application/json'], - '{"error": {"message": "Invalid Authentication"}}'), - 404 => new Response(404, ['Content-Type' => 'application/json'], - '{"error": {"message": "You must be a member of an organization to use the API"}}'), - 429 => new Response(429, ['Content-Type' => 'application/json'], - '{"error": {"message": "Rate limit reached for requests"}}'), + 401 => new Response( + 401, + ['Content-Type' => 'application/json'], + json_encode(['error' => 'Invalid Authentication']), + ), + 404 => new Response( + 404, + ['Content-Type' => 'application/json'], + json_encode(['error' => 'You must be a member of an organization to use the API']), + ), + 429 => new Response( + 429, + ['Content-Type' => 'application/json'], + json_encode(['error' => 'Rate limit reached for requests']), + ), ]; $processor = new process_summarise_text($this->provider, $this->action); @@ -316,7 +325,7 @@ public function test_process_error(): void { $mock->append(new Response( 401, ['Content-Type' => 'application/json'], - json_encode(['error' => ['message' => 'Invalid Authentication']]), + json_encode(['error' => 'Invalid Authentication']), )); $processor = new process_summarise_text($this->provider, $this->action); diff --git a/ai/provider/openai/UPGRADING.md b/ai/provider/openai/UPGRADING.md new file mode 100644 index 0000000000000..d3fbe1bc22775 --- /dev/null +++ b/ai/provider/openai/UPGRADING.md @@ -0,0 +1,20 @@ +# aiprovider_openai Upgrade notes + +## 5.0.7 + +### Added + +- A new `aiprovider_openai\aimodel\openai_image_base` interface has been added. Image generation model classes must now implement this interface to declare their `response_format`, `output_format`, size, and quality mappings. Existing custom model classes that handle image generation should implement this interface to ensure correct API parameters are sent. + + For more information see [MDL-85352](https://tracker.moodle.org/browse/MDL-85352) +- A new `gptimage1` model class has been added to support gpt-image-1.5. + This model uses `output_format=png` instead of `response_format`, and maps Moodle quality values to the values expected by the API: 'standard' maps to 'medium' and 'hd' maps to 'high'. + + For more information see [MDL-85352](https://tracker.moodle.org/browse/MDL-85352) + +### Changed + +- The `dalle3` model class now implements `openai_image_base` and switches from returning a URL to returning `response_format=b64_json`. + The image is now decoded directly from the API response instead of being downloaded via a second HTTP request. Size and quality logic has been moved into the model class. + + For more information see [MDL-85352](https://tracker.moodle.org/browse/MDL-85352) diff --git a/ai/provider/openai/classes/aimodel/dalle3.php b/ai/provider/openai/classes/aimodel/dalle3.php index 93d5d918e6d9d..55281140b460a 100644 --- a/ai/provider/openai/classes/aimodel/dalle3.php +++ b/ai/provider/openai/classes/aimodel/dalle3.php @@ -25,8 +25,7 @@ * @copyright 2025 Huong NguyenSpecify the format that the password field is using.
Use \'internal\' if you want the external database to manage usernames and email addresses, but Moodle to manage passwords. If you use \'internal\', you must provide a populated email address field in the external database, and you must enable the \auth_db\task\sync_users scheduled task. Moodle will send an email to new users with a temporary password.
'; @@ -65,7 +65,7 @@ $string['auth_dbupdateusers_description'] = 'As well as inserting new users, update existing users.'; $string['auth_dbupdatinguser'] = 'Updating user {$a->name} id {$a->id}'; $string['auth_dbuser'] = 'Username with read access to the database'; -$string['auth_dbuser_key'] = 'DB user'; +$string['auth_dbuser_key'] = 'Database user'; $string['auth_dbuserstoadd'] = 'User entries to add: {$a}'; $string['auth_dbuserstoremove'] = 'User entries to remove: {$a}'; $string['auth_dbnoexttable'] = 'External table not specified.'; diff --git a/auth/db/settings.php b/auth/db/settings.php index 54f3450eb0a1f..cfb9a9a184338 100644 --- a/auth/db/settings.php +++ b/auth/db/settings.php @@ -54,15 +54,11 @@ new lang_string('auth_dbtype_key', 'auth_db'), new lang_string('auth_dbtype', 'auth_db'), 'mysqli', $dboptions)); - // Sybase quotes. $yesno = array( new lang_string('no'), new lang_string('yes'), ); - $settings->add(new admin_setting_configselect('auth_db/sybasequoting', - new lang_string('auth_dbsybasequoting', 'auth_db'), new lang_string('auth_dbsybasequotinghelp', 'auth_db'), 0, $yesno)); - // DB Name. $settings->add(new admin_setting_configtext('auth_db/name', get_string('auth_dbname_key', 'auth_db'), get_string('auth_dbname', 'auth_db'), '', PARAM_RAW_TRIMMED)); diff --git a/auth/db/tests/db_test.php b/auth/db/tests/db_test.php index 8c7de967a5e6f..c9c0708fffe6b 100644 --- a/auth/db/tests/db_test.php +++ b/auth/db/tests/db_test.php @@ -70,7 +70,6 @@ protected function init_auth_database() { case 'mysql': set_config('type', 'mysqli', 'auth_db'); set_config('setupsql', "SET NAMES 'UTF-8'", 'auth_db'); - set_config('sybasequoting', '0', 'auth_db'); if (!empty($CFG->dboptions['dbsocket'])) { $dbsocket = $CFG->dboptions['dbsocket']; if ((strpos($dbsocket, '/') === false and strpos($dbsocket, '\\') === false)) { @@ -87,7 +86,6 @@ protected function init_auth_database() { $setupsql .= "; SET search_path = '".$CFG->dboptions['dbschema']."'"; } set_config('setupsql', $setupsql, 'auth_db'); - set_config('sybasequoting', '0', 'auth_db'); if (!empty($CFG->dboptions['dbsocket']) and ($CFG->dbhost === 'localhost' or $CFG->dbhost === '127.0.0.1')) { if (strpos($CFG->dboptions['dbsocket'], '/') !== false) { $socket = $CFG->dboptions['dbsocket']; @@ -103,7 +101,6 @@ protected function init_auth_database() { case 'mssql': set_config('type', 'mssqlnative', 'auth_db'); - set_config('sybasequoting', '1', 'auth_db'); // The native sqlsrv driver uses a comma as separator between host and port. $dbhost = $CFG->dbhost; diff --git a/auth/db/version.php b/auth/db/version.php index 3fa6ad96a03e4..c49f366ad6704 100644 --- a/auth/db/version.php +++ b/auth/db/version.php @@ -24,6 +24,6 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2025041400; // The current plugin version (Date: YYYYMMDDXX). +$plugin->version = 2025041401; // The current plugin version (Date: YYYYMMDDXX). $plugin->requires = 2025040800; // Requires this Moodle version. $plugin->component = 'auth_db'; // Full name of the plugin (used for diagnostics) diff --git a/auth/email/auth.php b/auth/email/auth.php index 09805d9ee9859..ddfb7515b3d55 100644 --- a/auth/email/auth.php +++ b/auth/email/auth.php @@ -171,7 +171,7 @@ function can_confirm() { * @param string $confirmsecret */ function user_confirm($username, $confirmsecret) { - global $DB, $SESSION; + global $DB; $user = get_complete_user_data('username', $username); if (!empty($user)) { @@ -179,17 +179,15 @@ function user_confirm($username, $confirmsecret) { return AUTH_CONFIRM_ERROR; } else if ($user->secret === $confirmsecret && $user->confirmed) { + // Clean up stale wantsurl preference if user clicks confirmation link again. + unset_user_preference('auth_email_wantsurl', $user); return AUTH_CONFIRM_ALREADY; } else if ($user->secret === $confirmsecret) { // They have provided the secret key to get in $DB->set_field("user", "confirmed", 1, array("id"=>$user->id)); - - if ($wantsurl = get_user_preferences('auth_email_wantsurl', false, $user)) { - // Ensure user gets returned to page they were trying to access before signing up. - $SESSION->wantsurl = $wantsurl; - unset_user_preference('auth_email_wantsurl', $user); - } - + // Clean up the wantsurl preference regardless of how confirmation was triggered + // (e.g. /login/confirm.php, admin single confirm, bulk confirm, web service). + unset_user_preference('auth_email_wantsurl', $user); return AUTH_CONFIRM_OK; } } else { diff --git a/auth/email/tests/auth_test.php b/auth/email/tests/auth_test.php new file mode 100644 index 0000000000000..0e69a1333f9c0 --- /dev/null +++ b/auth/email/tests/auth_test.php @@ -0,0 +1,96 @@ +. + +namespace auth_email; + +/** + * Tests for email authentication plugin. + * + * @package auth_email + * @copyright 2026 Moodle Pty Ltd + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \auth_plugin_email + */ +final class auth_test extends \advanced_testcase { + /** + * Test that user_confirm() cleans up the auth_email_wantsurl preference + * when confirming a user for the first time (AUTH_CONFIRM_OK). + */ + public function test_user_confirm_cleans_up_wantsurl_preference(): void { + global $DB; + $this->resetAfterTest(true); + + // Create an unconfirmed user with the email auth method. + $user = $this->getDataGenerator()->create_user([ + 'auth' => 'email', + 'confirmed' => 0, + ]); + $secret = random_string(15); + $DB->set_field('user', 'secret', $secret, ['id' => $user->id]); + + // Simulate the wantsurl preference saved at signup time. + set_user_preference('auth_email_wantsurl', 'https://example.com/course/view.php?id=42', $user); + $this->assertTrue( + $DB->record_exists('user_preferences', ['userid' => $user->id, 'name' => 'auth_email_wantsurl']), + 'Preference should exist in DB before confirmation.' + ); + + $auth = get_auth_plugin('email'); + $result = $auth->user_confirm($user->username, $secret); + + $this->assertEquals(AUTH_CONFIRM_OK, $result); + $this->assertFalse( + $DB->record_exists('user_preferences', ['userid' => $user->id, 'name' => 'auth_email_wantsurl']), + 'auth_email_wantsurl preference must be removed from DB after successful confirmation.' + ); + } + + /** + * Test that user_confirm() cleans up the auth_email_wantsurl preference + * even when the user is already confirmed (AUTH_CONFIRM_ALREADY). + * + * This covers the edge case where a user clicks the confirmation link + * a second time — the stale preference should still be cleaned up. + */ + public function test_user_confirm_already_confirmed_cleans_up_wantsurl_preference(): void { + global $DB; + $this->resetAfterTest(true); + + // Create an already-confirmed user with the email auth method. + $user = $this->getDataGenerator()->create_user([ + 'auth' => 'email', + 'confirmed' => 1, + ]); + $secret = random_string(15); + $DB->set_field('user', 'secret', $secret, ['id' => $user->id]); + + // Simulate a stale wantsurl preference left over from signup. + set_user_preference('auth_email_wantsurl', 'https://example.com/course/view.php?id=42', $user); + $this->assertTrue( + $DB->record_exists('user_preferences', ['userid' => $user->id, 'name' => 'auth_email_wantsurl']), + 'Preference should exist in DB before re-confirmation.' + ); + + $auth = get_auth_plugin('email'); + $result = $auth->user_confirm($user->username, $secret); + + $this->assertEquals(AUTH_CONFIRM_ALREADY, $result); + $this->assertFalse( + $DB->record_exists('user_preferences', ['userid' => $user->id, 'name' => 'auth_email_wantsurl']), + 'auth_email_wantsurl preference must be removed from DB even when user is already confirmed.' + ); + } +} diff --git a/auth/ldap/lang/en/auth_ldap.php b/auth/ldap/lang/en/auth_ldap.php index 67fa20571efbe..748906cfd7e09 100644 --- a/auth/ldap/lang/en/auth_ldap.php +++ b/auth/ldap/lang/en/auth_ldap.php @@ -36,7 +36,7 @@ $string['auth_ldap_create_context'] = 'If you enable user creation with email confirmation, specify the context where users are created. This context should be different from other users to prevent security issues. You don\'t need to add this context to ldap_context-variable, Moodle will search for users from this context automatically.{{{ intro }}}
You can use an LDAP server to control your enrolments. It is assumed your LDAP tree contains groups that map to the courses, and that each of those groups/courses will have membership entries to map to students.
It is assumed that courses are defined as groups in LDAP, with each group having multiple membership fields (member or memberUid) that contain a uniqueidentification of the user.
To use LDAP enrolment, your users must to have a valid idnumber field. The LDAP groups must have that idnumber in the member fields for a user to be enrolled in the course. This will usually work well if you are already using LDAP Authentication.
Enrolments will be updated when the user logs in. You can also run a script to keep enrolments in synch. Look in enrol/ldap/cli/sync.php.
This plugin can also be set to automatically create new courses when new groups appear in LDAP.
'; +$string['pluginname_desc'] = 'You can use a Lightweight Directory Access Protocol (LDAP) server to control enrolment. It is assumed your LDAP tree contains groups that map to the courses, and that each of those groups/courses have membership entries to map to students. Also, courses are defined as groups in LDAP, with each group having multiple membership fields (member or memberUid) that contain a unique identification of the user.
To use LDAP enrolment, users must have a valid ID number field. The LDAP groups must have that ID number in the member fields for a user to be enrolled in the course. This works well with LDAP Authentication.
Enrolments are updated when the user logs in. You can also run a script enrol/ldap/cli/sync.php to keep enrolments in sync.
This plugin can also be set to automatically create new courses when new groups appear in LDAP.
'; $string['pluginnotenabled'] = 'Plugin not enabled!'; $string['role_mapping'] = 'For each role, you need to specify all LDAP contexts where the groups that represent the courses are located. Separate different contexts with a semicolon (;).
You also need to specify the attribute your LDAP server uses to hold the members of a group. This is usually \'member\' or \'memberUid\'.
'; $string['role_mapping_attribute'] = 'LDAP member attribute for {$a}'; diff --git a/enrol/lti/classes/local/ltiadvantage/task/sync_tool_grades.php b/enrol/lti/classes/local/ltiadvantage/task/sync_tool_grades.php index 96e26afe1cc77..5fddab5988c81 100644 --- a/enrol/lti/classes/local/ltiadvantage/task/sync_tool_grades.php +++ b/enrol/lti/classes/local/ltiadvantage/task/sync_tool_grades.php @@ -122,6 +122,7 @@ protected function sync_grades_for_resource($resource): array { 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."); diff --git a/enrol/lti/classes/tool_provider.php b/enrol/lti/classes/tool_provider.php index d81ef4baeae42..e50966f419d0c 100644 --- a/enrol/lti/classes/tool_provider.php +++ b/enrol/lti/classes/tool_provider.php @@ -259,6 +259,20 @@ protected function onLaunch() { // Get the updated user record. $user = $DB->get_record('user', ['id' => $user->id]); } else { + if ($dbuser->suspended) { + require_once($CFG->libdir . '/authlib.php'); + $failurereason = AUTH_LOGIN_SUSPENDED; + $event = \core\event\user_login_failed::create([ + 'userid' => $dbuser->id, + 'other' => [ + 'username' => $dbuser->username, + 'reason' => $failurereason + ] + ]); + $event->trigger(); + throw new \core\exception\moodle_exception('invalidlogin', 'core'); + } + if (helper::user_match($user, $dbuser)) { $user = $dbuser; } else { diff --git a/enrol/manual/UPGRADING.md b/enrol/manual/UPGRADING.md new file mode 100644 index 0000000000000..a0a37f5055969 --- /dev/null +++ b/enrol/manual/UPGRADING.md @@ -0,0 +1,9 @@ +# enrol_manual Upgrade notes + +## 5.0.7 + +### Removed + +- The unused parameter 'roleid' has been removed from the external function `unenrol_users()` + + For more information see [MDL-51152](https://tracker.moodle.org/browse/MDL-51152) diff --git a/enrol/manual/db/services.php b/enrol/manual/db/services.php index 7415421ae241f..a7d4e3fe198cb 100644 --- a/enrol/manual/db/services.php +++ b/enrol/manual/db/services.php @@ -39,7 +39,7 @@ 'classname' => 'enrol_manual_external', 'methodname' => 'unenrol_users', 'classpath' => 'enrol/manual/externallib.php', - 'description' => 'Manual unenrol users', + 'description' => 'Removes the manual enrolment of users in a course', 'capabilities'=> 'enrol/manual:unenrol', 'type' => 'write', ), diff --git a/enrol/manual/externallib.php b/enrol/manual/externallib.php index 005ff8619c52a..ce69b58a790f9 100644 --- a/enrol/manual/externallib.php +++ b/enrol/manual/externallib.php @@ -164,7 +164,6 @@ public static function unenrol_users_parameters() { array( 'userid' => new external_value(PARAM_INT, 'The user that is going to be unenrolled'), 'courseid' => new external_value(PARAM_INT, 'The course to unenrol the user from'), - 'roleid' => new external_value(PARAM_INT, 'The user role', VALUE_OPTIONAL), ) ) ) @@ -174,7 +173,7 @@ public static function unenrol_users_parameters() { /** * Unenrolment of users. * - * @param array $enrolments an array of course user and role ids + * @param array $enrolments an array of course users * @throws coding_exception * @throws dml_transaction_exception * @throws invalid_parameter_exception diff --git a/enrol/renderer.php b/enrol/renderer.php index b5423ae8b9eb8..9c5d459215c12 100644 --- a/enrol/renderer.php +++ b/enrol/renderer.php @@ -368,7 +368,7 @@ public function __construct(course_enrolment_manager $manager) { $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'; } /** diff --git a/enrol/self/tests/behat/cohort_restriction.feature b/enrol/self/tests/behat/cohort_restriction.feature new file mode 100644 index 0000000000000..ade9dfc0eae8d --- /dev/null +++ b/enrol/self/tests/behat/cohort_restriction.feature @@ -0,0 +1,33 @@ +@enrol @enrol_self +Feature: Self-enrolment cohort restriction + In order to prevent unauthorized access + As a admin + I need to restrict self-enrolment to cohort members + + Background: + And the following "users" exist: + | username | firstname | lastname | email | + | student1 | Student | 1 | student1@example.com | + | student2 | Student | 2 | student2@example.com | + And the following "cohorts" exist: + | name | idnumber | contextid | + | Cohort A | CH1 | 1 | + And the following "cohort members" exist: + | user | cohort | + | student1 | CH1 | + And the following "courses" exist: + | fullname | shortname | category | + | Course 1 | C1 | 0 | + And I log in as "admin" + And I add "Self enrolment" enrolment method in "Course 1" with: + | Custom instance name | Test student enrolment | + | Only cohort members | Cohort A | + + Scenario: Self enrolment as cohort member + Given I am on the "C1" "Course" page logged in as "student1" + When I press "Enrol me" + Then I should see "You are enrolled in the course." + + Scenario: Self enrolment as non cohort member + Given I am on the "C1" "Course" page logged in as "student2" + Then I should see "Only members of cohort 'Cohort A' can self-enrol." diff --git a/files/UPGRADING.md b/files/UPGRADING.md index f543019f2e7ef..ed0dc4ede4198 100644 --- a/files/UPGRADING.md +++ b/files/UPGRADING.md @@ -1,5 +1,16 @@ # core_files (subsystem) Upgrade notes +## 5.0.7 + +### Added + +- A new method called `removeopt()` has been created in the `curl` class to allow users to remove options previously set with `setopt()`. + + For more information see [MDL-87822](https://tracker.moodle.org/browse/MDL-87822) +- User can pass `'CURLOPT_USERPWD' => false` to the `$options` array for the `put()` method of `curl` to remove the `CURLOPT_USERPWD` option from the request. + + For more information see [MDL-87822](https://tracker.moodle.org/browse/MDL-87822) + ## 5.0 ### Added diff --git a/files/renderer.php b/files/renderer.php index c9c3493d9ee15..c37b22f6245b3 100644 --- a/files/renderer.php +++ b/files/renderer.php @@ -114,10 +114,14 @@ public function render_form_filemanager($fm) { array('unknownoriginal', 'repository'), array('confirmdeletefolder', 'repository'), array('confirmdeletefilewithhref', 'repository'), array('confirmrenamefolder', 'repository'), array('confirmrenamefile', 'repository'), array('newfolder', 'repository'), array('edit', 'moodle'), - array('originalextensionchange', 'repository'), array('originalextensionremove', 'repository'), + ['originalextensionremove', 'repository'], array('aliaseschange', 'repository'), ['nofilesselected', 'repository'], ['confirmdeleteselectedfile', 'repository'], ['selectall', 'moodle'], ['deselectall', 'moodle'], ['selectallornone', 'form'], + ['updateinvalidfiletype', 'repository'], + ['updatefileextensiontitle', 'repository'], + ['originalextensionchange', 'repository'], + ['invalidfiletypetitle', 'repository'], ) ); if ($this->page->requires->should_create_one_time_item_now('core_file_managertemplate')) { diff --git a/filter/activitynames/classes/text_filter.php b/filter/activitynames/classes/text_filter.php index ca8e321d25deb..5b7d759dee1bb 100644 --- a/filter/activitynames/classes/text_filter.php +++ b/filter/activitynames/classes/text_filter.php @@ -20,6 +20,7 @@ use cache_store; use core\output\html_writer; use core_collator; +use course_modinfo; use filterobject; /** @@ -124,7 +125,12 @@ protected function get_activity_list($courseid) { $sortedactivities = []; foreach ($modinfo->cms as $cm) { // Use normal access control and visibility, but exclude labels and hidden activities. - if ($cm->visible && $cm->has_view() && $cm->uservisible) { + if ( + $cm->visible + && $cm->has_view() + && $cm->uservisible + && course_modinfo::is_mod_type_visible_on_course($cm->modname) + ) { $sortedactivities[] = (object)[ 'name' => $cm->name, 'url' => $cm->url, diff --git a/filter/activitynames/tests/text_filter_test.php b/filter/activitynames/tests/text_filter_test.php index a86bd2faa87f5..8d771043d8114 100644 --- a/filter/activitynames/tests/text_filter_test.php +++ b/filter/activitynames/tests/text_filter_test.php @@ -41,9 +41,18 @@ public function test_links(): void { 'page', ['course' => $course->id, 'name' => 'Test (2)'] ); + // Create a label and question bank that should not be linked to. + $this->getDataGenerator()->create_module( + 'label', + ['course' => $course->id, 'name' => 'Label 1', 'intro' => 'Label 1'] + ); + $this->getDataGenerator()->create_module( + 'qbank', + ['course' => $course->id, 'name' => 'Question bank 1'] + ); // Format text with all three entries in HTML. - $html = 'Please read the two pages Test 1 and Test (2).
'; + $html = 'Please read the two pages Test 1 and Test (2), but not Label 1 or Question bank 1.
'; $filtered = format_text($html, FORMAT_HTML, ['context' => $context]); // Find all the glossary links in the result. diff --git a/filter/codehighlighter/amd/build/prism-init.min.js b/filter/codehighlighter/amd/build/prism-init.min.js index ef6d33bd43327..9b01993316504 100644 --- a/filter/codehighlighter/amd/build/prism-init.min.js +++ b/filter/codehighlighter/amd/build/prism-init.min.js @@ -5,6 +5,6 @@ * @copyright 2023 Meirza