From 1cebb26b6ff385c46397ffa86f9ac7562b323048 Mon Sep 17 00:00:00 2001 From: Mark Johnson Date: Thu, 31 Jul 2025 10:52:43 +0100 Subject: [PATCH 001/309] MDL-86169 enrol_imsenterprise: Re-activate suspended enrolments When enrolling a user, the enrolment status was not being passed, meaning that if there was an existing suspended enrolment (due to MDL-65061) it would not be re-activated. --- public/enrol/imsenterprise/lib.php | 9 +- .../tests/imsenterprise_unenrol_test.php | 100 ++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/public/enrol/imsenterprise/lib.php b/public/enrol/imsenterprise/lib.php index 6d1d174eba1b9..ac1c38fa05030 100644 --- a/public/enrol/imsenterprise/lib.php +++ b/public/enrol/imsenterprise/lib.php @@ -707,7 +707,14 @@ protected function process_membership_tag($tagcontents) { $einstance = $DB->get_record('enrol', array('id' => $enrolid)); } - $this->enrol_user($einstance, $memberstoreobj->userid, $moodleroleid, $timeframe->begin, $timeframe->end); + $this->enrol_user( + $einstance, + $memberstoreobj->userid, + $moodleroleid, + $timeframe->begin, + $timeframe->end, + ENROL_USER_ACTIVE, + ); $this->log_line("Enrolled user #$memberstoreobj->userid ($member->idnumber) " ."to role $member->roletype in course $memberstoreobj->course"); diff --git a/public/enrol/imsenterprise/tests/imsenterprise_unenrol_test.php b/public/enrol/imsenterprise/tests/imsenterprise_unenrol_test.php index 21ed5948ebbbe..d0a00eca1ffe0 100644 --- a/public/enrol/imsenterprise/tests/imsenterprise_unenrol_test.php +++ b/public/enrol/imsenterprise/tests/imsenterprise_unenrol_test.php @@ -520,6 +520,106 @@ public function test_disable_enrolments_only(): void { ['userid' => $dbuser->id, 'id' => $dbrole->id])); } + /** + * Re-enroling a user with a suspended enrolment re-actives that enrolment. + */ + public function test_reenable_suspended_enrolment(): void { + + global $DB; + + $this->imsplugin->set_config('imsunenrol', 1); + $this->imsplugin->set_config('unenrolaction', ENROL_EXT_REMOVED_SUSPEND); + + $courses = $this->generate_test_course_records(1); + $users = $this->generate_test_user_records(1); + + // Add a new enrolment for the same user via IMS file. + $coursemembership = $this->link_users_with_courses( + $users, + $courses, + // Role types: 01=Learner, 02=Instructor, 03=Content Dev, 04=Member, 05=Manager, 06=Mentor, 07=Admin, 08=TA. + // Role statuses: 0=Inactive, 1=Active. + // Role recstatus: 1=Add, 2=Update, 3=Delete. + // Format of matrix elements: ::. + [ + ['01:1:1'], // Course 1. + ] + ); + + $this->set_xml_file($users, $courses, $coursemembership); + $this->imsplugin->cron(); + + // Capture DB ids. + $dbuser = $DB->get_record('user', ['idnumber' => $users[0]->idnumber], '*', MUST_EXIST); + + $dbenrolment = $DB->get_record( + 'user_enrolments', + [ + 'userid' => $dbuser->id, + 'status' => ENROL_USER_ACTIVE, + ], + '*', + MUST_EXIST, + ); + + // Unenrol the user, check that the enrolment is suspended. + $coursemembership = $this->link_users_with_courses( + $users, + $courses, + // Role types: 01=Learner, 02=Instructor, 03=Content Dev, 04=Member, 05=Manager, 06=Mentor, 07=Admin, 08=TA. + // Role statuses: 0=Inactive, 1=Active. + // Role recstatus: 1=Add, 2=Update, 3=Delete. + // Format of matrix elements: ::. + [ + ['01:0:3'], // Course 1. + ] + ); + + $this->set_xml_file($users, $courses, $coursemembership); + $this->imsplugin->cron(); + + $this->assertEquals( + 1, + $DB->count_records( + 'user_enrolments', + [ + 'userid' => $dbuser->id, + 'id' => $dbenrolment->id, + 'status' => ENROL_USER_SUSPENDED, + ] + ), + ); + + // Re-import the original enrolment. + $coursemembership = $this->link_users_with_courses( + $users, + $courses, + // Role types: 01=Learner, 02=Instructor, 03=Content Dev, 04=Member, 05=Manager, 06=Mentor, 07=Admin, 08=TA. + // Role statuses: 0=Inactive, 1=Active. + // Role recstatus: 1=Add, 2=Update, 3=Delete. + // Format of matrix elements: ::. + [ + ['01:1:1'], // Course 1. + ] + ); + + $this->set_xml_file($users, $courses, $coursemembership); + $this->imsplugin->cron(); + + // The user's original enrolment should be active again. + $this->assertEquals( + 1, + $DB->count_records( + 'user_enrolments', + [ + 'userid' => $dbuser->id, + 'id' => $dbenrolment->id, + 'status' => ENROL_USER_ACTIVE, + ], + ), + ); + } + /** * Enrolments are disabled but retained) and roles removed */ From 25a277a2d4310cf606366a340ba342db0779d469 Mon Sep 17 00:00:00 2001 From: Jayce Birrell Date: Fri, 30 Jan 2026 12:31:23 +1030 Subject: [PATCH 002/309] MDL-87792 forms: do not use empty string as the placeholder render --- .../moodle-form-dateselector-debug.js | 9 +++++++-- .../moodle-form-dateselector-min.js | 4 ++-- .../moodle-form-dateselector/moodle-form-dateselector.js | 9 +++++++-- public/lib/form/yui/src/dateselector/js/dateselector.js | 9 +++++++-- public/lib/formslib.php | 1 + 5 files changed, 24 insertions(+), 8 deletions(-) diff --git a/public/lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector-debug.js b/public/lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector-debug.js index e378576277ff9..53e4b6be06f6a 100644 --- a/public/lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector-debug.js +++ b/public/lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector-debug.js @@ -107,8 +107,13 @@ M.form.dateselector = { showNextMonth: true, firstdayofweek: parseInt(config.firstdayofweek, 10), headerRenderer: function(date) { - // We fetch the current language's preferred time format from the language pack. var calendar = this; + var headerNode = calendar.get('contentBox') + .one('#' + calendar._calendarId + '_header'); + var currentHeader = headerNode ? headerNode.getContent() : ''; + var placeholder = M.util.get_string('loading', 'moodle'); + + // We fetch the current language's preferred time format from the language pack. require(['core/user_date', 'core/notification'], function(UserDate, Notification) { UserDate.get([{ timestamp: Math.floor(date.getTime() / 1000), @@ -122,7 +127,7 @@ M.form.dateselector = { return dateStrs[0]; }).catch(Notification.exception); }); - return ''; + return currentHeader || placeholder; }, WEEKDAYS_MEDIUM: [ diff --git a/public/lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector-min.js b/public/lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector-min.js index 2533d921a3310..10235da5c061d 100644 --- a/public/lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector-min.js +++ b/public/lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector-min.js @@ -1,2 +1,2 @@ -YUI.add("moodle-form-dateselector",function(n,e){var t,o;n.mix(n.Node.prototype,{firstOptionValue:function(){return"select"===this.get("nodeName").toLowerCase()&&this.one("option").get("value")},lastOptionValue:function(){return"select"===this.get("nodeName").toLowerCase()&&this.all("option").item(this.optionSize()-1).get("value")},optionSize:function(){return"select"===this.get("nodeName").toLowerCase()&&parseInt(this.all("option").size(),10)},selectedOptionValue:function(){return"select"===this.get("nodeName").toLowerCase()&&this.all("option").item(this.get("selectedIndex")).get("value")}}),M.form=M.form||{},M.form.dateselector={panel:null,calendar:null,currentowner:null,hidetimeout:null,repositiontimeout:null,init_date_selectors:function(e){null===this.panel&&this.initPanel(e),n.all(".fdate_time_selector").each(function(){e.node=this,new t(e)}),n.all(".fdate_selector").each(function(){e.node=this,new t(e)})},initPanel:function(e){this.panel=new n.Overlay({visible:!1,bodyContent:n.Node.create('
'),id:"dateselector-calendar-panel",constrain:!0}),this.panel.render(document.body),this.panel.on("focus",function(){var e,t=0;n.all(" [role=dialog], [role=menubar], .moodle-has-zindex").each(function(e){e=this.findZIndex(e);t'),id:"dateselector-calendar-panel",constrain:!0}),this.panel.render(document.body),this.panel.on("focus",function(){var e,t=0;n.all(" [role=dialog], [role=menubar], .moodle-has-zindex").each(function(e){e=this.findZIndex(e);trequires->yui_module($module, $function, $config); $PAGE->requires->string_for_js('strftimemonthyear', 'langconfig'); + $PAGE->requires->string_for_js('loading', 'moodle'); } } From 6ec49ddf1fb83a1d17d18a33b7fb7cf37adc5d34 Mon Sep 17 00:00:00 2001 From: Hannes Funk Date: Tue, 17 Feb 2026 11:30:46 +0100 Subject: [PATCH 003/309] MDL-87974 theme_boost: Fix chevrons for non-standard drop toggles --- public/theme/boost/scss/moodle/core.scss | 8 ++++++++ public/theme/boost/style/moodle.css | 8 ++++++++ public/theme/classic/style/moodle.css | 8 ++++++++ 3 files changed, 24 insertions(+) diff --git a/public/theme/boost/scss/moodle/core.scss b/public/theme/boost/scss/moodle/core.scss index bc3600a9c8019..9474a7fd78210 100644 --- a/public/theme/boost/scss/moodle/core.scss +++ b/public/theme/boost/scss/moodle/core.scss @@ -2796,6 +2796,12 @@ body.dragging { margin-left: 0; margin-right: 4px; width: 9px; + display: inline-block; + vertical-align: 0.255em; +} + +.dropleft .dropdown-toggle::after { + content: none; } .dir-rtl .dropleft .dropdown-toggle::before { @@ -2805,6 +2811,7 @@ body.dragging { .dropright .dropdown-toggle::after { border: 0; font: var(--fa-font-solid); + font-size: 9px; content: fa-content($fa-var-chevron-right); } @@ -2816,6 +2823,7 @@ body.dragging { .dropup .dropdown-toggle::after { border: 0; font: var(--fa-font-solid); + font-size: 9px; content: fa-content($fa-var-chevron-up); } diff --git a/public/theme/boost/style/moodle.css b/public/theme/boost/style/moodle.css index f2e93bf2b31b6..a6a16d7e485d3 100644 --- a/public/theme/boost/style/moodle.css +++ b/public/theme/boost/style/moodle.css @@ -28290,6 +28290,12 @@ body.dragging .dragging { margin-left: 0; margin-right: 4px; width: 9px; + display: inline-block; + vertical-align: 0.255em; +} + +.dropleft .dropdown-toggle::after { + content: none; } .dir-rtl .dropleft .dropdown-toggle::before { @@ -28299,6 +28305,7 @@ body.dragging .dragging { .dropright .dropdown-toggle::after { border: 0; font: var(--fa-font-solid); + font-size: 9px; content: "\f054"; } @@ -28309,6 +28316,7 @@ body.dragging .dragging { .dropup .dropdown-toggle::after { border: 0; font: var(--fa-font-solid); + font-size: 9px; content: "\f077"; } diff --git a/public/theme/classic/style/moodle.css b/public/theme/classic/style/moodle.css index 8e4363615b0bb..42270482b5b28 100644 --- a/public/theme/classic/style/moodle.css +++ b/public/theme/classic/style/moodle.css @@ -28290,6 +28290,12 @@ body.dragging .dragging { margin-left: 0; margin-right: 4px; width: 9px; + display: inline-block; + vertical-align: 0.255em; +} + +.dropleft .dropdown-toggle::after { + content: none; } .dir-rtl .dropleft .dropdown-toggle::before { @@ -28299,6 +28305,7 @@ body.dragging .dragging { .dropright .dropdown-toggle::after { border: 0; font: var(--fa-font-solid); + font-size: 9px; content: "\f054"; } @@ -28309,6 +28316,7 @@ body.dragging .dragging { .dropup .dropdown-toggle::after { border: 0; font: var(--fa-font-solid); + font-size: 9px; content: "\f077"; } From a3adbecd954d5f53db08f4da83fb84c6ba344e33 Mon Sep 17 00:00:00 2001 From: Tim Hunt Date: Mon, 1 Dec 2025 15:51:37 +0000 Subject: [PATCH 004/309] MDL-87365 tasks: failed task shouldn't stop a new instance being queued --- public/lib/classes/task/manager.php | 28 ++++++-- public/lib/tests/task/adhoc_task_test.php | 80 +++++++++++++++++++++++ 2 files changed, 101 insertions(+), 7 deletions(-) diff --git a/public/lib/classes/task/manager.php b/public/lib/classes/task/manager.php index 4ab7d573231af..c41ecf3b14ca0 100644 --- a/public/lib/classes/task/manager.php +++ b/public/lib/classes/task/manager.php @@ -185,16 +185,20 @@ public static function reset_scheduled_tasks_for_component($componentname) { * @return bool */ protected static function task_is_scheduled($task) { - return false !== self::get_queued_adhoc_task_record($task); + return false !== self::get_queued_adhoc_task_record($task, false); } /** - * Checks if the task with the same classname, component and customdata is already scheduled + * Checks if the task with the same classname, component and customdata is already scheduled. + * + * Note, $includefailed defaults to true only because of backwards compatibility. + * It is very likely that you want to pass false here. * * @param adhoc_task $task + * @param bool $includefailed should tasks that have failed and will not be retried be included? * @return \stdClass|false */ - public static function get_queued_adhoc_task_record($task) { + public static function get_queued_adhoc_task_record($task, bool $includefailed = true) { global $DB; $record = self::record_from_adhoc_task($task); @@ -206,11 +210,20 @@ public static function get_queued_adhoc_task_record($task) { $params[] = $record->userid; $sql .= " AND userid = ? "; } - return $DB->get_record_select('task_adhoc', $sql, $params); + + if (!$includefailed) { + $sql .= " AND (attemptsavailable > 0 OR attemptsavailable IS NULL)"; + } + + $queuedtasks = $DB->get_records_select('task_adhoc', $sql, $params, 'timecreated DESC, id DESC', '*', 0, 1); + return reset($queuedtasks); } /** - * Schedule a new task, or reschedule an existing adhoc task which has matching data. + * Schedule an ad-hoc task to run at a set time in the future, or if already queued, reset that time. + * + * So, it only really makes sense to use this method if you have called + * $task->set_next_run_time(), otherwise just use manager::queue_adhoc_task(). * * Only a task matching the same user, classname, component, and customdata will be rescheduled. * If these values do not match exactly then a new task is scheduled. @@ -221,7 +234,7 @@ public static function get_queued_adhoc_task_record($task) { public static function reschedule_or_queue_adhoc_task(adhoc_task $task): void { global $DB; - if ($existingrecord = self::get_queued_adhoc_task_record($task)) { + if ($existingrecord = self::get_queued_adhoc_task_record($task, false)) { // Only update the next run time if it is explicitly set on the task. $nextruntime = $task->get_next_run_time(); if ($nextruntime && ($existingrecord->nextruntime != $nextruntime)) { @@ -238,7 +251,8 @@ public static function reschedule_or_queue_adhoc_task(adhoc_task $task): void { * * @param \core\task\adhoc_task $task - The new adhoc task information to store. * @param bool $checkforexisting - If set to true and the task with the same user, classname, component and customdata - * is already scheduled then it will not schedule a new task. Can be used only for ASAP tasks. + * is already scheduled (and has not giving up re-trying after failures) then it will not schedule a new task. + * Can be used only for ASAP tasks, otherwise use {@see reschedule_or_queue_adhoc_task()}. * @return boolean - True if the config was saved. */ public static function queue_adhoc_task(adhoc_task $task, $checkforexisting = false) { diff --git a/public/lib/tests/task/adhoc_task_test.php b/public/lib/tests/task/adhoc_task_test.php index 7f6aa63fd5475..b975beb0d86b0 100644 --- a/public/lib/tests/task/adhoc_task_test.php +++ b/public/lib/tests/task/adhoc_task_test.php @@ -541,6 +541,45 @@ public function test_reschedule_or_queue_adhoc_task_no_existing(): void { $this->assertEquals(1, count(manager::get_adhoc_tasks('core\task\adhoc_test_task'))); } + /** + * Ensure that the reschedule_or_queue_adhoc_task function will schedule a new task if no tasks exist. + */ + public function test_reschedule_or_queue_adhoc_task_after_failure(): void { + global $DB; + $this->resetAfterTest(); + + $clock = $this->mock_clock_with_frozen(); + + // Schedule adhoc task. + $task = new adhoc_test_task(); + $task->set_custom_data(['courseid' => 10]); + $task->set_next_run_time($clock->time()); // Not realistic. Normally in the future but does not matter. + manager::reschedule_or_queue_adhoc_task($task); + $this->assertEquals(1, count(manager::get_adhoc_tasks('core\task\adhoc_test_task'))); + $taskrecord1 = manager::get_queued_adhoc_task_record($task); + $this->assertObjectHasProperty('id', $taskrecord1); + $this->assertEquals($clock->time(), $taskrecord1->nextruntime); + + // Now mark the task permanently failed. + $DB->update_record('task_adhoc', (object) [ + 'id' => $taskrecord1->id, + 'faildelay' => 86400, + 'attemptsavailable' => 0, + ]); + + // Now, schedule the task again. Should create a new task. + $task = new adhoc_test_task(); + $task->set_custom_data(['courseid' => 10]); + $task->set_next_run_time($clock->time() + HOURSECS); + manager::reschedule_or_queue_adhoc_task($task); + $this->assertEquals(2, count(manager::get_adhoc_tasks('core\task\adhoc_test_task'))); + $taskrecord2 = manager::get_queued_adhoc_task_record($task); + $this->assertNotEquals($taskrecord1->id, $taskrecord2->id); + $this->assertEquals($clock->time() + HOURSECS, $taskrecord2->nextruntime); + $this->assertEquals(0, $taskrecord2->faildelay); + $this->assertEquals(12, $taskrecord2->attemptsavailable); + } + /** * Ensure that the reschedule_or_queue_adhoc_task function will schedule a new task if a task for the same user does * not exist. @@ -706,6 +745,47 @@ public function test_queue_adhoc_task_if_not_scheduled(): void { $this->assertEquals(6, count(manager::get_adhoc_tasks('core\task\adhoc_test_task'))); } + /** + * Test that, after a permanent failure, queue_adhoc_task(..., checkforexisting: true) works. + */ + public function test_queue_adhoc_task_if_not_scheduled_after_failure(): void { + global $DB; + $this->resetAfterTest(); + + // Schedule adhoc task. + $task = new adhoc_test_task(); + $task->set_custom_data(['courseid' => 10]); + $this->assertNotEmpty(manager::queue_adhoc_task($task, true)); + $this->assertEquals(1, count(manager::get_adhoc_tasks('core\task\adhoc_test_task'))); + $taskrecord1 = manager::get_queued_adhoc_task_record($task); + $this->assertObjectHasProperty('id', $taskrecord1); + + // Verify again that re-scheduling the same task does nothing. + $task = new adhoc_test_task(); + $task->set_custom_data(['courseid' => 10]); + $this->assertFalse(manager::queue_adhoc_task($task, true)); + $this->assertEquals(1, count(manager::get_adhoc_tasks('core\task\adhoc_test_task'))); + $taskrecord2 = manager::get_queued_adhoc_task_record($task); + $this->assertEquals($taskrecord1->id, $taskrecord2->id); + + // Now mark the task permanently failed. + $DB->update_record('task_adhoc', (object) [ + 'id' => $taskrecord1->id, + 'faildelay' => 86400, + 'attemptsavailable' => 0, + ]); + + // Now, schedule the task again. Should create a new task. + $task = new adhoc_test_task(); + $task->set_custom_data(['courseid' => 10]); + $this->assertNotEmpty(manager::queue_adhoc_task($task, true)); + $this->assertEquals(2, count(manager::get_adhoc_tasks('core\task\adhoc_test_task'))); + $taskrecord3 = manager::get_queued_adhoc_task_record($task); + $this->assertNotEquals($taskrecord1->id, $taskrecord3->id); + $this->assertEquals(0, $taskrecord3->faildelay); + $this->assertEquals(12, $taskrecord3->attemptsavailable); + } + /** * Test that when no userid is specified, it returns empty from the DB * too. From 576f1bd059d0e1b6fd7a5bfc1b55784fa4e2a7da Mon Sep 17 00:00:00 2001 From: Paul Holden Date: Tue, 30 Dec 2025 15:21:43 +0000 Subject: [PATCH 005/309] MDL-87497 theme_boost: truncate day names in YUI calendar dialogue. --- public/theme/boost/scss/moodle/core.scss | 5 +++++ public/theme/boost/style/moodle.css | 7 +++++++ public/theme/classic/style/moodle.css | 7 +++++++ 3 files changed, 19 insertions(+) diff --git a/public/theme/boost/scss/moodle/core.scss b/public/theme/boost/scss/moodle/core.scss index bc3600a9c8019..80f2537c9e96e 100644 --- a/public/theme/boost/scss/moodle/core.scss +++ b/public/theme/boost/scss/moodle/core.scss @@ -1793,6 +1793,11 @@ body.lockscroll { display: inline-block; } +.yui3-calendar-weekday { + max-width: 40px; + @include text-truncate(); +} + dd:before, dd:after { display: block; diff --git a/public/theme/boost/style/moodle.css b/public/theme/boost/style/moodle.css index cada2fbfeddfe..efeedd32cdcad 100644 --- a/public/theme/boost/style/moodle.css +++ b/public/theme/boost/style/moodle.css @@ -27388,6 +27388,13 @@ body.lockscroll { display: inline-block; } +.yui3-calendar-weekday { + max-width: 40px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + dd:before, dd:after { display: block; diff --git a/public/theme/classic/style/moodle.css b/public/theme/classic/style/moodle.css index a9aeae78db55c..e2eb49cc4b6db 100644 --- a/public/theme/classic/style/moodle.css +++ b/public/theme/classic/style/moodle.css @@ -27388,6 +27388,13 @@ body.lockscroll { display: inline-block; } +.yui3-calendar-weekday { + max-width: 40px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + dd:before, dd:after { display: block; From a8b3451dc6ee12b49e5c6b44e91a1e78e2aff0a0 Mon Sep 17 00:00:00 2001 From: Dan Marsden Date: Tue, 17 Mar 2026 14:56:48 +1300 Subject: [PATCH 006/309] MDL-83526 core: Improve SameSite handling. --- public/lib/classes/session/manager.php | 27 ++++---------------------- 1 file changed, 4 insertions(+), 23 deletions(-) diff --git a/public/lib/classes/session/manager.php b/public/lib/classes/session/manager.php index dc803f2b487c0..a217634bf3425 100644 --- a/public/lib/classes/session/manager.php +++ b/public/lib/classes/session/manager.php @@ -387,9 +387,11 @@ protected static function prepare_cookies() { 'httponly' => $CFG->cookiehttponly, ]; - if (self::should_use_samesite_none()) { - // If $samesite is empty, we don't want there to be any SameSite attribute. + if (\core_useragent::is_moodle_app()) { + // Moodle Mobile app for Android requires SameSite=None to allow embedding content such as H5P and SCORM. $sessionoptions['samesite'] = 'None'; + } else { + $sessionoptions['samesite'] = 'Lax'; } session_set_cookie_params($sessionoptions); @@ -643,27 +645,6 @@ public static function login_user(\stdClass $user) { self::set_user($user); } - /** - * Returns a valid setting for the SameSite cookie attribute. - * - * @return string The desired setting for the SameSite attribute on the cookie. Empty string indicates the SameSite attribute - * should not be set at all. - */ - private static function should_use_samesite_none(): bool { - // We only want None or no attribute at this point. When we have cookie handling compatible with Lax, - // we can look at checking a setting. - - // Browser support for none is not consistent yet. There are known issues with Safari, and IE11. - // Things are stablising, however as they're not stable yet we will deal specifically with the version of chrome - // that introduces a default of lax, setting it to none for the current version of chrome (2 releases before the change). - // We also check you are using secure cookies and HTTPS because if you are not running over HTTPS - // then setting SameSite=None will cause your session cookie to be rejected. - if (\core_useragent::is_chrome() && \core_useragent::check_chrome_version('78') && is_moodle_cookie_secure()) { - return true; - } - return false; - } - /** * Terminate current user session. * @return void From 713446d849fbed7f2ecebe9a754e93804a68ac45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luca=20B=C3=B6sch?= Date: Wed, 8 Apr 2026 12:02:57 +0200 Subject: [PATCH 007/309] MDL-87219 course: Remove hidden class in course index visible activities --- .../format/classes/output/local/state/cm.php | 5 ++++- .../behat/courseindex_completion.feature | 20 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/public/course/format/classes/output/local/state/cm.php b/public/course/format/classes/output/local/state/cm.php index b83995a899c45..1534436f14c42 100644 --- a/public/course/format/classes/output/local/state/cm.php +++ b/public/course/format/classes/output/local/state/cm.php @@ -95,7 +95,10 @@ public function export_for_template(renderer_base $output): stdClass { // Check the user access type to this cm. $info = new info_module($cm); - $data->accessvisible = ($data->visible && $info->is_available_for_all()); + $information = ''; + $data->accessvisible = $data->visible && ( + ($info->is_available_for_all() || $info->is_available($information, true, $USER->id)) + ); // Add url if the activity is compatible. $url = $cm->url; diff --git a/public/course/format/tests/behat/courseindex_completion.feature b/public/course/format/tests/behat/courseindex_completion.feature index c3e8fc496ee7e..32bf2c06bf712 100644 --- a/public/course/format/tests/behat/courseindex_completion.feature +++ b/public/course/format/tests/behat/courseindex_completion.feature @@ -150,3 +150,23 @@ Feature: Course index completion icons | 1 | False | When I am on the "C1" "Course" page logged in as "student1" And "Done" "icon" should exist in the "courseindex-content" "region" + + @javascript + Scenario: Activities are dimmed only when restricted + Given the following "activities" exist: + | activity | name | intro | course | idnumber | section | + | assign | Activity sample 2 | Restricted assignment description | C1 | sample2 | 1 | + And I log in as "teacher1" + And I am on "Course 1" course homepage with editing mode on + And I open "Activity sample 2" actions menu + And I click on "Edit settings" "link" in the "Activity sample 2" activity + 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 "Activity sample 1" + And I press "Save and return to course" + When I am on the "Course 1" "course" page logged in as "student1" + Then the "class" attribute of "//li[contains(@class, 'courseindex-item') and contains(., 'Activity sample 2')]" "xpath_element" should contain "dimmed" + And I toggle the manual completion state of "Activity sample 1" + And I should see "Activity sample 2" in the "courseindex-content" "region" + And the "class" attribute of "//li[contains(@class, 'courseindex-item') and contains(., 'Activity sample 2')]" "xpath_element" should not contain "dimmed" From 6bf818c04c624bd895e966bfd08363d18ec2aeb2 Mon Sep 17 00:00:00 2001 From: Cameron Ball Date: Mon, 13 Apr 2026 13:37:38 +0800 Subject: [PATCH 008/309] MDL-88422 core: Assert PSR response interfaces in router tests --- public/lib/tests/classes/router/route_testcase.php | 4 ++-- public/lib/tests/router/response_handler_test.php | 13 +++++++------ .../schema/response/payload_response_test.php | 3 ++- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/public/lib/tests/classes/router/route_testcase.php b/public/lib/tests/classes/router/route_testcase.php index f5f10b5dfd73a..836209e402871 100644 --- a/public/lib/tests/classes/router/route_testcase.php +++ b/public/lib/tests/classes/router/route_testcase.php @@ -391,7 +391,7 @@ protected function assert_valid_response( ResponseInterface $response, ?int $statuscode = 200, ): void { - $this->assertInstanceOf(Response::class, $response); + $this->assertInstanceOf(ResponseInterface::class, $response); $this->assertEquals( $statuscode, $response->getStatusCode(), @@ -409,7 +409,7 @@ protected function assert_exception_response( ResponseInterface $response, ?int $responsecode = null, ): void { - $this->assertInstanceOf(Response::class, $response); + $this->assertInstanceOf(ResponseInterface::class, $response); $this->assertNotEquals( 200, $response->getStatusCode(), diff --git a/public/lib/tests/router/response_handler_test.php b/public/lib/tests/router/response_handler_test.php index 1e30042830266..08fae7b825845 100644 --- a/public/lib/tests/router/response_handler_test.php +++ b/public/lib/tests/router/response_handler_test.php @@ -24,6 +24,7 @@ use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; use GuzzleHttp\Psr7\ServerRequest; +use Psr\Http\Message\ResponseInterface; /** * Tests for \core\router\response_handler. @@ -41,7 +42,7 @@ public function test_standardise_response_from_response(): void { $handler = di::get(response_handler::class); $result = $handler->standardise_response($response); - $this->assertEquals($response, $result); + $this->assertSame($response, $result); } public function test_standardise_response_from_payload_response(): void { @@ -51,7 +52,7 @@ public function test_standardise_response_from_payload_response(): void { $handler = di::get(response_handler::class); $result = $handler->standardise_response($payload); - $this->assertInstanceOf(Response::class, $result); + $this->assertInstanceOf(ResponseInterface::class, $result); // The body should be json and contain the same data. $value = json_decode($result->getBody()); @@ -80,7 +81,7 @@ public function test_standardise_response_from_payload_response_and_response(): $handler = di::get(response_handler::class); $result = $handler->standardise_response($payload); - $this->assertInstanceOf(Response::class, $result); + $this->assertInstanceOf(ResponseInterface::class, $result); // The body should be json and contain the same data. $value = json_decode($result->getBody()); @@ -121,7 +122,7 @@ public function test_standardise_response_from_view_response(): void { $handler = di::get(response_handler::class); $result = $handler->standardise_response($response); - $this->assertInstanceOf(Response::class, $result); + $this->assertInstanceOf(ResponseInterface::class, $result); // The content type should be application/json and the text/plain header should have been replaced. $this->assertStringContainsString('text/html', $result->getHeaderLine('Content-Type')); @@ -150,7 +151,7 @@ public function test_get_response_from_exception(): void { $handler = di::get(response_handler::class); $result = $handler->get_response_from_exception($request, $exception); - $this->assertInstanceOf(Response::class, $result); + $this->assertInstanceOf(ResponseInterface::class, $result); // The body should be json and contain the exception message. $value = json_decode($result->getBody(), true); @@ -179,7 +180,7 @@ public function test_get_response_from_response_aware_exception(): void { $handler = di::get(response_handler::class); $result = $handler->get_response_from_exception($request, $exception); - $this->assertInstanceOf(Response::class, $result); + $this->assertInstanceOf(ResponseInterface::class, $result); // The body should be json and contain the exception message. $value = json_decode($result->getBody(), true); diff --git a/public/lib/tests/router/schema/response/payload_response_test.php b/public/lib/tests/router/schema/response/payload_response_test.php index bada7c5eb2dbe..518dc79fec66d 100644 --- a/public/lib/tests/router/schema/response/payload_response_test.php +++ b/public/lib/tests/router/schema/response/payload_response_test.php @@ -20,6 +20,7 @@ use core\tests\router\route_testcase; use GuzzleHttp\Psr7\Response; use GuzzleHttp\Psr7\ServerRequest; +use Psr\Http\Message\ResponseInterface; /** * Tests for the payload response. @@ -76,6 +77,6 @@ public function test_response_standardisation(): void { // Note: The standardisation itself is tested elsewhere. $response = $handler->standardise_response($payload); - $this->assertInstanceOf(Response::class, $response); + $this->assertInstanceOf(ResponseInterface::class, $response); } } From 0ed1a967bb8687dbda742bf0a5978940aefae69e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yerai=20Rodr=C3=ADguez?= Date: Mon, 13 Apr 2026 11:48:17 +0200 Subject: [PATCH 009/309] MDL-88403 customfield: fix recalculating numbers for shared CF For shared custom fields, the number provider was not being recalculated due to a mismatch between the task custom data and the custom field's actual field component/area. --- .../field/number/classes/task/recalculate.php | 10 +- .../number/tests/task/recalculate_test.php | 97 +++++++++++++++++++ 2 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 public/customfield/field/number/tests/task/recalculate_test.php diff --git a/public/customfield/field/number/classes/task/recalculate.php b/public/customfield/field/number/classes/task/recalculate.php index b0b55cb9af646..2151cf1fa7725 100644 --- a/public/customfield/field/number/classes/task/recalculate.php +++ b/public/customfield/field/number/classes/task/recalculate.php @@ -17,6 +17,7 @@ namespace customfield_number\task; use core\task\adhoc_task; +use core_customfield\customfield\shared_handler; use core_customfield\field_controller; use customfield_number\provider_base; @@ -78,10 +79,15 @@ public function execute() { */ protected function field_is_scheduled(field_controller $field): bool { $customdata = $this->get_custom_data(); - if (!empty($customdata->component) && $field->get_handler()->get_component() !== $customdata->component) { + $handler = $field->get_handler(); + // Shared custom fields can be enabled for any component/area, so they should not be filtered out. + if ($handler instanceof shared_handler) { + return true; + } + if (!empty($customdata->component) && $handler->get_component() !== $customdata->component) { return false; } - if (!empty($customdata->area) && $field->get_handler()->get_area() !== $customdata->area) { + if (!empty($customdata->area) && $handler->get_area() !== $customdata->area) { return false; } return true; diff --git a/public/customfield/field/number/tests/task/recalculate_test.php b/public/customfield/field/number/tests/task/recalculate_test.php new file mode 100644 index 0000000000000..188f5ec3c79f0 --- /dev/null +++ b/public/customfield/field/number/tests/task/recalculate_test.php @@ -0,0 +1,97 @@ +. + +declare(strict_types=1); + +namespace customfield_number\task; + +use core_customfield\api; +use core_customfield\external\toggle_shared_category; +use customfield_number\test_provider; + +/** + * Test the recalculate adhoc task. + * + * @package customfield_number + * @covers \customfield_number\task\recalculate + * @copyright 2026 Yerai Rodríguez + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +final class recalculate_test extends \advanced_testcase { + /** + * Test that schedule_for_fieldtype recalculates shared fields when scheduled with a specific component/area. + + * A shared custom field (handler core_customfield/shared) enabled for courses should be recalculated + * when the adhoc task is scheduled with component='core_course' and area='course'. + * + * @return void + */ + public function test_schedule_for_fieldtype_with_shared_field(): void { + global $DB; + + $this->resetAfterTest(); + $this->setAdminUser(); + + $this->load_fixture('customfield_number', 'test_provider.php'); + + $category = $this->getDataGenerator()->create_custom_field_category([ + 'component' => 'core_customfield', + 'area' => 'shared', + ]); + + toggle_shared_category::execute($category->get('id'), 'core_course', 'course', 0, true); + + $sharedfield = $this->getDataGenerator()->create_custom_field([ + 'categoryid' => $category->get('id'), + 'shortname' => 'seconds', + 'type' => 'number', + 'configdata' => [ + 'fieldtype' => test_provider::class, + ], + ]); + + $clock = $this->mock_clock_with_frozen(); + $fieldid = $sharedfield->get('id'); + $fields = [$fieldid => $sharedfield]; + + $course = $this->getDataGenerator()->create_course(); + $courseid = (int)$course->id; + + // Discard tasks and data created during setup to isolate the explicitly scheduled task below. + $DB->delete_records('task_adhoc'); + $DB->delete_records('customfield_data'); + + // Confirm current value is null. + $data = api::get_instance_fields_data($fields, $courseid, true, 'core_course', 'course'); + $this->assertNull($data[$fieldid]->get_value()); + + // Schedule recalculation with core_course/course component/area for a shared field. + recalculate::schedule_for_fieldtype( + fieldtype: test_provider::class, + component: 'core_course', + area: 'course', + ); + $this->run_all_adhoc_tasks(); + + // The shared field should have been recalculated for the course. + $data = api::get_instance_fields_data($fields, $courseid, true, 'core_course', 'course'); + $this->assertEquals($clock->time() % 3600, $data[$fieldid]->get_value()); + + // Data should not exist in the shared context. + $data = api::get_instance_fields_data($fields, $courseid, true, 'core_customfield', 'shared'); + $this->assertNull($data[$fieldid]->get_value()); + } +} From d1a8764be8118868f699fcb7f0bb856c94a07257 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luca=20B=C3=B6sch?= Date: Mon, 6 Apr 2026 10:58:18 +0200 Subject: [PATCH 010/309] MDL-88375 tool_customlang: title row without borders. --- public/admin/tool/customlang/templates/translator.mustache | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/admin/tool/customlang/templates/translator.mustache b/public/admin/tool/customlang/templates/translator.mustache index d6ffb9ecec5d1..d78d4c8dbc691 100644 --- a/public/admin/tool/customlang/templates/translator.mustache +++ b/public/admin/tool/customlang/templates/translator.mustache @@ -69,7 +69,7 @@
-
+
{{#str}}headingcomponent, tool_customlang{{/str}} From c0965f938d0f7be7deac4d8835c6ab7b946b1c14 Mon Sep 17 00:00:00 2001 From: Muhammad Arnaldo Date: Thu, 2 Apr 2026 16:27:52 +0700 Subject: [PATCH 011/309] MDL-87930 core: fix hidden restricted subsections in navigation --- .../classes/navigation/global_navigation.php | 4 ++ .../navigation/global_navigation_test.php | 61 +++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/public/lib/classes/navigation/global_navigation.php b/public/lib/classes/navigation/global_navigation.php index 909affaa5d6ca..bd88e3200cbc5 100644 --- a/public/lib/classes/navigation/global_navigation.php +++ b/public/lib/classes/navigation/global_navigation.php @@ -1213,11 +1213,15 @@ protected function load_section_activities_navigation( return $activitynodes; } + $format = course_get_format($section->course); foreach ($section->get_sequence_cm_infos() as $cm) { $activitydata = $activitiesdata[$cm->id]; // If activity is a delegated section, load a section node instead of the activity one. if ($activitydata->delegatedsection) { + if (!$format->is_section_visible($activitydata->delegatedsection)) { + continue; + } $activitynodes[$activitydata->id] = $this->load_section_navigation( parentnode: $sectionnode, section: $activitydata->delegatedsection, diff --git a/public/lib/tests/navigation/global_navigation_test.php b/public/lib/tests/navigation/global_navigation_test.php index 408cf55426a15..5f002f7bece2c 100644 --- a/public/lib/tests/navigation/global_navigation_test.php +++ b/public/lib/tests/navigation/global_navigation_test.php @@ -54,4 +54,65 @@ public function test_module_extends_navigation(): void { $this->assertTrue($node->exposed_module_extends_navigation('data')); $this->assertFalse($node->exposed_module_extends_navigation('test1')); } + + /** + * Test that subsections with hidden restrictions (eye closed) are not shown in the navigation + * block, and that subsections with visible restrictions (eye open) still appear. + */ + public function test_load_section_activities_navigation_hidden_subsection_visibility(): void { + global $PAGE, $CFG; + require_once($CFG->dirroot . '/course/lib.php'); + + $this->resetAfterTest(); + $this->setAdminUser(); + set_config('enableavailability', 1); + + $generator = $this->getDataGenerator(); + $course = $generator->create_course(['numsections' => 1]); + + $student = $generator->create_user(); + $generator->enrol_user($student->id, $course->id, 'student'); + + // Profile condition that can never be met: no test user is assigned this reserved address. + $nevermatchemail = '{"type":"profile","sf":"email","op":"isequalto","v":"nomail@moodle.invalid"}'; + // Flag showc:[false] = eye closed (restriction hidden from students). + $eyeclosed = '{"op":"&","c":[' . $nevermatchemail . '],"showc":[false]}'; + // Flag showc:[true] = eye open (restriction visible to students, MDL-87671 scenario). + $eyeopen = '{"op":"&","c":[' . $nevermatchemail . '],"showc":[true]}'; + + $hiddensubsection = $generator->create_module('subsection', [ + 'course' => $course->id, + 'section' => 1, + 'availability' => $eyeclosed, + ]); + $visiblesubsection = $generator->create_module('subsection', [ + 'course' => $course->id, + 'section' => 1, + 'availability' => $eyeopen, + ]); + + rebuild_course_cache($course->id, true); + $this->setUser($student); + $PAGE->set_url('/course/view.php', ['id' => $course->id]); + $PAGE->set_course($course); + $PAGE->set_context(\core\context\course::instance($course->id)); + + $modinfo = get_fast_modinfo($course); + $section1 = $modinfo->get_section_info(1); + $hiddeninfo = $modinfo->get_section_info_by_component('mod_subsection', $hiddensubsection->id); + $visibleinfo = $modinfo->get_section_info_by_component('mod_subsection', $visiblesubsection->id); + + $nav = new exposed_global_navigation($PAGE); + $nav->set_initialised(); + + [, $activities] = $nav->exposed_generate_sections_and_activities($course); + + $sectionnode = $nav->add('Section 1', null, navigation_node::TYPE_SECTION, null, $section1->id); + $nav->exposed_load_section_activities_navigation($sectionnode, $section1, $activities); + + // Eye-closed restricted subsection must NOT appear in the navigation block. + $this->assertFalse($sectionnode->find($hiddeninfo->id, navigation_node::TYPE_SECTION)); + // Eye-open restricted subsection MUST appear in navigation (MDL-87671 behaviour). + $this->assertNotFalse($sectionnode->find($visibleinfo->id, navigation_node::TYPE_SECTION)); + } } From 2beab578c55a61c65879072343b9fcb5c3f9dbf6 Mon Sep 17 00:00:00 2001 From: AMOS bot Date: Fri, 17 Apr 2026 00:07:52 +0000 Subject: [PATCH 012/309] Automatically generated installer lang files --- public/install/lang/de/install.php | 2 +- public/install/lang/ru/install.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/install/lang/de/install.php b/public/install/lang/de/install.php index a76ea17ab2d4e..f09cead0fa75a 100644 --- a/public/install/lang/de/install.php +++ b/public/install/lang/de/install.php @@ -36,7 +36,7 @@ $string['clialreadyconfigured'] = 'Die Datei config.php existiert bereits. Bitte benutzen Sie admin/cli/install_database.php, wenn Sie diese Site installieren möchten.'; $string['clialreadyinstalled'] = 'Die Datei config.php existiert bereits. Bitte benutzen Sie admin/cli/install_database.php, wenn Sie diese Site aktualisieren möchten.'; $string['cliinstallheader'] = 'Installation von Moodle {$a} über die Befehlszeile'; -$string['clitablesexist'] = 'Die Datenbank-Tabellen existieren bereits. Die CLI Installation kann nicht fortgesetzt werden.'; +$string['clitablesexist'] = 'Die Datenbank-Tabellen existieren bereits. Die CLI-Installation (Command Line Interface) kann nicht fortgesetzt werden.'; $string['databasehost'] = 'Datenbank-Server'; $string['databasename'] = 'Datenbank-Name'; $string['databasetypehead'] = 'Datenbank-Treiber wählen'; diff --git a/public/install/lang/ru/install.php b/public/install/lang/ru/install.php index a533d848b428f..d319c7cca9f3d 100644 --- a/public/install/lang/ru/install.php +++ b/public/install/lang/ru/install.php @@ -36,7 +36,7 @@ $string['clialreadyconfigured'] = 'Файл config.php уже существует. Если Вы хотите установить Moodle на этот сайт, используйте admin/cli/install_database.php.'; $string['clialreadyinstalled'] = 'Файл config.php уже существует. Если Вы хотите обновить сайт Moodle, то используйте скрипт admin/cli/upgrade.php.'; $string['cliinstallheader'] = 'Программа установки Moodle {$a} в режиме командной строки'; -$string['clitablesexist'] = 'Таблицы базы данных уже существуют, невозможно продолжить установку в режиме командной строки.'; +$string['clitablesexist'] = 'Таблицы базы данных уже присутствуют; установка через интерфейс командной строки невозможна.'; $string['databasehost'] = 'Сервер баз данных'; $string['databasename'] = 'Название базы данных'; $string['databasetypehead'] = 'Выберите драйвер базы данных'; From 2d607d5a2208e92038251324275988d53373c654 Mon Sep 17 00:00:00 2001 From: AMOS bot Date: Sat, 18 Apr 2026 00:07:49 +0000 Subject: [PATCH 013/309] Automatically generated installer lang files --- public/install/lang/eu/install.php | 2 +- public/install/lang/fi_wp/langconfig.php | 33 +++++++++++++++ public/install/lang/gd/install.php | 52 ++++++++++++++++++++++++ public/install/lang/syc/langconfig.php | 32 +++++++++++++++ 4 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 public/install/lang/fi_wp/langconfig.php create mode 100644 public/install/lang/gd/install.php create mode 100644 public/install/lang/syc/langconfig.php diff --git a/public/install/lang/eu/install.php b/public/install/lang/eu/install.php index 3b650e7f1d2bf..53e7d0814d0e0 100644 --- a/public/install/lang/eu/install.php +++ b/public/install/lang/eu/install.php @@ -36,7 +36,7 @@ $string['clialreadyconfigured'] = 'Dagoeneko badago config.php konfigurazio-fitxategia. Mesedez erabili admin/cli/install_database.php Moodle gune honetan instalatu nahi baduzu.'; $string['clialreadyinstalled'] = 'Dagoeneko badago config.php konfigurazio-fitxategia. Mesedez erabili admin/cli/upgrade.php zure Moodle gunea eguneratu nahi baduzu.'; $string['cliinstallheader'] = 'Moodle {$a} komando-lerro bidezko instalaziorako programa'; -$string['clitablesexist'] = 'Datu-base taulak dagoeneko existitzen dira. CLI instalazioak ezin du jarraitu.'; +$string['clitablesexist'] = 'Datu-base taulak dagoeneko existitzen dira. Komando Lerroko Interfaze (CLI) bidezko instalazioak ezin du jarraitu.'; $string['databasehost'] = 'Datu-basearen ostalaria'; $string['databasename'] = 'Datu-basearen izena'; $string['databasetypehead'] = 'Aukeratu datu-base kontrolatzailea'; diff --git a/public/install/lang/fi_wp/langconfig.php b/public/install/lang/fi_wp/langconfig.php new file mode 100644 index 0000000000000..07a344adf34a7 --- /dev/null +++ b/public/install/lang/fi_wp/langconfig.php @@ -0,0 +1,33 @@ +. + +/** + * Automatically generated strings for Moodle installer + * + * Do not edit this file manually! It contains just a subset of strings + * needed during the very first steps of installation. This file was + * generated automatically by export-installer.php (which is part of AMOS + * {@link https://moodledev.io/general/projects/api/amos}) using the + * list of strings defined in public/install/stringnames.txt file. + * + * @package installer + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$string['parentlanguage'] = 'fi'; +$string['thislanguage'] = 'Suomi'; diff --git a/public/install/lang/gd/install.php b/public/install/lang/gd/install.php new file mode 100644 index 0000000000000..6fa711ed76df4 --- /dev/null +++ b/public/install/lang/gd/install.php @@ -0,0 +1,52 @@ +. + +/** + * Automatically generated strings for Moodle installer + * + * Do not edit this file manually! It contains just a subset of strings + * needed during the very first steps of installation. This file was + * generated automatically by export-installer.php (which is part of AMOS + * {@link https://moodledev.io/general/projects/api/amos}) using the + * list of strings defined in public/install/stringnames.txt file. + * + * @package installer + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$string['admindirname'] = 'Iùl-lann rianachd'; +$string['availablelangs'] = 'Pacaichean cànain rim faotainn'; +$string['chooselanguagehead'] = 'Tagh cànan'; +$string['chooselanguagesub'] = 'Tagh cànan airson a’ chur an sàs. Thèid an cànan seo a chleachdadh cuideachd mar chànan gnàthach na làraich, ged a dh’fhaodadh sin atharrachadh an dèidh làimh.'; +$string['cliinstallheader'] = 'Prògram cur an sàs loidhne-àithne Mhoodle {$a}'; +$string['clitablesexist'] = 'Tha clàran stòir-dàta ann mar-thà; chan urrainn cur an sàs CLI cumail air.'; +$string['databasehost'] = 'Òstair stòir-dàta'; +$string['databasename'] = 'Ainm stòir-dàta'; +$string['databasetypehead'] = 'Tagh dràibhear an stòir-dàta'; +$string['dataroot'] = 'Iùl-lann dàta'; +$string['datarootpermission'] = 'Cead iùl-lannan dàta'; +$string['dirroot'] = 'Iùl-lann Moodle'; +$string['environmenthead'] = 'A’ sgrùdadh na h-àrainneachd agad...'; +$string['errorsinenvironment'] = 'Dh’fhàillig an sgrùdadh àrainneachd!'; +$string['installation'] = 'Cuir an sàs'; +$string['langdownloaderror'] = 'Gu mì-fhortanach, cha b’ urrainn an cànan “{$a}) a luchdachadh a-nuas. Cumaidh am pròiseas cur an sàs a’ dol ann am Beurla.'; +$string['paths'] = 'Slighean'; +$string['pathserrcreatedataroot'] = 'Chan urrainn an iùl-lann dàta ({$a->dataroot}) a chruthachadh leis an stàlaichear.'; +$string['pathshead'] = 'Dearbh slighean'; +$string['pathsrodataroot'] = 'Chan eil an iùl-lann freumh-dàta so-sgrìobhte.'; +$string['pathsroparentdataroot'] = 'Chan eil an iùl-lann tuisteach ({$a->parent}) so-sgrìobhte. Chan urrainn an iùl-lann dàta ({$a->dataroot}) a chruthachadh leis an stàlaichear.'; diff --git a/public/install/lang/syc/langconfig.php b/public/install/lang/syc/langconfig.php new file mode 100644 index 0000000000000..6625587a8d007 --- /dev/null +++ b/public/install/lang/syc/langconfig.php @@ -0,0 +1,32 @@ +. + +/** + * Automatically generated strings for Moodle installer + * + * Do not edit this file manually! It contains just a subset of strings + * needed during the very first steps of installation. This file was + * generated automatically by export-installer.php (which is part of AMOS + * {@link https://moodledev.io/general/projects/api/amos}) using the + * list of strings defined in public/install/stringnames.txt file. + * + * @package installer + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$string['thislanguage'] = 'ܠܫܢܐ ܣܘܪܝܝܐ'; From 1764f23c1d028b9c17b5a7039a838fdb87c512e3 Mon Sep 17 00:00:00 2001 From: AMOS bot Date: Sun, 19 Apr 2026 00:07:56 +0000 Subject: [PATCH 014/309] Automatically generated installer lang files --- public/install/lang/syc/langconfig.php | 1 + 1 file changed, 1 insertion(+) diff --git a/public/install/lang/syc/langconfig.php b/public/install/lang/syc/langconfig.php index 6625587a8d007..916b54c7ce6ae 100644 --- a/public/install/lang/syc/langconfig.php +++ b/public/install/lang/syc/langconfig.php @@ -29,4 +29,5 @@ defined('MOODLE_INTERNAL') || die(); +$string['thisdirection'] = 'rtl'; $string['thislanguage'] = 'ܠܫܢܐ ܣܘܪܝܝܐ'; From 220e815d3e5976b933c27d8957111a564d7c7cf9 Mon Sep 17 00:00:00 2001 From: Brendan Heywood Date: Sat, 6 Dec 2025 01:29:04 +1100 Subject: [PATCH 015/309] MDL-86088 cachestore_file: Fix warnings when cache is purged --- public/cache/stores/file/lib.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/cache/stores/file/lib.php b/public/cache/stores/file/lib.php index b4559c334edd7..c591e3a498408 100644 --- a/public/cache/stores/file/lib.php +++ b/public/cache/stores/file/lib.php @@ -469,7 +469,7 @@ public function get($key) { return false; } // Open ensuring the file for reading in binary format. - if (!$handle = fopen($file, 'rb')) { + if (!$handle = @fopen($file, 'rb')) { return false; } @@ -482,7 +482,7 @@ public function get($key) { // Read the data in 1Mb chunks. Small caches will not loop more than once. We don't use filesize as it may // be cached with a different value than what we need to read from the file. do { - $data .= fread($handle, 1048576); + $data .= @fread($handle, 1048576); } while (!feof($handle)); $this->lastiobytes = strlen($data); From 261124a076d939146bf40681a3bc311707cb8a77 Mon Sep 17 00:00:00 2001 From: Jayce Birrell Date: Mon, 17 Nov 2025 13:00:05 +1030 Subject: [PATCH 016/309] MDL-80321 mod_lesson: redirect to correct page on unlimited attempts --- public/mod/lesson/locallib.php | 3 +- public/mod/lesson/tests/locallib_test.php | 178 ++++++++++++++++++++++ 2 files changed, 180 insertions(+), 1 deletion(-) diff --git a/public/mod/lesson/locallib.php b/public/mod/lesson/locallib.php index ac88d080537b3..77f9fa84a3188 100644 --- a/public/mod/lesson/locallib.php +++ b/public/mod/lesson/locallib.php @@ -2979,11 +2979,12 @@ public function get_last_page_seen($retriescount) { $attempt = end($allattempts); $attemptpage = $this->load_page($attempt->pageid); $jumpto = $DB->get_field('lesson_answers', 'jumpto', array('id' => $attempt->answerid)); + $maxattempts = $this->properties->maxattempts; // Convert the jumpto to a proper page id. if ($jumpto == 0) { // Check if a question has been incorrectly answered AND no more attempts at it are left. $nattempts = $this->get_attempts($attempt->retry, false, $attempt->pageid, $USER->id); - if (count($nattempts) >= $this->properties->maxattempts) { + if (count($nattempts) >= $maxattempts && $maxattempts > 0) { // If maxattempts is 0, unlimited attempts are allowed. $lastpageseen = $this->get_next_page($attemptpage->nextpageid); } else { $lastpageseen = $attempt->pageid; diff --git a/public/mod/lesson/tests/locallib_test.php b/public/mod/lesson/tests/locallib_test.php index 29a2dc0f2b70d..8d6234f8e135d 100644 --- a/public/mod/lesson/tests/locallib_test.php +++ b/public/mod/lesson/tests/locallib_test.php @@ -443,4 +443,182 @@ public function test_count_attempts_and_participants_with_groups(): void { $this->getDataGenerator()->create_group_member(['userid' => $teacher->id, 'groupid' => $group1->id]); $this->assertEquals(2, $lesson->count_all_participants([$group1->id])); } + + /** + * Data provider for test_get_last_page_seen_with_this_page_jumpto. + * + * Each case defines: + * - maxattempts: Lesson setting (0 = unlimited). + * - attemptcount: Number of wrong attempts on the question page. + * - expectnextpage Whether we expect get_last_page_seen() to return the next page. + * + * @return array + */ + public static function get_last_page_seen_dataprovider(): array { + return [ + // Unlimited attempts: even with multiple wrong attempts, we should stay on the same page. + 'unlimited_attempts_one_attempt' => [0, 1, false], + 'unlimited_attempts_two_attempts' => [0, 2, false], + + // Maxattempts = 1, with one wrong attempt: attempts are exhausted, move to next page. + 'single_attempt_limit_reached' => [1, 1, true], + + // Maxattempts = 2, with one wrong attempt: not exhausted yet, stay on same page. + 'two_attempt_limit_not_reached' => [2, 1, false], + + // Maxattempts = 2, with two wrong attempts: exhausted, move to next page. + 'two_attempt_limit_reached' => [2, 2, true], + ]; + } + + /** + * Helper to create a lesson with a question page followed by a content page, + * where the wrong answer on the question jumps to "This page". + * + * Returns: + * - course => course record + * - lesson => lesson object + * - questionpage => question page record + * - nextpage => next page record + * - wronganswer => wrong answer record (jumpto = LESSON_THISPAGE) + * + * @param int $maxattempts The lesson maxattempts setting (0 = unlimited). + * @return array + */ + private function create_lesson_with_this_page_wrong_answer(int $maxattempts): array { + global $DB; + + // Course and lesson. + $course = $this->getDataGenerator()->create_course(); + $lessonrecord = $this->getDataGenerator()->create_module('lesson', [ + 'course' => $course->id, + 'maxattempts' => $maxattempts, + ]); + $lesson = new lesson($lessonrecord); + + /** @var \mod_lesson_generator $lessongenerator */ + $lessongenerator = $this->getDataGenerator()->get_plugin_generator('mod_lesson'); + + // Create a multichoice question page followed by a content page. + $questionpage = [ + 'title' => 'Question page', + 'content' => 'What animal is an amphibian?', + 'qtype' => 'multichoice', + 'lessonid' => $lesson->id, + ]; + $nextpage = [ + 'title' => 'Next content page', + 'content' => 'This is the next page.', + 'qtype' => 'content', + 'lessonid' => $lesson->id, + ]; + + $lessongenerator->create_page($questionpage); + $lessongenerator->create_page($nextpage); + + // Add a correct answer (Next page) and an incorrect answer (This page). + $lessongenerator->create_answer([ + 'page' => $questionpage['title'], + 'answer' => 'Frog', + 'response' => 'Correct', + 'jumpto' => 'Next page', + 'score' => 1, + ]); + $lessongenerator->create_answer([ + 'page' => $questionpage['title'], + 'answer' => 'Cat', + 'response' => 'Incorrect', + 'jumpto' => 'This page', + 'score' => 0, + ]); + + $lessongenerator->finish_generate_answer(); + + $questionpagedb = $DB->get_record('lesson_pages', [ + 'lessonid' => $lesson->id, + 'qtype' => LESSON_PAGE_MULTICHOICE, + ], '*', MUST_EXIST); + + $nextpagedb = $DB->get_record('lesson_pages', [ + 'lessonid' => $lesson->id, + 'qtype' => LESSON_PAGE_BRANCHTABLE, + ], '*', MUST_EXIST); + + // Fetch the wrong answer record. + $wronganswerdb = $DB->get_record('lesson_answers', [ + 'lessonid' => $lesson->id, + 'pageid' => $questionpagedb->id, + 'score' => 0, + ], '*', MUST_EXIST); + + return [ + 'course' => $course, + 'lesson' => $lesson, + 'questionpage' => $questionpagedb, + 'nextpage' => $nextpagedb, + 'wronganswer' => $wronganswerdb, + ]; + } + + /** + * Test get_last_page_seen() when the last attempts were wrong answers with a + * "This page" jump, for different maxattempts and attempt counts. + * + * Scenario: + * - Lesson with a multichoice question followed by a content page. + * - Wrong answer on the question jumps to "This page". + * - We record wrong attempts on that question. + * - We call get_last_page_seen(0) to determine where resuming should start. + * + * For unlimited attempts (maxattempts = 0) we should always stay on the question page. + * For limited attempts, once count(attempts) >= maxattempts, we should go to the next page. + * + * @dataProvider get_last_page_seen_dataprovider + * @param int $maxattempts Lesson setting (0 = unlimited). + * @param int $attemptcount Number of wrong attempts to create on the question page. + * @param bool $expectnextpage Whether we expect the next page to be returned. + * @covers ::get_last_page_seen + */ + public function test_get_last_page_seen_with_this_page_jumpto(int $maxattempts, int $attemptcount, bool $expectnextpage): void { + global $DB; + + $this->resetAfterTest(); + $this->setAdminUser(); + + // Build the lesson scenario. + [ + 'course' => $course, + 'lesson' => $lesson, + 'questionpage' => $questionpage, + 'nextpage' => $nextpage, + 'wronganswer' => $wronganswer, + ] = $this->create_lesson_with_this_page_wrong_answer($maxattempts); + // Create and enrol a student. + $student = $this->getDataGenerator()->create_and_enrol($course, 'student'); + + // Act as the student. + $this->setUser($student); + + // Create the required number of wrong attempts on the question page. + for ($i = 0; $i < $attemptcount; $i++) { + $attempt = (object) [ + 'lessonid' => $lesson->id, + 'pageid' => $questionpage->id, + 'userid' => $student->id, + 'answerid' => $wronganswer->id, + 'retry' => 0, + 'correct' => 0, + 'useranswer' => 'Cat', // Cat is the incorrect answer. + 'timeseen' => time(), + ]; + $DB->insert_record('lesson_attempts', $attempt); + } + + // Call the method under test. + $lastpageid = $lesson->get_last_page_seen(0); + + // Decide which page we expect based on the data provider. + $expectedpageid = $expectnextpage ? $nextpage->id : $questionpage->id; + $this->assertEquals($expectedpageid, $lastpageid); + } } From bdfb8fbde2352e04f961cf033ce2afe596b0a662 Mon Sep 17 00:00:00 2001 From: Stefan Hanauska Date: Wed, 18 Feb 2026 07:24:58 +0100 Subject: [PATCH 017/309] MDL-87879 courseformat: Exclude not displaying modules from state update --- public/course/format/classes/stateactions.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/course/format/classes/stateactions.php b/public/course/format/classes/stateactions.php index 65acef08f0a42..d1a91be536ab1 100644 --- a/public/course/format/classes/stateactions.php +++ b/public/course/format/classes/stateactions.php @@ -1022,7 +1022,7 @@ public function section_state( foreach ($modinfo->sections[$sectioninfo->section] as $modnumber) { $mod = $modinfo->cms[$modnumber]; - if ($mod->is_visible_on_course_page()) { + if ($mod->is_visible_on_course_page() && $mod->is_of_type_that_can_display()) { $cmids[$mod->id] = true; } } From 51be65ccb6992466d74fbaf6769ada95bf72b7d9 Mon Sep 17 00:00:00 2001 From: yusufwib01 Date: Tue, 7 Apr 2026 23:01:43 +0700 Subject: [PATCH 018/309] MDL-86816 quiz: skip restricted users in open-soon notifications --- .../mod/quiz/classes/notification_helper.php | 10 +++- .../quiz/tests/notification_helper_test.php | 56 +++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/public/mod/quiz/classes/notification_helper.php b/public/mod/quiz/classes/notification_helper.php index 1e499adad9163..ab6e7318e3a93 100644 --- a/public/mod/quiz/classes/notification_helper.php +++ b/public/mod/quiz/classes/notification_helper.php @@ -74,27 +74,31 @@ public static function get_users_within_quiz(int $quizid): array { // Get quiz data. $quizobj = quiz_settings::create($quizid); $quiz = $quizobj->get_quiz(); + $coursemodule = $quizobj->get_cm(); // Get our users. $users = get_enrolled_users( - context: \context_module::instance($quizobj->get_cm()->id), + context: \context_module::instance($coursemodule->id), withcapability: 'mod/quiz:attempt', userfields: 'u.id, u.firstname, u.suspended, u.auth', onlyactive: true, ); // Filter a list of users who meet the availability conditions. - $info = new \core_availability\info_module($quizobj->get_cm()); + $info = new \core_availability\info_module($coursemodule); $users = $info->filter_user_list($users); // Check for any override dates. $overrides = $quizobj->get_override_manager()->get_all_overrides(); foreach ($users as $key => $user) { - if ($user->suspended || ($user->auth == 'nologin')) { + // Skip users who are suspended, use nologin auth, or cannot access the quiz. + $isuservisible = \core_availability\info_module::is_user_visible($coursemodule, $user->id, false); + if ($user->suspended || $user->auth == 'nologin' || !$isuservisible) { unset($users[$key]); continue; } + // Time open and time close dates can be user specific with an override. // We begin by assuming it is the same as recorded in the quiz. $user->timeopen = $quiz->timeopen; diff --git a/public/mod/quiz/tests/notification_helper_test.php b/public/mod/quiz/tests/notification_helper_test.php index a5b6c7439ad5a..8718a2c2222bc 100644 --- a/public/mod/quiz/tests/notification_helper_test.php +++ b/public/mod/quiz/tests/notification_helper_test.php @@ -199,6 +199,62 @@ public function test_get_users_within_quiz(): void { $this->assertEquals([$user2->id, $user3->id], array_keys($users)); } + /** + * Test users failing a grade condition are excluded from open-soon notifications. + */ + public function test_get_users_within_quiz_with_grade_restriction(): void { + global $DB; + + $this->resetAfterTest(); + $generator = $this->getDataGenerator(); + $clock = $this->mock_clock_with_frozen(); + + $course = $generator->create_course(); + $user1 = $generator->create_user(); // Scores 90% — should NOT receive notification. + $user2 = $generator->create_user(); // Scores 50% — SHOULD receive notification. + $generator->enrol_user($user1->id, $course->id, 'student'); + $generator->enrol_user($user2->id, $course->id, 'student'); + + $quizgenerator = $generator->get_plugin_generator('mod_quiz'); + + // Create a graded quiz and assign scores: user1 passes (>=60%), user2 fails (<60%). + $quiz = $quizgenerator->create_instance(['course' => $course->id, 'grade' => 100]); + grade_update('mod/quiz', $course->id, 'mod', 'quiz', $quiz->id, 0, ['userid' => $user1->id, 'rawgrade' => 90]); + grade_update('mod/quiz', $course->id, 'mod', 'quiz', $quiz->id, 0, ['userid' => $user2->id, 'rawgrade' => 50]); + $gradeitem = \grade_item::fetch( + [ + 'itemtype' => 'mod', + 'itemmodule' => 'quiz', + 'iteminstance' => $quiz->id, + 'courseid' => $course->id, + 'itemnumber' => 0, + ] + ); + + // Create a remedial quiz opening within 48 hours, restricted to students who scored <60%. + $remedialquiz = $quizgenerator->create_instance(['course' => $course->id, 'timeopen' => $clock->time() + DAYSECS]); + $cm = get_coursemodule_from_instance('quiz', $remedialquiz->id, $course->id); + $DB->set_field('course_modules', 'availability', json_encode([ + 'op' => '&', + 'showc' => [true], + 'c' => [ + [ + 'type' => 'grade', + 'id' => (int) $gradeitem->id, + 'max' => 60.0, + ], + ], + ]), ['id' => $cm->id]); + + rebuild_course_cache($course->id, true); + + $users = notification_helper::get_users_within_quiz($remedialquiz->id); + + // Only user2 should be returned because user1 does not meet the grade condition. + $this->assertCount(1, $users); + $this->assertArrayHasKey($user2->id, $users); + } + /** * Test sending the quiz open soon notification to a user. */ From 73191598a7b8060c85eba0972e90b4216200c1d3 Mon Sep 17 00:00:00 2001 From: yusufwib01 Date: Wed, 1 Apr 2026 23:58:02 +0700 Subject: [PATCH 019/309] MDL-88137 tool_mfa: prevent lock counter exceeding lockout threshold --- .../local/factor/object_factor_base.php | 11 ++++-- .../mfa/tests/object_factor_base_test.php | 37 +++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/public/admin/tool/mfa/classes/local/factor/object_factor_base.php b/public/admin/tool/mfa/classes/local/factor/object_factor_base.php index 3195451bf5965..1e7172f9ac64e 100644 --- a/public/admin/tool/mfa/classes/local/factor/object_factor_base.php +++ b/public/admin/tool/mfa/classes/local/factor/object_factor_base.php @@ -654,13 +654,18 @@ public function increment_lock_counter(): void { return; } + // Do not increment beyond the lockout threshold. + $lockthreshold = get_config('tool_mfa', 'lockout'); + if ($this->lockcounter >= $lockthreshold) { + return; + } + $this->lockcounter++; // Update record in DB. $DB->set_field('tool_mfa', 'lockcounter', $this->lockcounter, ['userid' => $USER->id, 'factor' => $this->name]); - // Now lock this factor if over the counter. - $lockthreshold = get_config('tool_mfa', 'lockout'); - if ($this->lockcounter >= $lockthreshold) { + // Now lock this factor if the counter has reached the threshold. + if ($this->lockcounter == $lockthreshold) { $this->set_state(\tool_mfa\plugininfo\factor::STATE_LOCKED); } } diff --git a/public/admin/tool/mfa/tests/object_factor_base_test.php b/public/admin/tool/mfa/tests/object_factor_base_test.php index 4602802c6b6ff..98c8ca4832daa 100644 --- a/public/admin/tool/mfa/tests/object_factor_base_test.php +++ b/public/admin/tool/mfa/tests/object_factor_base_test.php @@ -111,4 +111,41 @@ public function test_replace_user_factor(): void { $this->assertEquals(1, count($activefactors)); $this->assertEquals($factor2->id, $activefactors[0]->id); } + + /** + * Tests that the lock counter does not exceed the lockout threshold. + * + * @covers ::increment_lock_counter + * @covers ::get_remaining_attempts + * @return void + */ + public function test_increment_lock_counter_does_not_exceed_threshold(): void { + $this->resetAfterTest(); + $user = $this->getDataGenerator()->create_user(); + $this->setUser($user); + + $this->set_factor_state('totp', 1, 100); + + $lockoutthreshold = 3; + set_config('lockout', $lockoutthreshold, 'tool_mfa'); + + $totpfactor = \tool_mfa\plugininfo\factor::get_factor('totp'); + $totpfactor->setup_user_factor((object) ['secret' => 'fakekey', 'devicename' => 'fakedevice']); + + // Reach the lockout threshold. + $factor = \tool_mfa\plugininfo\factor::get_factor('totp'); + for ($i = 0; $i < $lockoutthreshold; $i++) { + $factor->increment_lock_counter(); + } + + $this->assertEquals(0, $factor->get_remaining_attempts()); + $this->assertEquals(\tool_mfa\plugininfo\factor::STATE_LOCKED, $factor->get_state()); + + // Simulate a page refresh, remaining attempts must not go negative. + $refreshedfactor = \tool_mfa\plugininfo\factor::get_factor('totp'); + $refreshedfactor->increment_lock_counter(); + + $this->assertEquals(0, $refreshedfactor->get_remaining_attempts()); + $this->assertEquals(\tool_mfa\plugininfo\factor::STATE_LOCKED, $refreshedfactor->get_state()); + } } From ad455e86b1de38d3cc2a732ac5735b6a6824a2f1 Mon Sep 17 00:00:00 2001 From: AMOS bot Date: Tue, 21 Apr 2026 00:09:30 +0000 Subject: [PATCH 020/309] Automatically generated installer lang files --- public/install/lang/da/install.php | 2 ++ public/install/lang/gd/install.php | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/public/install/lang/da/install.php b/public/install/lang/da/install.php index 3e058b09fa338..3a61a4b2645c0 100644 --- a/public/install/lang/da/install.php +++ b/public/install/lang/da/install.php @@ -69,6 +69,8 @@ $string['pathswrongadmindir'] = 'Adminmappe eksisterer ikke'; $string['phpextension'] = '{$a} PHP-extension'; $string['phpversion'] = 'PHP-version'; +$string['webserverconfigproblemdescription'] = 'Din webserver er ikke konfigureret til at forhindre adgang til filer uden for /public-mappen. Se dokumentationen Opgradering – Omstrukturering af kodemapper for detaljer om, hvordan du konfigurerer din webserver korrekt. Når konfigurationen er opdateret, besøg webroden igen'; +$string['webservernotconfigured'] = 'Web server not configured'; $string['welcomep10'] = '{$a->installername} ({$a->installerversion})'; $string['welcomep20'] = 'Du ser denne side fordi du har installeret og åbnet pakken {$a->packname} {$a->packversion} på din computer. Tillykke med det!'; diff --git a/public/install/lang/gd/install.php b/public/install/lang/gd/install.php index 6fa711ed76df4..d342b2808ad1b 100644 --- a/public/install/lang/gd/install.php +++ b/public/install/lang/gd/install.php @@ -50,3 +50,23 @@ $string['pathshead'] = 'Dearbh slighean'; $string['pathsrodataroot'] = 'Chan eil an iùl-lann freumh-dàta so-sgrìobhte.'; $string['pathsroparentdataroot'] = 'Chan eil an iùl-lann tuisteach ({$a->parent}) so-sgrìobhte. Chan urrainn an iùl-lann dàta ({$a->dataroot}) a chruthachadh leis an stàlaichear.'; +$string['pathssubadmindir'] = 'Tha àireamh glè bheag de dh’òstairean-lìn a’ cleachdadh /rianachd mar URL sònraichte às am faod thu pannal riaghlaidh no a leithid a ruigheachd. Gu mì-fhortanach, tha seo a’ dol an aghaidh àite àbhaisteach duilleagan rianachd Moodle. ’S urrainn dhut seo a chur ceart le ainm ùr a thoirt air an iùl-lann rianachd anns a’ chur an sàs agad, agus an t-ainm ùr sin a chur an seo. Mar eisimpleir: moodlerianachd. Cuiridh seo ceangalan rianachd ceart ann am Moodle.'; +$string['pathssubdataroot'] = '

Iùl-lann anns an tèid an t-susbaint fhaidhlichean uile a chaidh a luchdachadh a-nìos le luchd-cleachdaidh a stòradh le Moodle.

+

Bu chòir don iùl-lann seo a bhith so-leughte agus so-sgrìobhte le neach-cleachdaidh an fhrithealaiche-lìn (mar as trice ‘www-data’. ‘nobody’, no ‘apache’).

+

Chan fhaod gun teid a ruighinn gu dìreach bhon lìon.

+

Mura h-eil an iùl-lann ann am bith an-dràsta, feuchaidh am pròiseas ri cur an sàs a chruthachadh.

'; +$string['pathssubdirroot'] = '

An t-slighe slàn chun iùl-lann anns a bheil còd Moodle.

'; +$string['pathssubwwwroot'] = '

An seòladh slàn far an tèid Moodle a ruigheachd, i.e. an seòladh a chuireas luchd-cleachdaidh a-steach ann am bàr-seòlaidh a’ bhrobhsair aca gus Moodle a ruigheachd.

+

Chan urrainn Moodle a ruighinn a’ cleachdadh iomadach seòladh. Mas urrainn an làrach agad a ruighinn tro iomadach seòladh, tagh am fear as fhasa agus stèidhich ath-stiùireadh maireannach airson gach aon de na seòlaidhean eile.

+

Mas urrainn an làrach agad a ruighinn an dà chuid bhon eadar-lìon, agus bho lìonra bhon taobh a-staigh (air ainmeachadh uaireannan mar Eadra-lìon), cleachd an seòladh poblach an seo.

+

Mura h-eil an seòladh a th’ ann an-dràsta ceart, atharraich an URL ann am bàr-seòlaidh a’ bhrobhsair agad agus ath-thòisich an cur an sàs.

'; +$string['pathswrongadmindir'] = 'Chan eil an iùl-lann rianachd ann am bith'; +$string['phpextension'] = 'Leudachan PHP {$a}'; +$string['phpversion'] = 'Tionndadh PHP'; +$string['welcomep10'] = '{$a->installername} ({$a->installerversion})'; +$string['welcomep20'] = 'Tha thu a’ faicinn na duilleig seo leis gun do shoirbhich le cur an sàs agus cur air bhog na pacaid {$a->packname} {$a->packversion}anns a’ choimpiutair agad. Meal do naidheachd!'; +$string['welcomep30'] = 'Tha an sgaoileadh seo den {$a->installername} a’ gabhail a-steach na h-aplacaidean gus àrainneachd anns an obraich Moodle a chruthachadh, sin:'; +$string['welcomep40'] = 'Tha a’ phacaid cuideachd a’ gabhail a-steach Moodle {$a->moodlerelease} ({$a->moodleversion}).'; +$string['welcomep50'] = 'Tha cleachdadh nan aplacaid uile sa phacaid seo air a riaghladh leis na ceadachan fa leth aca. ’S e a\' phacaid {$a->installername} slàn open source agus tha i air a sgaoileadh fon cheadachas GPL'; +$string['welcomep60'] = 'Bheir na duilleagan a leanas thu tro cheumannan a tha furasta ri leantainn gus Moodle a rèiteachadh agus a stèidheachadh air a’ choimpiutair agad. Faodaidh tu gabhail ris na suidheachaidhean gnàthach no, gu roghainneil, an leasachadh a-rèir na feumalachdan agad fhein.'; +$string['wwwroot'] = 'Seòladh lìn'; From 05f54abd4e23101f540a50e7ea3c494543cc53bd Mon Sep 17 00:00:00 2001 From: Brendan Heywood Date: Fri, 20 Mar 2026 18:11:44 +1100 Subject: [PATCH 021/309] MDL-88162 course: Allow course delete task to retry --- public/course/classes/task/course_delete_modules.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/public/course/classes/task/course_delete_modules.php b/public/course/classes/task/course_delete_modules.php index 0ce504414ddfa..ceda8767be05e 100644 --- a/public/course/classes/task/course_delete_modules.php +++ b/public/course/classes/task/course_delete_modules.php @@ -89,11 +89,13 @@ public function execute() { } /** - * Sets attemptsavailable to false. + * Explicitly set attemptsavailable to true as their are valid + * reasons why a delete may fail intermittently and then work + * when allowed to retry. See MDL-88162 for more details. * * @return boolean */ public function retry_until_success(): bool { - return false; + return true; } } From e6334ef0079db95cfe2009a2f414a6db3a5ccdcb Mon Sep 17 00:00:00 2001 From: Muhammad Arnaldo Date: Tue, 28 Oct 2025 21:34:22 +0700 Subject: [PATCH 022/309] MDL-83815 core_rating: Grade update after forum rating removal Prevents the previous non-null rating value from persisting in the gradebook and single view report after an instructor sets a forum post rating back to "Rate...". This ensures the gradebook correctly reflects the null rating and allows forum rating settings to be updated as expected after a rating is removed. --- public/rating/lib.php | 42 +++++++++++++++++++++++++----------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/public/rating/lib.php b/public/rating/lib.php index 9d0a779a8b941..2da1494669d7a 100644 --- a/public/rating/lib.php +++ b/public/rating/lib.php @@ -834,25 +834,33 @@ public function get_user_grades($options) { $aggregationfield = "1.0 * {$aggregationfield}"; } - // If userid is not 0 we only want the grade for a single user. - $singleuserwhere = ''; + // If userid is not 0, fetch the grade for a single user. + // Use LEFT JOIN to include the user even if they have no ratings, + // ensuring a result is returned when ratings are deleted. if ($options->userid != 0) { - $params['userid1'] = intval($options->userid); - $singleuserwhere = "AND i.{$itemtableusercolumn} = :userid1"; + $params['userid'] = intval($options->userid); + $sql = "SELECT u.id, u.id AS userid, {$aggregationstring}({$aggregationfield}) AS rawgrade + FROM {user} u + LEFT JOIN {{$itemtable}} i ON u.id = i.{$itemtableusercolumn} + LEFT JOIN {rating} r ON r.itemid = i.id + AND r.contextid = :contextid + AND r.component = :component + AND r.ratingarea = :ratingarea + WHERE u.id = :userid + GROUP BY u.id"; + } else { + // MDL-24648 The where line used to be "WHERE (r.contextid is null or r.contextid=:contextid)". + // r.contextid will be null for users who haven't been rated yet. + // No longer including users who haven't been rated to reduce memory requirements. + $sql = "SELECT u.id, u.id AS userid, {$aggregationstring}({$aggregationfield}) AS rawgrade + FROM {user} u + JOIN {{$itemtable}} i ON u.id = i.{$itemtableusercolumn} + JOIN {rating} r ON r.itemid = i.id + WHERE r.contextid = :contextid + AND r.component = :component + AND r.ratingarea = :ratingarea + GROUP BY u.id"; } - - // MDL-24648 The where line used to be "WHERE (r.contextid is null or r.contextid=:contextid)". - // r.contextid will be null for users who haven't been rated yet. - // No longer including users who haven't been rated to reduce memory requirements. - $sql = "SELECT u.id as id, u.id AS userid, {$aggregationstring}({$aggregationfield}) AS rawgrade - FROM {user} u - LEFT JOIN {{$itemtable}} i ON u.id=i.{$itemtableusercolumn} - LEFT JOIN {rating} r ON r.itemid=i.id - WHERE r.contextid = :contextid AND - r.component = :component AND - r.ratingarea = :ratingarea - $singleuserwhere - GROUP BY u.id"; $results = $DB->get_records_sql($sql, $params); if ($results) { From 17a7e4d7c5890b01b5f737e8cc873ff3210a11da Mon Sep 17 00:00:00 2001 From: Brendan Heywood Date: Sat, 6 Dec 2025 02:16:42 +1100 Subject: [PATCH 023/309] MDL-83091 theme: Serve correct 404's when cached files not found --- public/lib/csslib.php | 5 ++++- public/lib/jslib.php | 5 ++++- public/theme/font.php | 9 +++++++-- public/theme/image.php | 9 +++++++-- public/theme/jquery.php | 5 ++++- public/theme/yui_image.php | 5 ++++- 6 files changed, 30 insertions(+), 8 deletions(-) diff --git a/public/lib/csslib.php b/public/lib/csslib.php index 67c633a9a0b9f..1da4d05bc6923 100644 --- a/public/lib/csslib.php +++ b/public/lib/csslib.php @@ -104,7 +104,9 @@ function css_send_cached_css($csspath, $etag) { header('Content-Length: '.filesize($csspath)); } - readfile($csspath); + if (readfile($csspath) === false) { + css_send_css_not_found(); + } die; } @@ -204,6 +206,7 @@ function css_send_unmodified($lastmodified, $etag) { * Sends a 404 message about CSS not being found. */ function css_send_css_not_found() { + header_remove(); header('HTTP/1.0 404 not found'); die('CSS was not found, sorry.'); } diff --git a/public/lib/jslib.php b/public/lib/jslib.php index 3444d8677eff1..7a4b3ced2fd32 100644 --- a/public/lib/jslib.php +++ b/public/lib/jslib.php @@ -54,7 +54,9 @@ function js_send_cached($jspath, $etag, $filename = 'javascript.php') { header('Content-Length: '.filesize($jspath)); } - readfile($jspath); + if (readfile($jspath) === false) { + js_send_css_not_found(); + } die; } @@ -128,6 +130,7 @@ function js_write_cache_file_content($file, $content) { * Sends a 404 message about CSS not being found. */ function js_send_css_not_found() { + header_remove(); header('HTTP/1.0 404 not found'); die('JS was not found, sorry.'); } diff --git a/public/theme/font.php b/public/theme/font.php index be8a3af130e07..9e0fd98208c2f 100644 --- a/public/theme/font.php +++ b/public/theme/font.php @@ -202,7 +202,9 @@ function send_cached_font($fontpath, $etag, $font, $mimetype) { // No need to gzip already compressed fonts. - readfile($fontpath); + if (readfile($fontpath) === false) { + font_not_found(); + } die; } @@ -215,11 +217,14 @@ function send_uncached_font($fontpath, $font, $mimetype) { header('Content-Type: '.$mimetype); header('Content-Length: '.filesize($fontpath)); - readfile($fontpath); + if (readfile($fontpath) === false) { + font_not_found(); + } die; } function font_not_found() { + header_remove(); header('HTTP/1.0 404 not found'); die('font was not found, sorry.'); } diff --git a/public/theme/image.php b/public/theme/image.php index caa81ca9d3127..ca99c6f66b707 100644 --- a/public/theme/image.php +++ b/public/theme/image.php @@ -255,7 +255,9 @@ function send_cached_image($imagepath, $etag) { header('Content-Length: '.filesize($imagepath)); } - readfile($imagepath); + if (readfile($imagepath) === false) { + image_not_found(); + } die; } @@ -273,11 +275,14 @@ function send_uncached_image($imagepath) { header('Content-Type: '.$mimetype); header('Content-Length: '.filesize($imagepath)); - readfile($imagepath); + if (readfile($imagepath) === false) { + image_not_found(); + } die; } function image_not_found() { + header_remove(); header('HTTP/1.0 404 not found'); die('Image was not found, sorry.'); } diff --git a/public/theme/jquery.php b/public/theme/jquery.php index 89fffdd007a73..6568ae6320a4d 100644 --- a/public/theme/jquery.php +++ b/public/theme/jquery.php @@ -143,13 +143,16 @@ header('Content-Length: '.filesize($file)); } -readfile($file); +if (readfile($file) === false) { + jquery_file_not_found(); +} die; function jquery_file_not_found() { // Note: we can not disclose the exact file path here, sorry. + header_remove(); header('HTTP/1.0 404 not found'); die('File was not found, sorry.'); } diff --git a/public/theme/yui_image.php b/public/theme/yui_image.php index c432691ffd4de..4daf2b7242184 100644 --- a/public/theme/yui_image.php +++ b/public/theme/yui_image.php @@ -135,11 +135,14 @@ function yui_image_cached($imagepath, $imagename, $mimetype, $etag) { // no need to gzip already compressed images ;-) - readfile($imagepath); + if (readfile($imagepath) === false) { + yui_image_not_found(); + } die; } function yui_image_not_found() { + header_remove(); header('HTTP/1.0 404 not found'); die('Image was not found, sorry.'); } From 4aeb6358dfe98fd256dba1163ac2c1966abc262b Mon Sep 17 00:00:00 2001 From: Paul Holden Date: Wed, 14 Jan 2026 14:09:39 +0000 Subject: [PATCH 024/309] MDL-87668 output: ensure toggle element reflects current values. This affected the various AI provider/placement admin tables, where using the toggle and then refreshing the page could show the previous value on reload. --- public/lib/templates/toggle.mustache | 1 + 1 file changed, 1 insertion(+) diff --git a/public/lib/templates/toggle.mustache b/public/lib/templates/toggle.mustache index d7676cfc30458..c6c78e18ac93d 100644 --- a/public/lib/templates/toggle.mustache +++ b/public/lib/templates/toggle.mustache @@ -37,6 +37,7 @@
Date: Mon, 29 Dec 2025 12:26:48 +0000 Subject: [PATCH 025/309] MDL-87566 communication: defensive usage of processor instance. Ensure it actually exists before trying to interact with it. --- public/communication/classes/helper.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/communication/classes/helper.php b/public/communication/classes/helper.php index dbb4b4460a307..13fba0a0dfcfb 100644 --- a/public/communication/classes/helper.php +++ b/public/communication/classes/helper.php @@ -178,7 +178,7 @@ public static function update_course_communication_room_membership( groupid: $coursegroup->id, context: $coursecontext, ); - $instanceusers = $communication->get_processor()->get_all_userids_for_instance(); + $instanceusers = $communication->get_processor()?->get_all_userids_for_instance() ?? []; // The difference between the instance users and the group members are the ones we want to check. $roomuserstocheck = array_diff( From b417f8cf2ed76924f4ab128c63c9332890b7076a Mon Sep 17 00:00:00 2001 From: Paul Holden Date: Thu, 29 Jan 2026 16:12:40 +0000 Subject: [PATCH 026/309] MDL-87801 blocks: fix empty check for title in block edit controls. See also 4be66b0d. --- public/lib/blocklib.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/public/lib/blocklib.php b/public/lib/blocklib.php index b803163b317ec..e8375b56e29ca 100644 --- a/public/lib/blocklib.php +++ b/public/lib/blocklib.php @@ -1344,8 +1344,9 @@ public function edit_controls($block) { $controls = array(); $actionurl = $this->page->url->out(false, array('sesskey'=> sesskey())); - $blocktitle = $block->title; - if (empty($blocktitle)) { + + $blocktitle = (string) $block->title; + if ($blocktitle === '') { $blocktitle = $block->arialabel; } From c59bea20f953172f0b95dcfec424d400f48b47c6 Mon Sep 17 00:00:00 2001 From: Paul Holden Date: Fri, 17 Apr 2026 13:14:11 +0100 Subject: [PATCH 027/309] MDL-88479 output: correct initial spacing of paging size selector. See also 0faeff2e, which largely resolved this. We just need to match the expected DOM attributes here. --- public/lib/templates/paged_content_paging_bar.mustache | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/lib/templates/paged_content_paging_bar.mustache b/public/lib/templates/paged_content_paging_bar.mustache index 51593cc389abb..c05e0125e0747 100644 --- a/public/lib/templates/paged_content_paging_bar.mustache +++ b/public/lib/templates/paged_content_paging_bar.mustache @@ -93,7 +93,7 @@ > {{#itemsperpage}} Date: Wed, 22 Apr 2026 00:07:49 +0000 Subject: [PATCH 028/309] Automatically generated installer lang files --- public/install/lang/es_mx/install.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/install/lang/es_mx/install.php b/public/install/lang/es_mx/install.php index b464af097ba73..326129946accc 100644 --- a/public/install/lang/es_mx/install.php +++ b/public/install/lang/es_mx/install.php @@ -36,7 +36,7 @@ $string['clialreadyconfigured'] = 'El archivo de configuración config.php ya existe. Por favor, use admin/cli/install_database.php para instalar Moodle para este sitio'; $string['clialreadyinstalled'] = 'El archivo de configuración config.php ya existe. Por favor, utilice admin/cli/install_database.php para actualizar Moodle para este sitio.'; $string['cliinstallheader'] = 'Programa de instalación Moodle de línea de comando {$a}'; -$string['clitablesexist'] = 'Tablas de base de datos ya existentes, la instalación CLI no puede continuar.'; +$string['clitablesexist'] = 'Tablas de base de datos ya existentes, la instalación por Interfaz de Línea de Comando (CLI) no puede continuar.'; $string['databasehost'] = 'host de la Base de Datos'; $string['databasename'] = 'Nombre de la base de datos'; $string['databasetypehead'] = 'Seleccione el controlador de la base de datos'; From b1fcd35db4ee52b44b1ba3ef4285c17a48bcf8fe Mon Sep 17 00:00:00 2001 From: Stefan Hanauska Date: Tue, 17 Feb 2026 16:57:33 +0100 Subject: [PATCH 029/309] MDL-87983 calendar: Check hidden categories --- public/calendar/lib.php | 11 ++- public/calendar/tests/lib_test.php | 141 +++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 4 deletions(-) diff --git a/public/calendar/lib.php b/public/calendar/lib.php index 9b88e4bb64e84..210f83b08bef0 100644 --- a/public/calendar/lib.php +++ b/public/calendar/lib.php @@ -2682,7 +2682,10 @@ function calendar_can_edit_subscription($subscriptionorid) { $category = null; if (!empty($categoryid)) { - $category = \core_course_category::get($categoryid); + $category = \core_course_category::get($categoryid, IGNORE_MISSING, true); + if (!$category) { + return false; + } } calendar_get_allowed_types($allowed, $courseid, null, $category); switch ($subscription->eventtype) { @@ -2690,13 +2693,13 @@ function calendar_can_edit_subscription($subscriptionorid) { return ($USER->id == $subscription->userid && $allowed->user); case 'course': if (isset($allowed->courses[$courseid])) { - return $allowed->courses[$courseid]; + return (bool)$allowed->courses[$courseid]; } else { return false; } case 'category': if (isset($allowed->categories[$categoryid])) { - return $allowed->categories[$categoryid]; + return (bool)$allowed->categories[$categoryid]; } else { return false; } @@ -2704,7 +2707,7 @@ function calendar_can_edit_subscription($subscriptionorid) { return $allowed->site; case 'group': if (isset($allowed->groups[$groupid])) { - return $allowed->groups[$groupid]; + return (bool)$allowed->groups[$groupid]; } else { return false; } diff --git a/public/calendar/tests/lib_test.php b/public/calendar/tests/lib_test.php index 51725c37580b0..a011cd9c65e6b 100644 --- a/public/calendar/tests/lib_test.php +++ b/public/calendar/tests/lib_test.php @@ -23,6 +23,7 @@ * @copyright 2017 Mark Nelson * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +#[\PHPUnit\Framework\Attributes\CoversFunction('calendar_can_edit_subscription')] final class lib_test extends \advanced_testcase { /** @@ -1253,4 +1254,144 @@ public function test_calendar_format_event_location(string $location, string $ex $event = create_event(['location' => $location]); $this->assertMatchesRegularExpression("|^({$expectedpattern})$|", calendar_format_event_location($event)); } + + /** + * Test that a user with the correct capability can edit a calendar subscription. + * + * @return void + */ + public function test_calendar_can_edit_subscription(): void { + $this->resetAfterTest(); + $generator = $this->getDataGenerator(); + + // Create a user and a course. + $user1 = $generator->create_user(); + $user2 = $generator->create_user(); + $user3 = $generator->create_user(); + + $categorya = $generator->create_category(); + $categoryb = $generator->create_category(); + $categorycontext = \core\context\coursecat::instance($categorya->id); + + $course = $generator->create_course(['groupmode' => SEPARATEGROUPS]); + $coursecontext = \core\context\course::instance($course->id); + + // Enrol the users in the course. + $generator->enrol_user($user1->id, $course->id, 'teacher'); + $generator->enrol_user($user2->id, $course->id, 'student'); + $generator->enrol_user($user3->id, $course->id, 'student'); + + // Generate a group and add user2 and user3 to it. + $group = $generator->create_group(['courseid' => $course->id]); + groups_add_member($group, $user2); + groups_add_member($group, $user3); + + // Create a role with the capability to manage calendar subscriptions in the course category A and assign it to the user. + $roleid = $generator->create_role(); + assign_capability('moodle/calendar:manageentries', CAP_ALLOW, $roleid, $categorycontext->id, true); + assign_capability( + 'moodle/category:manage', + CAP_ALLOW, + $roleid, + \core\context\coursecat::instance($categorya->id)->id, + true + ); + role_assign($roleid, $user1->id, $categorycontext->id); + + // Create a role with the capability to manage calendar subscriptions of groups. + $groupmanagerroleid = $generator->create_role(); + assign_capability('moodle/calendar:managegroupentries', CAP_ALLOW, $groupmanagerroleid, $coursecontext->id, true); + role_assign($groupmanagerroleid, $user2->id, $coursecontext->id); + + // Set the current user to the one we just created. + $this->setUser($user1); + + $subscription = new \stdClass(); + $subscription->eventtype = 'user'; + $subscription->name = 'test user subscription user1'; + $subscription->userid = $user1->id; + $usersubscriptionid = calendar_add_subscription($subscription); + + // The user should be able to edit own subscriptions. + $this->assertTrue(calendar_can_edit_subscription($usersubscriptionid)); + + $this->setUser($user2); + // The user should not be able to edit other user's subscriptions. + $this->assertFalse(calendar_can_edit_subscription($usersubscriptionid)); + + $this->setAdminUser(); + $subscription = new \stdClass(); + $subscription->name = 'test site subscription'; + $subscription->eventtype = 'site'; + $sitesubscriptionid = calendar_add_subscription($subscription); + + // Admin should be able to edit site subscriptions. + $this->assertTrue(calendar_can_edit_subscription($sitesubscriptionid)); + + $this->setUser($user1); + // The user should not be able to edit site subscriptions. + $this->assertFalse(calendar_can_edit_subscription($sitesubscriptionid)); + + $subscription = new \stdClass(); + $subscription->name = 'test category A subscription'; + $subscription->eventtype = 'category'; + $subscription->categoryid = $categorya->id; + $categoryasubscriptionid = calendar_add_subscription($subscription); + + $this->setAdminUser(); + $subscription = new \stdClass(); + $subscription->name = 'test category B subscription'; + $subscription->eventtype = 'category'; + $subscription->categoryid = $categoryb->id; + $categorybsubscriptionid = calendar_add_subscription($subscription); + + $this->setUser($user1); + // The user should not be able to edit category subscriptions in category B. + $this->assertFalse(calendar_can_edit_subscription($categorybsubscriptionid)); + + // The user should be able to edit category subscriptions in category A. + $this->assertTrue(calendar_can_edit_subscription($categoryasubscriptionid)); + + $this->setUser($user2); + // The user should not be able to edit category subscriptions in category A. + $this->assertFalse(calendar_can_edit_subscription($categoryasubscriptionid)); + + $this->setUser($user1); + $subscription = new \stdClass(); + $subscription->name = 'test course subscription'; + $subscription->eventtype = 'course'; + $subscription->courseid = $course->id; + $coursesubscriptionid = calendar_add_subscription($subscription); + + // The user should be able to edit course subscriptions in the course. + $this->assertTrue(calendar_can_edit_subscription($coursesubscriptionid)); + + $this->setUser($user2); + // The user should not be able to edit course subscriptions in the course. + $this->assertFalse(calendar_can_edit_subscription($coursesubscriptionid)); + + $this->setUser($user2); + $subscription = new \stdClass(); + $subscription->name = 'test group subscription'; + $subscription->eventtype = 'group'; + $subscription->courseid = $course->id; + $subscription->groupid = $group->id; + $groupsubscriptionid = calendar_add_subscription($subscription); + + // The user should be able to edit group subscriptions. + $this->assertTrue(calendar_can_edit_subscription($groupsubscriptionid)); + + $this->setUser($user3); + // The user should not be able to edit group subscriptions. + $this->assertFalse(calendar_can_edit_subscription($groupsubscriptionid)); + + // This additional test covers a case where a category with a subscription is hidden from the user. + // This should just return false instead of throwing an exception. + $this->setAdminUser(); + $coursecat = \core_course_category::get($categoryb->id); + $coursecat->hide(); + + $this->setUser($user1); + $this->assertFalse(calendar_can_edit_subscription($categorybsubscriptionid)); + } } From 46881b03a54365503084125462562ea18ab349fb Mon Sep 17 00:00:00 2001 From: Stefan Hanauska Date: Wed, 22 Apr 2026 08:36:32 +0200 Subject: [PATCH 030/309] MDL-88512 course: Delete subscriptions when deleting category --- public/course/classes/category.php | 3 +++ public/course/tests/category_test.php | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/public/course/classes/category.php b/public/course/classes/category.php index 83e7821178c83..3a6d05645e244 100644 --- a/public/course/classes/category.php +++ b/public/course/classes/category.php @@ -2063,6 +2063,9 @@ public function delete_full($showfeedback = true) { // Delete all events in the category. $DB->delete_records('event', array('categoryid' => $this->id)); + // Delete all event subscriptions in the category. + $DB->delete_records('event_subscriptions', ['categoryid' => $this->id]); + // Finally delete the category and it's context. $categoryrecord = $this->get_db_record(); $DB->delete_records('course_categories', array('id' => $this->id)); diff --git a/public/course/tests/category_test.php b/public/course/tests/category_test.php index 67aab5c232d08..f630fa6432b61 100644 --- a/public/course/tests/category_test.php +++ b/public/course/tests/category_test.php @@ -317,6 +317,8 @@ public function test_delete(): void { $this->assertEquals($category4->id, $DB->get_field('course', 'category', array('id' => $course3->id))); $this->assertEquals($category3->id, $DB->get_field('course', 'category', array('id' => $course1->id))); + $DB->insert_record('event_subscriptions', ['url' => 'invalid', 'categoryid' => $category3->id]); + // Delete category 3 completely. $this->assertFalse($category3->can_delete_full()); // No luck! // Add necessary capabilities. @@ -335,6 +337,8 @@ public function test_delete(): void { $this->assertEquals(1, $DB->get_field_sql('SELECT count(*) FROM {course} WHERE id <> ?', array(SITEID))); $this->assertEquals(array('id' => $course4->id, 'category' => $category1->id), (array)$DB->get_record_sql('SELECT id, category from {course} where id <> ?', array(SITEID))); + + $this->assertEquals(0, $DB->count_records('event_subscriptions', ['categoryid' => $category3->id])); } public function test_get_children(): void { From 1de280390fe9f2dcffe420aa849e0024b9439f8c Mon Sep 17 00:00:00 2001 From: Paul Holden Date: Thu, 5 Mar 2026 22:15:37 +0000 Subject: [PATCH 031/309] MDL-88133 repository: avoid double escaping of file listing. Escaping already happens in the front end, which means it should not happen in the backend lest we double escape the content. --- .../contentbank/classes/browser/contentbank_browser.php | 2 +- public/repository/contentbank/classes/helper.php | 2 +- public/repository/local/lib.php | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/public/repository/contentbank/classes/browser/contentbank_browser.php b/public/repository/contentbank/classes/browser/contentbank_browser.php index c8bb979328db0..4441acc729b89 100644 --- a/public/repository/contentbank/classes/browser/contentbank_browser.php +++ b/public/repository/contentbank/classes/browser/contentbank_browser.php @@ -136,7 +136,7 @@ private function get_context_folders(): array { return array_reduce($children, function ($list, $child) { $browser = \repository_contentbank\helper::get_contentbank_browser($child); if ($browser->can_access_content()) { - $name = $child->get_context_name(false); + $name = $child->get_context_name(false, false, false); $path = base64_encode(json_encode(['contextid' => $child->id])); $list[] = \repository_contentbank\helper::create_context_folder_node($name, $path); } diff --git a/public/repository/contentbank/classes/helper.php b/public/repository/contentbank/classes/helper.php index bb0309146d034..79b548e43600a 100644 --- a/public/repository/contentbank/classes/helper.php +++ b/public/repository/contentbank/classes/helper.php @@ -129,7 +129,7 @@ public static function create_contentbank_content_node(\core_contentbank\content public static function create_navigation_node(\context $context): array { return [ 'path' => base64_encode(json_encode(['contextid' => $context->id])), - 'name' => $context->get_context_name(false) + 'name' => $context->get_context_name(false, false, false), ]; } } diff --git a/public/repository/local/lib.php b/public/repository/local/lib.php index 6a3ec3c62ea00..b7b4fa16b93ca 100644 --- a/public/repository/local/lib.php +++ b/public/repository/local/lib.php @@ -210,7 +210,7 @@ private function get_node(file_info $fileinfo) { global $OUTPUT; $encodedpath = base64_encode(json_encode($fileinfo->get_params())); $node = array( - 'title' => $fileinfo->get_visible_name(), + 'title' => html_entity_decode($fileinfo->get_visible_name()), 'datemodified' => $fileinfo->get_timemodified(), 'datecreated' => $fileinfo->get_timecreated() ); @@ -251,7 +251,7 @@ private function get_node_path(file_info $fileinfo) { $encodedpath = base64_encode(json_encode($fileinfo->get_params())); return array( 'path' => $encodedpath, - 'name' => $fileinfo->get_visible_name() + 'name' => html_entity_decode($fileinfo->get_visible_name()), ); } From 2be7997370d909d4924c48b8c044ba9c2eef15e9 Mon Sep 17 00:00:00 2001 From: Paul Holden Date: Fri, 6 Feb 2026 16:11:31 +0000 Subject: [PATCH 032/309] MDL-87896 admin: inform admin of incorrect upgrade key. --- public/admin/renderer.php | 16 ++++++++++++++++ public/lang/en/admin.php | 1 + public/lib/upgradelib.php | 6 +++++- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/public/admin/renderer.php b/public/admin/renderer.php index 11c52fcbc4ff0..2bbaae3843196 100644 --- a/public/admin/renderer.php +++ b/public/admin/renderer.php @@ -2264,11 +2264,27 @@ public function environment_check_table($result, $environment_results) { * @return string */ public function upgradekey_form_page($url) { + return $this->upgradekey_form_page_with_validation($url, false); + } + /** + * Render a simple page for providing the upgrade key, providing validation for failed attempts + * + * @param moodle_url $url + * @param bool $upgradekeyerror + * @return string + */ + public function upgradekey_form_page_with_validation(moodle_url $url, bool $upgradekeyerror): string { $output = ''; $output .= $this->header(); $output .= $this->heading(get_string('upgradekeyreq', 'core_admin')); $output .= $this->container_start('upgradekeyreq w-25'); + + // Inform user if they got it wrong. + if ($upgradekeyerror) { + $output .= $this->warning(get_string('upgradekeyerror', 'core_admin'), 'danger'); + } + $output .= html_writer::start_tag('form', array('method' => 'POST', 'action' => $url)); $output .= html_writer::empty_tag('input', [ 'id' => 'upgradekey', diff --git a/public/lang/en/admin.php b/public/lang/en/admin.php index c7450a5405f05..6e66e345e774c 100644 --- a/public/lang/en/admin.php +++ b/public/lang/en/admin.php @@ -1553,6 +1553,7 @@ $string['upgradeerror'] = 'Unknown error upgrading {$a->plugin} to version {$a->version}. Cannot continue.'; $string['upgradeforumread'] = 'A new feature has been added in Moodle 1.5 to track read/unread forum posts.
To use this functionality you need to
update your tables.'; $string['upgradeforumreadinfo'] = 'A new feature has been added in Moodle 1.5 to track read/unread forum posts. To use this functionality you need to update your tables with all the tracking information for existing posts. Depending on the size of your site this can take a long time (hours) and can be quite taxing on the database, so it\'s best to do it during a quiet period. However, your site will continue functioning during this upgrade and users won\'t be affected. Once you start this process you should let it finish (keep your browser window open). However, if you stop the process by closing the window: don\'t worry, you can start over.

Do you want to start the upgrading process now?'; +$string['upgradekeyerror'] = 'Incorrect upgrade key. Please check the value against $CFG->upgradekey in your site configuration.'; $string['upgradekeyreq'] = 'Upgrade key required'; $string['upgradekeyset'] = 'Upgrade key (leave empty to not set it)'; $string['upgradelogs'] = 'For full functionality, your old logs need to be upgraded. More information'; diff --git a/public/lib/upgradelib.php b/public/lib/upgradelib.php index 4236eeee2e0f6..62bd63eaac466 100644 --- a/public/lib/upgradelib.php +++ b/public/lib/upgradelib.php @@ -2518,7 +2518,11 @@ function check_upgrade_key($upgradekeyhash) { /** @var core_admin_renderer $output */ $output = $PAGE->get_renderer('core', 'admin'); - echo $output->upgradekey_form_page(new moodle_url('/admin/index.php', array('cache' => 0))); + + echo $output->upgradekey_form_page_with_validation( + new moodle_url('/admin/index.php', ['cache' => 0]), + $upgradekeyhash !== null, + ); die(); } else { // This should not happen. From dc03a6451ff391d1a06fa1e2edc5986038ef5cf1 Mon Sep 17 00:00:00 2001 From: Paul Holden Date: Wed, 22 Apr 2026 19:05:54 +0100 Subject: [PATCH 033/309] MDL-88517 mod_bigbluebuttonbn: shift open/close dates during reset. --- .../restore_bigbluebuttonbn_stepslib.php | 6 +++ public/mod/bigbluebuttonbn/lib.php | 20 ++++++++- public/mod/bigbluebuttonbn/tests/lib_test.php | 44 +++++++++++++++---- 3 files changed, 59 insertions(+), 11 deletions(-) diff --git a/public/mod/bigbluebuttonbn/backup/moodle2/restore_bigbluebuttonbn_stepslib.php b/public/mod/bigbluebuttonbn/backup/moodle2/restore_bigbluebuttonbn_stepslib.php index 2d9462689ebfc..801989fed4852 100644 --- a/public/mod/bigbluebuttonbn/backup/moodle2/restore_bigbluebuttonbn_stepslib.php +++ b/public/mod/bigbluebuttonbn/backup/moodle2/restore_bigbluebuttonbn_stepslib.php @@ -58,6 +58,12 @@ protected function process_bigbluebuttonbn(array $data) { global $DB; $data = (object) $data; $data->course = $this->get_courseid(); + + // Any changes to the list of dates that needs to be rolled should be same during course restore and course reset. + // See MDL-9367. + $data->openingtime = $this->apply_date_offset($data->openingtime); + $data->closingtime = $this->apply_date_offset($data->closingtime); + // Check if we are in backup::MODE_IMPORT (we set a new meetingid) or backup::MODE_GENERAL (we keep the same meetingid). if ($this->get_task()->get_info()->mode == backup::MODE_IMPORT || empty($data->meetingid)) { // We are in backup::MODE_IMPORT, we need to renew the meetingid. diff --git a/public/mod/bigbluebuttonbn/lib.php b/public/mod/bigbluebuttonbn/lib.php index 7041d174a3249..3621d897768da 100644 --- a/public/mod/bigbluebuttonbn/lib.php +++ b/public/mod/bigbluebuttonbn/lib.php @@ -329,8 +329,24 @@ function bigbluebuttonbn_reset_userdata(stdClass $data) { $items = reset::reset_course_items(); $status = []; - // Any changes to the list of dates that needs to be rolled should be same during course restore and course reset. - // See MDL-9367. + $componentstr = get_string('modulenameplural', 'bigbluebuttonbn'); + + // Updating dates - shift may be negative too. + if (!empty($data->timeshift)) { + // Any changes to the list of dates that needs to be rolled should be same during course restore and course reset. + // See MDL-9367. + shift_course_mod_dates('bigbluebuttonbn', [ + 'openingtime', + 'closingtime', + ], $data->timeshift, $data->courseid); + + $status[] = [ + 'component' => $componentstr, + 'item' => get_string('date'), + 'error' => false, + ]; + } + if (array_key_exists('recordings', $items) && !empty($data->reset_bigbluebuttonbn_recordings)) { // Remove all the recordings from a BBB server that are linked to the room/activities in this course. reset::reset_recordings($data->courseid); diff --git a/public/mod/bigbluebuttonbn/tests/lib_test.php b/public/mod/bigbluebuttonbn/tests/lib_test.php index db1383893973d..ea852b9bc5c8d 100644 --- a/public/mod/bigbluebuttonbn/tests/lib_test.php +++ b/public/mod/bigbluebuttonbn/tests/lib_test.php @@ -463,30 +463,56 @@ public function test_bigbluebuttonbn_reset_course_form_defaults(): void { */ public function test_bigbluebuttonbn_reset_userdata(): void { global $DB; + $this->resetAfterTest(); - $data = new stdClass(); - $user = $this->getDataGenerator()->create_user(); + $this->setAdminUser(); - list($bbactivitycontext, $bbactivitycm, $bbactivity) = $this->create_instance(); + $now = time(); + + $user = $this->getDataGenerator()->create_user(); + [$bbactivitycontext, $bbactivitycm, $bbactivity] = $this->create_instance(null, [ + 'openingtime' => $now + HOURSECS, + 'closingtime' => $now + DAYSECS, + ]); $this->getDataGenerator()->enrol_user($user->id, $this->course->id); $this->setUser($user); logger::log_meeting_joined_event(instance::get_from_instanceid($bbactivity->id), 0); + + $data = new stdClass(); $data->courseid = $this->get_course()->id; $data->reset_bigbluebuttonbn_tags = true; $data->reset_bigbluebuttonbn_logs = true; $data->course = $bbactivity->course; + $data->timeshift = DAYSECS * 2; + // Add and Join. $this->assertCount(2, $DB->get_records('bigbluebuttonbn_logs', ['bigbluebuttonbnid' => $bbactivity->id])); $results = bigbluebuttonbn_reset_userdata($data); $this->assertCount(0, $DB->get_records('bigbluebuttonbn_logs', ['bigbluebuttonbnid' => $bbactivity->id])); $this->assertEquals([ - 'component' => 'BigBlueButton', - 'item' => 'Deleted tags', - 'error' => false - ], - $results[0] - ); + [ + 'component' => 'BigBlueButton', + 'item' => 'Date', + 'error' => false, + ], + [ + 'component' => 'BigBlueButton', + 'item' => 'Deleted tags', + 'error' => false, + ], + [ + 'component' => 'BigBlueButton', + 'item' => 'Deleted custom logs', + 'error' => false, + ], + ], $results); + + // Reload the instance data. + $instance = $DB->get_record('bigbluebuttonbn', ['id' => $bbactivity->id]); + + $this->assertEquals($bbactivity->openingtime + (DAYSECS * 2), $instance->openingtime); + $this->assertEquals($bbactivity->closingtime + (DAYSECS * 2), $instance->closingtime); } /** From 53e4cb278c47f2777c71f5c303a4e59327aac16b Mon Sep 17 00:00:00 2001 From: AMOS bot Date: Thu, 23 Apr 2026 00:07:48 +0000 Subject: [PATCH 034/309] Automatically generated installer lang files --- public/install/lang/cs/install.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/install/lang/cs/install.php b/public/install/lang/cs/install.php index 083599878dcaf..d368d2ef3987c 100644 --- a/public/install/lang/cs/install.php +++ b/public/install/lang/cs/install.php @@ -36,7 +36,7 @@ $string['clialreadyconfigured'] = 'Konfigurační soubor config.php již existuje. Spusťte admin/cli/install_database.php, pokud chcete provést instalaci databáze.'; $string['clialreadyinstalled'] = 'Konfigurační soubor config.php již existuje. Spusťte admin/cli/upgrade.php, pokud chcete provést upgrade vašich stránek.'; $string['cliinstallheader'] = 'Moodle {$a} - průvodce instalací z příkazové řádky'; -$string['clitablesexist'] = 'Databázové tabulky již existují; CLI instalace nemůže pokračovat.'; +$string['clitablesexist'] = 'Databázové tabulky již existují; Instalace z rozhraní příkazového řádku (CLI) nemůže pokračovat.'; $string['databasehost'] = 'Databázový server'; $string['databasename'] = 'Název databáze'; $string['databasetypehead'] = 'Vyberte databázový ovladač'; From 95bc7fca78cac74062502228e02485253d612897 Mon Sep 17 00:00:00 2001 From: james-cnz <5689414+james-cnz@users.noreply.github.com> Date: Mon, 9 Jan 2023 13:15:28 +1300 Subject: [PATCH 035/309] MDL-66780 course: Fix section visibility with restrictions --- public/availability/classes/info.php | 10 +- public/course/classes/cm_info.php | 2 +- public/course/format/classes/base.php | 4 +- public/course/renderer.php | 2 +- .../tests/behat/section_visibility.feature | 125 ++++++++++++++++++ .../grade/report/user/classes/report/user.php | 2 +- 6 files changed, 131 insertions(+), 14 deletions(-) diff --git a/public/availability/classes/info.php b/public/availability/classes/info.php index a573374bfeaaa..8fa2ea0d7315f 100644 --- a/public/availability/classes/info.php +++ b/public/availability/classes/info.php @@ -208,15 +208,7 @@ public function is_available(&$information, $grabthelot = false, $userid = 0, $this->modinfo = null; return true; } else { - // If the item is marked as 'not visible' then we don't change the available - // flag (visible/available are treated distinctly), but we remove any - // availability info. If the item is hidden with the eye icon, it doesn't - // make sense to show 'Available from ' or similar, because even - // when that date arrives it will still not be available unless somebody - // toggles the eye icon. - if ($this->visible) { - $information = $tree->get_result_information($this, $result); - } + $information = $tree->get_result_information($this, $result); $this->modinfo = null; return false; diff --git a/public/course/classes/cm_info.php b/public/course/classes/cm_info.php index f131dd1142098..226b6801e53b9 100644 --- a/public/course/classes/cm_info.php +++ b/public/course/classes/cm_info.php @@ -1599,7 +1599,7 @@ private function update_user_visible() { ($this->visibleoncoursepage || has_any_capability($capabilities, $this->get_context(), $userid)); // Activity that is not available, not hidden from course page and has availability // info is actually visible on the course page (with availability info and without a link). - if (!$this->uservisible && $this->visibleoncoursepage && $this->availableinfo) { + if ($this->visibleoncoursepage && $this->visible && $this->availableinfo) { $this->uservisibleoncoursepage = true; } } diff --git a/public/course/format/classes/base.php b/public/course/format/classes/base.php index 081c6767e8575..a2d8ddbc7779c 100644 --- a/public/course/format/classes/base.php +++ b/public/course/format/classes/base.php @@ -1716,8 +1716,8 @@ public function is_section_visible(section_info $section): bool { // but there is some available info text which explains the reason & should display, // OR it is hidden but the course has a setting to display hidden sections as unavailable. return $section->uservisible || - ($section->visible && !$section->available && !empty($section->availableinfo)) || - (!$section->visible && !$hidesections); + ($section->visible || !$hidesections) + && ($section->available || !empty($section->availableinfo)); } /** diff --git a/public/course/renderer.php b/public/course/renderer.php index a53c21d898ba8..47f0a417c39b5 100644 --- a/public/course/renderer.php +++ b/public/course/renderer.php @@ -323,7 +323,7 @@ public function course_section_cm_unavailable_error_message(cm_info $cm) { if ($cm->uservisible) { return null; } - if (!$cm->availableinfo) { + if (!$cm->visible || !$cm->availableinfo) { return get_string('activityiscurrentlyhidden'); } diff --git a/public/course/tests/behat/section_visibility.feature b/public/course/tests/behat/section_visibility.feature index dae2f92d02465..975819ac0abce 100644 --- a/public/course/tests/behat/section_visibility.feature +++ b/public/course/tests/behat/section_visibility.feature @@ -104,3 +104,128 @@ Feature: Show/hide course sections And I click on "Section 3" "link" in the "region-main" "region" And I should not see "Section 2" in the "region-main" "region" And I should see "Section 1" in the "region-main" "region" + + @javascript + Scenario Outline: Check if students can see sections: hidden and fully restricted + # Set visibility status: fully hidden or partly hidden + Given I navigate to "Settings" in current page administration + And I set the following fields to these values: + | Course layout | Show all sections on one page | + | Hidden sections | | + And I press "Save and display" + And I am on "Course 1" course homepage with editing mode on + And I hide section "2" + # Set availability status: fully restricted + And I edit the section "2" + And I expand all fieldsets + And I click on "Add restriction..." "button" + And I click on "Date" "button" in the "Add restriction..." "dialogue" + And I set the following fields to these values: + | direction | | + | x[year] | 2000 | + 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 + # Check if students can see the section + When I am on the "Course 1" course page logged in as student1 + Then I see "Section 2" in the "region-main" "region" + And I see "Test hidden forum 22 name" in the "region-main" "region" + + # For these tests, visibility is hidden, and unavailable sections are fully restricted. + Examples: + | hiddensectionssetting | availability | seename | seecontent | + | Hide completely | until | should not | should not | + | Show section names only | until | should not | should not | + + @javascript + Scenario Outline: Check if students can see sections: hidden and not fully restricted + # Set visibility status: fully hidden or partly hidden + Given I navigate to "Settings" in current page administration + And I set the following fields to these values: + | Course layout | Show all sections on one page | + | Hidden sections | | + And I press "Save and display" + And I am on "Course 1" course homepage with editing mode on + And I hide section "2" + # Set availability status: partly restricted or unrestricted + And I edit the section "2" + And I expand all fieldsets + And I click on "Add restriction..." "button" + And I click on "Date" "button" in the "Add restriction..." "dialogue" + And I set the following fields to these values: + | direction | | + | x[year] | 2000 | + And I press "Save changes" + And I log out + # Check if students can see the section + When I am on the "Course 1" course page logged in as student1 + Then I see "Section 2" in the "region-main" "region" + And I see "Test hidden forum 22 name" in the "region-main" "region" + + # For these tests, visibility is hidden, and unavailable sections are partly restricted. + Examples: + | hiddensectionssetting | availability | seename | seecontent | + | Hide completely | until | should not | should not | + | Hide completely | from | should not | should not | + | Show section names only | until | should | should not | + | Show section names only | from | should | should not | + + @javascript + Scenario Outline: Check if students can see sections: shown and fully restricted + # Set visibility status: shown + Given I navigate to "Settings" in current page administration + And I set the following fields to these values: + | Course layout | Show all sections on one page | + | Hidden sections | | + And I press "Save and display" + And I am on "Course 1" course homepage with editing mode on + # Set availability status: fully restricted + And I edit the section "2" + And I expand all fieldsets + And I click on "Add restriction..." "button" + And I click on "Date" "button" in the "Add restriction..." "dialogue" + And I set the following fields to these values: + | direction | | + | x[year] | 2000 | + 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 + # Check if students can see the section + When I am on the "Course 1" course page logged in as student1 + Then I see "Section 2" in the "region-main" "region" + And I see "Test hidden forum 22 name" in the "region-main" "region" + + # For this test, visibility is shown, and unavailable sections are fully restricted. + Examples: + | hiddensectionssetting | availability | seename | seecontent | + | Show section names only | until | should not | should not | + + @javascript + Scenario Outline: Check if students can see sections: shown and not fully restricted + # Set visibility status: shown + Given I navigate to "Settings" in current page administration + And I set the following fields to these values: + | Course layout | Show all sections on one page | + | Hidden sections | | + And I press "Save and display" + And I am on "Course 1" course homepage with editing mode on + # Set availability status: partly restricted or unrestricted + And I edit the section "2" + And I expand all fieldsets + And I click on "Add restriction..." "button" + And I click on "Date" "button" in the "Add restriction..." "dialogue" + And I set the following fields to these values: + | direction | | + | x[year] | 2000 | + And I press "Save changes" + And I log out + # Check if students can see the section + When I am on the "Course 1" course page logged in as student1 + Then I see "Section 2" in the "region-main" "region" + And I see "Test hidden forum 22 name" in the "region-main" "region" + + # For these tests, visibility is shown, and unavailable sections are partly restricted. + Examples: + | hiddensectionssetting | availability | seename | seecontent | + | Show section names only | until | should | should not | + | Show section names only | from | should | should | diff --git a/public/grade/report/user/classes/report/user.php b/public/grade/report/user/classes/report/user.php index 76f592ac005e9..7b9420628d3cc 100644 --- a/public/grade/report/user/classes/report/user.php +++ b/public/grade/report/user/classes/report/user.php @@ -545,7 +545,7 @@ private function fill_table_recursive(array &$element) { if (!$cm->uservisible) { // If there is 'availableinfo' text then it is only greyed // out and not entirely hidden. - if (!$cm->availableinfo) { + if (!$cm->visible || !$cm->availableinfo) { $hide = true; } } From 037f7d896f08ce7906350d3561df84cd3243c592 Mon Sep 17 00:00:00 2001 From: AMOS bot Date: Fri, 24 Apr 2026 00:08:18 +0000 Subject: [PATCH 036/309] Automatically generated installer lang files --- public/install/lang/no/install.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/install/lang/no/install.php b/public/install/lang/no/install.php index 149c6e4532275..442af5bbdf170 100644 --- a/public/install/lang/no/install.php +++ b/public/install/lang/no/install.php @@ -36,7 +36,7 @@ $string['clialreadyconfigured'] = 'Konfigurasjonsfilen config.php finnes allerede. Vennligst bruk admin/cli/install_database.php hvis du vil installere Moodle på denne portalen.'; $string['clialreadyinstalled'] = 'Filen config.php eksisterer allerede. Vennligst bruk admin/cli/install_database.php hvis du vil oppgradere Moodle på denne portalen.'; $string['cliinstallheader'] = 'Moodle {$a} kommandolinje installasjonsprogram'; -$string['clitablesexist'] = 'Databasetabeller finnes allerede, CLI installasjon kan ikke fortsette.'; +$string['clitablesexist'] = 'Databasetabellene finnes allerede; installasjonen av kommandolinjegrensesnittet (CLI) kan ikke fortsette.'; $string['databasehost'] = 'Databasevert'; $string['databasename'] = 'Databasenavn'; $string['databasetypehead'] = 'Velg databasedriver'; From dd53d611404df5b49db1acf10fbb4beae26f622f Mon Sep 17 00:00:00 2001 From: Alexander Van der Bellen Date: Mon, 13 Apr 2026 22:39:41 +0800 Subject: [PATCH 037/309] MDL-88428 core_cron: Allow exhausted tasks when running all failed --- public/lib/classes/cron.php | 6 ++++-- public/lib/tests/cron_test.php | 28 ++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/public/lib/classes/cron.php b/public/lib/classes/cron.php index 120b9ec33cfd3..197099085df11 100644 --- a/public/lib/classes/cron.php +++ b/public/lib/classes/cron.php @@ -352,6 +352,10 @@ public static function run_adhoc_task(int $taskid): void { /** * Execute all failed adhoc tasks. * + * This includes tasks that have exhausted their retry limits. + * It is intended for manual intervention from CLI or UI scripts, + * matching the behaviour of manually triggering individual failed tasks. + * * @param string|null $classname Run only tasks of this class */ public static function run_failed_adhoc_tasks(?string $classname = null): void { @@ -364,8 +368,6 @@ public static function run_failed_adhoc_tasks(?string $classname = null): void { $params['classname'] = \core\task\manager::get_canonical_class_name($classname); } - // Only rerun the failed tasks that allow to be re-tried or have the remaining attempts available. - $where .= ' AND (attemptsavailable > 0 OR attemptsavailable IS NULL)'; $tasks = $DB->get_records_sql("SELECT * from {task_adhoc} WHERE $where", $params); foreach ($tasks as $t) { self::run_adhoc_task($t->id); diff --git a/public/lib/tests/cron_test.php b/public/lib/tests/cron_test.php index 8e2a6bd384c39..f63438a3cc312 100644 --- a/public/lib/tests/cron_test.php +++ b/public/lib/tests/cron_test.php @@ -154,4 +154,32 @@ public function test_setup_user(): void { // phpcs:enable } + + /** + * Test running failed adhoc tasks ignores the attemptsavailable filter. + */ + public function test_run_failed_adhoc_tasks(): void { + global $DB; + $this->resetAfterTest(); + + require_once(__DIR__ . '/fixtures/task_fixtures.php'); + + // Create a standard test task. + $task = new \core\task\adhoc_test_task(); + \core\task\manager::queue_adhoc_task($task); + + // Force it into an exhausted, failed state. + $DB->set_field('task_adhoc', 'faildelay', 60); + $DB->set_field('task_adhoc', 'attemptsavailable', 0); + + $this->assertEquals(1, $DB->count_records('task_adhoc')); + + // Silence the output of the CLI runner. + ob_start(); + cron::run_failed_adhoc_tasks(); + ob_end_clean(); + + // The task should have run and been deleted. + $this->assertEquals(0, $DB->count_records('task_adhoc')); + } } From 509ba2349ee64025a2fa8006f133371e53ccf197 Mon Sep 17 00:00:00 2001 From: Huong Nguyen Date: Fri, 24 Apr 2026 11:12:14 +0700 Subject: [PATCH 038/309] MDL-87219 course: Remove failed Behat test --- .../behat/courseindex_completion.feature | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/public/course/format/tests/behat/courseindex_completion.feature b/public/course/format/tests/behat/courseindex_completion.feature index 32bf2c06bf712..c3e8fc496ee7e 100644 --- a/public/course/format/tests/behat/courseindex_completion.feature +++ b/public/course/format/tests/behat/courseindex_completion.feature @@ -150,23 +150,3 @@ Feature: Course index completion icons | 1 | False | When I am on the "C1" "Course" page logged in as "student1" And "Done" "icon" should exist in the "courseindex-content" "region" - - @javascript - Scenario: Activities are dimmed only when restricted - Given the following "activities" exist: - | activity | name | intro | course | idnumber | section | - | assign | Activity sample 2 | Restricted assignment description | C1 | sample2 | 1 | - And I log in as "teacher1" - And I am on "Course 1" course homepage with editing mode on - And I open "Activity sample 2" actions menu - And I click on "Edit settings" "link" in the "Activity sample 2" activity - 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 "Activity sample 1" - And I press "Save and return to course" - When I am on the "Course 1" "course" page logged in as "student1" - Then the "class" attribute of "//li[contains(@class, 'courseindex-item') and contains(., 'Activity sample 2')]" "xpath_element" should contain "dimmed" - And I toggle the manual completion state of "Activity sample 1" - And I should see "Activity sample 2" in the "courseindex-content" "region" - And the "class" attribute of "//li[contains(@class, 'courseindex-item') and contains(., 'Activity sample 2')]" "xpath_element" should not contain "dimmed" From 574cac553df303eef8ac9b34aabfbf63cee45daf Mon Sep 17 00:00:00 2001 From: AMOS bot Date: Sat, 25 Apr 2026 00:07:49 +0000 Subject: [PATCH 039/309] Automatically generated installer lang files --- public/install/lang/fr/install.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/install/lang/fr/install.php b/public/install/lang/fr/install.php index f544aa94134b6..00ad1756b5647 100644 --- a/public/install/lang/fr/install.php +++ b/public/install/lang/fr/install.php @@ -36,7 +36,7 @@ $string['clialreadyconfigured'] = 'Le fichier config.php existe déjà. Veuillez utiliser admin/cli/install_database.php pour installer Moodle sur ce site.'; $string['clialreadyinstalled'] = 'Le fichier config.php existe déjà. Veuillez utiliser admin/cli/install_database.php si vous désirez mettre à jour ce site Moodle.'; $string['cliinstallheader'] = 'Programme d’installation de Moodle {$a} en ligne de commande'; -$string['clitablesexist'] = 'Les tables de la base de données sont déjà présentes ; l’installation en ligne de commande ne peut pas continuer.'; +$string['clitablesexist'] = 'Les tables de la base de données sont déjà présentes ; l’installation en ligne de commande (CLI) ne peut pas continuer.'; $string['databasehost'] = 'Serveur de base de données'; $string['databasename'] = 'Nom de la base de données'; $string['databasetypehead'] = 'Sélectionner un pilote de base de données'; From 0f86534d400bd789bb80ddb0d566fcaaa8cd9a2b Mon Sep 17 00:00:00 2001 From: Huong Nguyen Date: Mon, 27 Apr 2026 09:40:16 +0700 Subject: [PATCH 040/309] weekly release 5.1.4+ --- public/version.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/version.php b/public/version.php index c1d14cbd6bbcf..493c776f0faf3 100644 --- a/public/version.php +++ b/public/version.php @@ -29,9 +29,9 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2025100604.00; // 20251006 = branching date YYYYMMDD - do not modify! +$version = 2025100604.01; // 20251006 = branching date YYYYMMDD - do not modify! // RR = release increments - 00 in DEV branches. // .XX = incremental changes. -$release = '5.1.4 (Build: 20260420)'; // Human-friendly version name +$release = '5.1.4+ (Build: 20260427)'; // Human-friendly version name $branch = '501'; // This version's branch. $maturity = MATURITY_STABLE; // This version's maturity level. From 611388f811abd96b093282a59fb462b52ac440ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luca=20B=C3=B6sch?= Date: Fri, 3 Apr 2026 13:13:01 +0200 Subject: [PATCH 041/309] MDL-88369 backup: Multilang course name in recycle bin. --- public/admin/tool/recyclebin/index.php | 3 ++- .../recyclebin/tests/behat/basic_functionality.feature | 9 ++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/public/admin/tool/recyclebin/index.php b/public/admin/tool/recyclebin/index.php index 24334b184de97..dd6e993765db1 100644 --- a/public/admin/tool/recyclebin/index.php +++ b/public/admin/tool/recyclebin/index.php @@ -92,6 +92,7 @@ if ($action == 'restore' || $action == 'delete') { $itemid = required_param('itemid', PARAM_INT); $item = $recyclebin->get_item($itemid); + $item->name = format_string($item->name, options: ['context' => $context]); } switch ($action) { @@ -182,7 +183,7 @@ $row = array(); // Build item name. - $name = $item->name; + $name = format_string($item->name, options: ['context' => $context]); if ($context->contextlevel == CONTEXT_COURSE) { if (isset($modules[$item->module])) { $mod = $modules[$item->module]; diff --git a/public/admin/tool/recyclebin/tests/behat/basic_functionality.feature b/public/admin/tool/recyclebin/tests/behat/basic_functionality.feature index 307b328e49e84..8e6e3bd5b7fbb 100644 --- a/public/admin/tool/recyclebin/tests/behat/basic_functionality.feature +++ b/public/admin/tool/recyclebin/tests/behat/basic_functionality.feature @@ -11,9 +11,9 @@ Feature: Basic recycle bin functionality | student1 | Student | 1 | student@asd.com | | student2 | Student | 2 | student2@asd.com | And the following "courses" exist: - | fullname | shortname | initsections | - | Course 1 | C1 | 1 | - | Course 2 | C2 | 0 | + | fullname | shortname | initsections | + | Course 1 | C1 | 1 | + | CourseCurso 2 | C2 | 0 | And the following "activities" exist: | activity | course | section | name | intro | | assign | C1 | 1 | Test assign 1 | Test 1 | @@ -78,6 +78,8 @@ Feature: Basic recycle bin functionality @javascript Scenario: Restore a deleted course Given I log in as "admin" + And the "multilang" filter is "on" + And the "multilang" filter applies to "content and headings" And I go to the courses management page And I click on "delete" action for "Course 2" in management course listing And I press "Delete" @@ -89,6 +91,7 @@ Feature: Basic recycle bin functionality And I should not see "Course 2" When I navigate to "Recycle bin" in current page administration Then I should see "Course 2" + But I should not see "Curso 2" And I should see "Contents will be permanently deleted after 14 days" And I click on "Restore" "link" in the "region-main" "region" And I should see "'Course 2' has been restored" From f71050f487637bd1f5e2b92ed19171ca82b5a95d Mon Sep 17 00:00:00 2001 From: Andi Permana Date: Mon, 27 Apr 2026 13:33:12 +0700 Subject: [PATCH 042/309] MDL-88424 libraries: fix PHP 8.1 stripos() null deprecation in XHProf --- public/lib/xhprof/readme_moodle.txt | 2 ++ public/lib/xhprof/xhprof_lib/utils/xhprof_lib.php | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/public/lib/xhprof/readme_moodle.txt b/public/lib/xhprof/readme_moodle.txt index aecfc85a28945..5557af58609bf 100644 --- a/public/lib/xhprof/readme_moodle.txt +++ b/public/lib/xhprof/readme_moodle.txt @@ -16,6 +16,7 @@ Our changes: Look for "moodle" in code (commit #3 - always mimic from current m * xhprof_html/typeahead.php -| * xhprof_html/css/xhprof.css: Minor tweaks to report styles * xhprof_lib/utils/callgraph_utils.php: Modified to use $CFG->pathtodot + * xhprof_lib/utils/xhprof_lib.php: fix PHP 8.1 stripos() null deprecation in xhprof_parse_parent_child() TODO: * improvements to the listing mode: various commodity details like: @@ -43,3 +44,4 @@ TODO: 20211209 - MDL-71705 - Ilya Tregubov (ilyatregubov): Upgrade to 2.3.5 release; 20221214 - MDL-76397 - Stevani Andolo (stevandoMoodle): Upgrade to 2.3.9 release; 20251024 - MDL-86235 - Andi Permana (andimendunia): Add table-hover class to profiling table +20260424 - MDL-88424 - Andi Permana (andimendunia): Fix PHP 8.1 stripos() null deprecation in xhprof_parse_parent_child() diff --git a/public/lib/xhprof/xhprof_lib/utils/xhprof_lib.php b/public/lib/xhprof/xhprof_lib/utils/xhprof_lib.php index 507e66949836d..0159c8b88a3fa 100644 --- a/public/lib/xhprof/xhprof_lib/utils/xhprof_lib.php +++ b/public/lib/xhprof/xhprof_lib/utils/xhprof_lib.php @@ -155,7 +155,10 @@ function xhprof_parse_parent_child($parent_child) { return $ret; } - return array(null, $ret[0]); + // Start moodle modification: fix PHP 8.1 stripos() null deprecation. + // return array(null, $ret[0]); + return array('', $ret[0]); + // End moodle modification. } /** From e2a44023ffb697d4191a976630c38e765118a634 Mon Sep 17 00:00:00 2001 From: Andi Permana Date: Tue, 7 Apr 2026 09:38:50 +0700 Subject: [PATCH 043/309] MDL-87555 enrol_fee: add scheduled task to process enrolment expirations --- .../fee/classes/task/process_expirations.php | 44 ++++ public/enrol/fee/db/tasks.php | 38 ++++ public/enrol/fee/lang/en/enrol_fee.php | 1 + public/enrol/fee/tests/fee_test.php | 214 ++++++++++++++++++ public/enrol/fee/version.php | 2 +- 5 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 public/enrol/fee/classes/task/process_expirations.php create mode 100644 public/enrol/fee/db/tasks.php create mode 100644 public/enrol/fee/tests/fee_test.php diff --git a/public/enrol/fee/classes/task/process_expirations.php b/public/enrol/fee/classes/task/process_expirations.php new file mode 100644 index 0000000000000..d51983d520e6b --- /dev/null +++ b/public/enrol/fee/classes/task/process_expirations.php @@ -0,0 +1,44 @@ +. + +namespace enrol_fee\task; + +/** + * Process expirations task. + * + * @package enrol_fee + * @copyright 2026 Andi Permana + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class process_expirations extends \core\task\scheduled_task { + /** + * Name for this task. + * + * @return string + */ + public function get_name() { + return get_string('processexpirationstask', 'enrol_fee'); + } + + /** + * Run task for processing expirations. + */ + public function execute() { + $enrol = enrol_get_plugin('fee'); + $trace = new \text_progress_trace(); + $enrol->process_expirations($trace); + } +} diff --git a/public/enrol/fee/db/tasks.php b/public/enrol/fee/db/tasks.php new file mode 100644 index 0000000000000..13cadc6d3c636 --- /dev/null +++ b/public/enrol/fee/db/tasks.php @@ -0,0 +1,38 @@ +. + +/** + * Task definition for enrol_fee. + * + * @package enrol_fee + * @copyright 2026 Andi Permana + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$tasks = [ + [ + 'classname' => '\enrol_fee\task\process_expirations', + 'blocking' => 0, + 'minute' => '*', + 'hour' => '*', + 'day' => '*', + 'month' => '*', + 'dayofweek' => '*', + 'disabled' => 0, + ], +]; diff --git a/public/enrol/fee/lang/en/enrol_fee.php b/public/enrol/fee/lang/en/enrol_fee.php index dc1b89aea520b..115235d7d3c4b 100644 --- a/public/enrol/fee/lang/en/enrol_fee.php +++ b/public/enrol/fee/lang/en/enrol_fee.php @@ -50,6 +50,7 @@ $string['pluginname'] = 'Enrolment on payment'; $string['pluginname_desc'] = 'The enrolment on payment enrolment method allows you to set up courses requiring a payment. If the fee for any course is set to zero, then students are not asked to pay for entry. There is a site-wide fee that you set here as a default for the whole site and then a course setting that you can set for each course individually. The course fee overrides the site fee.'; $string['privacy:metadata'] = 'The enrolment on payment enrolment plugin does not store any personal data.'; +$string['processexpirationstask'] = 'Process enrolment expirations'; $string['purchasedescription'] = 'Enrolment in course {$a}'; $string['sendpaymentbutton'] = 'Select payment type'; $string['status'] = 'Allow enrolment on payment enrolments'; diff --git a/public/enrol/fee/tests/fee_test.php b/public/enrol/fee/tests/fee_test.php new file mode 100644 index 0000000000000..cded4dec3f12c --- /dev/null +++ b/public/enrol/fee/tests/fee_test.php @@ -0,0 +1,214 @@ +. + +namespace enrol_fee; + +/** + * enrol_fee tests. + * + * @package enrol_fee + * @copyright 2026 Andi Permana + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \enrol_fee_plugin + */ +final class fee_test extends \advanced_testcase { + /** + * Enable the enrol_fee plugin. + */ + protected function enable_plugin(): void { + $enabled = enrol_get_plugins(true); + $enabled['fee'] = true; + set_config('enrol_plugins_enabled', implode(',', array_keys($enabled))); + } + + /** + * Disable the enrol_fee plugin. + */ + protected function disable_plugin(): void { + $enabled = enrol_get_plugins(true); + unset($enabled['fee']); + set_config('enrol_plugins_enabled', implode(',', array_keys($enabled))); + } + + /** + * Test basic plugin state and default config. + */ + public function test_basics(): void { + $this->assertFalse(enrol_is_enabled('fee')); + $plugin = enrol_get_plugin('fee'); + $this->assertInstanceOf('enrol_fee_plugin', $plugin); + $this->assertEquals(ENROL_EXT_REMOVED_SUSPENDNOROLES, get_config('enrol_fee', 'expiredaction')); + } + + /** + * Test that sync does not error when there is nothing to do. + */ + public function test_sync_nothing(): void { + $this->resetAfterTest(); + $this->enable_plugin(); + + $feeplugin = enrol_get_plugin('fee'); + $feeplugin->sync(new \null_progress_trace()); + } + + /** + * Test that process_expirations() correctly handles all expiry actions: + * - ENROL_EXT_REMOVED_KEEP: no changes to expired enrolments. + * - ENROL_EXT_REMOVED_SUSPENDNOROLES: expired active enrolments are suspended and roles removed. + * - ENROL_EXT_REMOVED_UNENROL: expired enrolments are fully removed from user_enrolments. + * + * @covers \enrol_fee_plugin::sync + */ + public function test_expired(): void { + global $DB; + $this->resetAfterTest(); + $this->enable_plugin(); + + /** @var \enrol_fee_plugin $feeplugin */ + $feeplugin = enrol_get_plugin('fee'); + $manualplugin = enrol_get_plugin('manual'); + $this->assertNotEmpty($manualplugin); + + $now = time(); + $trace = new \null_progress_trace(); + + // Prepare roles. + $studentrole = $DB->get_record('role', ['shortname' => 'student']); + $this->assertNotEmpty($studentrole); + $teacherrole = $DB->get_record('role', ['shortname' => 'teacher']); + $this->assertNotEmpty($teacherrole); + $managerrole = $DB->get_record('role', ['shortname' => 'manager']); + $this->assertNotEmpty($managerrole); + + // Prepare users. + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + $user3 = $this->getDataGenerator()->create_user(); + $user4 = $this->getDataGenerator()->create_user(); + + // Prepare courses. + $course1 = $this->getDataGenerator()->create_course(); + $course2 = $this->getDataGenerator()->create_course(); + $context1 = \context_course::instance($course1->id); + $context2 = \context_course::instance($course2->id); + + // Add fee enrolment instances. + $instanceid1 = $feeplugin->add_instance($course1, [ + 'status' => ENROL_INSTANCE_ENABLED, + 'roleid' => $studentrole->id, + 'cost' => 10, + 'currency' => 'USD', + ]); + $instanceid2 = $feeplugin->add_instance($course2, [ + 'status' => ENROL_INSTANCE_ENABLED, + 'roleid' => $studentrole->id, + 'cost' => 10, + 'currency' => 'USD', + ]); + $instanceid2b = $feeplugin->add_instance($course2, [ + 'status' => ENROL_INSTANCE_ENABLED, + 'roleid' => $teacherrole->id, + 'cost' => 10, + 'currency' => 'USD', + ]); + + $instance1 = $DB->get_record('enrol', ['id' => $instanceid1], '*', MUST_EXIST); + $instance2 = $DB->get_record('enrol', ['id' => $instanceid2], '*', MUST_EXIST); + $instance2b = $DB->get_record('enrol', ['id' => $instanceid2b], '*', MUST_EXIST); + + // Enrol a user via manual in course2 to verify it is not affected by fee expiry sync. + $maninstance2 = $DB->get_record('enrol', ['courseid' => $course2->id, 'enrol' => 'manual'], '*', MUST_EXIST); + $manualplugin->enrol_user($maninstance2, $user1->id, $teacherrole->id); + + $this->assertEquals(1, $DB->count_records('user_enrolments')); + $this->assertEquals(1, $DB->count_records('role_assignments')); + + // Enrol users via fee: + // course1: user1 active (no end), user2 active (no end), user3 expired 60s ago. + $feeplugin->enrol_user($instance1, $user1->id, $studentrole->id); + $feeplugin->enrol_user($instance1, $user2->id, $studentrole->id); + $feeplugin->enrol_user($instance1, $user3->id, $studentrole->id, 0, $now - 60); + + // Course2: user1 no end, user2 expired 1h ago, user3 ends in 1h, user1 as teacher expired 60s ago. + $feeplugin->enrol_user($instance2, $user1->id, $studentrole->id, 0, 0); + $feeplugin->enrol_user($instance2, $user2->id, $studentrole->id, 0, $now - 60 * 60); + $feeplugin->enrol_user($instance2, $user3->id, $studentrole->id, 0, $now + 60 * 60); + $feeplugin->enrol_user($instance2b, $user1->id, $teacherrole->id, $now - 60 * 60 * 24 * 7, $now - 60); + $feeplugin->enrol_user($instance2b, $user4->id, $teacherrole->id); + + // Manually assign manager role to user3 in course1. + role_assign($managerrole->id, $user3->id, $context1->id); + + $this->assertEquals(9, $DB->count_records('user_enrolments')); + $this->assertEquals(9, $DB->count_records('role_assignments')); + $this->assertEquals(6, $DB->count_records('role_assignments', ['roleid' => $studentrole->id])); + $this->assertEquals(2, $DB->count_records('role_assignments', ['roleid' => $teacherrole->id])); + $this->assertEquals(1, $DB->count_records('role_assignments', ['roleid' => $managerrole->id])); + + // Test 1: ENROL_EXT_REMOVED_KEEP — nothing should change. + $feeplugin->set_config('expiredaction', ENROL_EXT_REMOVED_KEEP); + + $this->assertSame(0, $feeplugin->sync($trace)); + $this->assertEquals(9, $DB->count_records('user_enrolments')); + $this->assertEquals(9, $DB->count_records('role_assignments')); + + // Test 2: ENROL_EXT_REMOVED_SUSPENDNOROLES — suspend expired + remove roles. + $feeplugin->set_config('expiredaction', ENROL_EXT_REMOVED_SUSPENDNOROLES); + + $feeplugin->sync($trace); + // User_enrolments count stays the same (suspend, not unenrol). + $this->assertEquals(9, $DB->count_records('user_enrolments')); + // Roles removed for the 3 expired enrolments (user3/course1, user2/course2, user1-teacher/course2). + $this->assertEquals(6, $DB->count_records('role_assignments')); + $this->assertEquals(4, $DB->count_records('role_assignments', ['roleid' => $studentrole->id])); + $this->assertEquals(1, $DB->count_records('role_assignments', ['roleid' => $teacherrole->id])); + // Expired users should no longer have roles in their respective expired enrolment contexts. + $this->assertFalse($DB->record_exists('role_assignments', [ + 'contextid' => $context1->id, 'userid' => $user3->id, 'roleid' => $studentrole->id, + ])); + $this->assertFalse($DB->record_exists('role_assignments', [ + 'contextid' => $context2->id, 'userid' => $user2->id, 'roleid' => $studentrole->id, + ])); + $this->assertFalse($DB->record_exists('role_assignments', [ + 'contextid' => $context2->id, 'userid' => $user1->id, 'roleid' => $teacherrole->id, + ])); + // User1's student role in course2 (non-expired) must still be there. + $this->assertTrue($DB->record_exists('role_assignments', [ + 'contextid' => $context2->id, 'userid' => $user1->id, 'roleid' => $studentrole->id, + ])); + + // Test 3: ENROL_EXT_REMOVED_UNENROL — fully remove expired enrolments. + $feeplugin->set_config('expiredaction', ENROL_EXT_REMOVED_UNENROL); + + // Re-assign the roles that were stripped in test 2 so the count baseline is accurate. + role_assign($studentrole->id, $user3->id, $context1->id); + role_assign($studentrole->id, $user2->id, $context2->id); + role_assign($teacherrole->id, $user1->id, $context2->id); + $this->assertEquals(9, $DB->count_records('user_enrolments')); + $this->assertEquals(9, $DB->count_records('role_assignments')); + + $feeplugin->sync($trace); + // 3 expired enrolments removed: user3/instance1, user2/instance2, user1/instance2b. + $this->assertEquals(6, $DB->count_records('user_enrolments')); + $this->assertFalse($DB->record_exists('user_enrolments', ['enrolid' => $instance1->id, 'userid' => $user3->id])); + $this->assertFalse($DB->record_exists('user_enrolments', ['enrolid' => $instance2->id, 'userid' => $user2->id])); + $this->assertFalse($DB->record_exists('user_enrolments', ['enrolid' => $instance2b->id, 'userid' => $user1->id])); + // Roles cleaned up too. + $this->assertEquals(5, $DB->count_records('role_assignments')); + $this->assertEquals(4, $DB->count_records('role_assignments', ['roleid' => $studentrole->id])); + $this->assertEquals(1, $DB->count_records('role_assignments', ['roleid' => $teacherrole->id])); + } +} diff --git a/public/enrol/fee/version.php b/public/enrol/fee/version.php index b5a473d81ccee..a1f9d6bd36d6a 100644 --- a/public/enrol/fee/version.php +++ b/public/enrol/fee/version.php @@ -24,6 +24,6 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2025100600; // The current plugin version (Date: YYYYMMDDXX). +$plugin->version = 2025100601; // The current plugin version (Date: YYYYMMDDXX). $plugin->requires = 2025092600; // Requires this Moodle version. $plugin->component = 'enrol_fee'; // Full name of the plugin (used for diagnostics). From 146e1927c9a498ba82d73751f62aaacff101a227 Mon Sep 17 00:00:00 2001 From: Philipp Memmel Date: Thu, 26 Feb 2026 09:27:22 +0100 Subject: [PATCH 044/309] MDL-88083 mod_assign: Do not count assignments of unenrolled users @Co-authored-by: Rajneel Totaram --- public/mod/assign/locallib.php | 28 ++++++------ .../tests/behat/submissions_count.feature | 45 +++++++++++++++++++ public/mod/assign/tests/locallib_test.php | 38 ++++++++++++++++ 3 files changed, 96 insertions(+), 15 deletions(-) create mode 100644 public/mod/assign/tests/behat/submissions_count.feature diff --git a/public/mod/assign/locallib.php b/public/mod/assign/locallib.php index 3d4a4d8462962..bd33ff2609dce 100644 --- a/public/mod/assign/locallib.php +++ b/public/mod/assign/locallib.php @@ -2803,7 +2803,6 @@ public function count_submissions_with_status_and_groups(string $status, array $ if ($this->get_instance()->teamsubmission) { // Team submission will filter by groupid. - $gsql = ''; $select .= " AND userid = 0 "; if (!empty($groupids)) { // If there are groups, we need to filter by them. @@ -2814,21 +2813,20 @@ public function count_submissions_with_status_and_groups(string $status, array $ return $DB->count_records_select('assign_submission', $select, $params, 'COUNT(userid)'); } else { - // Individual submission will filter using groups_members. - if (empty($groupids)) { - return $DB->count_records_select('assign_submission', $select, $params, 'COUNT(userid)'); - } - - // If there are groups, we need to filter by them. - [$gsql, $gparams] = $DB->get_in_or_equal($groupids, SQL_PARAMS_NAMED); - $sql = "SELECT COUNT(s.userid) - FROM {assign_submission} s, {groups_members} gm - WHERE $select AND - s.userid = gm.userid AND (gm.groupid $gsql OR gm.groupid = 0)"; - $params = array_merge($params, $gparams); + // Individual submission: only count submissions from users currently enrolled in the course. + // When $groupids is non-empty, it also filters by group membership. + [$esql, $eparams] = get_enrolled_sql($this->get_context(), '', $groupids, false); + $params += $eparams; + $sql = "SELECT COUNT(DISTINCT s.userid) + FROM {assign_submission} s + JOIN ($esql) e ON e.id = s.userid + WHERE s.assignment = :assignid + AND s.status = :submissionstatus + AND s.latest = 1 + AND s.timemodified IS NOT NULL"; + + return $DB->count_records_sql($sql, $params); } - - return $DB->count_records_sql($sql, $params); } /** diff --git a/public/mod/assign/tests/behat/submissions_count.feature b/public/mod/assign/tests/behat/submissions_count.feature new file mode 100644 index 0000000000000..df5100965ea52 --- /dev/null +++ b/public/mod/assign/tests/behat/submissions_count.feature @@ -0,0 +1,45 @@ +@mod @mod_assign +Feature: Assignment submission count includes submissions from active students in the course + In order to show accurate submission counts + As a teacher + I should see submissions from active students in the course + + Background: + Given the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | 1 | teacher1@example.com | + | student1 | Student | 1 | student1@example.com | + | student2 | Student | 2 | student2@example.com | + | student3 | Student | 3 | student3@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 | + | student2 | C1 | student | + | student3 | C1 | student | + And the following "activities" exist: + | activity | course | name | submissiondrafts | assignsubmission_onlinetext_enabled | + | assign | C1 | Assignment 1 | 0 | 1 | + And the following "mod_assign > submissions" exist: + | assign | user | onlinetext | + | Assignment 1 | student1 | Submission by student 1 | + | Assignment 1 | student2 | Submission by student 2 | + | Assignment 1 | student3 | Submission by student 3 | + + @javascript + Scenario: Submission count excludes submissions from unenrolled students + Given I am on the "C1" "Course" page logged in as "teacher1" + And I navigate to course participants + And I click on "Unenrol" "icon" in the "student3" "table_row" + And I click on "Unenrol" "button" in the "Unenrol" "dialogue" + And I should not see "Student 3" in the "participants" "table" + When I am on the "Course 1" "course > activities > assign" page + Then the following should exist in the "Table listing all Assignment activities" table: + | Name | Submissions | + | Assignment 1 | 2 of 2 | + And I am on "Course 1" course homepage + And I follow "Assignment 1" + And I should see "2" in the "Submitted" "table_row" diff --git a/public/mod/assign/tests/locallib_test.php b/public/mod/assign/tests/locallib_test.php index e16ea1212580a..1e6846276792f 100644 --- a/public/mod/assign/tests/locallib_test.php +++ b/public/mod/assign/tests/locallib_test.php @@ -1661,6 +1661,44 @@ public function test_count_submissions_with_status_and_groups_team_submission(): )); } + /** + * Tests that unenrolled users are not included in the count of submissions with status and groups. + * + * @covers \assign::count_submissions_with_status_and_groups + */ + public function test_count_submissions_with_status_and_groups_unenrolled_users(): void { + global $DB; + + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + $assign = $this->create_instance($course, [ + 'assignsubmission_onlinetext_enabled' => 1, + ]); + + $this->getDataGenerator()->create_and_enrol($course, 'editingteacher'); + + // Create 3 students and have them all submit. + $students = []; + for ($i = 0; $i < 3; $i++) { + $student = $this->getDataGenerator()->create_and_enrol($course, 'student'); + $this->add_submission($student, $assign); + $this->submit_for_grading($student, $assign); + $students[] = $student; + } + + // All 3 enrolled students have submitted. + $this->assertEquals(3, $assign->count_submissions_with_status_and_groups(ASSIGN_SUBMISSION_STATUS_SUBMITTED)); + + // Unenrol one student. + $manualenrol = enrol_get_plugin('manual'); + $enrolinstance = $DB->get_record('enrol', ['courseid' => $course->id, 'enrol' => 'manual'], '*', MUST_EXIST); + $manualenrol->unenrol_user($enrolinstance, $students[0]->id); + + // Only 2 submissions should be counted now because the unenrolled user's submission must be excluded. + $this->assertEquals(2, $assign->count_submissions_with_status_and_groups(ASSIGN_SUBMISSION_STATUS_SUBMITTED)); + } + public function test_count_submissions_need_grading_with_groups(): void { $this->resetAfterTest(); From 23a8d74316a4bb6d39c84ac25f9e9d4b952a19c9 Mon Sep 17 00:00:00 2001 From: Paul Holden Date: Wed, 29 Apr 2026 10:10:41 +0100 Subject: [PATCH 045/309] MDL-88586 block_rss_client: fix comparison of course ID parameter. --- public/blocks/rss_client/viewfeed.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/blocks/rss_client/viewfeed.php b/public/blocks/rss_client/viewfeed.php index 935182c1cf7d9..bd7f72d45aa43 100644 --- a/public/blocks/rss_client/viewfeed.php +++ b/public/blocks/rss_client/viewfeed.php @@ -31,7 +31,7 @@ $courseid = optional_param('courseid', 0, PARAM_INT); $rssid = required_param('rssid', PARAM_INT); -if ($courseid = SITEID) { +if ($courseid == SITEID) { $courseid = 0; } if ($courseid) { From 4e0b090308ce67869291ef0908f5bf1e5260089b Mon Sep 17 00:00:00 2001 From: Muhammad Arnaldo Date: Mon, 27 Apr 2026 14:12:34 +0700 Subject: [PATCH 046/309] MDL-88400 backup: fix backup failing for long shortnames --- .../util/dbops/backup_plan_dbops.class.php | 3 + .../dbops/tests/backup_plan_dbops_test.php | 125 ++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 public/backup/util/dbops/tests/backup_plan_dbops_test.php diff --git a/public/backup/util/dbops/backup_plan_dbops.class.php b/public/backup/util/dbops/backup_plan_dbops.class.php index 4dae3e5d56370..6d15b05606b9f 100644 --- a/public/backup/util/dbops/backup_plan_dbops.class.php +++ b/public/backup/util/dbops/backup_plan_dbops.class.php @@ -241,6 +241,9 @@ public static function get_default_backup_filename($format, $type, $id, $users, } $shortname = str_replace(' ', '_', $shortname); $shortname = core_text::strtolower(trim(clean_filename($shortname), '_')); + // Truncate by bytes to keep total filename within the 255-byte OS limit. + // Use 170 bytes as a conservative budget for the shortname. + $shortname = core_text::str_max_bytes($shortname, 170); } // The name will always contain the ID, but we append the course short name if requested. diff --git a/public/backup/util/dbops/tests/backup_plan_dbops_test.php b/public/backup/util/dbops/tests/backup_plan_dbops_test.php new file mode 100644 index 0000000000000..0bf568056ee54 --- /dev/null +++ b/public/backup/util/dbops/tests/backup_plan_dbops_test.php @@ -0,0 +1,125 @@ +. + +namespace core_backup; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php'); + +/** + * Tests for backup_plan_dbops. + * + * @package core_backup + * @category test + * @copyright 2026 Muhammad Arnaldo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \backup_plan_dbops::get_default_backup_filename + */ +final class backup_plan_dbops_test extends \advanced_testcase { + /** + * Test that get_default_backup_filename returns a valid filename within the OS 255-byte limit. + * + * @param string $shortname The course shortname. + * @dataProvider get_default_backup_filename_provider + */ + public function test_get_default_backup_filename(string $shortname): void { + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(['shortname' => $shortname]); + + $filename = \backup_plan_dbops::get_default_backup_filename( + \backup::FORMAT_MOODLE, + \backup::TYPE_1COURSE, + $course->id, + true, + false, + ); + + $this->assertLessThanOrEqual(255, strlen($filename)); + $this->assertTrue(mb_check_encoding($filename, 'UTF-8')); + } + + /** + * Data provider for test_get_default_backup_filename. + * + * @return array + */ + public static function get_default_backup_filename_provider(): array { + // 255 ASCII chars = 255 bytes = maximum shortname length allowed by the DB column. + $ascii255 = str_repeat('a', 255); + + // 255 Cyrillic chars = 510 bytes (each Cyrillic char is 2 bytes in UTF-8). + $cyrillic255 = str_repeat('и', 255); + + // 255 CJK chars = 765 bytes (each CJK char is 3 bytes in UTF-8). + $cjk255 = str_repeat('中', 255); + + // 1 ASCII byte + 254 Cyrillic = 255 chars = 509 bytes. The odd byte shifts the Cyrillic block + // so a byte-based substr() splits mid-character, producing invalid UTF-8. + $oddalignedcyrillic = 'a' . str_repeat('и', 254); + + return [ + 'short ascii shortname' => ['CS101'], + '255 ascii chars - max shortname length' => [$ascii255], + '255 cyrillic chars' => [$cyrillic255], + '255 cjk chars' => [$cjk255], + 'odd-aligned ascii+cyrillic' => [$oddalignedcyrillic], + 'shortname with spaces' => ['My Course Name Here'], + ]; + } + + /** + * Test that a short ASCII shortname is preserved and not truncated. + */ + public function test_get_default_backup_filename_preserves_short_name(): void { + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(['shortname' => 'CS101']); + + $filename = \backup_plan_dbops::get_default_backup_filename( + \backup::FORMAT_MOODLE, + \backup::TYPE_1COURSE, + $course->id, + true, + false, + ); + + $this->assertStringContainsString('cs101', strtolower($filename)); + } + + /** + * Test that when useidonly is true, the shortname does not appear in the filename. + */ + public function test_get_default_backup_filename_useidonly_excludes_shortname(): void { + $this->resetAfterTest(); + + $shortname = 'UniqueShortname123'; + $course = $this->getDataGenerator()->create_course(['shortname' => $shortname]); + + $filename = \backup_plan_dbops::get_default_backup_filename( + \backup::FORMAT_MOODLE, + \backup::TYPE_1COURSE, + $course->id, + true, + false, + true, + ); + + $this->assertStringNotContainsString(strtolower($shortname), $filename); + } +} From 18a3c0986db5407fab6495e9af03446566f1a7f0 Mon Sep 17 00:00:00 2001 From: Paul Holden Date: Fri, 6 Mar 2026 07:22:04 +0000 Subject: [PATCH 047/309] MDL-88136 admin: fix invalid port list configuration error. --- public/lib/adminlib.php | 8 +++++--- public/lib/tests/admintree_test.php | 32 ++++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/public/lib/adminlib.php b/public/lib/adminlib.php index 5788cf19feab1..3e42ebcde0323 100644 --- a/public/lib/adminlib.php +++ b/public/lib/adminlib.php @@ -4303,8 +4303,9 @@ public function validate($data) { $badentries[] = $entry; } - if ($badentries) { - return get_string('validateerrorlist', 'admin', join(', ', $badentries)); + if (count($badentries) > 0) { + $badentries = implode(get_string('listsep', 'core_langconfig') . ' ', $badentries); + return get_string('validateerrorlist', 'admin', $badentries); } return true; } @@ -4421,7 +4422,8 @@ public function validate($data) { $badentries[] = $port; } } - if ($badentries) { + if (count($badentries) > 0) { + $badentries = implode(get_string('listsep', 'core_langconfig') . ' ', $badentries); return get_string('validateerrorlist', 'admin', $badentries); } return true; diff --git a/public/lib/tests/admintree_test.php b/public/lib/tests/admintree_test.php index a2512e11ec544..34483f6da05dc 100644 --- a/public/lib/tests/admintree_test.php +++ b/public/lib/tests/admintree_test.php @@ -382,6 +382,8 @@ public function test_emptydurationvalue(): void { * * For testing the admin settings element only. Test for blocked hosts functionality can be found * in lib/tests/curl_security_helper_test.php + * + * @covers \admin_setting_configmixedhostiplist */ public function test_mixedhostiplist(): void { $this->resetAfterTest(); @@ -427,10 +429,38 @@ public function test_mixedhostiplist(): void { } // Invalid settings. - $this->assertEquals('These entries are invalid: nonvalid site name', $adminsetting->write_setting('nonvalid site name')); + $this->assertEquals('These entries are invalid: cat dog, fish horse', $adminsetting->write_setting("cat dog\nfish horse")); $this->assertEquals('Empty lines are not valid', $adminsetting->write_setting("localhost\n")); } + /** + * Test settings for configportlist + * + * @covers \admin_setting_configportlist + */ + public function test_portlist(): void { + $this->resetAfterTest(); + + $adminsetting = new \admin_setting_configportlist('abc_cde/portlist', 'some desc', '', ''); + + // Test valid settings. + $validsimplesettings = [ + '443', + "80\n443", + ]; + + foreach ($validsimplesettings as $setting) { + $errormessage = $adminsetting->write_setting($setting); + $this->assertEmpty($errormessage, $errormessage); + $this->assertSame($setting, get_config('abc_cde', 'portlist')); + $this->assertSame($setting, $adminsetting->get_setting()); + } + + // Invalid settings. + $this->assertEquals('These entries are invalid: cat, dog', $adminsetting->write_setting("cat\ndog")); + $this->assertEquals('Empty lines are not valid', $adminsetting->write_setting("80\n")); + } + /** * Verifies the $ADMIN global (adminroot cache) is properly reset when changing users, which might occur naturally during cron. */ From e4ac4f37e423194c31c0957fb51c243c2ffbc8bf Mon Sep 17 00:00:00 2001 From: Paul Holden Date: Thu, 30 Apr 2026 15:14:56 +0100 Subject: [PATCH 048/309] MDL-88605 user: update calendar preferences for correct user. --- public/user/calendar.php | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/public/user/calendar.php b/public/user/calendar.php index 5f7ec2bef13d5..b7d3f9e2bab7f 100644 --- a/public/user/calendar.php +++ b/public/user/calendar.php @@ -45,11 +45,12 @@ // Create form. $calendarform = new core_user\form\calendar_form(null, array('userid' => $user->id)); -$user->timeformat = get_user_preferences('calendar_timeformat', ''); -$user->startwday = calendar_get_starting_weekday(); -$user->maxevents = get_user_preferences('calendar_maxevents', $defaultmaxevents); -$user->lookahead = get_user_preferences('calendar_lookahead', $defaultlookahead); -$user->persistflt = get_user_preferences('calendar_persistflt', 0); +$user->timeformat = get_user_preferences('calendar_timeformat', '', $user); +$user->startwday = get_user_preferences('calendar_startwday', calendar_get_starting_weekday(), $user); +$user->maxevents = get_user_preferences('calendar_maxevents', $defaultmaxevents, $user); +$user->lookahead = get_user_preferences('calendar_lookahead', $defaultlookahead, $user); +$user->persistflt = get_user_preferences('calendar_persistflt', 0, $user); + $calendarform->set_data($user); $redirect = new moodle_url("/user/preferences.php", array('userid' => $user->id)); @@ -58,14 +59,14 @@ } else if ($calendarform->is_submitted() && $calendarform->is_validated() && confirm_sesskey()) { $data = $calendarform->get_data(); - $usernew = ['id' => $USER->id, + useredit_update_user_preference([ + 'id' => $user->id, 'preference_calendar_timeformat' => $data->timeformat, 'preference_calendar_startwday' => $data->startwday, 'preference_calendar_maxevents' => $data->maxevents, 'preference_calendar_lookahead' => $data->lookahead, - 'preference_calendar_persistflt' => $data->persistflt - ]; - useredit_update_user_preference($usernew); + 'preference_calendar_persistflt' => $data->persistflt, + ]); // Calendar type. $calendartype = $data->calendartype; From 1f8c3bf17444f227bd2122f6c2807a0ea6d32fc0 Mon Sep 17 00:00:00 2001 From: Hugo Ribeiro Date: Thu, 30 Apr 2026 16:18:30 +0100 Subject: [PATCH 049/309] MDL-88559 badges: fix count on null --- public/badges/classes/backpack_api.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/badges/classes/backpack_api.php b/public/badges/classes/backpack_api.php index 749c8c03680ec..615adfc98bb79 100644 --- a/public/badges/classes/backpack_api.php +++ b/public/badges/classes/backpack_api.php @@ -553,7 +553,7 @@ public function get_badges($collection, $expanded = false) { } // Now we can make requests. $badges = $this->curl_request('badges', $collection->entityid); - if (count($badges) == 0) { + if (empty($badges)) { return []; } $badges = $badges[0]; From aee5df068fb9db67be73a6f1812a5b880ad94ab1 Mon Sep 17 00:00:00 2001 From: Stephan Robotta Date: Sat, 14 Mar 2026 09:47:04 +0100 Subject: [PATCH 050/309] MDL-88217 questions: Fix warning for numeric cloze questions Co-authored-by: David Woloszyn --- .../multianswer/edit_multianswer_form.php | 2 +- .../multianswer/tests/question_type_test.php | 59 +++++++++++++++++++ .../question/type/numerical/questiontype.php | 2 +- 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/public/question/type/multianswer/edit_multianswer_form.php b/public/question/type/multianswer/edit_multianswer_form.php index a192637c8d538..1580c24554d9c 100644 --- a/public/question/type/multianswer/edit_multianswer_form.php +++ b/public/question/type/multianswer/edit_multianswer_form.php @@ -390,7 +390,7 @@ public function set_data($question) { foreach ($subquestion->answer as $key => $answer) { if ($subquestion->qtype == 'numerical' && $key == 0) { $defaultvalues[$prefix.'tolerance['.$key.']'] = - $subquestion->tolerance[0]; + $subquestion->tolerance[0] ?? 0; } if (is_array($answer)) { $answer = $answer['text']; diff --git a/public/question/type/multianswer/tests/question_type_test.php b/public/question/type/multianswer/tests/question_type_test.php index b184fc1e946d3..4fe795d7c09a3 100644 --- a/public/question/type/multianswer/tests/question_type_test.php +++ b/public/question/type/multianswer/tests/question_type_test.php @@ -493,4 +493,63 @@ public function test_save_question_options(): void { $this->assertTrue($DB->record_exists('question', ['id' => $originalsubq2->id])); $this->assertTrue($DB->record_exists('qtype_multichoice_options', ['questionid' => $originalsubq2->id])); } + + /** + * Test saving numerical cloze questions with various syntaxes do not throw errors. + */ + public function test_numerical_cloze_questions_save_cleanly(): void { + $this->resetAfterTest(true); + $this->setAdminUser(); + + /** @var \core_question_generator $generator */ + $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); + $cat = $generator->create_question_category([]); + + // Questions with cloze syntax mixed in. + $testcases = [ + 'Valid numerical with wildcard feedback' => + 'What is 2+2? {1:NUMERICAL:=4:0~*#Try again}', + 'Nonsensical but accepted syntax' => + 'What is 2+2? {1:NUMERICAL:*#Nonsense~=4:0#Correct}', + ]; + + foreach ($testcases as $label => $questiontext) { + $fromform = (object)[ + 'category' => $cat->id . ',' . $cat->contextid, + 'name' => 'Test cloze question', + 'questiontext' => [ + 'text' => $questiontext, + 'format' => FORMAT_HTML, + ], + 'defaultmark' => 1, + ]; + $question = new stdClass(); + $question->qtype = 'multianswer'; + + // Capture errors during save (warnings and deprecations only). + $capturedwarnings = []; + set_error_handler(function($errno, $errstr) use (&$capturedwarnings) { + if ($errno === E_WARNING || $errno === E_DEPRECATED) { + $capturedwarnings[] = $errstr; + } + return true; + }, E_WARNING | E_DEPRECATED); + + try { + $savedquestion = $this->qtype->save_question($question, $fromform); + } finally { + restore_error_handler(); + } + + $this->assertEmpty( + $capturedwarnings, + "PHP error triggered for case '{$label}'" + ); + + $this->assertNotEmpty( + $savedquestion->id, + "Question not saved for case '{$label}'" + ); + } + } } diff --git a/public/question/type/numerical/questiontype.php b/public/question/type/numerical/questiontype.php index 387073d135b25..53323a7a897fa 100644 --- a/public/question/type/numerical/questiontype.php +++ b/public/question/type/numerical/questiontype.php @@ -229,7 +229,7 @@ public function save_question_options($question) { } $options->question = $question->id; $options->answer = $answer->id; - if (trim($question->tolerance[$key]) == '') { + if (!array_key_exists($key, $question->tolerance) || trim($question->tolerance[$key]) == '') { $options->tolerance = ''; } else { $options->tolerance = $this->apply_unit($question->tolerance[$key], From bbeafc5ea644bb0d9361bf68a01cde89895c0025 Mon Sep 17 00:00:00 2001 From: David Woloszyn Date: Wed, 29 Apr 2026 10:44:06 +1000 Subject: [PATCH 051/309] MDL-88453 mod_scorm: One section page view returns to section on exit --- public/mod/scorm/player.php | 17 +++++++--- .../tests/behat/scorm_display_options.feature | 32 +++++++++++++++++-- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/public/mod/scorm/player.php b/public/mod/scorm/player.php index 28b5fe66056b1..01d1699990db0 100644 --- a/public/mod/scorm/player.php +++ b/public/mod/scorm/player.php @@ -167,15 +167,24 @@ $completion = new completion_info($course); $completion->set_module_viewed($cm); -// Generate the exit button. +// Generate the exit button URL depending on our course format and display options. +$format = course_get_format($course); +$formatoptions = $format->get_format_options(); +$coursedisplay = $formatoptions['coursedisplay'] ?? null; $exiturl = ""; if (empty($scorm->popup) || $displaymode == 'popup') { - if ($course->format == 'singleactivity' && $scorm->skipview == SCORM_SKIPVIEW_ALWAYS - && !has_capability('mod/scorm:viewreport', context_module::instance($cm->id))) { + if ( + $format->get_format() == 'singleactivity' && + $scorm->skipview == SCORM_SKIPVIEW_ALWAYS && + !has_capability('mod/scorm:viewreport', context_module::instance($cm->id)) + ) { // Redirect students back to site home to avoid redirect loop. $exiturl = $CFG->wwwroot; + } else if ($coursedisplay == COURSE_DISPLAY_MULTIPAGE) { + // Redirect back to the current section if one section per page is being used. + $exiturl = course_get_url($course, $cm->sectionnum, ['sr' => $cm->sectionnum])->out(); } else { - // Redirect back to the correct section if one section per page is being used. + // Redirect back to the current section anchor on the course page. $exiturl = course_get_url($course, $cm->sectionnum)->out(); } } diff --git a/public/mod/scorm/tests/behat/scorm_display_options.feature b/public/mod/scorm/tests/behat/scorm_display_options.feature index eb7a01d5b142d..e2682e01989b8 100644 --- a/public/mod/scorm/tests/behat/scorm_display_options.feature +++ b/public/mod/scorm/tests/behat/scorm_display_options.feature @@ -10,15 +10,17 @@ Feature: Scorm display options | teacher1 | Teacher | One | teacher1@example.com | | student1 | Student | One | student1@example.com | And the following "courses" exist: - | fullname | shortname | format | activitytype | - | Course 1 | C1 | topics | | - | Course 2 | C2 | singleactivity | scorm | + | fullname | shortname | format | activitytype | coursedisplay | + | Course 1 | C1 | topics | | 0 | + | Course 2 | C2 | singleactivity | scorm | 0 | + | Course 3 | C3 | topics | | 1 | And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | | student1 | C1 | student | | teacher1 | C2 | editingteacher | | student1 | C2 | student | + | student1 | C3 | student | @javascript Scenario Outline: Teacher can change to various Scorm package display options @@ -56,3 +58,27 @@ Feature: Scorm display options And "Enter" "button" should exist And "Exit activity" "link" should not exist And I should not see "Golf Explained - Minimum Run-time Calls" + + Scenario: Student returns to the correct section after exiting Scorm activity (one section per page) + Given the following "activities" exist: + | activity | course | name | packagefilepath | section | + | scorm | C3 | C3 Scorm 1 | mod/scorm/tests/packages/RuntimeMinimumCalls_SCORM12.zip | 1 | + | scorm | C3 | C3 Scorm 2 | mod/scorm/tests/packages/RuntimeMinimumCalls_SCORM12.zip | 2 | + And I am on the "C3 Scorm 1" "scorm activity" page logged in as student1 + And I press "Enter" + When I click on "Exit activity" "link" + # Student should be returned to the section they were in, and not the course overview. + Then I should see "C3 Scorm 1" + And I should not see "C3 Scorm 2" + + Scenario: Student returns to the course overview after exiting Scorm activity (all sections on one page) + Given the following "activities" exist: + | activity | course | name | packagefilepath | section | + | scorm | C1 | C1 Scorm 1 | mod/scorm/tests/packages/RuntimeMinimumCalls_SCORM12.zip | 1 | + | scorm | C1 | C1 Scorm 2 | mod/scorm/tests/packages/RuntimeMinimumCalls_SCORM12.zip | 2 | + And I am on the "C1 Scorm 1" "scorm activity" page logged in as student1 + And I press "Enter" + When I click on "Exit activity" "link" + # Student should be returned to the course overview page displaying both activities. + Then I should see "C1 Scorm 1" + And I should see "C1 Scorm 2" From 3a2c8a08acc8580d7e1f1020f68b28cb3859378c Mon Sep 17 00:00:00 2001 From: Shamim Rezaie Date: Fri, 1 May 2026 17:23:11 +1000 Subject: [PATCH 052/309] weekly release 5.1.4+ --- public/version.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/version.php b/public/version.php index 493c776f0faf3..075021bbaf0bf 100644 --- a/public/version.php +++ b/public/version.php @@ -29,9 +29,9 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2025100604.01; // 20251006 = branching date YYYYMMDD - do not modify! +$version = 2025100604.02; // 20251006 = branching date YYYYMMDD - do not modify! // RR = release increments - 00 in DEV branches. // .XX = incremental changes. -$release = '5.1.4+ (Build: 20260427)'; // Human-friendly version name +$release = '5.1.4+ (Build: 20260501)'; // Human-friendly version name $branch = '501'; // This version's branch. $maturity = MATURITY_STABLE; // This version's maturity level. From 17b8c26804f53055fdb6e80724914eeb0fb6bc73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luca=20B=C3=B6sch?= Date: Sun, 15 Feb 2026 19:09:54 +0100 Subject: [PATCH 053/309] MDL-68682 mod_lesson: display shortanswer fields inline. --- public/mod/lesson/pagetypes/shortanswer.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/public/mod/lesson/pagetypes/shortanswer.php b/public/mod/lesson/pagetypes/shortanswer.php index 4dc8c0680d388..4ed3c4b79ec8d 100644 --- a/public/mod/lesson/pagetypes/shortanswer.php +++ b/public/mod/lesson/pagetypes/shortanswer.php @@ -462,6 +462,7 @@ public function definition() { $placeholder = $matches[0]; $contentsparts = explode( $placeholder, $contents, 2); $attrs['size'] = round(strlen($placeholder) * 1.1); + $attrs['class'] = 'd-inline-block w-auto'; } // Disable shortforms. @@ -476,9 +477,9 @@ public function definition() { if ($placeholder) { $contentsgroup = array(); - $contentsgroup[] = $mform->createElement('static', '', '', $contentsparts[0]); + $contentsgroup[] = $mform->createElement('static', '', '', str_replace(['

'], '', $contentsparts[0])); $contentsgroup[] = $mform->createElement('text', 'answer', '', $attrs); - $contentsgroup[] = $mform->createElement('static', '', '', $contentsparts[1]); + $contentsgroup[] = $mform->createElement('static', '', '', str_replace(['

'], '', $contentsparts[1])); $mform->addGroup($contentsgroup, '', '', '', false); } else { $mform->addElement('html', $OUTPUT->container($contents, 'contents')); From 041fb0012bdc964ebca1a850f8a1c18931b4032e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luca=20B=C3=B6sch?= Date: Fri, 12 Dec 2025 16:40:23 +0100 Subject: [PATCH 054/309] MDL-87455 workshop: Striped hovering fields table with BS 5. --- public/mod/workshop/renderer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/mod/workshop/renderer.php b/public/mod/workshop/renderer.php index cb3661ded8369..f702e26395a15 100644 --- a/public/mod/workshop/renderer.php +++ b/public/mod/workshop/renderer.php @@ -441,7 +441,7 @@ protected function render_workshop_grading_report(workshop_grading_report $gradi } $table = new html_table(); - $table->attributes['class'] = 'grading-report table-striped table-hover'; + $table->attributes['class'] = 'grading-report table table-striped table-hover'; $sortbyfirstname = $this->helper_sortable_heading(get_string('firstname'), 'firstname', $options->sortby, $options->sorthow); $sortbylastname = $this->helper_sortable_heading(get_string('lastname'), 'lastname', $options->sortby, $options->sorthow); From c8b4f0868855bcabb64163175236754d702c3ec9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luca=20B=C3=B6sch?= Date: Fri, 12 Dec 2025 19:43:29 +0100 Subject: [PATCH 055/309] MDL-87459 feedback: Filter feedback name in mailed out subject, too. --- public/mod/feedback/lib.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/mod/feedback/lib.php b/public/mod/feedback/lib.php index e78e473cff156..a3a55bd6cff37 100644 --- a/public/mod/feedback/lib.php +++ b/public/mod/feedback/lib.php @@ -2502,7 +2502,7 @@ function feedback_send_email($cm, $feedback, $course, $user, $completed = null) } } - $a = array('username' => $info->username, 'feedbackname' => $feedback->name); + $a = ['username' => $info->username, 'feedbackname' => $info->feedback]; $postsubject = get_string('feedbackcompleted', 'feedback', $a); $posttext = feedback_send_email_text($info, $course); @@ -2596,7 +2596,7 @@ function feedback_send_email_anonym($cm, $feedback, $course) { $info->feedback = format_string($feedback->name, true); $info->url = $CFG->wwwroot.'/mod/feedback/show_entries.php?id=' . $cm->id; - $a = array('username' => $info->username, 'feedbackname' => $feedback->name); + $a = ['username' => $info->username, 'feedbackname' => $info->feedback]; $postsubject = get_string('feedbackcompleted', 'feedback', $a); $posttext = feedback_send_email_text($info, $course); From 8c538ea9420df446eadcd49122ff78f0b2f851c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luca=20B=C3=B6sch?= Date: Wed, 8 Apr 2026 12:32:12 +0200 Subject: [PATCH 056/309] MDL-88395 login: Vertical space when multiple OAuth2 buttons present. --- public/lib/templates/loginform.mustache | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/lib/templates/loginform.mustache b/public/lib/templates/loginform.mustache index 577e181f51a79..750c7444ad01a 100644 --- a/public/lib/templates/loginform.mustache +++ b/public/lib/templates/loginform.mustache @@ -166,7 +166,7 @@