From 2039518cc9ebdde43a3d3eedef7f6c3ce12b340d Mon Sep 17 00:00:00 2001 From: Sara Arjona Date: Thu, 15 Jan 2026 15:43:59 +0100 Subject: [PATCH 1/4] MDL-87281 subsection: Remove task to delete existing descriptions Although originally introduced as a scheduled task, we are pivoting to a manual execution model to match the indentation removal UX. This gives administrators more control over when the process runs. The task logic was simple enough to be integrated directly into the subsection removal code, eliminating the need for a background job. --- .upgradenotes/MDL-87280-2025121211100178.yml | 8 -- .../remove_existing_descriptions_task.php | 46 --------- public/mod/subsection/db/tasks.php | 39 -------- public/mod/subsection/lang/en/subsection.php | 1 - ...remove_existing_descriptions_task_test.php | 99 ------------------- public/mod/subsection/version.php | 2 +- 6 files changed, 1 insertion(+), 194 deletions(-) delete mode 100644 .upgradenotes/MDL-87280-2025121211100178.yml delete mode 100644 public/mod/subsection/classes/task/remove_existing_descriptions_task.php delete mode 100644 public/mod/subsection/db/tasks.php delete mode 100644 public/mod/subsection/tests/task/remove_existing_descriptions_task_test.php diff --git a/.upgradenotes/MDL-87280-2025121211100178.yml b/.upgradenotes/MDL-87280-2025121211100178.yml deleted file mode 100644 index 2b4a417d750..00000000000 --- a/.upgradenotes/MDL-87280-2025121211100178.yml +++ /dev/null @@ -1,8 +0,0 @@ -issueNumber: MDL-87280 -notes: - mod_subsection: - - message: >- - A new scheduled task, `remove_existing_descriptions`, has been added. - Once enabled, this task will remove the descriptions for all existing - subsection instances. - type: improved diff --git a/public/mod/subsection/classes/task/remove_existing_descriptions_task.php b/public/mod/subsection/classes/task/remove_existing_descriptions_task.php deleted file mode 100644 index c4515cc5b11..00000000000 --- a/public/mod/subsection/classes/task/remove_existing_descriptions_task.php +++ /dev/null @@ -1,46 +0,0 @@ -. - -namespace mod_subsection\task; - -use core\task\scheduled_task; - -/** - * A scheduled task to remove existing descriptions from subsection instances. - * - * @package mod_subsection - * @copyright 2025 Sara Arjona - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ -class remove_existing_descriptions_task extends scheduled_task { - /** - * Return the task name. - * - * @return string The name of the task. - */ - public function get_name(): string { - return get_string('removeexistingdescriptions', 'mod_subsection'); - } - - /** - * Execute the task. - */ - public function execute(): void { - global $DB; - - $DB->set_field('course_sections', 'summary', '', ['component' => 'mod_subsection']); - } -} diff --git a/public/mod/subsection/db/tasks.php b/public/mod/subsection/db/tasks.php deleted file mode 100644 index 7a78fbc02a5..00000000000 --- a/public/mod/subsection/db/tasks.php +++ /dev/null @@ -1,39 +0,0 @@ -. - -/** - * Definition of Subsection scheduled tasks. - * - * @package mod_subsection - * @category task - * @copyright 2025 Sara Arjona - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - -defined('MOODLE_INTERNAL') || die(); - -$tasks = [ - [ - 'classname' => '\mod_subsection\task\remove_existing_descriptions_task', - 'blocking' => 0, - 'minute' => 'R', - 'hour' => '0', - 'day' => '*', - 'dayofweek' => '*', - 'month' => '*', - 'disabled' => true, - ], -]; diff --git a/public/mod/subsection/lang/en/subsection.php b/public/mod/subsection/lang/en/subsection.php index 4989639fcb5..3893fbe662a 100644 --- a/public/mod/subsection/lang/en/subsection.php +++ b/public/mod/subsection/lang/en/subsection.php @@ -29,7 +29,6 @@ $string['pluginadministration'] = 'Subsection administration'; $string['pluginname'] = 'Subsection'; $string['privacy:metadata'] = 'Subsection does not store any personal data'; $string['quickcreatename'] = 'New subsection'; -$string['removeexistingdescriptions'] = 'Remove existing descriptions from subsection instances'; $string['subsection:addinstance'] = 'Add subsection'; $string['subsection:view'] = 'View subsection'; $string['subsectionname'] = 'Name'; diff --git a/public/mod/subsection/tests/task/remove_existing_descriptions_task_test.php b/public/mod/subsection/tests/task/remove_existing_descriptions_task_test.php deleted file mode 100644 index f1ea59fc1bc..00000000000 --- a/public/mod/subsection/tests/task/remove_existing_descriptions_task_test.php +++ /dev/null @@ -1,99 +0,0 @@ -. - -namespace mod_subsection\task; - -/** - * Class containing unit tests for the remove existing descriptions task. - * - * @package mod_subsection - * @copyright 2025 Sara Arjona - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ -#[\PHPUnit\Framework\Attributes\CoversClass(remove_existing_descriptions_task::class)] -final class remove_existing_descriptions_task_test extends \advanced_testcase { - /** - * Test remove_existing_descriptions task. - */ - public function test_remove_existing_descriptions(): void { - global $DB; - - $this->resetAfterTest(); - - $course = $this->getDataGenerator()->create_course(['format' => 'topics', 'numsections' => 1]); - $this->getDataGenerator()->create_module('subsection', ['course' => $course->id, 'section' => 1]); - // Add description to course sections and the subsection. - $DB->set_field( - 'course_sections', - 'summary', - 'Section with description', - ['course' => $course->id], - ); - // Add another subsection without description. - $this->getDataGenerator()->create_module('subsection', ['course' => $course->id, 'section' => 1]); - - // Check only 2 sections and 1 subsection have description. - $this->assertEquals( - 3, - $DB->count_records_select( - 'course_sections', - 'course = :courseid AND summary != \'\'', - ['courseid' => $course->id], - ), - ); - $this->assertEquals( - 2, - $DB->count_records_select( - 'course_sections', - 'course = :courseid AND component = \'mod_subsection\'', - ['courseid' => $course->id], - ), - ); - $this->assertEquals( - 1, - $DB->count_records_select( - 'course_sections', - 'course = :courseid AND component = \'mod_subsection\' AND summary != \'\'', - ['courseid' => $course->id], - ), - ); - - // Run the task. - ob_start(); - $task = new remove_existing_descriptions_task(); - $task->execute(); - ob_end_clean(); - - // Check only 2 sections keep having description after running the task. - $this->assertEquals( - 2, - $DB->count_records_select( - 'course_sections', - 'course = :courseid AND summary != \'\'', - ['courseid' => $course->id], - ), - ); - // Check no subsection has description after running the task. - $this->assertEquals( - 0, - $DB->count_records_select( - 'course_sections', - 'course = :courseid AND component = \'mod_subsection\' AND summary != \'\'', - ['courseid' => $course->id], - ), - ); - } -} diff --git a/public/mod/subsection/version.php b/public/mod/subsection/version.php index e1592e53f6d..31062af637f 100644 --- a/public/mod/subsection/version.php +++ b/public/mod/subsection/version.php @@ -25,6 +25,6 @@ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'mod_subsection'; -$plugin->version = 2025100601; +$plugin->version = 2025100602; $plugin->requires = 2025092600; $plugin->maturity = MATURITY_STABLE; From 40aa295388f92a0880bb6160b3ab5c05f69ac5d1 Mon Sep 17 00:00:00 2001 From: Sara Arjona Date: Tue, 23 Dec 2025 11:38:58 +0100 Subject: [PATCH 2/4] MDL-87281 course: Add task to migrate subsection descriptions --- .upgradenotes/MDL-87281-2025122310312088.yml | 10 + .../migrate_subsection_descriptions_task.php | 130 +++++ ...rate_subsection_descriptions_task_test.php | 530 ++++++++++++++++++ 3 files changed, 670 insertions(+) create mode 100644 .upgradenotes/MDL-87281-2025122310312088.yml create mode 100644 public/mod/subsection/classes/task/migrate_subsection_descriptions_task.php create mode 100644 public/mod/subsection/tests/task/migrate_subsection_descriptions_task_test.php diff --git a/.upgradenotes/MDL-87281-2025122310312088.yml b/.upgradenotes/MDL-87281-2025122310312088.yml new file mode 100644 index 00000000000..88de4a08750 --- /dev/null +++ b/.upgradenotes/MDL-87281-2025122310312088.yml @@ -0,0 +1,10 @@ +issueNumber: MDL-87281 +notes: + mod_subsection: + - message: >- + A new ad-hoc task, `migrate_subsection_descriptions_task`, has been + added. This task will migrate all existing subsection descriptions + into Text and media. To ensure system stability, the task processes + records in batches of 100 and clears the original description upon + successful migration. + type: improved diff --git a/public/mod/subsection/classes/task/migrate_subsection_descriptions_task.php b/public/mod/subsection/classes/task/migrate_subsection_descriptions_task.php new file mode 100644 index 00000000000..93bc2e1c2e9 --- /dev/null +++ b/public/mod/subsection/classes/task/migrate_subsection_descriptions_task.php @@ -0,0 +1,130 @@ +. + +namespace mod_subsection\task; + +use core\task\adhoc_task; +use core_courseformat\formatactions; + +/** + * A task to migrate to text and media and remove existing descriptions from subsection instances. + * + * NOTE: + * - This task requires that both the label and subsection modules are enabled. + * - It processes subsections in batches of 100 to reduce server overload. + * - This task will be removed in Moodle 7.0. By then, the remaining descriptions will be removed. + * + * @package mod_subsection + * @copyright 2025 Sara Arjona + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class migrate_subsection_descriptions_task extends adhoc_task { + /** + * Execute the task. + */ + public function execute(): void { + global $CFG, $DB; + + $enabledplugins = \core\plugininfo\mod::get_enabled_plugins(); + if (!isset($enabledplugins['label']) || !isset($enabledplugins['subsection'])) { + // The label or subsection module is not enabled, nothing to do. + mtrace('Text and media area or Subsection module is not enabled. Skipping migration task.'); + return; + } + require_once($CFG->dirroot . '/course/lib.php'); + + // Process subsections in batches to reduce server overload. + $migratedcount = 0; + $subsections = $DB->get_recordset_select( + table: 'course_sections', + select: 'component = :component AND summary != :empty', + params: ['component' => 'mod_subsection', 'empty' => ''], + limitnum: 100, + ); + $transaction = $DB->start_delegated_transaction(); + foreach ($subsections as $subsection) { + // Create a label with the subsection summary as intro. + $label = [ + 'modulename' => 'label', + 'course' => $subsection->course, + 'section' => $subsection->section, + 'visible' => 1, + 'introeditor' => [ + 'text' => $subsection->summary, + 'format' => $subsection->summaryformat, + 'itemid' => 0, + ], + ]; + $label = \create_module((object) $label); + + // Move the files from the subsection summary to the label intro. + $fs = get_file_storage(); + $files = $fs->get_area_files( + contextid: \context_course::instance($subsection->course)->id, + component: 'course', + filearea: 'section', + itemid: $subsection->id, + ); + foreach ($files as $file) { + $filerecord = [ + 'contextid' => \context_module::instance($label->coursemodule)->id, + 'component' => 'mod_label', + 'filearea' => 'intro', + 'itemid' => 0, + 'timemodified' => time(), + ]; + if ($fs->create_file_from_storedfile($filerecord, $file)) { + // Remove the file from the subsection area. + $file->delete(); + } + } + + // Move the label at the beginning of the subsection. + $section = get_fast_modinfo($subsection->course)->get_section_info($subsection->section); + $beforemod = explode(',', trim($section->sequence))[0] ?? null; + if ($beforemod) { + formatactions::cm($subsection->course)->move_before($label->coursemodule, $beforemod); + } + + // Clear the summary field. + $DB->set_field( + table: 'course_sections', + newfield: 'summary', + newvalue: '', + conditions: ['id' => $subsection->id], + ); + $migratedcount++; + } + $transaction->allow_commit(); + if ($migratedcount > 0) { + mtrace('Subsection descriptions migration task completed. Total migrated subsections: ' . $migratedcount); + } else { + mtrace('No subsection descriptions found to migrate.'); + } + $subsections->close(); + + $pendingcount = $DB->count_records_select( + table: 'course_sections', + select: 'component = :component AND summary != :empty', + params: ['component' => 'mod_subsection', 'empty' => ''], + ); + if ($pendingcount > 0) { + $task = new self(); + \core\task\manager::queue_adhoc_task($task); + mtrace('Subsection descriptions migration task pending subsections: ' . $pendingcount . '. Scheduled new ad-hoc task.'); + } + } +} diff --git a/public/mod/subsection/tests/task/migrate_subsection_descriptions_task_test.php b/public/mod/subsection/tests/task/migrate_subsection_descriptions_task_test.php new file mode 100644 index 00000000000..8a3c0c17867 --- /dev/null +++ b/public/mod/subsection/tests/task/migrate_subsection_descriptions_task_test.php @@ -0,0 +1,530 @@ +. + +namespace mod_subsection\task; + +/** + * Class containing unit tests for the migrate subsection descriptions task. + * + * @package mod_subsection + * @copyright 2025 Sara Arjona + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +#[\PHPUnit\Framework\Attributes\CoversClass(migrate_subsection_descriptions_task::class)] +final class migrate_subsection_descriptions_task_test extends \advanced_testcase { + /** + * Test migrate_subsection_descriptions task. + */ + public function test_migrate_subsection_descriptions(): void { + global $DB; + + $this->resetAfterTest(); + $this->setAdminUser(); + + $course = $this->getDataGenerator()->create_course(['format' => 'topics', 'numsections' => 1]); + $summarytext = 'Section with description'; + $this->getDataGenerator()->create_module('subsection', ['course' => $course->id, 'section' => 1]); + // Add forum to the subsection to test the order of the modules is preserved. + $this->getDataGenerator()->create_module( + 'forum', + [ + 'course' => $course->id, + 'name' => 'Forum in subsection', + 'section' => 2, + ], + ); + // Add description to course sections and the subsection. + $DB->set_field( + 'course_sections', + 'summary', + $summarytext, + ['course' => $course->id], + ); + // Add another subsection without description. + $this->getDataGenerator()->create_module('subsection', ['course' => $course->id, 'section' => 1]); + + // Check only 2 sections and 1 subsection have description. + $this->assertEquals( + 3, + $DB->count_records_select( + 'course_sections', + 'course = :courseid AND summary != \'\'', + ['courseid' => $course->id], + ), + ); + $this->assertEquals( + 2, + $DB->count_records_select( + 'course_sections', + 'course = :courseid AND component = \'mod_subsection\'', + ['courseid' => $course->id], + ), + ); + $this->assertEquals( + 1, + $DB->count_records_select( + 'course_sections', + 'course = :courseid AND component = \'mod_subsection\' AND summary != \'\'', + ['courseid' => $course->id], + ), + ); + $this->assertEquals( + 0, + $DB->count_records_select( + 'label', + 'course = :courseid', + ['courseid' => $course->id], + ), + ); + $cms = get_fast_modinfo($course->id)->get_cms(); + // Check that the activities are in the expected initial order. + $this->assertEquals( + [ + 'Subsection 1', + 'Subsection 2', + 'Forum in subsection', + ], + array_values(array_map(fn($cminfo) => $cminfo->name, $cms)) + ); + + // Run the task. + $task = new migrate_subsection_descriptions_task(); + \core\task\manager::queue_adhoc_task($task); + ob_start(); + $this->runAdhocTasks(migrate_subsection_descriptions_task::class); + $output = ob_get_contents(); + ob_end_clean(); + + // Check one subsection migrated message shown. + $this->assertStringContainsString( + 'Subsection descriptions migration task completed. Total migrated subsections: 1', + trim($output), + ); + // Check only 2 sections keep having description after running the task. + $this->assertEquals( + 2, + $DB->count_records_select( + 'course_sections', + 'course = :courseid AND summary != \'\'', + ['courseid' => $course->id], + ), + ); + // Check no subsection has description after running the task. + $this->assertEquals( + 0, + $DB->count_records_select( + 'course_sections', + 'course = :courseid AND component = \'mod_subsection\' AND summary != \'\'', + ['courseid' => $course->id], + ), + ); + // Check text&media module created for the migrated description. + $this->assertEquals( + 1, + $DB->count_records_select( + 'label', + 'course = :courseid', + ['courseid' => $course->id], + ), + ); + $this->assertEquals( + $summarytext, + $DB->get_field_select( + 'label', + 'intro', + 'course = :courseid', + ['courseid' => $course->id], + ), + ); + $cms = get_fast_modinfo($course->id)->get_cms(); + // Check that the label is in the expected position. + $this->assertEquals( + [ + 'Subsection 1', + 'Subsection 2', + 'label', + 'Forum in subsection', + ], + array_values(array_map(fn($cminfo) => $cminfo->name, $cms)) + ); + + // Check no subsections left to migrate. + $task = new migrate_subsection_descriptions_task(); + \core\task\manager::queue_adhoc_task($task); + ob_start(); + $this->runAdhocTasks(migrate_subsection_descriptions_task::class); + $output = ob_get_contents(); + ob_end_clean(); + $this->assertStringContainsString( + 'No subsection descriptions found to migrate.', + trim($output), + ); + } + + /** + * Test migrate_subsection_descriptions task with attached files. + */ + public function test_migrate_subsection_descriptions_with_files(): void { + global $DB; + + $this->resetAfterTest(); + $this->setAdminUser(); + + $course = $this->getDataGenerator()->create_course(['format' => 'topics', 'numsections' => 1]); + $summarytext = 'Subsection text with link'; + $this->getDataGenerator()->create_module('subsection', ['course' => $course->id, 'section' => 1]); + $subsection = $DB->get_record( + 'course_sections', + ['course' => $course->id, 'section' => 2], + ); + // Add description to the subsection. + $DB->set_field( + 'course_sections', + 'summary', + $summarytext, + ['course' => $course->id, 'section' => $subsection->section], + ); + $filerecord = [ + 'component' => 'course', + 'filearea' => 'section', + 'contextid' => \context_course::instance($course->id)->id, + 'itemid' => $subsection->id, + 'filename' => 'intro.txt', + 'filepath' => '/', + ]; + $fs = get_file_storage(); + $fs->create_file_from_string($filerecord, 'Test intro file'); + + // Check subsection has description with file, and there is no label. + $this->assertEquals( + 1, + $DB->count_records_select( + 'course_sections', + 'course = :courseid AND component = \'mod_subsection\' AND summary != \'\'', + ['courseid' => $course->id], + ), + ); + $this->assertEquals( + 1, + $DB->count_records_select( + 'files', + 'component = :component AND filearea = :filearea AND filename = :filename', + [ + 'component' => 'course', + 'filearea' => 'section', + 'filename' => 'intro.txt', + ], + ), + ); + $this->assertEquals( + 0, + $DB->count_records_select( + 'label', + 'course = :courseid', + ['courseid' => $course->id], + ), + ); + $this->assertEquals( + 0, + $DB->count_records_select( + 'files', + 'component = :component AND filearea = :filearea AND filename = :filename', + [ + 'component' => 'mod_label', + 'filearea' => 'intro', + 'filename' => 'intro.txt', + ], + ), + ); + + // Run the task. + $task = new migrate_subsection_descriptions_task(); + \core\task\manager::queue_adhoc_task($task); + ob_start(); + $this->runAdhocTasks(migrate_subsection_descriptions_task::class); + ob_end_clean(); + + // Check no subsection has description after running the task. + $this->assertEquals( + 0, + $DB->count_records_select( + 'course_sections', + 'course = :courseid AND component = \'mod_subsection\' AND summary != \'\'', + ['courseid' => $course->id], + ), + ); + // Check text&media module created for the migrated description. + $this->assertEquals( + 1, + $DB->count_records_select( + 'label', + 'course = :courseid', + ['courseid' => $course->id], + ), + ); + $this->assertEquals( + $summarytext, + $DB->get_field_select( + 'label', + 'intro', + 'course = :courseid', + ['courseid' => $course->id], + ), + ); + // Check the file has been migrated too. + $this->assertEquals( + 1, + $DB->count_records_select( + 'files', + 'component = :component AND filearea = :filearea AND filename = :filename', + [ + 'component' => 'mod_label', + 'filearea' => 'intro', + 'filename' => 'intro.txt', + ], + ), + ); + $this->assertEquals( + 0, + $DB->count_records_select( + 'files', + 'component = :component AND filearea = :filearea AND filename = :filename', + [ + 'component' => 'course', + 'filearea' => 'section', + 'filename' => 'intro.txt', + ], + ), + ); + } + + /** + * Test migrate_subsection_descriptions task when label or subsection module is not enabled. + */ + public function test_migrate_subsection_descriptions_modules_not_enabled(): void { + global $DB; + + $this->resetAfterTest(); + $this->setAdminUser(); + + $course = $this->getDataGenerator()->create_course(['format' => 'topics', 'numsections' => 1]); + $summarytext = 'Section with description'; + $this->getDataGenerator()->create_module('subsection', ['course' => $course->id, 'section' => 1]); + // Add description to course sections and the subsection. + $DB->set_field( + 'course_sections', + 'summary', + $summarytext, + ['course' => $course->id], + ); + // Check only 2 sections and 1 subsection have description. + $this->assertEquals( + 3, + $DB->count_records_select( + 'course_sections', + 'course = :courseid AND summary != \'\'', + ['courseid' => $course->id], + ), + ); + $this->assertEquals( + 1, + $DB->count_records_select( + 'course_sections', + 'course = :courseid AND component = \'mod_subsection\' AND summary != \'\'', + ['courseid' => $course->id], + ), + ); + $this->assertEquals( + 0, + $DB->count_records_select( + 'label', + 'course = :courseid', + ['courseid' => $course->id], + ), + ); + // Disable label module. + \core\plugininfo\mod::enable_plugin('label', 0); + + // Run the task. + $task = new migrate_subsection_descriptions_task(); + \core\task\manager::queue_adhoc_task($task); + ob_start(); + $this->runAdhocTasks(migrate_subsection_descriptions_task::class); + $output = ob_get_contents(); + ob_end_clean(); + + // Check one subsection migrated message shown. + $this->assertStringContainsString( + 'Text and media area or Subsection module is not enabled. Skipping migration task.', + trim($output), + ); + + // Check nothing has changed. + $this->assertEquals( + 1, + $DB->count_records_select( + 'course_sections', + 'course = :courseid AND component = \'mod_subsection\' AND summary != \'\'', + ['courseid' => $course->id], + ), + ); + $this->assertEquals( + 0, + $DB->count_records_select( + 'label', + 'course = :courseid', + ['courseid' => $course->id], + ), + ); + + // Enable label and disable subsection module. + \core\plugininfo\mod::enable_plugin('label', 1); + \core\plugininfo\mod::enable_plugin('subsection', 0); + + // Run the task. + ob_start(); + $task = new migrate_subsection_descriptions_task(); + $task->execute(); + ob_end_clean(); + + // Check nothing has changed. + $this->assertEquals( + 1, + $DB->count_records_select( + 'course_sections', + 'course = :courseid AND component = \'mod_subsection\' AND summary != \'\'', + ['courseid' => $course->id], + ), + ); + $this->assertEquals( + 0, + $DB->count_records_select( + 'label', + 'course = :courseid', + ['courseid' => $course->id], + ), + ); + } + + /** + * Test migrate_subsection_descriptions task reschedule when more than 100 subsections to process. + */ + public function test_migrate_subsection_descriptions_rescheduletask(): void { + global $DB; + + if (!PHPUNIT_LONGTEST) { + $this->markTestSkipped('PHPUNIT_LONGTEST is not defined'); + } + + $this->resetAfterTest(); + $this->setAdminUser(); + + $course = $this->getDataGenerator()->create_course(['format' => 'topics', 'numsections' => 1]); + for ($i = 0; $i < 101; $i++) { + $this->getDataGenerator()->create_module('subsection', ['course' => $course->id, 'section' => 1]); + } + // Add description to course sections and subsections. + $DB->set_field( + 'course_sections', + 'summary', + 'Section with description', + ['course' => $course->id], + ); + + // Check 101 subsections have description. + $this->assertEquals( + 101, + $DB->count_records_select( + 'course_sections', + 'course = :courseid AND component = \'mod_subsection\'', + ['courseid' => $course->id], + ), + ); + $this->assertEquals( + 0, + $DB->count_records_select( + 'label', + 'course = :courseid', + ['courseid' => $course->id], + ), + ); + + // Run the task. + $task = new migrate_subsection_descriptions_task(); + \core\task\manager::queue_adhoc_task($task); + ob_start(); + $this->runAdhocTasks(migrate_subsection_descriptions_task::class); + $output = ob_get_contents(); + ob_end_clean(); + + // Check subsection migrated message shown. + $this->assertStringContainsString( + 'Subsection descriptions migration task completed. Total migrated subsections: 100', + trim($output), + ); + $this->assertStringContainsString( + 'Subsection descriptions migration task pending subsections: 1. Scheduled new ad-hoc task.', + trim($output), + ); + // Check only 1 subsection keep having description after running the task. + $this->assertEquals( + 1, + $DB->count_records_select( + 'course_sections', + 'course = :courseid AND component = \'mod_subsection\' AND summary != \'\'', + ['courseid' => $course->id], + ), + ); + // Check text&media module created for the migrated description subsections. + $this->assertEquals( + 100, + $DB->count_records_select( + 'label', + 'course = :courseid', + ['courseid' => $course->id], + ), + ); + + // Re-run the task to process the remaining subsection (it should have been queued by the previous run). + ob_start(); + $this->runAdhocTasks(migrate_subsection_descriptions_task::class); + $output = ob_get_contents(); + ob_end_clean(); + + $this->assertStringContainsString( + 'Subsection descriptions migration task completed. Total migrated subsections: 1', + trim($output), + ); + // Check no subsections keep having description after running the task. + $this->assertEquals( + 0, + $DB->count_records_select( + 'course_sections', + 'course = :courseid AND component = \'mod_subsection\' AND summary != \'\'', + ['courseid' => $course->id], + ), + ); + // Check text&media module created for the migrated description subsections. + $this->assertEquals( + 101, + $DB->count_records_select( + 'label', + 'course = :courseid', + ['courseid' => $course->id], + ), + ); + } +} From 00b1f3a2a6ffbfc1f4711b5ee9a98cbf537d4c2a Mon Sep 17 00:00:00 2001 From: Sara Arjona Date: Tue, 13 Jan 2026 18:10:57 +0100 Subject: [PATCH 3/4] MDL-87281 output: Support custom title and danger for confirm_action --- .upgradenotes/MDL-87281-2026011403463805.yml | 9 +++++++++ .../lib/classes/output/actions/confirm_action.php | 13 ++++++++++++- public/lib/javascript-static.js | 12 ++++++++++-- 3 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 .upgradenotes/MDL-87281-2026011403463805.yml diff --git a/.upgradenotes/MDL-87281-2026011403463805.yml b/.upgradenotes/MDL-87281-2026011403463805.yml new file mode 100644 index 00000000000..130c75e81ba --- /dev/null +++ b/.upgradenotes/MDL-87281-2026011403463805.yml @@ -0,0 +1,9 @@ +issueNumber: MDL-87281 +notes: + core: + - message: >- + The __construct() method of the confirm_action class now accepts two + optional new parameters: `$title` (string) to set the dialogue's + heading, and `$dialogtype` (string) to specify the visual style of the + action ('delete' for displaying the danger button). + type: improved diff --git a/public/lib/classes/output/actions/confirm_action.php b/public/lib/classes/output/actions/confirm_action.php index f18ce1c1fbd..663863517a6 100644 --- a/public/lib/classes/output/actions/confirm_action.php +++ b/public/lib/classes/output/actions/confirm_action.php @@ -34,8 +34,17 @@ class confirm_action extends component_action { * @param string $callback Deprecated since 2.7 * @param string $continuelabel The string to use for he continue button * @param string $cancellabel The string to use for the cancel button + * @param string|null $title The title of the confirmation dialog + * @param string|null $dialogtype The type of dialog (primary button style). E.g. 'delete' */ - public function __construct($message, $callback = null, $continuelabel = null, $cancellabel = null) { + public function __construct( + $message, + $callback = null, + $continuelabel = null, + $cancellabel = null, + ?string $title = null, + ?string $dialogtype = null, + ) { if ($callback !== null) { debugging( 'The callback argument to new confirm_action() has been deprecated.' . @@ -48,6 +57,8 @@ class confirm_action extends component_action { 'message' => $message, 'continuelabel' => $continuelabel, 'cancellabel' => $cancellabel, + 'title' => $title, + 'dialogtype' => $dialogtype, ]); } } diff --git a/public/lib/javascript-static.js b/public/lib/javascript-static.js index 68d07a7b3bc..a933b341759 100644 --- a/public/lib/javascript-static.js +++ b/public/lib/javascript-static.js @@ -205,6 +205,8 @@ M.util.CollapsibleRegion.prototype.icon = null; * @param {Object} [args.callbackargs] Any arguments to pass to the callback. * @param {String} [args.cancellabel] The label to use on the cancel button. * @param {String} [args.continuelabel] The label to use on the continue button. + * @param {String} [args.title] The title of the confirmation dialog. + * @param {String} [args.dialogtype] The type of dialog - 'delete' shows a delete/cancel dialog. */ M.util.show_confirm_dialog = (e, { message, @@ -212,6 +214,8 @@ M.util.show_confirm_dialog = (e, { callback = null, scope = null, callbackargs = [], + title = null, + dialogtype = null, } = {}) => { if (e.preventDefault) { e.preventDefault(); @@ -226,8 +230,12 @@ M.util.show_confirm_dialog = (e, { scope = e.target; } - Notification.saveCancelPromise( - Str.get_string('confirmation', 'admin'), + let method = 'saveCancelPromise'; + if (dialogtype === 'delete') { + method = 'deleteCancelPromise'; + } + Notification[method]( + title || Str.get_string('confirmation', 'admin'), message, continuelabel || Str.get_string('yes', 'moodle'), ) From 5ccca059d9d9b1e833175ffd95bb00094508f535 Mon Sep 17 00:00:00 2001 From: Sara Arjona Date: Mon, 12 Jan 2026 14:48:33 +0100 Subject: [PATCH 4/4] MDL-87281 subsection: Add description cleanup links to settings page --- public/mod/subsection/cleandescriptions.php | 52 +++++++++++++ public/mod/subsection/lang/en/subsection.php | 22 ++++++ public/mod/subsection/settings.php | 73 +++++++++++++++++- .../subsection_clean_descriptions.feature | 46 +++++++++++ .../subsections_with_descriptions.mbz | Bin 0 -> 8177 bytes 5 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 public/mod/subsection/cleandescriptions.php create mode 100644 public/mod/subsection/tests/behat/subsection_clean_descriptions.feature create mode 100644 public/mod/subsection/tests/fixtures/subsections_with_descriptions.mbz diff --git a/public/mod/subsection/cleandescriptions.php b/public/mod/subsection/cleandescriptions.php new file mode 100644 index 00000000000..f5170e0323a --- /dev/null +++ b/public/mod/subsection/cleandescriptions.php @@ -0,0 +1,52 @@ +. + +/** + * Remove or migrate subsection descriptions + * + * @copyright 2026 Sara Arjona + * @package mod_subsection + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +require_once('../../config.php'); + +require_admin(); + +$action = required_param('action', PARAM_ALPHA); +$count = optional_param('count', 0, PARAM_INT); +$return = new moodle_url('/admin/settings.php', ['section' => 'mod_subsection_settings']); + +$PAGE->set_url('/mod/subsection/cleandescriptions.php'); +$PAGE->set_context(context_system::instance()); + +require_sesskey(); +if ($action === 'delete') { + // Remove all existing subsection descriptions. + $DB->set_field('course_sections', 'summary', '', ['component' => 'mod_subsection']); + redirect( + $return, + get_string('descriptionsdeletedsuccess', 'mod_subsection', $count), + null, + \core\output\notification::NOTIFY_SUCCESS + ); +} else if ($action === 'migrate') { + // Schedule the ad-hoc task to migrate subsection descriptions. + \core\task\manager::queue_adhoc_task(new \mod_subsection\task\migrate_subsection_descriptions_task(), true); + redirect($return); +} else { + throw new moodle_exception('invalidaction', 'mod_subsection'); +} diff --git a/public/mod/subsection/lang/en/subsection.php b/public/mod/subsection/lang/en/subsection.php index 3893fbe662a..b82aff3bd7a 100644 --- a/public/mod/subsection/lang/en/subsection.php +++ b/public/mod/subsection/lang/en/subsection.php @@ -23,6 +23,28 @@ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +$string['cleandescriptionsdetail'] = 'Subsection pages and descriptions are no longer supported in Moodle 5.2. +

This site has {$a->count} subsection descriptions that are no longer visible to users.

+

You can choose to: +

    +
  • Migrate these descriptions to Text and Media areas.
    The Text and media areas will be displayed at the top of each subsection.
    {$a->migratelink}

    +
  • +
  • Delete these descriptions permanently.
    This will completely erase descriptions from the database.
    {$a->deletelink} +
  • +
+

'; +$string['deleteconfirmbutton'] = 'Delete all descriptions'; +$string['deleteconfirmtext'] = 'This will permanently delete {$a} subsection descriptions from the database.

You can\'t undo this. Are you sure you want to delete all descriptions?'; +$string['deleteconfirmtitle'] = 'Delete all subsection descriptions?'; +$string['deletelinktext'] = 'Delete descriptions'; +$string['descriptionsdeletedsuccess'] = '{$a} subsection descriptions deleted.'; +$string['descriptionsmigratedpending'] = 'Subsection descriptions waiting to be migrated: {$a}'; +$string['descriptionsmigratedsuccess'] = 'The migration task for all subsection descriptions has been created. This task will run in the background and may take a few minutes.'; +$string['invalidaction'] = 'Invalid action specified.'; +$string['migrateconfirmbutton'] = 'Migrate all descriptions'; +$string['migrateconfirmtext'] = 'This will migrate {$a} subsection descriptions to Text and Media areas.

You can\'t undo this. Are you sure you want to migrate all descriptions?'; +$string['migrateconfirmtitle'] = 'Migrate all subsection descriptions?'; +$string['migratelinktext'] = 'Migrate descriptions'; $string['modulename'] = 'Subsection'; $string['modulenameplural'] = 'Subsections'; $string['pluginadministration'] = 'Subsection administration'; diff --git a/public/mod/subsection/settings.php b/public/mod/subsection/settings.php index 9025c8122d6..41ef83e9d16 100644 --- a/public/mod/subsection/settings.php +++ b/public/mod/subsection/settings.php @@ -28,7 +28,78 @@ defined('MOODLE_INTERNAL') || die(); if ($hassiteconfig) { $settings = new admin_settingpage('mod_subsection_settings', new lang_string('pluginname', 'mod_subsection')); - // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedIf if ($ADMIN->fulltree) { + // Add description cleanup and migration links. + $count = $DB->count_records_select( + table: 'course_sections', + select: 'component = :component AND summary != :empty', + params: ['component' => 'mod_subsection', 'empty' => ''], + ); + $task = \core\task\manager::get_queued_adhoc_task_record(new \mod_subsection\task\migrate_subsection_descriptions_task()); + if ($task) { + // There is a pending migration task, show notification and pending count. + $notification = $OUTPUT->notification( + get_string('descriptionsmigratedsuccess', 'mod_subsection'), + \core\output\notification::NOTIFY_SUCCESS, + ); + $settings->add(new admin_setting_heading( + 'migratedescriptionsnotification', + '', + $notification, + )); + $settings->add(new admin_setting_heading( + 'pendingcleandescriptions', + '', + new lang_string('descriptionsmigratedpending', 'mod_subsection', $count), + )); + } else if ($count > 0) { + // Show migration and deletion links. + $migrateaction = new \confirm_action( + message: get_string('migrateconfirmtext', 'mod_subsection', $count), + continuelabel: get_string('migrateconfirmbutton', 'mod_subsection'), + title: get_string('migrateconfirmtitle', 'mod_subsection'), + ); + $migrateurl = new moodle_url( + '/mod/subsection/cleandescriptions.php', + ['action' => 'migrate', 'count' => $count, 'sesskey' => sesskey()], + ); + $migratelink = $OUTPUT->action_link( + url: $migrateurl, + text: get_string('migratelinktext', 'mod_subsection'), + action: $migrateaction, + attributes: ['class' => 'btn btn-secondary'], + ); + + $deleteaction = new \confirm_action( + message: get_string('deleteconfirmtext', 'mod_subsection', $count), + continuelabel: get_string('deleteconfirmbutton', 'mod_subsection'), + title: get_string('deleteconfirmtitle', 'mod_subsection'), + dialogtype: 'delete', + ); + $deleteurl = new moodle_url( + '/mod/subsection/cleandescriptions.php', + ['action' => 'delete', 'count' => $count, 'sesskey' => sesskey()], + ); + $deletelink = $OUTPUT->action_link( + url: $deleteurl, + text: get_string('deletelinktext', 'mod_subsection'), + action: $deleteaction, + attributes: ['class' => 'btn btn-secondary'], + ); + + $settings->add(new admin_setting_heading( + 'cleandescriptions', + '', + new lang_string( + 'cleandescriptionsdetail', + 'mod_subsection', + [ + 'count' => $count, + 'migratelink' => $migratelink, + 'deletelink' => $deletelink, + ], + ), + )); + } } } diff --git a/public/mod/subsection/tests/behat/subsection_clean_descriptions.feature b/public/mod/subsection/tests/behat/subsection_clean_descriptions.feature new file mode 100644 index 00000000000..7aa8f8ad2c8 --- /dev/null +++ b/public/mod/subsection/tests/behat/subsection_clean_descriptions.feature @@ -0,0 +1,46 @@ +@mod @mod_subsection @_file_upload +Feature: Subsection clean descriptions + In order to manage subsection descriptions + As an administrator + I want to be able to delete or migrate subsection descriptions + + Background: + Given the following "courses" exist: + | fullname | shortname | category | + | Course 1 | C1 | 0 | + And the following config values are set as admin: + | enableasyncbackup | 0 | + And I am on the "Course 1" "restore" page logged in as "admin" + And I press "Manage course backups" + And I upload "mod/subsection/tests/fixtures/subsections_with_descriptions.mbz" file to "Files" filemanager + And I press "Save changes" + And I restore "subsections_with_descriptions.mbz" backup into a new course using this options: + | Schema | Course name | Course 2 | + | Schema | Course short name | C2 | + + @javascript + Scenario: Migrate subsection descriptions + Given I navigate to "Plugins > Activity modules > Subsection" in site administration + And I should see "This site has 2 subsection descriptions that are no longer visible to users." + When I click on "Migrate descriptions" "link" in the "region-main" "region" + And I should see "This will migrate 2 subsection descriptions to Text and Media areas." in the "Migrate all subsection descriptions?" "dialogue" + And I click on "Migrate all descriptions" "button" in the "Migrate all subsection descriptions?" "dialogue" + Then I should see "The migration task for all subsection descriptions has been created." in the "region-main" "region" + And I should see "Subsection descriptions waiting to be migrated: 2" in the "region-main" "region" + And I reload the page + And I should see "The migration task for all subsection descriptions has been created." in the "region-main" "region" + And I should see "Subsection descriptions waiting to be migrated: 2" in the "region-main" "region" + And I run all adhoc tasks + And I reload the page + And I should not see "Subsection descriptions waiting to be migrated:" in the "region-main" "region" + + @javascript + Scenario: Delete subsection descriptions + Given I navigate to "Plugins > Activity modules > Subsection" in site administration + And I should see "This site has 2 subsection descriptions that are no longer visible to users." + When I click on "Delete descriptions" "link" in the "region-main" "region" + And I should see "This will permanently delete 2 subsection descriptions from the database." in the "Delete all subsection descriptions?" "dialogue" + And I click on "Delete all descriptions" "button" in the "Delete all subsection descriptions?" "dialogue" + Then I should see "2 subsection descriptions deleted." in the "region-main" "region" + And I reload the page + And I should not see "Subsection pages and descriptions are no longer supported in Moodle 5.2" diff --git a/public/mod/subsection/tests/fixtures/subsections_with_descriptions.mbz b/public/mod/subsection/tests/fixtures/subsections_with_descriptions.mbz new file mode 100644 index 0000000000000000000000000000000000000000..2550090994f318dc2abe255605157d5ee9537170 GIT binary patch literal 8177 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 literal 0 HcmV?d00001