diff --git a/classes/actions.php b/classes/actions.php index a2525ee..e40d684 100644 --- a/classes/actions.php +++ b/classes/actions.php @@ -350,9 +350,9 @@ public static function duplicate_to_course(array $modules, int $targetcourseid, // sorted by their id: // Let order of mods in a section be mod1, mod2, mod3, mod4, mod5. If we duplicate mod2, mod4, the order afterwards will be // mod1, mod2, mod3, mod4, mod5, mod2(dup), mod4(dup). - $duplicatedmods = []; - $cms = []; $errors = []; + $sourcecms = []; + $duplicatedmods = []; $filtersectionshook = new filter_sections_same_course($sourcecourseid, array_keys($sourcemodinfo->get_section_info_all())); \core\di::get(\core\hook\manager::class)->dispatch($filtersectionshook); $srcfilteredsections = $filtersectionshook->get_sectionnums(); @@ -363,28 +363,28 @@ public static function duplicate_to_course(array $modules, int $targetcourseid, if (!in_array($sourcecm->sectionnum, $srcfilteredsections)) { throw new moodle_exception('sectionrestricted', 'block_massaction', '', $sourcecm->sectionnum); } + $sourcecms[] = $sourcecm; + } - try { - $duplicatedmod = massactionutils::duplicate_cm_to_course( - $targetmodinfo->get_course(), - $sourcemodinfo->get_cm($cmid) - ); - } catch (\Exception $e) { - $errors[$cmid] = 'cmid:' . $cmid . '(' . $e->getMessage() . ')'; - $event = \block_massaction\event\course_modules_duplicated_failed::create( - [ - 'context' => \context_course::instance($sourcecourseid), - 'other' => [ - 'cmid' => $cmid, - 'error' => $errors[$cmid], - ], - ] - ); - $event->trigger(); - continue; - } - $cms[$cmid] = $duplicatedmod; - $duplicatedmods[] = $duplicatedmod; + try { + $duplicatedmods = massactionutils::duplicate_cms_to_course( + $targetmodinfo->get_course(), + $sourcecms + ); + } catch (\Exception $e) { + $event = \block_massaction\event\course_modules_duplicated_failed::create( + [ + 'context' => \context_course::instance($sourcecourseid), + 'other' => [ + 'cmid' => implode(',', array_map(function ($cm) { + return $cm->id; + }, $sourcecms)), + 'error' => $e->getMessage(), + ], + ] + ); + $event->trigger(); + $errors[] = $e->getMessage(); } // We need to reload new course structure. @@ -392,15 +392,15 @@ public static function duplicate_to_course(array $modules, int $targetcourseid, $targetsection = $targetmodinfo->get_section_info($sectionnum); if ($sectionnum != -1) { // A target section has been specified, so we have to move the course modules. - foreach ($duplicatedmods as $modid) { + foreach (array_values($duplicatedmods) as $modid) { moveto_module($targetmodinfo->get_cm($modid), $targetsection); } } $event = \block_massaction\event\course_modules_duplicated::create([ 'context' => \context_course::instance($sourcecourseid), 'other' => [ - 'cms' => $cms, - 'failed' => array_keys($errors), + 'cms' => $modules, + 'failed' => $errors, ], ]); $event->trigger(); diff --git a/classes/massaction_backup_controller.php b/classes/massaction_backup_controller.php new file mode 100644 index 0000000..af2c32b --- /dev/null +++ b/classes/massaction_backup_controller.php @@ -0,0 +1,53 @@ +. + +namespace block_massaction; + +use backup; +use backup_controller; + +/** + * A specialized controller for creating backups of multiple activities. + * + * @package block_massaction + * @copyright 2026 ISB Bayern + * @author Stefan Hanauska + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class massaction_backup_controller extends backup_controller { + /** + * Builds a backup controller for multiple activities. + * + * @param integer $userid The user ID for whom the backup is being created. + * @param array $cmids An array of course module IDs to be included in the backup. + */ + public function __construct(int $userid, array $cmids) { + if (empty($cmids)) { + throw new \invalid_argument_exception('At least one cmid must be provided'); + } + parent::__construct( + backup::TYPE_1ACTIVITY, + array_values($cmids)[0], + backup::FORMAT_MOODLE, + backup::INTERACTIVE_NO, + backup::MODE_IMPORT, + $userid + ); + $this->plan = new massaction_backup_plan($this); + $this->plan = massaction_backup_plan_builder::build_multiple_activities_plan($this, $cmids); + $this->plan->set_built(); + } +} diff --git a/classes/massaction_backup_plan.php b/classes/massaction_backup_plan.php new file mode 100644 index 0000000..ee313a5 --- /dev/null +++ b/classes/massaction_backup_plan.php @@ -0,0 +1,38 @@ +. + +namespace block_massaction; + +use backup_plan; + +/** + * A specialized backup plan that provides a method to set the built flag to true. + * + * @package block_massaction + * @copyright 2026 ISB Bayern + * @author Stefan Hanauska + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class massaction_backup_plan extends backup_plan { + /** + * Set built flag to true, so that the plan can be executed. This is needed as the plan is built in a separate builder class. + * + * @return void + */ + public function set_built() { + $this->built = true; + } +} diff --git a/classes/massaction_backup_plan_builder.php b/classes/massaction_backup_plan_builder.php new file mode 100644 index 0000000..0cfce03 --- /dev/null +++ b/classes/massaction_backup_plan_builder.php @@ -0,0 +1,57 @@ +. + +namespace block_massaction; + +use backup_controller; +use backup_plan_builder; +use backup_root_task; +use backup_final_task; + +/** + * A specialized backup plan builder that provides a method to build a plan for creating a backup from multiple activities. + * + * @package block_massaction + * @copyright 2026 ISB Bayern + * @author Stefan Hanauska + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class massaction_backup_plan_builder extends backup_plan_builder { + /** + * Builds a plan that creates a backup from multiple activities. + * + * @param backup_controller $controller The backup controller for which the plan is being built. + * @param array $cmids An array of course module IDs to be included in the backup. + * @return massaction_backup_plan + */ + public static function build_multiple_activities_plan(backup_controller $controller, array $cmids): massaction_backup_plan { + if (empty($cmids)) { + throw new \invalid_argument_exception('At least one cmid must be provided'); + } + + $plan = $controller->get_plan(); + + $plan->add_task(new backup_root_task('root_task')); + + foreach ($cmids as $cmid) { + self::build_activity_plan($controller, $cmid); + } + + $plan->add_task(new backup_final_task('final_task')); + + return $plan; + } +} diff --git a/classes/massaction_restore_controller.php b/classes/massaction_restore_controller.php new file mode 100644 index 0000000..873c563 --- /dev/null +++ b/classes/massaction_restore_controller.php @@ -0,0 +1,55 @@ +. + +namespace block_massaction; + +use backup; +use restore_controller; + +/** + * Class massaction_restore_controller + * + * @package block_massaction + * @copyright 2026 ISB Bayern + * @author Stefan Hanauska + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class massaction_restore_controller extends restore_controller { + /** + * Constructs a massaction_restore_controller instance. + * + * @param string $tempdir The temporary directory where the backup file is located. + * @param integer $courseid The ID of the course into which the backup will be restored. + * @param integer $userid The user ID for whom the restore is being performed. + */ + public function __construct(string $tempdir, int $courseid, int $userid) { + parent::__construct( + $tempdir, + $courseid, + backup::INTERACTIVE_NO, + backup::MODE_IMPORT, + $userid, + backup::TARGET_CURRENT_ADDING + ); + $cmids = array_keys($this->get_info()->activities); + $this->progress = new \core\progress\none(); + $this->progress->start_progress('Constructing restore_controller'); + $this->plan = new massaction_restore_plan($this); + $this->plan = massaction_restore_plan_builder::build_multiple_activities_plan($this, $cmids); + $this->plan->set_built(); + $this->progress->end_progress(); + } +} diff --git a/classes/massaction_restore_plan.php b/classes/massaction_restore_plan.php new file mode 100644 index 0000000..99f1447 --- /dev/null +++ b/classes/massaction_restore_plan.php @@ -0,0 +1,38 @@ +. + +namespace block_massaction; + +use restore_plan; + +/** + * A specialized restore plan that provides a method to set the built flag to true. + * + * @package block_massaction + * @copyright 2026 ISB Bayern + * @author Stefan Hanauska + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class massaction_restore_plan extends restore_plan { + /** + * Set built flag to true, so that the plan can be executed. This is needed as the plan is built in a separate builder class. + * + * @return void + */ + public function set_built() { + $this->built = true; + } +} diff --git a/classes/massaction_restore_plan_builder.php b/classes/massaction_restore_plan_builder.php new file mode 100644 index 0000000..1852ef7 --- /dev/null +++ b/classes/massaction_restore_plan_builder.php @@ -0,0 +1,59 @@ +. + +namespace block_massaction; + +use restore_plan_builder; +use restore_root_task; +use restore_final_task; +/** + * A specialized restore plan builder that provides a method to build a plan for restoring multiple activities. + * + * @package block_massaction + * @copyright 2026 ISB Bayern + * @author Stefan Hanauska + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class massaction_restore_plan_builder extends restore_plan_builder { + /** + * Builds a plan that restores a backup of multiple activities. + * + * @param massaction_restore_controller $controller The restore controller for which the plan is being built. + * @param array $cmids An array of course module IDs that should be restored. + * @return massaction_restore_plan + */ + public static function build_multiple_activities_plan(massaction_restore_controller $controller, array $cmids): massaction_restore_plan { + if (empty($cmids)) { + throw new \invalid_argument_exception('At least one cmid must be provided'); + } + + $plan = $controller->get_plan(); + + $plan->add_task(new restore_root_task('root_task')); + $controller->get_progress()->progress(); + + foreach ($cmids as $cmid) { + self::build_activity_plan($controller, $cmid); + } + + $plan->add_task(new restore_final_task('final_task')); + $controller->get_progress()->progress(); + + $plan->set_built(); + + return $plan; + } +} diff --git a/classes/massactionutils.php b/classes/massactionutils.php index baef940..4a6c571 100644 --- a/classes/massactionutils.php +++ b/classes/massactionutils.php @@ -169,4 +169,93 @@ public static function duplicate_cm_to_course(object $course, object $cm): int { } return $newcmid; } + + /** + * Duplicate multiple course modules to a given course. This method bundles the course modules into one backup to + * keep dependencies between the modules. + * + * @param object $course + * @param array $cms array of course module objects to be duplicated + * @return array mapping of old cmid to new cmid + */ + public static function duplicate_cms_to_course(object $course, array $cms): array { + global $CFG, $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'); + + if (empty($cms)) { + return []; + } + + // Verify all modules support backup. + $cmids = []; + foreach ($cms as $cm) { + if (!plugin_supports('mod', $cm->modname, FEATURE_BACKUP_MOODLE2)) { + $a = new stdClass(); + $a->modtype = get_string('modulename', $cm->modname); + $a->modname = format_string($cm->name); + throw new moodle_exception('duplicatenosupport', 'error', '', $a); + } + $cmids[] = $cm->id; + } + + // Create specialized backup controller. + $bc = new massaction_backup_controller($USER->id, $cmids); + + $backupid = $bc->get_backupid(); + $backupbasepath = $bc->get_plan()->get_basepath(); + + $bc->execute_plan(); + $bc->destroy(); + + // Restore the backup to the target course. + $rc = new massaction_restore_controller($backupid, $course->id, $USER->id); + + // Make sure that the restore_general_groups setting is always enabled. + $plan = $rc->get_plan(); + $groupsetting = $plan->get_setting('groups'); + if (empty($groupsetting->get_value())) { + $groupsetting->set_value(true); + } + + 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(); + + // Get the mapping of old cmids to new cmids. + $newcmids = []; + $tasks = $rc->get_plan()->get_tasks(); + foreach ($tasks as $task) { + if (is_subclass_of($task, 'restore_activity_task')) { + $oldcontextid = $task->get_old_contextid(); + $newcmid = $task->get_moduleid(); + + // Find which original cm matches this context. + foreach ($cms as $cm) { + $cmcontext = context_module::instance($cm->id); + if ($cmcontext->id == $oldcontextid) { + $newcmids[$cm->id] = $newcmid; + break; + } + } + } + } + + $rc->destroy(); + + // Clean up the backup files if needed. + if (empty($CFG->keeptempdirectoriesonbackup)) { + fulldelete($backupbasepath); + } + + return $newcmids; + } }