diff --git a/backup/moodle2/backup_stepslib.php b/backup/moodle2/backup_stepslib.php index 642e815b2cb..340bc33490b 100644 --- a/backup/moodle2/backup_stepslib.php +++ b/backup/moodle2/backup_stepslib.php @@ -400,9 +400,14 @@ class backup_section_structure_step extends backup_structure_step { // Define each element separated - $section = new backup_nested_element('section', array('id'), array( + $section = new backup_nested_element( + 'section', + ['id'], + [ 'number', 'name', 'summary', 'summaryformat', 'sequence', 'visible', - 'availabilityjson', 'timemodified')); + 'availabilityjson', 'component', 'itemid', 'timemodified', + ] + ); // attach format plugin structure to $section element, only one allowed $this->add_plugin_structure('format', $section, false); diff --git a/backup/moodle2/restore_stepslib.php b/backup/moodle2/restore_stepslib.php index 917438db1a5..a715c372035 100644 --- a/backup/moodle2/restore_stepslib.php +++ b/backup/moodle2/restore_stepslib.php @@ -1629,6 +1629,8 @@ class restore_section_structure_step extends restore_structure_step { $data, true); } } + $section->component = $data->component ?? null; + $section->itemid = $data->itemid ?? null; $newitemid = $DB->insert_record('course_sections', $section); $section->id = $newitemid; diff --git a/backup/moodle2/tests/backup_stepslib_test.php b/backup/moodle2/tests/backup_stepslib_test.php new file mode 100644 index 00000000000..525ab9c2a0e --- /dev/null +++ b/backup/moodle2/tests/backup_stepslib_test.php @@ -0,0 +1,66 @@ +. + +namespace core_backup; + +/** + * Tests for Moodle 2 steplib classes. + * + * @package core_backup + * @copyright 2023 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class backup_stepslib_test extends \advanced_testcase { + /** + * Setup to include all libraries. + */ + public static function setUpBeforeClass(): void { + global $CFG; + require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php'); + require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php'); + require_once($CFG->dirroot . '/backup/moodle2/backup_stepslib.php'); + } + + /** + * Test for the section structure step included elements. + * + * @covers \backup_section_structure_step::define_structure + */ + public function test_backup_section_structure_step(): void { + $this->resetAfterTest(); + $course = $this->getDataGenerator()->create_course(['numsections' => 3, 'format' => 'topics']); + $this->setAdminUser(); + + $step = new \backup_section_structure_step('section_commons', 'section.xml'); + + $reflection = new \ReflectionClass($step); + $method = $reflection->getMethod('define_structure'); + $method->setAccessible(true); + $structure = $method->invoke($step); + + $elements = $structure->get_final_elements(); + $this->assertArrayHasKey('number', $elements); + $this->assertArrayHasKey('name', $elements); + $this->assertArrayHasKey('summary', $elements); + $this->assertArrayHasKey('summaryformat', $elements); + $this->assertArrayHasKey('sequence', $elements); + $this->assertArrayHasKey('visible', $elements); + $this->assertArrayHasKey('availabilityjson', $elements); + $this->assertArrayHasKey('component', $elements); + $this->assertArrayHasKey('itemid', $elements); + $this->assertArrayHasKey('timemodified', $elements); + } +} diff --git a/backup/moodle2/tests/restore_stepslib_test.php b/backup/moodle2/tests/restore_stepslib_test.php new file mode 100644 index 00000000000..3ffcbe6ae58 --- /dev/null +++ b/backup/moodle2/tests/restore_stepslib_test.php @@ -0,0 +1,144 @@ +. + +namespace core_backup; + +use backup; + +/** + * Tests for Moodle 2 restore steplib classes. + * + * @package core_backup + * @copyright 2023 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class restore_stepslib_test extends \advanced_testcase { + /** + * Setup to include all libraries. + */ + public static function setUpBeforeClass(): void { + global $CFG; + require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php'); + require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php'); + require_once($CFG->dirroot . '/backup/moodle2/restore_stepslib.php'); + } + + /** + * Makes a backup of the course. + * + * @param \stdClass $course The course object. + * @return string Unique identifier for this backup. + */ + protected function backup_course(\stdClass $course): string { + global $CFG, $USER; + + // Turn off file logging, otherwise it can't delete the file (Windows). + $CFG->backup_file_logger_level = backup::LOG_NONE; + + // Do backup with default settings. MODE_IMPORT means it will just + // create the directory and not zip it. + $bc = new \backup_controller( + backup::TYPE_1COURSE, + $course->id, + backup::FORMAT_MOODLE, + backup::INTERACTIVE_NO, + backup::MODE_IMPORT, + $USER->id + ); + $backupid = $bc->get_backupid(); + $bc->execute_plan(); + $bc->destroy(); + + return $backupid; + } + + /** + * Restores a backup that has been made earlier. + * + * @param string $backupid The unique identifier of the backup. + * @return int The new course id. + */ + protected function restore_replacing_content(string $backupid): int { + global $CFG, $USER; + + // Create course to restore into, and a user to do the restore. + $generator = $this->getDataGenerator(); + $course = $generator->create_course(); + + // Turn off file logging, otherwise it can't delete the file (Windows). + $CFG->backup_file_logger_level = backup::LOG_NONE; + + // Do restore to new course with default settings. + $rc = new \restore_controller( + $backupid, + $course->id, + backup::INTERACTIVE_NO, + backup::MODE_GENERAL, + $USER->id, + backup::TARGET_EXISTING_DELETING + ); + + $precheck = $rc->execute_precheck(); + $this->assertTrue($precheck); + $rc->get_plan()->get_setting('role_assignments')->set_value(true); + $rc->get_plan()->get_setting('permissions')->set_value(true); + $rc->execute_plan(); + $rc->destroy(); + + return $course->id; + } + + /** + * Test for the section structure step included elements. + * + * @covers \restore_section_structure_step::process_section + */ + public function test_restore_section_structure_step(): void { + global $DB; + + $this->resetAfterTest(); + $this->setAdminUser(); + + $course = $this->getDataGenerator()->create_course(['numsections' => 2, 'format' => 'topics']); + // Section 2 has an existing delegate class. + course_update_section( + $course, + $DB->get_record('course_sections', ['course' => $course->id, 'section' => 2]), + [ + 'component' => 'test_component', + 'itemid' => 1, + ] + ); + + $backupid = $this->backup_course($course); + $newcourseid = $this->restore_replacing_content($backupid); + + $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)); + + $validatefields = ['name', 'summary', 'summaryformat', 'visible', 'component', 'itemid']; + + $this->assertEquals($originalsections[1]->name, $restoredsections[1]->name); + + foreach ($validatefields as $field) { + $this->assertEquals($originalsections[1]->$field, $restoredsections[1]->$field); + $this->assertEquals($originalsections[2]->$field, $restoredsections[2]->$field); + } + + } +} diff --git a/course/format/classes/sectiondelegate.php b/course/format/classes/sectiondelegate.php new file mode 100644 index 00000000000..ff5b7ad4983 --- /dev/null +++ b/course/format/classes/sectiondelegate.php @@ -0,0 +1,58 @@ +. + +namespace core_courseformat; + +use section_info; + +/** + * Section delegate base class. + * + * Plugins using delegate sections must extend this class into + * their PLUGINNAME\courseformat\sectiondelegate class. + * + * @package core_courseformat + * @copyright 2023 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class sectiondelegate { + + /** + * Constructor. + * @param section_info $sectioninfo + */ + public function __construct( + protected section_info $sectioninfo + ) { + } + + /** + * Get the section info instance if available. + * + * @param section_info $sectioninfo + * @return section_info|null + */ + public static function instance(section_info $sectioninfo): ?self { + if (empty($sectioninfo->component)) { + return null; + } + $classname = $sectioninfo->component . '\courseformat\sectiondelegate'; + if (!class_exists($classname)) { + return null; + } + return new $classname($sectioninfo); + } +} diff --git a/course/format/tests/sectiondelegate_test.php b/course/format/tests/sectiondelegate_test.php new file mode 100644 index 00000000000..ffe4d0069e4 --- /dev/null +++ b/course/format/tests/sectiondelegate_test.php @@ -0,0 +1,75 @@ +. + +namespace core_courseformat; + +/** + * Section delaegate tests. + * + * @package core_course + * @copyright 2023 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \core_courseformat\sectiondelegate + * @coversDefaultClass \core_courseformat\sectiondelegate + */ +class sectiondelegate_test extends \advanced_testcase { + + /** + * Setup to ensure that fixtures are loaded. + */ + public static function setUpBeforeClass(): void { + global $CFG; + require_once($CFG->libdir . '/tests/fixtures/sectiondelegatetest.php'); + } + + /** + * Test that the instance method returns the correct class. + * @covers ::instance + */ + public function test_instance(): void { + global $DB; + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(['format' => 'topics', 'numsections' => 3]); + + // Section 2 has an existing delegate class. + course_update_section( + $course, + $DB->get_record('course_sections', ['course' => $course->id, 'section' => 2]), + [ + 'component' => 'test_component', + 'itemid' => 1, + ] + ); + + // Section 3 has a missing delegate class. + course_update_section( + $course, + $DB->get_record('course_sections', ['course' => $course->id, 'section' => 3]), + [ + 'component' => 'missing_component', + 'itemid' => 1, + ] + ); + + $modinfo = get_fast_modinfo($course->id); + $sectioninfos = $modinfo->get_section_info_all(); + + $this->assertNull(sectiondelegate::instance($sectioninfos[1])); + $this->assertInstanceOf('\test_component\courseformat\sectiondelegate', sectiondelegate::instance($sectioninfos[2])); + $this->assertNull(sectiondelegate::instance($sectioninfos[3])); + } +} diff --git a/course/format/upgrade.txt b/course/format/upgrade.txt index 0aeb8026889..121afc8c2d3 100644 --- a/course/format/upgrade.txt +++ b/course/format/upgrade.txt @@ -46,6 +46,7 @@ always linked because a new page, section.php, has been created to display any s - course/format/renderer.php - course/format/topics/renderer.php - course/format/weeks/renderer.php +* New core_courseformat\sectiondelegate class. The class can be extended by plugins to take control of a course section. === 4.3 === * New core_courseformat\output\activitybadge class that can be extended by any module to display content near the activity name. diff --git a/course/lib.php b/course/lib.php index 818d8407017..aa06cadd3f7 100644 --- a/course/lib.php +++ b/course/lib.php @@ -561,6 +561,8 @@ function course_create_section($courseorid, $position = 0, $skipcheck = false) { $cw->name = null; $cw->visible = 1; $cw->availability = null; + $cw->component = null; + $cw->itemid = null; $cw->timemodified = time(); $cw->id = $DB->insert_record("course_sections", $cw); diff --git a/lib/db/install.xml b/lib/db/install.xml index 1a994f74a39..b2e32778f1a 100644 --- a/lib/db/install.xml +++ b/lib/db/install.xml @@ -1,5 +1,5 @@ - @@ -386,6 +386,8 @@ + + diff --git a/lib/db/upgrade.php b/lib/db/upgrade.php index d0a47fcc39e..5fb5bb7f514 100644 --- a/lib/db/upgrade.php +++ b/lib/db/upgrade.php @@ -864,5 +864,28 @@ function xmldb_main_upgrade($oldversion) { upgrade_main_savepoint(true, 2023120100.01); } + if ($oldversion < 2023120700.01) { + + // Define field component to be added to course_sections. + $table = new xmldb_table('course_sections'); + $field = new xmldb_field('component', XMLDB_TYPE_CHAR, '100', null, null, null, null, 'availability'); + + // Conditionally launch add field component. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Define field itemid to be added to course_sections. + $field = new xmldb_field('itemid', XMLDB_TYPE_INTEGER, '10', null, null, null, null, 'component'); + + // Conditionally launch add field itemid. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Main savepoint reached. + upgrade_main_savepoint(true, 2023120700.01); + } + return true; } diff --git a/lib/modinfolib.php b/lib/modinfolib.php index ff640ed1e89..8428430b34c 100644 --- a/lib/modinfolib.php +++ b/lib/modinfolib.php @@ -33,6 +33,7 @@ if (!defined('MAX_MODINFO_CACHE_SIZE')) { } use core_courseformat\output\activitybadge; +use core_courseformat\sectiondelegate; /** * Information about a course that is cached in the course table 'modinfo' field (and then in @@ -618,7 +619,7 @@ class course_modinfo { 'course_sections', ['course' => $course->id], 'section', - 'id, section, course, name, summary, summaryformat, sequence, visible, availability' + 'id, section, course, name, summary, summaryformat, sequence, visible, availability, component, itemid' ); $compressedsections = []; $courseformat = course_get_format($course); @@ -2994,8 +2995,9 @@ class cached_cm_info { * @property-read int $visible Section visibility (1 = visible) - from course_sections table * @property-read string $summary Section summary text if specified - from course_sections table * @property-read int $summaryformat Section summary text format (FORMAT_xx constant) - from course_sections table - * @property-read string $availability Availability information as JSON string - - * from course_sections table + * @property-read string $availability Availability information as JSON string - from course_sections table + * @property-read string|null $component Optional section delegate component - from course_sections table + * @property-read int|null $itemid Optional section delegate item id - from course_sections table * @property-read array $conditionscompletion Availability conditions for this section based on the completion of * course-modules (array from course-module id to required completion state * for that module) - from cached data in sectioncache field @@ -3058,6 +3060,21 @@ class section_info implements IteratorAggregate { */ private $_availability; + /** + * @var string|null the delegated component if any. + */ + private ?string $_component = null; + + /** + * @var int|null the delegated instance item id if any. + */ + private ?int $_itemid = null; + + /** + * @var sectiondelegate|null Section delegate instance if any. + */ + private ?sectiondelegate $_delegateinstance = null; + /** * Availability conditions for this section based on the completion of * course-modules (array from course-module id to required completion state @@ -3116,7 +3133,9 @@ class section_info implements IteratorAggregate { 'summary' => '', 'summaryformat' => '1', // FORMAT_HTML, but must be a string 'visible' => '1', - 'availability' => null + 'availability' => null, + 'component' => null, + 'itemid' => null, ); /** @@ -3415,6 +3434,20 @@ class section_info implements IteratorAggregate { return $this->sectionnum; } + /** + * Get the delegate component instance. + */ + public function get_component_instance(): ?sectiondelegate { + if (empty($this->_component)) { + return null; + } + if ($this->_delegateinstance !== null) { + return $this->_delegateinstance; + } + $this->_delegateinstance = sectiondelegate::instance($this); + return $this->_delegateinstance; + } + /** * Prepares section data for inclusion in sectioncache cache, removing items * that are set to defaults, and adding availability data if required. diff --git a/lib/tests/fixtures/sectiondelegatetest.php b/lib/tests/fixtures/sectiondelegatetest.php new file mode 100644 index 00000000000..0e9959ddf9d --- /dev/null +++ b/lib/tests/fixtures/sectiondelegatetest.php @@ -0,0 +1,29 @@ +. + +namespace test_component\courseformat; + +use core_courseformat\sectiondelegate as sectiondelegatebase; + +/** + * Test class for section delegate. + * + * @package core_courseformat + * @copyright 2023 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class sectiondelegate extends sectiondelegatebase { +} diff --git a/lib/tests/modinfolib_test.php b/lib/tests/modinfolib_test.php index e949586f77c..7e789b1b7aa 100644 --- a/lib/tests/modinfolib_test.php +++ b/lib/tests/modinfolib_test.php @@ -36,6 +36,15 @@ use Exception; * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class modinfolib_test extends advanced_testcase { + /** + * Setup to ensure that fixtures are loaded. + */ + public static function setUpBeforeClass(): void { + global $CFG; + require_once($CFG->dirroot . '/course/lib.php'); + require_once($CFG->libdir . '/tests/fixtures/sectiondelegatetest.php'); + } + public function test_section_info_properties() { global $DB, $CFG; @@ -1358,4 +1367,36 @@ class modinfolib_test extends advanced_testcase { // Obviously, modinfo should include the Page now. $this->assertCount(1, $modinfo->get_instances_of('page')); } + + /** + * Test for get_component_instance. + * @covers \section_info::get_component_instance + */ + public function test_get_component_instance(): void { + global $DB; + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(['format' => 'topics', 'numsections' => 2]); + + course_update_section( + $course, + $DB->get_record('course_sections', ['course' => $course->id, 'section' => 2]), + [ + 'component' => 'test_component', + 'itemid' => 1, + ] + ); + + $modinfo = get_fast_modinfo($course->id); + $sectioninfos = $modinfo->get_section_info_all(); + + $this->assertNull($sectioninfos[1]->get_component_instance()); + $this->assertNull($sectioninfos[1]->component); + $this->assertNull($sectioninfos[1]->itemid); + + $this->assertInstanceOf('\core_courseformat\sectiondelegate', $sectioninfos[2]->get_component_instance()); + $this->assertInstanceOf('\test_component\courseformat\sectiondelegate', $sectioninfos[2]->get_component_instance()); + $this->assertEquals('test_component', $sectioninfos[2]->component); + $this->assertEquals(1, $sectioninfos[2]->itemid); + } } diff --git a/version.php b/version.php index e3af558e2bc..88b9f316404 100644 --- a/version.php +++ b/version.php @@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2023121500.00; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2023121500.01; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes. $release = '4.4dev (Build: 20231215)'; // Human-friendly version name