MDL-86858 core_courseformat: Implement duplicate module in cmactions

This commit is contained in:
Laurent David
2026-02-12 09:36:01 +01:00
parent dbf9b2bc1f
commit dc4122df5a
5 changed files with 599 additions and 130 deletions
@@ -0,0 +1,7 @@
issueNumber: MDL-86858
notes:
core_courseformat:
- message: >-
Create a new core_courseformat\cmactions::duplicate aiming to replace
the course/lib.php version
type: improved
@@ -0,0 +1,7 @@
issueNumber: MDL-86858
notes:
core_course:
- message: >-
The duplicate_module has been deprecated in favor of
core_courseformat\cmactions::duplicate
type: deprecated
@@ -396,6 +396,172 @@ class cmactions extends baseactions {
rebuild_course_cache($cm->course, false, true);
}
/**
* Duplicate a course module.
*
* @param int $cmid Course module id.
* @param int|null $targetsectionid Target section id. If null, the original section is used.
* @param string|null $newname If provided, the duplicated module will be renamed to this name, if not we
* use the default ' (copy)' postfix.
* @return \core_course\cm_info |null The duplicated course module info object, or null if duplication failed.
* @throws \core\exception\coding_exception
* @throws moodle_exception When course module does not exist in the course
*/
public function duplicate(
int $cmid,
?int $targetsectionid = null,
?string $newname = null
): ?\core_course\cm_info {
global $CFG, $DB, $USER;
require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php');
require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php');
require_once($CFG->libdir . '/filelib.php');
$modinfo = get_fast_modinfo($this->course);
$cm = $modinfo->get_cm($cmid);
$targetsection = $modinfo->get_section_info_by_id($targetsectionid ?? $cm->get_section_info()->id, MUST_EXIST);
// Plugins with this feature flag set to false must ALWAYS be in section 0.
if (!course_modinfo::is_mod_type_visible_on_course($cm->modname)) {
if ($modinfo->get_section_info(0, MUST_EXIST)->id != $targetsectionid) {
throw new \core\exception\coding_exception(
'Modules with FEATURE_CAN_DISPLAY set to false can not be moved from section 0'
);
}
}
$a = new stdClass();
$a->modtype = get_string('modulename', $cm->modname);
$a->modname = format_string($cm->name);
if (!plugin_supports('mod', $cm->modname, FEATURE_BACKUP_MOODLE2)) {
throw new moodle_exception('duplicatenosupport', 'error', '', $a);
}
// Backup the activity.
$bc = new \backup_controller(
\backup::TYPE_1ACTIVITY,
$cm->id,
\backup::FORMAT_MOODLE,
\backup::INTERACTIVE_NO,
\backup::MODE_IMPORT,
$USER->id
);
$backupid = $bc->get_backupid();
$backupbasepath = $bc->get_plan()->get_basepath();
$bc->execute_plan();
$bc->destroy();
// Restore the backup immediately.
$rc = new \restore_controller(
$backupid,
$this->course->id,
\backup::INTERACTIVE_NO,
\backup::MODE_IMPORT,
$USER->id,
\backup::TARGET_CURRENT_ADDING
);
// Make sure that the restore_general_groups setting is always enabled when duplicating an activity.
$plan = $rc->get_plan();
$groupsetting = $plan->get_setting('groups');
if (empty($groupsetting->get_value())) {
$groupsetting->set_value(true);
}
$cmcontext = \context_module::instance($cm->id);
if (!$rc->execute_precheck()) {
$precheckresults = $rc->get_precheck_results();
if (is_array($precheckresults) && !empty($precheckresults['errors'])) {
if (empty($CFG->keeptempdirectoriesonbackup)) {
fulldelete($backupbasepath);
}
}
}
$rc->execute_plan();
// Now a bit hacky part follows - we try to get the cmid of the newly
// restored copy of the module.
$newcmid = null;
$tasks = $rc->get_plan()->get_tasks();
foreach ($tasks as $task) {
if (is_subclass_of($task, 'restore_activity_task')) {
if ($task->get_old_contextid() == $cmcontext->id) {
$newcmid = $task->get_moduleid();
break;
}
}
}
$rc->destroy();
if (empty($CFG->keeptempdirectoriesonbackup)) {
fulldelete($backupbasepath);
}
// If we know the cmid of the new course module, let us move it
// right below the original one. otherwise it will stay at the
// end of the section.
if ($newcmid) {
// Proceed with activity renaming before everything else. We don't use APIs here to avoid
// triggering a lot of create/update duplicated events.
if (!$newname) {
// Add ' (copy)' language string postfix to duplicated module.
$newname = get_string('duplicatedmodule', 'moodle', $cm->name);
}
if ($newname !== $cm->name) {
$this->rename($newcmid, $newname);
}
// Move the new module to the target section.
if (isset($targetsectionid) && $targetsectionid != $cm->section) {
$this->move_end_section($newcmid, $targetsection->id);
} else {
// Move the new module right after the original one, so it means before the next one.
$sectioninfo = $cm->get_section_info();
$cmsequence = $sectioninfo->get_sequence_cm_infos();
$cmidsequence = array_map(fn($cm) => $cm->id, $cmsequence); // We get the cmid => sequence key map.
$aftercmposition = array_search($cm->id, $cmidsequence, true);
$nextcm = null;
if (array_key_exists($aftercmposition + 1, $cmsequence)) {
$nextcm = $cmsequence[$aftercmposition + 1];
}
if ($nextcm) {
$this->move_before($newcmid, $nextcm->id);
} else {
$this->move_end_section($newcmid, $sectioninfo->id);
}
}
// Copy permission overrides to new course module.
$newcmcontext = \context_module::instance($newcmid);
$overrides = $DB->get_records('role_capabilities', ['contextid' => $cmcontext->id]);
foreach ($overrides as $override) {
$override->contextid = $newcmcontext->id;
unset($override->id);
$DB->insert_record('role_capabilities', $override);
}
// Copy locally assigned roles to new course module.
$overrides = $DB->get_records('role_assignments', ['contextid' => $cmcontext->id]);
foreach ($overrides as $override) {
$override->contextid = $newcmcontext->id;
unset($override->id);
$DB->insert_record('role_assignments', $override);
}
// Trigger course module created event. We can trigger the event only if we know the newcmid.
$newcm = get_fast_modinfo($cm->course)->get_cm($newcmid);
$event = \core\event\course_module_created::create_from_cm($newcm);
$event->trigger();
}
return $newcm ?? null;
}
/**
* Schedule a course module for deletion in the background using an adhoc task.
*
@@ -207,7 +207,7 @@ final class cmactions_test extends \advanced_testcase {
$executedhook = null;
$testcallback = function(after_cm_name_edited $hook) use (&$executedhook): void {
$testcallback = function (after_cm_name_edited $hook) use (&$executedhook): void {
$executedhook = $hook;
};
$this->redirectHook(after_cm_name_edited::class, $testcallback);
@@ -738,4 +738,408 @@ final class cmactions_test extends \advanced_testcase {
$cmactions = new cmactions($course);
$cmactions->set_groupmode(10000, VISIBLEGROUPS);
}
/**
* Test duplicating a course module.
*
* @param array $coursedata Array defining the course structure. Keys are section names, values are arrays of cm names.
* @param string $cmname Name of the course module to duplicate.
* @param string|null $sectionname Name of the section to duplicate into, or null to duplicate into the same section.
* @param string|null $newname New name for the duplicated course module, or null to use default naming
* (original name + ' (copy)').
* @param array $expected Expected result array with keys: 'section' (int), 'position' (int), 'name' (string).
* @return void
*
*/
#[\PHPUnit\Framework\Attributes\DataProvider('duplicate_provider')]
public function test_duplicate(
array $coursedata,
string $cmname,
?string $sectionname,
?string $newname,
array $expected
): void {
$this->resetAfterTest();
$course = $this->create_course_from_data($coursedata);
// Lookup cmid and sectionid based on names.
$cmactions = new cmactions($course);
$modinfo = get_fast_modinfo($course);
$targetsectionid = null;
$allcms = $modinfo->get_cms();
$allcmsbyname = array_combine(
array_map(fn($cminfo) => $cminfo->get_name(), $allcms),
$allcms
);
$cmid = $allcmsbyname[$cmname]->id;
$allsectionsbyname = $this->get_sections_by_name($course);
if ($sectionname !== null) {
$targetsectionid = $allsectionsbyname[$sectionname]->id;
}
// For backup/restore operations, we need to be logged in.
$this->setAdminUser();
$newcm = $cmactions->duplicate(
cmid: $cmid,
targetsectionid: $targetsectionid,
newname: $newname,
);
// Verify expected result.
$mappedcourse = [];
$modinfo = get_fast_modinfo($course); // Refresh modinfo.
foreach ($modinfo->get_section_info_all() as $sectioninfo) {
if (empty($sectioninfo->name)) {
continue; // Ignore sections without a name.
}
$mappedcourse[$sectioninfo->name] = [];
foreach ($sectioninfo->get_sequence_cm_infos() as $cminfo) {
$mappedcourse[$sectioninfo->name][] = $cminfo->name;
}
}
$this->assertEquals($expected, $mappedcourse);
// We ignore obvious differences and also sections information as it is already tested above (and
// can differ due to section movements).
$ignoredproperties = ['id', 'url', 'instance', 'added', 'context', 'section', 'sectionid', 'sectionnum'];
// Make sure they are the same, except obvious id changes.
foreach ($modinfo->get_cm($cmid) as $prop => $value) {
if (in_array($prop, $ignoredproperties, true)) {
// Ignore obviously different properties.
continue;
}
if ($prop == 'name') {
if (empty($newname)) {
$value = get_string('duplicatedmodule', 'moodle', $value);
} else {
$value = $newname;
}
}
$this->assertEquals($value, $newcm->$prop);
}
}
/**
* Data provider for test_duplicate.
*
* @return \Generator
*/
public static function duplicate_provider(): \Generator {
yield 'duplicate after current module, no name provided' => [
'coursedata' => [
'Section 1' => [
'cm1',
'cm2',
'cm3',
],
],
'cmname' => 'cm1',
'sectionname' => null,
'newname' => null,
'expected' => [
'Section 1' => [
'cm1',
'cm1 (copy)',
'cm2',
'cm3',
],
],
];
yield 'duplicate after current module, name provided' => [
'coursedata' => [
'Section 1' => [
'cm1',
'cm2',
'cm3',
],
],
'cmname' => 'cm1',
'sectionname' => null,
'newname' => 'New name',
'expected' => [
'Section 1' => [
'cm1',
'New name',
'cm2',
'cm3',
],
],
];
yield 'duplicate at the end of a section, name not provided' => [
'coursedata' => [
'Section 1' => [
'cm1',
],
'Section 2' => [
'cm2',
],
],
'cmname' => 'cm1',
'sectionname' => 'Section 2',
'newname' => null,
'expected' => [
'Section 1' => [
'cm1',
],
'Section 2' => [
'cm2',
'cm1 (copy)',
],
],
];
yield 'duplicate at the end of a section, name provided' => [
'coursedata' => [
'Section 1' => [
'cm1',
],
'Section 2' => [
'cm2',
],
],
'cmname' => 'cm1',
'sectionname' => 'Section 2',
'newname' => 'New name',
'expected' => [
'Section 1' => [
'cm1',
],
'Section 2' => [
'cm2',
'New name',
],
],
];
}
/**
* Test duplicating a course with wrong cmid (from another course).
*
* @return void
*/
public function test_duplicate_wrong_cm(): void {
$this->resetAfterTest();
$generator = $this->getDataGenerator();
$course1 = $generator->create_course();
$course2 = $generator->create_course();
$cm5 = $generator->create_module(
'assign',
['course' => $course2->id, 'name' => 'cm5', 'section' => 1],
);
// Lookup cmid and sectionid based on names.
$cmactions = new cmactions($course1);
$this->expectException(\moodle_exception::class);
$this->expectExceptionMessage('Invalid course module ID: ' . $cm5->cmid);
// For backup/restore operations, we need to be logged in.
$this->setAdminUser();
$this->assertFalse(
$cmactions->duplicate(
cmid: $cm5->cmid,
)
);
}
/**
* Test duplicating a course with wrong targetsectionid.
*
* @return void
*/
public function test_duplicate_wrong_targetsectionid(): void {
$this->resetAfterTest();
$course = $this->getDataGenerator()->create_course(['numsections' => 2]);
$cm = $this->getDataGenerator()->create_module(
'assign',
['course' => $course->id, 'name' => 'cm1', 'section' => 1],
);
$cmactions = new cmactions($course);
$this->expectException(\moodle_exception::class);
$this->expectExceptionMessage('This section does not exist');
// For backup/restore operations, we need to be logged in.
$this->setAdminUser();
$this->assertFalse(
$cmactions->duplicate(
cmid: $cm->cmid,
targetsectionid: 99999,
)
);
}
/**
* Test that duplicating a module triggers the expected event.
*/
public function test_duplicate_module_created_event(): void {
global $USER;
$this->resetAfterTest();
$this->setAdminUser();
// Create an assign module.
$sink = $this->redirectEvents();
$course = $this->getDataGenerator()->create_course();
$module = $this->getDataGenerator()->create_module('assign', ['course' => $course]);
$sink->clear(); // Make sure we only capture events from duplication.
// Lookup cmid and sectionid based on names.
$cmactions = new cmactions($course);
$newcm = $cmactions->duplicate($module->cmid);
$events = $sink->get_events();
$eventscount = 0;
$sink->close();
foreach ($events as $event) {
if ($event instanceof \core\event\course_module_created) {
$eventscount++;
// Validate event data.
$this->assertInstanceOf('\core\event\course_module_created', $event);
$this->assertEquals($newcm->id, $event->objectid);
$this->assertEquals($USER->id, $event->userid);
$this->assertEquals($course->id, $event->courseid);
$url = new \core\url('/mod/assign/view.php', ['id' => $newcm->id]);
$this->assertEquals($url, $event->get_url());
}
}
// Only one \core\event\course_module_created event should be triggered.
$this->assertEquals(1, $eventscount);
}
/**
* Test that permissions are correctly duplicated when duplicating a module.
*/
public function test_duplicate_module_permissions(): void {
global $DB;
$this->resetAfterTest();
$this->setAdminUser();
// Create course and course module.
$course = self::getDataGenerator()->create_course();
$res = self::getDataGenerator()->create_module('assign', ['course' => $course]);
$cm = get_coursemodule_from_id('assign', $res->cmid, 0, false, MUST_EXIST);
$cmcontext = \context_module::instance($cm->id);
// Enrol student user.
$user = self::getDataGenerator()->create_user();
$roleid = $DB->get_field('role', 'id', ['shortname' => 'student'], MUST_EXIST);
self::getDataGenerator()->enrol_user($user->id, $course->id, $roleid);
// Add capability to original course module.
assign_capability('gradereport/grader:view', CAP_ALLOW, $roleid, $cmcontext->id);
// Duplicate module.
$cmactions = new cmactions($course);
$newcm = $cmactions->duplicate($res->cmid);
$newcmcontext = \context_module::instance($newcm->id);
// Assert that user still has capability.
$this->assertTrue(has_capability('gradereport/grader:view', $newcmcontext, $user));
// Assert that both modules contain the same count of overrides.
$overrides = $DB->get_records('role_capabilities', ['contextid' => $cmcontext->id]);
$newoverrides = $DB->get_records('role_capabilities', ['contextid' => $newcmcontext->id]);
$this->assertEquals(count($overrides), count($newoverrides));
}
/**
* Test that calendar events are correctly duplicated when duplicating a module with a due date.
*/
public function test_duplicate_calendar_event(): void {
global $DB;
$this->resetAfterTest();
$this->setAdminUser();
$course = self::getDataGenerator()->create_course();
$duedate = time() + 3600;
$module = self::getDataGenerator()->create_module('assign', ['course' => $course, 'duedate' => $duedate]);
$event = $DB->get_record('event', [
'modulename' => 'assign',
'instance' => $module->id,
'eventtype' => 'due',
], '*', MUST_EXIST);
$this->assertEquals($duedate, $event->timestart);
$cmactions = new cmactions($course);
$newcm = $cmactions->duplicate($module->cmid);
$newevent = $DB->get_record('event', [
'modulename' => 'assign',
'instance' => $newcm->instance,
'eventtype' => 'due',
], '*', MUST_EXIST);
$this->assertEquals($duedate, $newevent->timestart);
}
/**
* Test that local permissions are correctly duplicated when duplicating a module.
*/
public function test_duplicate_module_role_assignments(): void {
global $DB;
$this->resetAfterTest();
$this->setAdminUser();
// Create course and course module.
$course = self::getDataGenerator()->create_course();
$res = self::getDataGenerator()->create_module('assign', ['course' => $course]);
$cm = get_coursemodule_from_id('assign', $res->cmid, 0, false, MUST_EXIST);
$cmcontext = \context_module::instance($cm->id);
// Enrol student user.
$user = self::getDataGenerator()->create_user();
$roleid = $DB->get_field('role', 'id', ['shortname' => 'student'], MUST_EXIST);
self::getDataGenerator()->enrol_user($user->id, $course->id, $roleid);
// Assign user a new local role.
$newroleid = $DB->get_field('role', 'id', ['shortname' => 'editingteacher'], MUST_EXIST);
role_assign($newroleid, $user->id, $cmcontext->id);
// Duplicate module.
$cmactions = new cmactions($course);
$newcm = $cmactions->duplicate($res->cmid);
$newcmcontext = \context_module::instance($newcm->id);
// Assert that user still has role assigned.
$this->assertTrue(user_has_role_assignment($user->id, $newroleid, $newcmcontext->id));
// Assert that both modules contain the same count of overrides.
$overrides = $DB->get_records('role_assignments', ['contextid' => $cmcontext->id]);
$newoverrides = $DB->get_records('role_assignments', ['contextid' => $newcmcontext->id]);
$this->assertEquals(count($overrides), count($newoverrides));
}
/**
* Helper function to create a course from given data.
*
* @param array $coursedata Array defining the course structure.
* @return \stdClass The created course object.
*/
private function create_course_from_data(array $coursedata): \stdClass {
$generator = $this->getDataGenerator();
$course = $generator->create_course(['numsections' => count($coursedata), 'initsections' => 1]);
$allsections = $this->get_sections_by_name($course);
$allsectionsbyname = array_filter($allsections, fn($section) => !empty($section->name));
// Create course modules as per $coursedata.
foreach ($coursedata as $sectionname => $cmlist) {
$section = $allsectionsbyname[$sectionname];
foreach ($cmlist as $cm) {
$generator->create_module(
'assign',
['course' => $course->id, 'name' => $cm, 'section' => $section->section],
);
}
}
return $course;
}
/**
* Helper function to get sections by name.
*
* @param \stdClass $course The course object.
* @return array Array of sections indexed by their names.
*/
private function get_sections_by_name(\stdClass $course): array {
$modinfo = get_fast_modinfo($course);
$allsections = $modinfo->get_section_info_all();
return array_combine(
array_map(fn($section) => $section->name, $allsections),
$allsections
);
}
}
+14 -129
View File
@@ -2543,136 +2543,21 @@ function mod_duplicate_activity($course, $cm, $sr = null) {
*
* @return cm_info|null cminfo object if we sucessfully duplicated the mod and found the new cm.
*/
#[\core\attribute\deprecated(
replacement: 'core_courseformat\local\cmactions',
since: '5.2',
mdl: 'MDL-86858',
reason: 'Replaced by an cmactions::duplicate.',
)]
function duplicate_module($course, $cm, ?int $sectionid = null, bool $changename = true): ?cm_info {
global $CFG, $DB, $USER;
require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php');
require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php');
require_once($CFG->libdir . '/filelib.php');
// Plugins with this feature flag set to false must ALWAYS be in section 0.
if (!course_modinfo::is_mod_type_visible_on_course($cm->modname)) {
if (get_fast_modinfo($course)->get_section_info(0, MUST_EXIST)->id != $sectionid) {
throw new coding_exception('Modules with FEATURE_CAN_DISPLAY set to false can not be moved from section 0');
}
}
$a = new stdClass();
$a->modtype = get_string('modulename', $cm->modname);
$a->modname = format_string($cm->name);
if (!plugin_supports('mod', $cm->modname, FEATURE_BACKUP_MOODLE2)) {
throw new moodle_exception('duplicatenosupport', 'error', '', $a);
}
// Backup the activity.
$bc = new backup_controller(backup::TYPE_1ACTIVITY, $cm->id, backup::FORMAT_MOODLE,
backup::INTERACTIVE_NO, backup::MODE_IMPORT, $USER->id);
$backupid = $bc->get_backupid();
$backupbasepath = $bc->get_plan()->get_basepath();
$bc->execute_plan();
$bc->destroy();
// Restore the backup immediately.
$rc = new restore_controller($backupid, $course->id,
backup::INTERACTIVE_NO, backup::MODE_IMPORT, $USER->id, backup::TARGET_CURRENT_ADDING);
// Make sure that the restore_general_groups setting is always enabled when duplicating an activity.
$plan = $rc->get_plan();
$groupsetting = $plan->get_setting('groups');
if (empty($groupsetting->get_value())) {
$groupsetting->set_value(true);
}
$cmcontext = context_module::instance($cm->id);
if (!$rc->execute_precheck()) {
$precheckresults = $rc->get_precheck_results();
if (is_array($precheckresults) && !empty($precheckresults['errors'])) {
if (empty($CFG->keeptempdirectoriesonbackup)) {
fulldelete($backupbasepath);
}
}
}
$rc->execute_plan();
// Now a bit hacky part follows - we try to get the cmid of the newly
// restored copy of the module.
$newcmid = null;
$tasks = $rc->get_plan()->get_tasks();
foreach ($tasks as $task) {
if (is_subclass_of($task, 'restore_activity_task')) {
if ($task->get_old_contextid() == $cmcontext->id) {
$newcmid = $task->get_moduleid();
break;
}
}
}
$rc->destroy();
if (empty($CFG->keeptempdirectoriesonbackup)) {
fulldelete($backupbasepath);
}
// If we know the cmid of the new course module, let us move it
// right below the original one. otherwise it will stay at the
// end of the section.
if ($newcmid) {
// Proceed with activity renaming before everything else. We don't use APIs here to avoid
// triggering a lot of create/update duplicated events.
$newcm = get_coursemodule_from_id($cm->modname, $newcmid, $cm->course);
if ($changename) {
// Add ' (copy)' language string postfix to duplicated module.
$newname = get_string('duplicatedmodule', 'moodle', $newcm->name);
set_coursemodule_name($newcm->id, $newname);
}
$section = get_fast_modinfo($course)->get_section_info_by_id($sectionid ?? $cm->section);
$action = formatactions::cm($course);
if (isset($sectionid)) {
$action->move_end_section($newcm->id, $section->id);
} else {
$modarray = explode(",", trim($section->sequence));
$cmindex = array_search($cm->id, $modarray);
if ($cmindex !== false && $cmindex < count($modarray) - 1) {
$beforecmid = $modarray[$cmindex + 1];
$action->move_before($newcm->id, $beforecmid);
}
}
// Update calendar events with the duplicated module.
// The following line is to be removed in MDL-58906.
course_module_update_calendar_events($newcm->modname, null, $newcm);
// Copy permission overrides to new course module.
$newcmcontext = context_module::instance($newcm->id);
$overrides = $DB->get_records('role_capabilities', ['contextid' => $cmcontext->id]);
foreach ($overrides as $override) {
$override->contextid = $newcmcontext->id;
unset($override->id);
$DB->insert_record('role_capabilities', $override);
}
// Copy locally assigned roles to new course module.
$overrides = $DB->get_records('role_assignments', ['contextid' => $cmcontext->id]);
foreach ($overrides as $override) {
$override->contextid = $newcmcontext->id;
unset($override->id);
$DB->insert_record('role_assignments', $override);
}
// Trigger course module created event. We can trigger the event only if we know the newcmid.
$newcm = get_fast_modinfo($cm->course)->get_cm($newcmid);
$event = \core\event\course_module_created::create_from_cm($newcm);
$event->trigger();
}
return isset($newcm) ? $newcm : null;
\core\deprecation::emit_deprecation(__FUNCTION__);
$modinfo = get_fast_modinfo($course);
$cm = $modinfo->get_cm($cm->id);
return formatactions::cm($course->id)->duplicate(
cmid: $cm->id,
targetsectionid: $sectionid,
newname: $changename ? null : $cm->name, // This is the opposite of changename, if we provide null the name is changed.
);
}
/**