Merge branch 'MDL-87281-main' of https://github.com/sarjona/moodle
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
namespace 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 <[email protected]>
|
||||
* @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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
namespace 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 <[email protected]>
|
||||
* @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']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
/**
|
||||
* Remove or migrate subsection descriptions
|
||||
*
|
||||
* @copyright 2026 Sara Arjona <[email protected]>
|
||||
* @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');
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
/**
|
||||
* Definition of Subsection scheduled tasks.
|
||||
*
|
||||
* @package mod_subsection
|
||||
* @category task
|
||||
* @copyright 2025 Sara Arjona <[email protected]>
|
||||
* @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,
|
||||
],
|
||||
];
|
||||
@@ -23,13 +23,34 @@
|
||||
* @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.
|
||||
<p>This site has <strong>{$a->count} subsection descriptions</strong> that are no longer visible to users.</p>
|
||||
<p>You can choose to:
|
||||
<ul>
|
||||
<li><strong>Migrate these descriptions to Text and Media areas</strong>.<br/> The Text and media areas will be displayed at the top of each subsection.<br/>{$a->migratelink}<br/><br/>
|
||||
</li>
|
||||
<li><strong>Delete these descriptions permanently</strong>.<br/> This will completely erase descriptions from the database.<br/>{$a->deletelink}
|
||||
</li>
|
||||
</ul>
|
||||
</p>';
|
||||
$string['deleteconfirmbutton'] = 'Delete all descriptions';
|
||||
$string['deleteconfirmtext'] = 'This will permanently delete {$a} subsection descriptions from the database.<br/><br/>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: <strong>{$a}</strong>';
|
||||
$string['descriptionsmigratedsuccess'] = '<strong>The migration task for all subsection descriptions has been created</strong>. 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.<br/><br/>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';
|
||||
$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';
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
Binary file not shown.
@@ -0,0 +1,530 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
namespace mod_subsection\task;
|
||||
|
||||
/**
|
||||
* Class containing unit tests for the migrate subsection descriptions task.
|
||||
*
|
||||
* @package mod_subsection
|
||||
* @copyright 2025 Sara Arjona <[email protected]>
|
||||
* @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 <a href="@@PLUGINFILE@@/intro.txt">link</a>';
|
||||
$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],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
namespace mod_subsection\task;
|
||||
|
||||
/**
|
||||
* Class containing unit tests for the remove existing descriptions task.
|
||||
*
|
||||
* @package mod_subsection
|
||||
* @copyright 2025 Sara Arjona <[email protected]>
|
||||
* @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],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user