diff --git a/backup/moodle2/backup_plan_builder.class.php b/backup/moodle2/backup_plan_builder.class.php index 49244870e4e..508635997d8 100644 --- a/backup/moodle2/backup_plan_builder.class.php +++ b/backup/moodle2/backup_plan_builder.class.php @@ -133,6 +133,9 @@ abstract class backup_plan_builder { try { $plan->add_task(backup_factory::get_backup_activity_task($controller->get_format(), $id)); + // Some activities may have delegated section integrations. + self::build_delegated_section_plan($controller, $id); + // For the given activity, add as many block tasks as necessary $blockids = backup_plan_dbops::get_blockids_from_moduleid($id); foreach ($blockids as $blockid) { @@ -150,6 +153,44 @@ abstract class backup_plan_builder { } } + /** + * Build a course module delegated section backup plan. + * @param backup_controller $controller + * @param int $cmid the parent course module id. + */ + protected static function build_delegated_section_plan($controller, $cmid) { + global $CFG, $DB; + + // Check moduleid exists. + if (!$coursemodule = get_coursemodule_from_id(false, $cmid)) { + $controller->log(get_string('error_course_module_not_found', 'backup', $cmid), backup::LOG_WARNING); + } + $classname = 'mod_' . $coursemodule->modname . '\courseformat\sectiondelegate'; + if (!class_exists($classname)) { + return; + } + $sectionid = null; + try { + $sectionid = $classname::delegated_section_id($coursemodule); + } catch (dml_exception $error) { + $controller->log(get_string('error_delegate_section_not_found', 'backup', $cmid), backup::LOG_WARNING); + return; + } + + $plan = $controller->get_plan(); + $sectiontask = backup_factory::get_backup_section_task($controller->get_format(), $sectionid); + $sectiontask->set_delegated_cm($cmid); + $plan->add_task($sectiontask); + + // For the given section, add as many activity tasks as necessary. + $coursemodules = backup_plan_dbops::get_modules_from_sectionid($sectionid); + foreach ($coursemodules as $coursemodule) { + if (plugin_supports('mod', $coursemodule->modname, FEATURE_BACKUP_MOODLE2)) { + self::build_activity_plan($controller, $coursemodule->id); + } + } + } + /** * Build one 1-section backup */ @@ -185,8 +226,13 @@ abstract class backup_plan_builder { // For the given course, add as many section tasks as necessary $sections = backup_plan_dbops::get_sections_from_courseid($id); - foreach ($sections as $section) { - self::build_section_plan($controller, $section); + foreach ($sections as $sectionid) { + // Delegated sections are not course responsability. + $sectiondata = backup_plan_dbops::get_section_from_id($sectionid); + if (!empty($sectiondata->component)) { + continue; + } + self::build_section_plan($controller, $sectionid); } // For the given course, add as many block tasks as necessary diff --git a/backup/moodle2/backup_section_task.class.php b/backup/moodle2/backup_section_task.class.php index 16f33a70387..6389c3e6a87 100644 --- a/backup/moodle2/backup_section_task.class.php +++ b/backup/moodle2/backup_section_task.class.php @@ -42,6 +42,11 @@ class backup_section_task extends backup_task { */ protected stdClass $section; + /** + * @var int|null $delegatedcmid the course module that is delegating this section (if any) + */ + protected ?int $delegatedcmid = null; + /** * Constructor - instantiates one object of this class */ @@ -59,6 +64,39 @@ class backup_section_task extends backup_task { parent::__construct($name, $plan); } + /** + * Set the course module that is delegating this section. + * + * Delegated section can belong to any kind of plugin. However, when a delegated + * section belongs to a course module, the UI will present all settings according. + * + * @param int $cmid the course module id that is delegating this section + */ + public function set_delegated_cm(int $cmid) { + $this->delegatedcmid = $cmid; + } + + /** + * Get the course module that is delegating this section. + * + * @return int|null the course module id that is delegating this section + */ + public function get_delegated_cm(): ?int { + return $this->delegatedcmid; + } + + /** + * Get the delegate activity modname (if any). + * + * @return string|null the modname of the delegated activity + */ + public function get_modname(): ?string { + if (empty($this->section->component)) { + return null; + } + return core_component::normalize_component($this->section->component)[1]; + } + public function get_sectionid() { return $this->sectionid; } diff --git a/backup/moodle2/backup_stepslib.php b/backup/moodle2/backup_stepslib.php index e5496e40874..a2b99c4b997 100644 --- a/backup/moodle2/backup_stepslib.php +++ b/backup/moodle2/backup_stepslib.php @@ -119,6 +119,20 @@ abstract class backup_activity_structure_step extends backup_structure_step { // Return the root element (activity) return $activity; } + + /** + * Set a delegate section itemid mapping. + * + * @param string $pluginname the name of the plugin that is delegating the section. + * @param int $itemid the itemid of the section being delegated. + */ + protected function set_delegated_section_mapping(string $pluginname, int $itemid) { + backup_structure_dbops::insert_backup_ids_record( + $this->get_backupid(), + "course_section::$pluginname::$itemid", + $this->task->get_moduleid() + ); + } } /** @@ -2128,8 +2142,11 @@ class backup_main_structure_step extends backup_structure_step { $sections = new backup_nested_element('sections'); - $section = new backup_nested_element('section', null, array( - 'sectionid', 'title', 'directory')); + $section = new backup_nested_element( + 'section', + null, + ['sectionid', 'title', 'directory', 'parentcmid', 'modname'] + ); $course = new backup_nested_element('course', null, array( 'courseid', 'title', 'directory')); diff --git a/backup/moodle2/restore_plan_builder.class.php b/backup/moodle2/restore_plan_builder.class.php index 9246dc41c5a..b5cb20d6d5e 100644 --- a/backup/moodle2/restore_plan_builder.class.php +++ b/backup/moodle2/restore_plan_builder.class.php @@ -144,6 +144,9 @@ abstract class restore_plan_builder { $plan->add_task($task); $controller->get_progress()->progress(); + // Some activities may have delegated section integrations. + self::build_delegated_section_plan($controller, $infoactivity->moduleid); + // For the given activity path, add as many block tasks as necessary // TODO: Add blocks, we need to introspect xml here $blocks = backup_general_helper::get_blocks_from_path($task->get_taskbasepath()); @@ -161,6 +164,30 @@ abstract class restore_plan_builder { } + /** + * Build a course module delegated section backup plan. + * @param restore_controller $controller + * @param int $cmid the parent course module id. + */ + protected static function build_delegated_section_plan($controller, $cmid) { + $info = $controller->get_info(); + + // Find if some section depends on that course module. + $delegatedsectionid = null; + foreach ($info->sections as $sectionid => $section) { + // Delegated sections are not course responsability. + if (isset($section->parentcmid) && $section->parentcmid == $cmid) { + $delegatedsectionid = $sectionid; + break; + } + } + + if (!$delegatedsectionid) { + return; + } + self::build_section_plan($controller, $delegatedsectionid); + } + /** * Restore one 1-section backup */ @@ -215,6 +242,10 @@ abstract class restore_plan_builder { // For the given course, add as many section tasks as necessary foreach ($info->sections as $sectionid => $section) { + // Delegated sections are not course responsability. + if (isset($section->parentcmid) && !empty($section->parentcmid)) { + continue; + } self::build_section_plan($controller, $sectionid); } } diff --git a/backup/moodle2/restore_stepslib.php b/backup/moodle2/restore_stepslib.php index 22015c82005..98cac314e73 100644 --- a/backup/moodle2/restore_stepslib.php +++ b/backup/moodle2/restore_stepslib.php @@ -1612,8 +1612,28 @@ class restore_section_structure_step extends restore_structure_step { $section->course = $this->get_courseid(); $section->section = $data->number; $section->timemodified = $data->timemodified ?? 0; + $section->component = null; + $section->itemid = null; + + $secrec = $DB->get_record( + 'course_sections', + ['course' => $this->get_courseid(), 'section' => $data->number, 'component' => null] + ); + $createsection = empty($secrec); + + // Delegated sections are always restored as new sections. + if (!empty($data->component)) { + $section->itemid = $this->get_delegated_section_mapping($data->component, $data->itemid); + // If the delegate component does not set the mapping id, the section must be converted + // into a regular section. Otherwise, it won't be accessible. + $createsection = $createsection || $section->itemid !== null; + $section->component = ($section->itemid !== null) ? $data->component : null; + // The section number will be always the last of the course, no matter the case. + $section->section = $this->get_last_section_number($this->get_courseid()) + 1; + + } // Section doesn't exist, create it with all the info from backup - if (!$secrec = $DB->get_record('course_sections', ['course' => $this->get_courseid(), 'section' => $data->number])) { + if ($createsection) { $section->name = $data->name; $section->summary = $data->summary; $section->summaryformat = $data->summaryformat; @@ -1629,8 +1649,10 @@ class restore_section_structure_step extends restore_structure_step { $data, true); } } - $section->component = $data->component ?? null; - $section->itemid = $data->itemid ?? null; + + // Delegated sections should be always after the normal sections. + $this->displace_delegated_sections_after($section->section); + $newitemid = $DB->insert_record('course_sections', $section); $section->id = $newitemid; @@ -1802,6 +1824,57 @@ class restore_section_structure_step extends restore_structure_step { // Add section related files, with 'course_section' itemid to match $this->add_related_files('course', 'section', 'course_section'); } + + /** + * Create a delegate section mapping. + * + * @param string $component the component name (frankenstyle) + * @param int $oldsectionid The old section id. + * @return int|null The new section id or null if not found. + */ + protected function get_delegated_section_mapping($component, $oldsectionid): ?int { + $result = $this->get_mappingid("course_section::$component", $oldsectionid, null); + return $result; + } + + /** + * Displace delegated sections after the given section number. + * + * @param int $sectionnum The section number. + */ + protected function displace_delegated_sections_after(int $sectionnum): void { + global $DB; + + $sectionstomove = $DB->get_records_select( + 'course_sections', + 'course = ? AND component IS NOT NULL', + [$this->get_courseid()], + 'section DESC', 'id, section' + ); + foreach ($sectionstomove as $section) { + $sectionnum++; + $section->section = $sectionnum; + $DB->update_record('course_sections', $section); + } + } + + /** + * Get the last section number in the course. + * + * @param int $courseid The course id. + * @param bool $includedelegated If true, include delegated sections in the count. + * @return int The last section number. + */ + protected function get_last_section_number(int $courseid, bool $includedelegated = false): int { + global $DB; + + $delegtadefilter = $includedelegated ? '' : ' AND component IS NULL'; + + return (int) $DB->get_field_sql( + 'SELECT max(section) from {course_sections} WHERE course = ?' . $delegtadefilter, + [$courseid] + ); + } } /** @@ -4905,6 +4978,17 @@ abstract class restore_activity_structure_step extends restore_structure_step { $oldid = $this->task->get_old_activityid(); $this->set_mapping($modulename, $oldid, $newitemid, true); } + + /** + * Create a delegate section mapping. + * + * @param string $component The component name (frankenstyle) + * @param int $olditemid The old section id. + * @param int $newitemid The new section id. + */ + protected function set_delegated_section_mapping($component, $olditemid, $newitemid) { + $this->set_mapping("course_section::$component", $olditemid, $newitemid); + } } /** diff --git a/backup/moodle2/tests/restore_stepslib_test.php b/backup/moodle2/tests/restore_stepslib_test.php index 6a8eb5e162f..d5a2685823c 100644 --- a/backup/moodle2/tests/restore_stepslib_test.php +++ b/backup/moodle2/tests/restore_stepslib_test.php @@ -103,7 +103,7 @@ class restore_stepslib_test extends \advanced_testcase { } /** - * Test for the section structure step included elements. + * Test for delegate section behaviour. * * @covers \restore_section_structure_step::process_section */ @@ -114,7 +114,7 @@ class restore_stepslib_test extends \advanced_testcase { $this->setAdminUser(); $course = $this->getDataGenerator()->create_course(['numsections' => 2, 'format' => 'topics']); - // Section 2 has an existing delegate class. + // Section 2 has an existing delegate class for component that is not an activity. course_update_section( $course, $DB->get_record('course_sections', ['course' => $course->id, 'section' => 2]), @@ -130,16 +130,18 @@ class restore_stepslib_test extends \advanced_testcase { $originalsections = get_fast_modinfo($course->id)->get_section_info_all(); $restoredsections = get_fast_modinfo($newcourseid)->get_section_info_all(); - $this->assertEquals(count($originalsections), count($restoredsections)); + // Delegated sections depends on the plugin to be backuped and restored. + // In this case, the plugin is not backuped and restored, so the section is not restored. + $this->assertEquals(3, count($originalsections)); + $this->assertEquals(2, count($restoredsections)); $validatefields = ['name', 'summary', 'summaryformat', 'visible', 'component', 'itemid']; $this->assertEquals($originalsections[1]->name, $restoredsections[1]->name); foreach ($validatefields as $field) { + $this->assertEquals($originalsections[0]->$field, $restoredsections[0]->$field); $this->assertEquals($originalsections[1]->$field, $restoredsections[1]->$field); - $this->assertEquals($originalsections[2]->$field, $restoredsections[2]->$field); } - } } diff --git a/backup/util/dbops/backup_controller_dbops.class.php b/backup/util/dbops/backup_controller_dbops.class.php index 2bc367fd476..4992fb2e703 100644 --- a/backup/util/dbops/backup_controller_dbops.class.php +++ b/backup/util/dbops/backup_controller_dbops.class.php @@ -268,7 +268,10 @@ abstract class backup_controller_dbops extends backup_dbops { $contentinfo = array( 'sectionid' => $task->get_sectionid(), 'title' => $task->get_name(), - 'directory' => 'sections/' . 'section_' . $task->get_sectionid()); + 'directory' => 'sections/' . 'section_' . $task->get_sectionid(), + 'parentcmid' => $task->get_delegated_cm() ?? '', + 'modname' => $task->get_modname() ?? '', + ); // Now get section settings // Calculate prefix to find valid settings diff --git a/backup/util/dbops/backup_plan_dbops.class.php b/backup/util/dbops/backup_plan_dbops.class.php index d3a650bf91e..4a962ddfa6e 100644 --- a/backup/util/dbops/backup_plan_dbops.class.php +++ b/backup/util/dbops/backup_plan_dbops.class.php @@ -120,6 +120,17 @@ abstract class backup_plan_dbops extends backup_dbops { return $sectionsarr; } + /** + * Given one section id, returns the full section record. + * + * @param int $sectionid + * @return stdClass + */ + public static function get_section_from_id($sectionid): stdClass { + global $DB; + return $DB->get_record('course_sections', ['id' => $sectionid]); + } + /** * Given one course id, return its format in DB */ diff --git a/course/format/classes/sectiondelegatemodule.php b/course/format/classes/sectiondelegatemodule.php index 124ad1b34f8..61e5a2c7363 100644 --- a/course/format/classes/sectiondelegatemodule.php +++ b/course/format/classes/sectiondelegatemodule.php @@ -52,6 +52,29 @@ abstract class sectiondelegatemodule extends sectiondelegate { ); } + /** + * Get the delegated section id controlled by a specific cm. + * + * This method is used when reverse search is needed bu we cannot access the database. + * This happens mostly on backup and restore. Do NOT use for normal operations. + * + * @param stdClass|cm_info $cm a course module compatible data structure. + * @return int the section id. + */ + public static function delegated_section_id(stdClass|cm_info $cm): int { + global $DB; + return $DB->get_field( + 'course_sections', + 'id', + [ + 'course' => $cm->course, + 'component' => explode('\\', static::class)[0], + 'itemid' => $cm->instance, + ], + MUST_EXIST + ); + } + /** * Get the parent section of the current delegated section. * diff --git a/lang/en/backup.php b/lang/en/backup.php index 9fa54d69ea6..6f819156d76 100644 --- a/lang/en/backup.php +++ b/lang/en/backup.php @@ -198,6 +198,7 @@ $string['enableasyncbackup_help'] = 'If enabled, backup and restore operations w $string['enterasearch'] = 'Enter a search'; $string['error_block_for_module_not_found'] = 'Orphan block instance (id: {$a->bid}) for course module (id: {$a->mid}) found. This block will not be backed up'; $string['error_course_module_not_found'] = 'Orphan course module (id: {$a}) found. This module will not be backed up.'; +$string['error_delegate_section_not_found'] = 'Missing delegate section form course module (id: {$a}. The section will not be backed up.'; $string['errorcopyingbackupfile'] = "Failed to copy the backup file to the temporary folder before restoring."; $string['errorfilenamerequired'] = 'You must enter a valid filename for this backup'; $string['errorfilenametoolong'] = 'The filename must be less than 255 characters in length.';