diff --git a/course/format/classes/sectiondelegatemodule.php b/course/format/classes/sectiondelegatemodule.php
index 61e5a2c7363..c91ba739812 100644
--- a/course/format/classes/sectiondelegatemodule.php
+++ b/course/format/classes/sectiondelegatemodule.php
@@ -16,7 +16,13 @@
namespace core_courseformat;
+use action_menu;
use cm_info;
+use core_courseformat\base as course_format;
+use core_courseformat\formatactions;
+use core_courseformat\output\local\content\section\controlmenu;
+use core_courseformat\stateupdates;
+use renderer_base;
use section_info;
use stdClass;
@@ -110,4 +116,56 @@ abstract class sectiondelegatemodule extends sectiondelegate {
private function get_module_name(): string {
return \core_component::normalize_component($this->sectioninfo->component)[1];
}
+
+ /**
+ * Sync the section renaming with the activity name.
+ *
+ * @param section_info $section
+ * @param string|null $newname
+ * @return string|null
+ */
+ public function preprocess_section_name(section_info $section, ?string $newname): ?string {
+ $cm = get_coursemodule_from_instance($this->get_module_name(), $section->itemid);
+ if (!$cm) {
+ return $newname;
+ }
+ if (empty($newname) || $newname === $cm->name) {
+ return $cm->name;
+ }
+ formatactions::cm($section->course)->rename($cm->id, $newname);
+ return $newname;
+ }
+
+ /**
+ * Allow delegate plugin to modify the available section menu.
+ *
+ * @param course_format $format The course format instance.
+ * @param controlmenu $controlmenu The control menu instance.
+ * @param renderer_base $output The renderer instance.
+ * @return action_menu|null The new action menu with the list of edit control items or null if no action menu is available.
+ */
+ public function get_section_action_menu(
+ course_format $format,
+ controlmenu $controlmenu,
+ renderer_base $output,
+ ): ?action_menu {
+ $controlmenuclass = $format->get_output_classname('content\\cm\\controlmenu');
+ $controlmenu = new $controlmenuclass(
+ $format,
+ $this->sectioninfo,
+ $this->cm,
+ );
+ return $controlmenu->get_action_menu($output);
+ }
+
+ /**
+ * Add extra state updates when put or create a section.
+ *
+ * @param section_info $section the affected section.
+ * @param stateupdates $updates the state updates object to notify the UI.
+ */
+ public function put_section_state_extra_updates(section_info $section, stateupdates $updates): void {
+ $cm = get_coursemodule_from_instance($this->get_module_name(), $section->itemid);
+ $updates->add_cm_put($cm->id);
+ }
}
diff --git a/course/format/templates/local/courseindex/section.mustache b/course/format/templates/local/courseindex/section.mustache
index ca9186f84a8..0741eb70210 100644
--- a/course/format/templates/local/courseindex/section.mustache
+++ b/course/format/templates/local/courseindex/section.mustache
@@ -27,6 +27,7 @@
"number": 1,
"sectionurl": "#",
"indexcollapsed": 0,
+ "component": null,
"current": 1,
"visible": 1,
"hasrestrictions": 0,
@@ -56,7 +57,7 @@
}
}}
resetAfterTest();
+ $this->setAdminUser();
+
+ $manager = \core_plugin_manager::resolve_plugininfo_class('mod');
+ $manager::enable_plugin('subsection', 1);
+
+ $course = $this->getDataGenerator()->create_course();
+ $subsection = $this->getDataGenerator()->create_module('subsection', ['course' => $course]);
+ $otheractvity = $this->getDataGenerator()->create_module('forum', ['course' => $course]);
+ $this->setAdminUser();
+
+ // Initialise stateupdates.
+ $courseformat = course_get_format($course->id);
+
+ // Execute given method.
+ $updates = new stateupdates($courseformat);
+ $actions = new stateactions();
+ $actions->cm_moveright(
+ $updates,
+ $course,
+ [$subsection->cmid, $otheractvity->cmid],
+ );
+
+ // Format results in a way we can compare easily.
+ $results = $this->summarize_updates($updates);
+
+ // The state actions does not use create or remove actions because they are designed
+ // to refresh parts of the state.
+ $this->assertEquals(0, $results['create']['count']);
+ $this->assertEquals(0, $results['remove']['count']);
+
+ // Mod subsection should be ignored.
+ $this->assertEquals(1, $results['put']['count']);
+
+ // Validate course, section and cm.
+ $this->assertArrayHasKey($otheractvity->cmid, $results['put']['cm']);
+ $this->assertArrayNotHasKey($subsection->cmid, $results['put']['cm']);
+
+ // Validate activity indentation.
+ $mondinfo = get_fast_modinfo($course);
+ $this->assertEquals(1, $mondinfo->get_cm($otheractvity->cmid)->indent);
+ $this->assertEquals(1, $DB->get_field('course_modules', 'indent', ['id' => $otheractvity->cmid]));
+ $this->assertEquals(0, $mondinfo->get_cm($subsection->cmid)->indent);
+ $this->assertEquals(0, $DB->get_field('course_modules', 'indent', ['id' => $subsection->cmid]));
+
+ // Now move left.
+ $updates = new stateupdates($courseformat);
+ $actions->cm_moveleft(
+ $updates,
+ $course,
+ [$subsection->cmid, $otheractvity->cmid],
+ );
+
+ // Format results in a way we can compare easily.
+ $results = $this->summarize_updates($updates);
+
+ // The state actions does not use create or remove actions because they are designed
+ // to refresh parts of the state.
+ $this->assertEquals(0, $results['create']['count']);
+ $this->assertEquals(0, $results['remove']['count']);
+
+ // Mod subsection should be ignored.
+ $this->assertEquals(1, $results['put']['count']);
+
+ // Validate course, section and cm.
+ $this->assertArrayHasKey($otheractvity->cmid, $results['put']['cm']);
+ $this->assertArrayNotHasKey($subsection->cmid, $results['put']['cm']);
+
+ // Validate activity indentation.
+ $mondinfo = get_fast_modinfo($course);
+ $this->assertEquals(0, $mondinfo->get_cm($otheractvity->cmid)->indent);
+ $this->assertEquals(0, $DB->get_field('course_modules', 'indent', ['id' => $otheractvity->cmid]));
+ $this->assertEquals(0, $mondinfo->get_cm($subsection->cmid)->indent);
+ $this->assertEquals(0, $DB->get_field('course_modules', 'indent', ['id' => $subsection->cmid]));
+ }
+
+ /**
+ * Test for filter_cms_with_section_delegate protected method.
+ *
+ * @covers ::filter_cms_with_section_delegate
+ */
+ public function test_filter_cms_with_section_delegate(): void {
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $manager = \core_plugin_manager::resolve_plugininfo_class('mod');
+ $manager::enable_plugin('subsection', 1);
+
+ $course = $this->getDataGenerator()->create_course();
+ $subsection = $this->getDataGenerator()->create_module('subsection', ['course' => $course]);
+ $otheractvity = $this->getDataGenerator()->create_module('forum', ['course' => $course]);
+ $this->setAdminUser();
+
+ $courseformat = course_get_format($course->id);
+
+ $modinfo = $courseformat->get_modinfo();
+ $subsectioninfo = $modinfo->get_cm($subsection->cmid);
+ $otheractvityinfo = $modinfo->get_cm($otheractvity->cmid);
+
+ $actions = new stateactions();
+
+ $method = new ReflectionMethod($actions, 'filter_cms_with_section_delegate');
+ $result = $method->invoke($actions, [$subsectioninfo, $otheractvityinfo]);
+
+ $this->assertCount(1, $result);
+ $this->assertArrayHasKey($otheractvity->cmid, $result);
+ $this->assertArrayNotHasKey($subsection->cmid, $result);
+ $this->assertEquals($otheractvityinfo, $result[$otheractvityinfo->id]);
+ }
}
diff --git a/lib/plugins.json b/lib/plugins.json
index af427242c78..e3b44ae6ad5 100644
--- a/lib/plugins.json
+++ b/lib/plugins.json
@@ -320,6 +320,7 @@
"quiz",
"resource",
"scorm",
+ "subsection",
"survey",
"url",
"wiki",
diff --git a/lib/tests/modinfolib_test.php b/lib/tests/modinfolib_test.php
index cd69f15250f..84c1099560a 100644
--- a/lib/tests/modinfolib_test.php
+++ b/lib/tests/modinfolib_test.php
@@ -1787,4 +1787,64 @@ class modinfolib_test extends advanced_testcase {
$cacherevthree = $DB->get_field('course', 'cacherev', ['id' => $coursethree->id]);
$this->assertGreaterThan($prevcacherevthree, $cacherevthree);
}
+
+ /**
+ * Test get_sections_delegated_by_cm method
+ *
+ * @covers \course_modinfo::get_sections_delegated_by_cm
+ */
+ public function test_get_sections_delegated_by_cm(): void {
+ $this->resetAfterTest();
+
+ $manager = \core_plugin_manager::resolve_plugininfo_class('mod');
+ $manager::enable_plugin('subsection', 1);
+
+ $course = $this->getDataGenerator()->create_course(['numsections' => 1]);
+
+ $modinfo = get_fast_modinfo($course);
+ $delegatedsections = $modinfo->get_sections_delegated_by_cm();
+ $this->assertEmpty($delegatedsections);
+
+ // Add a section delegated by a course module.
+ $subsection = $this->getDataGenerator()->create_module('subsection', ['course' => $course]);
+ $modinfo = get_fast_modinfo($course);
+ $delegatedsections = $modinfo->get_sections_delegated_by_cm();
+ $this->assertCount(1, $delegatedsections);
+ $this->assertArrayHasKey($subsection->cmid, $delegatedsections);
+
+ // Add a section delegated by a block.
+ formatactions::section($course)->create_delegated('block_site_main_menu', 1);
+ $modinfo = get_fast_modinfo($course);
+ $delegatedsections = $modinfo->get_sections_delegated_by_cm();
+ // Sections delegated by a block shouldn't be returned.
+ $this->assertCount(1, $delegatedsections);
+ }
+
+ /**
+ * Test get_sections_delegated_by_cm method
+ *
+ * @covers \cm_info::get_delegated_section_info
+ */
+ public function test_get_delegated_section_info(): void {
+ $this->resetAfterTest();
+
+ $manager = \core_plugin_manager::resolve_plugininfo_class('mod');
+ $manager::enable_plugin('subsection', 1);
+
+ $course = $this->getDataGenerator()->create_course(['numsections' => 1]);
+
+ // Add a section delegated by a course module.
+ $subsection = $this->getDataGenerator()->create_module('subsection', ['course' => $course]);
+ $otheractivity = $this->getDataGenerator()->create_module('page', ['course' => $course]);
+
+ $modinfo = get_fast_modinfo($course);
+ $delegatedsections = $modinfo->get_sections_delegated_by_cm();
+
+ $delegated = $modinfo->get_cm($subsection->cmid)->get_delegated_section_info();
+ $this->assertNotNull($delegated);
+ $this->assertEquals($delegated, $delegatedsections[$subsection->cmid]);
+
+ $delegated = $modinfo->get_cm($otheractivity->cmid)->get_delegated_section_info();
+ $this->assertNull($delegated);
+ }
}
diff --git a/mod/subsection/backup/moodle2/backup_subsection_activity_task.class.php b/mod/subsection/backup/moodle2/backup_subsection_activity_task.class.php
new file mode 100644
index 00000000000..ee5be7eab37
--- /dev/null
+++ b/mod/subsection/backup/moodle2/backup_subsection_activity_task.class.php
@@ -0,0 +1,73 @@
+.
+
+/**
+ * The task that provides all the steps to perform a complete backup is defined here.
+ *
+ * @package mod_subsection
+ * @category backup
+ * @copyright 2023 Amaia Anabitarte
+ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+// More information about the backup process: {@link https://docs.moodle.org/dev/Backup_API}.
+// More information about the restore process: {@link https://docs.moodle.org/dev/Restore_API}.
+
+require_once($CFG->dirroot.'//mod/subsection/backup/moodle2/backup_subsection_stepslib.php');
+
+/**
+ * Provides all the settings and steps to perform a complete backup of mod_subsection.
+ */
+class backup_subsection_activity_task extends backup_activity_task {
+
+ /**
+ * Defines particular settings for the plugin.
+ */
+ protected function define_my_settings() {
+ return;
+ }
+
+ /**
+ * Defines particular steps for the backup process.
+ */
+ protected function define_my_steps() {
+ $this->add_step(new backup_subsection_activity_structure_step('subsection_structure', 'subsection.xml'));
+ }
+
+ /**
+ * Codes the transformations to perform in the activity in order to get transportable (encoded) links.
+ *
+ * @param string $content
+ * @return string
+ */
+ public static function encode_content_links($content) {
+ global $CFG;
+
+ $base = preg_quote($CFG->wwwroot, "/");
+
+ // Link to the list of subsections.
+ $search = "/(".$base."\/mod\/subsection\/index.php\?id\=)([0-9]+)/";
+ $content = preg_replace($search, '$@SUBSECTIONINDEX*$2@$', $content);
+
+ // Link to page view by moduleid.
+ $search = "/(".$base."\/mod\/subsection\/view.php\?id\=)([0-9]+)/";
+ $content = preg_replace($search, '$@SUBSECTIONVIEWBYID*$2@$', $content);
+
+ return $content;
+ }
+}
diff --git a/mod/subsection/backup/moodle2/backup_subsection_stepslib.php b/mod/subsection/backup/moodle2/backup_subsection_stepslib.php
new file mode 100644
index 00000000000..5c8b0fbb2ca
--- /dev/null
+++ b/mod/subsection/backup/moodle2/backup_subsection_stepslib.php
@@ -0,0 +1,48 @@
+.
+
+/**
+ * Backup steps for mod_subsection are defined here.
+ *
+ * @package mod_subsection
+ * @category backup
+ * @copyright 2023 Amaia Anabitarte
+ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+// More information about the backup process: {@link https://docs.moodle.org/dev/Backup_API}.
+
+/**
+ * Define the complete structure for backup, with file and id annotations.
+ */
+class backup_subsection_activity_structure_step extends backup_activity_structure_step {
+
+ /**
+ * Defines the structure of the resulting xml file.
+ *
+ * @return backup_nested_element The structure wrapped by the common 'activity' element.
+ */
+ protected function define_structure() {
+ // Define each element separated.
+ $subsection = new backup_nested_element('subsection', ['id'], ['name', 'timemodified']);
+
+ // Define sources.
+ $subsection->set_source_table('subsection', ['id' => backup::VAR_ACTIVITYID]);
+
+ // Return the root element (subsection), wrapped into standard activity structure.
+ return $this->prepare_activity_structure($subsection);
+ }
+}
diff --git a/mod/subsection/backup/moodle2/restore_subsection_activity_task.class.php b/mod/subsection/backup/moodle2/restore_subsection_activity_task.class.php
new file mode 100644
index 00000000000..c49739fed24
--- /dev/null
+++ b/mod/subsection/backup/moodle2/restore_subsection_activity_task.class.php
@@ -0,0 +1,94 @@
+.
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * The task that provides a complete restore of mod_subsection is defined here.
+ *
+ * @package mod_subsection
+ * @category backup
+ * @copyright 2023 Amaia Anabitarte
+ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+// More information about the backup process: {@link https://docs.moodle.org/dev/Backup_API}.
+// More information about the restore process: {@link https://docs.moodle.org/dev/Restore_API}.
+
+require_once($CFG->dirroot.'//mod/subsection/backup/moodle2/restore_subsection_stepslib.php');
+
+/**
+ * Restore task for mod_subsection.
+ */
+class restore_subsection_activity_task extends restore_activity_task {
+
+ /**
+ * Defines particular settings that this activity can have.
+ */
+ protected function define_my_settings() {
+ return;
+ }
+
+ /**
+ * Defines particular steps that this activity can have.
+ *
+ * @return base_step.
+ */
+ protected function define_my_steps() {
+ $this->add_step(new restore_subsection_activity_structure_step('subsection_structure', 'subsection.xml'));
+ }
+
+ /**
+ * Defines the contents in the activity that must be processed by the link decoder.
+ *
+ * @return array.
+ */
+ public static function define_decode_contents() {
+ $contents = [];
+
+ // Define the contents.
+
+ return $contents;
+ }
+
+ /**
+ * Defines the decoding rules for links belonging to the activity to be executed by the link decoder.
+ *
+ * @return array.
+ */
+ public static function define_decode_rules() {
+ $rules = [];
+
+ // Define the rules.
+
+ return $rules;
+ }
+
+ /**
+ * Defines the restore log rules that will be applied by the
+ * {@see restore_logs_processor} when restoring mod_subsection logs. It
+ * must return one array of {@see restore_log_rule} objects.
+ *
+ * @return array.
+ */
+ public static function define_restore_log_rules() {
+ $rules = [];
+
+ // Define the rules.
+
+ return $rules;
+ }
+}
diff --git a/mod/subsection/backup/moodle2/restore_subsection_stepslib.php b/mod/subsection/backup/moodle2/restore_subsection_stepslib.php
new file mode 100644
index 00000000000..9102fa137f8
--- /dev/null
+++ b/mod/subsection/backup/moodle2/restore_subsection_stepslib.php
@@ -0,0 +1,73 @@
+.
+
+/**
+ * All the steps to restore mod_subsection are defined here.
+ *
+ * @package mod_subsection
+ * @category backup
+ * @copyright 2023 Amaia Anabitarte
+ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+use mod_subsection\manager;
+
+// More information about the restore process: {@link https://docs.moodle.org/dev/Restore_API}.
+
+/**
+ * Defines the structure step to restore one mod_subsection activity.
+ */
+class restore_subsection_activity_structure_step extends restore_activity_structure_step {
+
+ /**
+ * Defines the structure to be restored.
+ *
+ * @return restore_path_element[].
+ */
+ protected function define_structure() {
+ $paths = [];
+ $paths[] = new restore_path_element('subsection', '/activity/subsection');
+
+ // Return the paths wrapped into standard activity structure.
+ return $this->prepare_activity_structure($paths);
+ }
+
+ /**
+ * Process the subsection element.
+ *
+ * @param \stdClass $data the data to be processed.
+ */
+ protected function process_subsection($data) {
+ global $DB;
+
+ $data = (object)$data;
+ $oldid = $data->id;
+ $data->course = $this->get_courseid();
+
+ // Insert the subsection record.
+ $newitemid = $DB->insert_record('subsection', $data);
+ // Immediately after inserting "activity" record, call this.
+ $this->apply_activity_instance($newitemid);
+ $this->set_delegated_section_mapping(manager::PLUGINNAME, $oldid, $newitemid);
+ }
+
+ /**
+ * Defines post-execution actions.
+ */
+ protected function after_execute() {
+ return;
+ }
+}
diff --git a/mod/subsection/classes/courseformat/sectiondelegate.php b/mod/subsection/classes/courseformat/sectiondelegate.php
new file mode 100644
index 00000000000..6c7d38e8ee1
--- /dev/null
+++ b/mod/subsection/classes/courseformat/sectiondelegate.php
@@ -0,0 +1,38 @@
+.
+
+namespace mod_subsection\courseformat;
+
+use action_menu;
+use core_courseformat\base as course_format;
+use core_courseformat\output\local\content\section\controlmenu;
+use core_courseformat\sectiondelegatemodule;
+use mod_subsection\manager;
+use renderer_base;
+
+/**
+ * Subsection plugin section delegate class.
+ *
+ * This class implements all the integrations needed to delegate core section logic to
+ * the plugin. For a basic subsection plugin, all methods are inherited from the
+ * sectiondelegatemodule class.
+ *
+ * @package mod_subsection
+ * @copyright 2023 Ferran Recio
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class sectiondelegate extends sectiondelegatemodule {
+}
diff --git a/mod/subsection/classes/event/course_module_instance_list_viewed.php b/mod/subsection/classes/event/course_module_instance_list_viewed.php
new file mode 100644
index 00000000000..b49eca44412
--- /dev/null
+++ b/mod/subsection/classes/event/course_module_instance_list_viewed.php
@@ -0,0 +1,29 @@
+.
+
+namespace mod_subsection\event;
+
+/**
+ * The mod_subsection viewed event class.
+ *
+ * @package mod_subsection
+ * @since Moodle 4.5
+ * @copyright 2023 Amaia Anabitarte
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class course_module_instance_list_viewed extends \core\event\course_module_instance_list_viewed {
+ // No code required here as the parent class handles it all.
+}
diff --git a/mod/subsection/classes/event/course_module_viewed.php b/mod/subsection/classes/event/course_module_viewed.php
new file mode 100644
index 00000000000..06d5474d3bb
--- /dev/null
+++ b/mod/subsection/classes/event/course_module_viewed.php
@@ -0,0 +1,41 @@
+.
+
+namespace mod_subsection\event;
+
+/**
+ * The mod_subsection viewed event class.
+ *
+ * @package mod_subsection
+ * @since Moodle 4.5
+ * @copyright 2023 Amaia Anabitarte
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class course_module_viewed extends \core\event\course_module_viewed {
+
+ /**
+ * Init method.
+ */
+ protected function init() {
+ $this->data['crud'] = 'r';
+ $this->data['edulevel'] = self::LEVEL_PARTICIPATING;
+ $this->data['objecttable'] = 'subsection';
+ }
+
+ public static function get_objectid_mapping() {
+ return ['db' => 'subsection', 'restore' => 'subsection'];
+ }
+}
diff --git a/mod/subsection/classes/local/callbacks/after_cm_name_edited_handler.php b/mod/subsection/classes/local/callbacks/after_cm_name_edited_handler.php
new file mode 100644
index 00000000000..e7abad2dbc4
--- /dev/null
+++ b/mod/subsection/classes/local/callbacks/after_cm_name_edited_handler.php
@@ -0,0 +1,48 @@
+.
+
+namespace mod_subsection\local\callbacks;
+
+use core_courseformat\hook\after_cm_name_edited;
+use core_courseformat\formatactions;
+use mod_subsection\manager;
+
+/**
+ * Class after activity renaming hook handler.
+ *
+ * @package mod_subsection
+ * @copyright 2024 Ferran Recio
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class after_cm_name_edited_handler {
+ /**
+ * Handle the activity name change.
+ *
+ * @param after_cm_name_edited $hook
+ */
+ public static function callback(after_cm_name_edited $hook): void {
+ $cm = $hook->get_cm();
+
+ if ($cm->modname !== manager::MODULE) {
+ return;
+ }
+
+ $section = get_fast_modinfo($cm->course)->get_section_info_by_component(manager::PLUGINNAME, $cm->instance);
+ if ($section) {
+ formatactions::section($cm->course)->update($section, ['name' => $hook->get_newname()]);
+ }
+ }
+}
diff --git a/mod/subsection/classes/manager.php b/mod/subsection/classes/manager.php
new file mode 100644
index 00000000000..3e6271e00ec
--- /dev/null
+++ b/mod/subsection/classes/manager.php
@@ -0,0 +1,183 @@
+.
+
+namespace mod_subsection;
+
+use cm_info;
+use context_module;
+use completion_info;
+use mod_subsection\event\course_module_viewed;
+use moodle_page;
+use stdClass;
+
+/**
+ * Class manager for subsection
+ *
+ * @package mod_subsection
+ * @copyright 2023 Amaia Anabitarte
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class manager {
+
+ /** Module name. */
+ const MODULE = 'subsection';
+
+ /** The plugin name. */
+ const PLUGINNAME = 'mod_subsection';
+
+ /** @var string plugin path. */
+ public $path;
+
+ /** @var stdClass course_module record. */
+ private $instance;
+
+ /** @var context_module the current context. */
+ private $context;
+
+ /** @var cm_info course_modules record. */
+ private $cm;
+
+ /**
+ * Class constructor.
+ *
+ * @param cm_info $cm course module info object
+ * @param stdClass $instance activity instance object.
+ */
+ public function __construct(cm_info $cm, stdClass $instance) {
+ global $CFG;
+ $this->cm = $cm;
+ $this->instance = $instance;
+ $this->context = context_module::instance($cm->id);
+ $this->instance->cmidnumber = $cm->idnumber;
+ $this->path = $CFG->dirroot . '/mod/' . self::MODULE;
+ }
+
+ /**
+ * Create a manager instance from an instance record.
+ *
+ * @param stdClass $instance an activity record
+ * @return manager
+ */
+ public static function create_from_instance(stdClass $instance): self {
+ $cm = get_coursemodule_from_instance(self::MODULE, $instance->id);
+ // Ensure that $this->cm is a cm_info object.
+ $cm = cm_info::create($cm);
+ return new self($cm, $instance);
+ }
+
+ /**
+ * Create a manager instance from a course_modules record.
+ *
+ * @param stdClass|cm_info $cm an activity record
+ * @return manager
+ */
+ public static function create_from_coursemodule($cm): self {
+ global $DB;
+ // Ensure that $this->cm is a cm_info object.
+ $cm = cm_info::create($cm);
+ $instance = $DB->get_record(self::MODULE, ['id' => $cm->instance], '*', MUST_EXIST);
+ return new self($cm, $instance);
+ }
+
+ /**
+ * Create a manager instance from a record id.
+ *
+ * @param int $courseid the course id
+ * @param int $id an activity id
+ * @return manager
+ */
+ public static function create_from_id(int $courseid, int $id): self {
+ $cm = get_coursemodule_from_instance('subsection', $id, $courseid);
+ return self::create_from_coursemodule($cm);
+ }
+
+ /**
+ * Create a manager instance from a subsection_record entry.
+ *
+ * @param stdClass $record the subsection_record record
+ * @return manager
+ */
+ public static function create_from_data_record($record): self {
+ global $DB;
+ $instance = $DB->get_record(self::MODULE, ['id' => $record->dataid], '*', MUST_EXIST);
+ $cm = get_coursemodule_from_instance(self::MODULE, $instance->id);
+ $cm = cm_info::create($cm);
+ return new self($cm, $instance);
+ }
+
+ /**
+ * Return the current context.
+ *
+ * @return context_module
+ */
+ public function get_context(): context_module {
+ return $this->context;
+ }
+
+ /**
+ * Return the current instance.
+ *
+ * @return stdClass the instance record
+ */
+ public function get_instance(): stdClass {
+ return $this->instance;
+ }
+
+ /**
+ * Return the current cm_info.
+ *
+ * @return cm_info the course module
+ */
+ public function get_coursemodule(): cm_info {
+ return $this->cm;
+ }
+
+ /**
+ * Return the current module renderer.
+ *
+ * @param moodle_page|null $page the current page
+ * @return \mod_subsection_renderer the module renderer
+ */
+ public function get_renderer(?moodle_page $page = null): \mod_subsection_renderer {
+ global $PAGE;
+ $page = $page ?? $PAGE;
+ return $page->get_renderer(self::PLUGINNAME);
+ }
+
+ /**
+ * Trigger module viewed event and set the module viewed for completion.
+ *
+ * @param stdClass $course course object
+ */
+ public function set_module_viewed(stdClass $course) {
+ global $CFG;
+ require_once($CFG->libdir . '/completionlib.php');
+
+ // Trigger module viewed event.
+ $event = course_module_viewed::create([
+ 'objectid' => $this->instance->id,
+ 'context' => $this->context,
+ ]);
+ $event->add_record_snapshot('course', $course);
+ $event->add_record_snapshot('course_modules', $this->cm);
+ $event->add_record_snapshot(self::MODULE, $this->instance);
+ $event->trigger();
+
+ // Completion.
+ $completion = new completion_info($course);
+ $completion->set_module_viewed($this->cm);
+ }
+}
diff --git a/mod/subsection/classes/privacy/provider.php b/mod/subsection/classes/privacy/provider.php
new file mode 100644
index 00000000000..5dbbf8ee805
--- /dev/null
+++ b/mod/subsection/classes/privacy/provider.php
@@ -0,0 +1,37 @@
+.
+
+namespace mod_subsection\privacy;
+
+/**
+ * Privacy API implementation for the Subsection plugin.
+ *
+ * @package mod_subsection
+ * @category privacy
+ * @copyright 2023 Amaia Anabitarte
+ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class provider implements \core_privacy\local\metadata\null_provider {
+
+ /**
+ * Returns stringid of a text explaining that this plugin stores no personal data.
+ *
+ * @return string
+ */
+ public static function get_reason(): string {
+ return 'privacy:metadata';
+ }
+}
diff --git a/mod/subsection/db/access.php b/mod/subsection/db/access.php
new file mode 100644
index 00000000000..7731f6c8fef
--- /dev/null
+++ b/mod/subsection/db/access.php
@@ -0,0 +1,41 @@
+.
+
+/**
+ * Plugin capabilities are defined here.
+ *
+ * @package mod_subsection
+ * @category access
+ * @copyright 2023 Amaia Anabitarte
+ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$capabilities = [
+
+ 'mod/subsection:addinstance' => [
+ 'riskbitmask' => RISK_XSS,
+
+ 'captype' => 'write',
+ 'contextlevel' => CONTEXT_COURSE,
+ 'archetypes' => [
+ 'editingteacher' => CAP_ALLOW,
+ 'manager' => CAP_ALLOW,
+ ],
+ 'clonepermissionsfrom' => 'moodle/course:manageactivities',
+ ],
+];
diff --git a/mod/subsection/db/hooks.php b/mod/subsection/db/hooks.php
new file mode 100644
index 00000000000..37b3fd1198d
--- /dev/null
+++ b/mod/subsection/db/hooks.php
@@ -0,0 +1,33 @@
+.
+
+/**
+ * Hook callbacks for Subsection
+ *
+ * @package mod_subsection
+ * @copyright 2024 Ferran Recio
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$callbacks = [
+ [
+ 'hook' => core_courseformat\hook\after_cm_name_edited::class,
+ 'callback' => 'mod_subsection\local\callbacks\after_cm_name_edited_handler::callback',
+ 'priority' => 0,
+ ],
+];
diff --git a/mod/subsection/db/install.php b/mod/subsection/db/install.php
new file mode 100644
index 00000000000..6578ac2bcd3
--- /dev/null
+++ b/mod/subsection/db/install.php
@@ -0,0 +1,36 @@
+.
+
+/**
+ * Code to be executed after the plugin's database scheme has been installed is defined here.
+ *
+ * @package mod_subsection
+ * @category upgrade
+ * @copyright 2023 Amaia Anabitarte
+ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/**
+ * Custom code to be run on installing the plugin.
+ */
+function xmldb_subsection_install() {
+ global $DB;
+
+ // Disable the chat activity module on new installs by default.
+ $DB->set_field('modules', 'visible', 0, ['name' => 'subsection']);
+
+ return true;
+}
diff --git a/mod/subsection/db/install.xml b/mod/subsection/db/install.xml
new file mode 100644
index 00000000000..a452b5315cf
--- /dev/null
+++ b/mod/subsection/db/install.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mod/subsection/db/uninstall.php b/mod/subsection/db/uninstall.php
new file mode 100644
index 00000000000..9b0d49697aa
--- /dev/null
+++ b/mod/subsection/db/uninstall.php
@@ -0,0 +1,31 @@
+.
+
+/**
+ * Code that is executed before the tables and data are dropped during the plugin uninstallation.
+ *
+ * @package mod_subsection
+ * @category upgrade
+ * @copyright 2023 Amaia Anabitarte
+ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/**
+ * Custom uninstallation procedure.
+ */
+function xmldb_subsection_uninstall() {
+ return true;
+}
diff --git a/mod/subsection/db/upgrade.php b/mod/subsection/db/upgrade.php
new file mode 100644
index 00000000000..9374187f511
--- /dev/null
+++ b/mod/subsection/db/upgrade.php
@@ -0,0 +1,43 @@
+.
+
+/**
+ * Plugin upgrade steps are defined here.
+ *
+ * @package mod_subsection
+ * @category upgrade
+ * @copyright 2023 Amaia Anabitarte
+ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/**
+ * Execute mod_subsection upgrade from the given old version.
+ *
+ * @param int $oldversion
+ * @return bool
+ */
+function xmldb_subsection_upgrade($oldversion) {
+ global $DB;
+
+ $dbman = $DB->get_manager();
+
+ // For further information please read {@link https://docs.moodle.org/dev/Upgrade_API}.
+ //
+ // You will also have to create the db/install.xml file by using the XMLDB Editor.
+ // Documentation for the XMLDB Editor can be found at {@link https://docs.moodle.org/dev/XMLDB_editor}.
+
+ return true;
+}
diff --git a/mod/subsection/index.php b/mod/subsection/index.php
new file mode 100644
index 00000000000..04910b166d2
--- /dev/null
+++ b/mod/subsection/index.php
@@ -0,0 +1,89 @@
+.
+
+/**
+ * Display information about all the mod_subsection modules in the requested course.
+ *
+ * @package mod_subsection
+ * @copyright 2023 Amaia Anabitarte
+ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require(__DIR__.'/../../config.php');
+
+require_once(__DIR__.'/lib.php');
+
+$id = required_param('id', PARAM_INT);
+
+$course = $DB->get_record('course', ['id' => $id], '*', MUST_EXIST);
+require_course_login($course);
+
+$coursecontext = context_course::instance($course->id);
+$event = \mod_subsection\event\course_module_instance_list_viewed::create(['context' => $coursecontext]);
+$event->add_record_snapshot('course', $course);
+$event->trigger();
+
+$PAGE->set_url('/mod/subsection/index.php', ['id' => $id]);
+$PAGE->set_title(format_string($course->fullname));
+$PAGE->set_heading(format_string($course->fullname));
+$PAGE->set_context($coursecontext);
+
+echo $OUTPUT->header();
+
+$modulenameplural = get_string('modulenameplural', 'mod_subsection');
+echo $OUTPUT->heading($modulenameplural);
+
+$subsections = get_all_instances_in_course('subsection', $course);
+
+if (empty($subsections)) {
+ notice(get_string('thereareno', 'moodle', $modulenameplural), "$CFG->wwwroot/course/view.php?id=$course->id");
+}
+
+$table = new html_table();
+$table->attributes['class'] = 'generaltable mod_index';
+
+if ($course->format == 'weeks') {
+ $table->head = [get_string('week'), get_string('name')];
+ $table->align = ['center', 'left'];
+} else if ($course->format == 'topics') {
+ $table->head = [get_string('topic'), get_string('name')];
+ $table->align = ['center', 'left', 'left', 'left'];
+} else {
+ $table->head = [get_string('name')];
+ $table->align = ['left', 'left', 'left'];
+}
+
+foreach ($subsections as $subsection) {
+ if (!$subsection->visible) {
+ $link = html_writer::link(
+ new moodle_url('/mod/subsection/view.php', ['id' => $subsection->coursemodule]),
+ format_string($subsection->name, true),
+ ['class' => 'dimmed']);
+ } else {
+ $link = html_writer::link(
+ new moodle_url('/mod/subsection/view.php', ['id' => $subsection->coursemodule]),
+ format_string($subsection->name, true));
+ }
+
+ if ($course->format == 'weeks' || $course->format == 'topics') {
+ $table->data[] = [$subsection->section, $link];
+ } else {
+ $table->data[] = [$link];
+ }
+}
+
+echo html_writer::table($table);
+echo $OUTPUT->footer();
diff --git a/mod/subsection/lang/en/subsection.php b/mod/subsection/lang/en/subsection.php
new file mode 100644
index 00000000000..c8fa796b536
--- /dev/null
+++ b/mod/subsection/lang/en/subsection.php
@@ -0,0 +1,34 @@
+.
+
+/**
+ * Plugin strings are defined here.
+ *
+ * @package mod_subsection
+ * @category string
+ * @copyright 2023 Amaia Anabitarte
+ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+$string['modulename'] = 'Subsection';
+$string['modulename_help'] = 'Delegated subsections';
+$string['modulenameplural'] = 'Subsections';
+$string['pluginadministration'] = 'Subsection administration';
+$string['pluginname'] = 'Subsection';
+$string['privacy:metadata'] = 'Subsection does not store any personal data';
+$string['subsection:addinstance'] = 'Add subsection';
+$string['subsection:view'] = 'View subsection';
+$string['subsectionname'] = 'Name';
diff --git a/mod/subsection/lib.php b/mod/subsection/lib.php
new file mode 100644
index 00000000000..9a8ed89e8df
--- /dev/null
+++ b/mod/subsection/lib.php
@@ -0,0 +1,209 @@
+.
+
+/**
+ * Library of interface functions and constants.
+ *
+ * @package mod_subsection
+ * @copyright 2023 Amaia Anabitarte
+ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+ use core_courseformat\formatactions;
+ use mod_subsection\manager;
+
+/**
+ * Return if the plugin supports $feature.
+ *
+ * @param string $feature Constant representing the feature.
+ * @return mixed True if module supports feature, false if not, null if doesn't know or string for the module purpose.
+ */
+function subsection_supports($feature) {
+ return match ($feature) {
+ FEATURE_MOD_ARCHETYPE => MOD_ARCHETYPE_RESOURCE,
+ FEATURE_GROUPS => false,
+ FEATURE_GROUPINGS => false,
+ FEATURE_MOD_INTRO => false,
+ FEATURE_COMPLETION_TRACKS_VIEWS => true,
+ FEATURE_GRADE_HAS_GRADE => false,
+ FEATURE_GRADE_OUTCOMES => false,
+ FEATURE_BACKUP_MOODLE2 => true,
+ FEATURE_SHOW_DESCRIPTION => false,
+ FEATURE_MOD_PURPOSE => MOD_PURPOSE_CONTENT,
+ default => null,
+ };
+}
+
+/**
+ * Saves a new instance of the mod_subsection into the database.
+ *
+ * Given an object containing all the necessary data, (defined by the form
+ * in mod_form.php) this function will create a new instance and return the id
+ * number of the instance.
+ *
+ * @param object $moduleinstance An object from the form.
+ * @param mod_subsection_mod_form $mform The form.
+ * @return int The id of the newly inserted record.
+ */
+function subsection_add_instance($moduleinstance, $mform = null) {
+ global $DB;
+
+ $moduleinstance->timecreated = time();
+
+ $id = $DB->insert_record('subsection', $moduleinstance);
+
+ formatactions::section($moduleinstance->course)->create_delegated(
+ manager::PLUGINNAME,
+ $id,
+ (object)[
+ 'name' => $moduleinstance->name,
+ ]);
+
+ return $id;
+}
+
+/**
+ * Updates an instance of the mod_subsection in the database.
+ *
+ * Given an object containing all the necessary data (defined in mod_form.php),
+ * this function will update an existing instance with new data.
+ *
+ * @param object $moduleinstance An object from the form in mod_form.php.
+ * @param mod_subsection_mod_form $mform The form.
+ * @return bool True if successful, false otherwise.
+ */
+function subsection_update_instance($moduleinstance, $mform = null) {
+ global $DB;
+
+ $moduleinstance->timemodified = time();
+ $moduleinstance->id = $moduleinstance->instance;
+
+ return $DB->update_record('subsection', $moduleinstance);
+}
+
+/**
+ * Removes an instance of the mod_subsection from the database.
+ *
+ * @param int $id Id of the module instance.
+ * @return bool True if successful, false on failure.
+ */
+function subsection_delete_instance($id) {
+ global $DB;
+
+ $exists = $DB->get_record('subsection', ['id' => $id]);
+ if (!$exists) {
+ return false;
+ }
+
+ $cm = get_coursemodule_from_instance(manager::MODULE, $id);
+ $delegatesection = get_fast_modinfo($cm->course)->get_section_info_by_component(manager::PLUGINNAME, $id);
+ if ($delegatesection) {
+ formatactions::section($cm->course)->delete($delegatesection);
+ }
+
+ $DB->delete_records('subsection', ['id' => $id]);
+
+ return true;
+}
+
+/**
+ * Returns the lists of all browsable file areas within the given module context.
+ *
+ * The file area 'intro' for the activity introduction field is added automatically
+ * by {@see file_browser::get_file_info_context_module()}.
+ *
+ * @package mod_subsection
+ * @category files
+ *
+ * @param stdClass $course
+ * @param stdClass $cm
+ * @param stdClass $context
+ * @return string[].
+ */
+function subsection_get_file_areas($course, $cm, $context) {
+ return [];
+}
+
+/**
+ * File browsing support for mod_subsection file areas.
+ *
+ * @package mod_subsection
+ * @category files
+ *
+ * @param file_browser $browser
+ * @param array $areas
+ * @param stdClass $course
+ * @param stdClass $cm
+ * @param stdClass $context
+ * @param string $filearea
+ * @param int $itemid
+ * @param string $filepath
+ * @param string $filename
+ * @return file_info|null file_info instance or null if not found.
+ */
+function subsection_get_file_info($browser, $areas, $course, $cm, $context, $filearea, $itemid, $filepath, $filename) {
+ return null;
+}
+
+/**
+ * Serves the files from the mod_subsection file areas.
+ *
+ * @package mod_subsection
+ * @category files
+ *
+ * @param stdClass $course The course object.
+ * @param stdClass $cm The course module object.
+ * @param stdClass $context The mod_subsection's context.
+ * @param string $filearea The name of the file area.
+ * @param array $args Extra arguments (itemid, path).
+ * @param bool $forcedownload Whether or not force download.
+ * @param array $options Additional options affecting the file serving.
+ */
+function subsection_pluginfile($course, $cm, $context, $filearea, $args, $forcedownload, $options = []) {
+ global $DB, $CFG;
+
+ if ($context->contextlevel != CONTEXT_MODULE) {
+ send_file_not_found();
+ }
+
+ require_login($course, true, $cm);
+ send_file_not_found();
+}
+
+/**
+ * Extends the global navigation tree by adding mod_subsection nodes if there is a relevant content.
+ *
+ * This can be called by an AJAX request so do not rely on $PAGE as it might not be set up properly.
+ *
+ * @param navigation_node $subsectionnode An object representing the navigation tree node.
+ * @param stdClass $course
+ * @param stdClass $module
+ * @param cm_info $cm
+ */
+function subsection_extend_navigation($subsectionnode, $course, $module, $cm) {
+}
+
+/**
+ * Extends the settings navigation with the mod_subsection settings.
+ *
+ * This function is called when the context for the page is a mod_subsection module.
+ * This is not called by AJAX so it is safe to rely on the $PAGE.
+ *
+ * @param settings_navigation $settingsnav {@see settings_navigation}
+ * @param navigation_node $subsectionnode {@see navigation_node}
+ */
+function subsection_extend_settings_navigation($settingsnav, $subsectionnode = null) {
+}
diff --git a/mod/subsection/mod_form.php b/mod/subsection/mod_form.php
new file mode 100644
index 00000000000..2cd1927b4bc
--- /dev/null
+++ b/mod/subsection/mod_form.php
@@ -0,0 +1,67 @@
+.
+
+/**
+ * The main mod_subsection configuration form.
+ *
+ * @package mod_subsection
+ * @copyright 2023 Amaia Anabitarte
+ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->dirroot.'/course/moodleform_mod.php');
+
+/**
+ * Module instance settings form.
+ *
+ * @package mod_subsection
+ * @copyright 2023 Amaia Anabitarte
+ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class mod_subsection_mod_form extends moodleform_mod {
+
+ /**
+ * Defines forms elements
+ */
+ public function definition() {
+ global $CFG;
+
+ $mform = $this->_form;
+
+ // Adding the "general" fieldset, where all the common settings are shown.
+ $mform->addElement('header', 'general', get_string('general', 'form'));
+
+ // Adding the standard "name" field.
+ $mform->addElement('text', 'name', get_string('subsectionname', 'mod_subsection'), ['size' => '64']);
+
+ if (!empty($CFG->formatstringstriptags)) {
+ $mform->setType('name', PARAM_TEXT);
+ } else {
+ $mform->setType('name', PARAM_CLEANHTML);
+ }
+
+ $mform->addRule('name', null, 'required', null, 'client');
+ $mform->addRule('name', get_string('maximumchars', '', 255), 'maxlength', 255, 'client');
+
+ // Add standard elements.
+ $this->standard_coursemodule_elements();
+
+ // Add standard buttons.
+ $this->add_action_buttons();
+ }
+}
diff --git a/mod/subsection/pix/monologo.svg b/mod/subsection/pix/monologo.svg
new file mode 100644
index 00000000000..b3bef3ef058
--- /dev/null
+++ b/mod/subsection/pix/monologo.svg
@@ -0,0 +1,3 @@
+
diff --git a/mod/subsection/renderer.php b/mod/subsection/renderer.php
new file mode 100644
index 00000000000..1ecf3393440
--- /dev/null
+++ b/mod/subsection/renderer.php
@@ -0,0 +1,25 @@
+.
+
+/**
+ * Subsection activity renderer.
+ *
+ * @copyright 2023 Amaia Anabitarte
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @package mod_subsection
+ */
+class mod_subsection_renderer extends plugin_renderer_base {
+}
diff --git a/mod/subsection/settings.php b/mod/subsection/settings.php
new file mode 100644
index 00000000000..9025c8122d6
--- /dev/null
+++ b/mod/subsection/settings.php
@@ -0,0 +1,34 @@
+.
+
+/**
+ * Plugin administration pages are defined here.
+ *
+ * @package mod_subsection
+ * @category admin
+ * @copyright 2023 Amaia Anabitarte
+ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+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) {
+ }
+}
diff --git a/mod/subsection/tests/behat/subsection_actionmenu.feature b/mod/subsection/tests/behat/subsection_actionmenu.feature
new file mode 100644
index 00000000000..a7008e0b53a
--- /dev/null
+++ b/mod/subsection/tests/behat/subsection_actionmenu.feature
@@ -0,0 +1,52 @@
+@mod @mod_subsection
+Feature: The module menu replaces the section menu when accessing the subsection page
+ In order to use subsections
+ As an teacher
+ I need to see the module action menu in the section page.
+
+ Background:
+ Given I enable "subsection" "mod" plugin
+ And the following "users" exist:
+ | username | firstname | lastname | email |
+ | teacher1 | Teacher | 1 | teacher1@example.com |
+ And the following "courses" exist:
+ | fullname | shortname | category | numsections |
+ | Course 1 | C1 | 0 | 2 |
+ And the following "activity" exists:
+ | activity | subsection |
+ | name | Subsection1 |
+ | course | C1 |
+ | idnumber | subsection1 |
+ | section | 1 |
+ And the following "course enrolments" exist:
+ | user | course | role |
+ | teacher1 | C1 | editingteacher |
+ And I am on the "C1" "Course" page logged in as "teacher1"
+
+ @javascript
+ Scenario: The action menu for subsection page meets the module menu
+ Given I click on "Subsection1" "link" in the "region-main" "region"
+ And I turn editing mode on
+ # Open the action menu.
+ When I click on "Edit" "icon" in the "[data-region='header-actions-container']" "css_element"
+ Then I should not see "Move right"
+ And I should not see "Assign roles"
+ And I should not see "Permalink"
+ And I should not see "Highlight"
+ And I should see "Edit settings"
+ And I should see "Move"
+ And I should see "Hide"
+ And I should see "Duplicate"
+ And I should see "Delete"
+
+ @javascript
+ Scenario: The action menu for subsection module has less options thant a regular activity
+ Given I turn editing mode on
+ When I open "Subsection1" actions menu
+ Then I should not see "Move right"
+ And I should not see "Assign roles"
+ And I should see "Edit settings"
+ And I should see "Move"
+ And I should see "Hide"
+ And I should see "Duplicate"
+ And I should see "Delete"
diff --git a/mod/subsection/tests/behat/subsection_handling.feature b/mod/subsection/tests/behat/subsection_handling.feature
new file mode 100644
index 00000000000..0224555f3b8
--- /dev/null
+++ b/mod/subsection/tests/behat/subsection_handling.feature
@@ -0,0 +1,61 @@
+@mod @mod_subsection
+Feature: Teachers create and destroy subsections
+ In order to use subsections
+ As an teacher
+ I need to create and destroy subsections
+
+ Background:
+ Given I enable "subsection" "mod" plugin
+ And the following "users" exist:
+ | username | firstname | lastname | email |
+ | teacher1 | Teacher | 1 | teacher1@example.com |
+ And the following "courses" exist:
+ | fullname | shortname | category | numsections |
+ | Course 1 | C1 | 0 | 2 |
+ And the following "course enrolments" exist:
+ | user | course | role |
+ | teacher1 | C1 | editingteacher |
+ And I log in as "teacher1"
+
+ Scenario: Subsections are not listed as regular sections
+ Given the following "activities" exist:
+ | activity | name | course | idnumber | section |
+ | subsection | Subsection1 | C1 | forum1 | 1 |
+ | data | Subactivity1 | C1 | data1 | 3 |
+ When I am on "Course 1" course homepage
+ Then "Subsection1" "section" should not exist
+ And I should not see "Subactivity1" in the "region-main" "region"
+ And I click on "Subsection1" "link" in the "region-main" "region"
+ And I should see "Subsection1" in the "page" "region"
+ And I should see "Subactivity1" in the "region-main" "region"
+
+ Scenario: Activities can be created in a subsection
+ Given the following "activities" exist:
+ | activity | name | course | idnumber | section |
+ | subsection | Subsection1 | C1 | forum1 | 1 |
+ When I add an "assign" activity to course "Course 1" section "3" and I fill the form with:
+ | Assignment name | Test assignment name |
+ | ID number | Test assignment name |
+ | Description | Test assignment description |
+ And I am on "Course 1" course homepage
+ And I click on "Subsection1" "link" in the "region-main" "region"
+ Then I should see "Test assignment name" in the "region-main" "region"
+ And I am on "Course 1" course homepage
+ And I should not see "Test assignment name" in the "region-main" "region"
+
+ @javascript
+ Scenario: Teacher can create activities in a subsection page with the activity chooser
+ Given the following "activities" exist:
+ | activity | name | course | idnumber | section |
+ | subsection | Subsection1 | C1 | forum1 | 1 |
+ When I am on "Course 1" course homepage with editing mode on
+ And I click on "Subsection1" "link" in the "region-main" "region"
+ And I add a "Assignment" to section "3" using the activity chooser
+ And I set the following fields to these values:
+ | Assignment name | Test assignment name |
+ | ID number | Test assignment name |
+ | Description | Test assignment description |
+ And I press "Save and return to course"
+ Then I should see "Test assignment name" in the "region-main" "region"
+ And I am on "Course 1" course homepage
+ And I should not see "Test assignment name" in the "region-main" "region"
diff --git a/mod/subsection/tests/behat/subsection_navigation.feature b/mod/subsection/tests/behat/subsection_navigation.feature
new file mode 100644
index 00000000000..e34012781a5
--- /dev/null
+++ b/mod/subsection/tests/behat/subsection_navigation.feature
@@ -0,0 +1,44 @@
+@mod @mod_subsection
+Feature: Teachers navigate to subsections
+ In order to use subsections
+ As an teacher
+ I need to navigate to subsections
+
+ Background:
+ Given I enable "subsection" "mod" plugin
+ And the following "users" exist:
+ | username | firstname | lastname | email |
+ | teacher1 | Teacher | 1 | teacher1@example.com |
+ And the following "courses" exist:
+ | fullname | shortname | category | numsections | initsections |
+ | Course 1 | C1 | 0 | 1 | 1 |
+ And the following "course enrolments" exist:
+ | user | course | role |
+ | teacher1 | C1 | editingteacher |
+ And the following "activities" exist:
+ | activity | name | course | idnumber | section |
+ | subsection | Subsection 1 | C1 | subsection1 | 1 |
+ | page | Page in Subsection 1 | C1 | page1 | 2 |
+ And I log in as "teacher1"
+
+ Scenario: Subsection section page shows parent section in the breadcrumb
+ When I am on the "C1 > Subsection 1" "course > section" page
+ Then "C1" "link" should exist in the ".breadcrumb" "css_element"
+ And "Section 1" "link" should exist in the ".breadcrumb" "css_element"
+ And "Subsection 1" "text" should exist in the ".breadcrumb" "css_element"
+
+ Scenario: Activity page shows subsection and its parent section in the breadcrumb
+ When I am on the "page1" "Activity" page
+ Then "C1" "link" should exist in the ".breadcrumb" "css_element"
+ And "Section 1" "link" should exist in the ".breadcrumb" "css_element"
+ And "Subsection 1" "link" should exist in the ".breadcrumb" "css_element"
+ And "Page in Subsection 1" "text" should exist in the ".breadcrumb" "css_element"
+
+ Scenario: Sections and Subsections are displayed in the navigation block
+ Given the following config values are set as admin:
+ | unaddableblocks | | theme_boost|
+ And I turn editing mode on
+ When I am on the "page1" "Activity" page
+ And I add the "Navigation" block if not present
+ Then "Section 1" "link" should appear before "Subsection 1" "link" in the "Navigation" "block"
+ And "Subsection 1" "link" should appear before "Page in Subsection 1" "link" in the "Navigation" "block"
diff --git a/mod/subsection/tests/behat/subsection_rename.feature b/mod/subsection/tests/behat/subsection_rename.feature
new file mode 100644
index 00000000000..cba16f2ef44
--- /dev/null
+++ b/mod/subsection/tests/behat/subsection_rename.feature
@@ -0,0 +1,54 @@
+@mod @mod_subsection
+Feature: Teachers can rename subsections
+ In order to change subsections name
+ As an teacher
+ I need to sync subsection and activity names
+
+ Background:
+ Given I enable "subsection" "mod" plugin
+ And the following "users" exist:
+ | username | firstname | lastname | email |
+ | teacher1 | Teacher | 1 | teacher1@example.com |
+ And the following "courses" exist:
+ | fullname | shortname | category | numsections |
+ | Course 1 | C1 | 0 | 2 |
+ And the following "course enrolments" exist:
+ | user | course | role |
+ | teacher1 | C1 | editingteacher |
+ And the following "activities" exist:
+ | activity | name | course | idnumber | section |
+ | subsection | Subsection activity | C1 | forum1 | 1 |
+ | data | Subactivity | C1 | data1 | 3 |
+ And I log in as "teacher1"
+ And I am on "Course 1" course homepage with editing mode on
+
+ @javascript
+ Scenario: Renaming the subsection activity changes the subsection name
+ Given I should see "Subsection activity" in the "page-content" "region"
+ When I set the field "Edit title" in the "Subsection activity" "activity" to "New name"
+ And I should not see "Subsection activity" in the "region-main" "region"
+ And I should see "New name" in the "page-content" "region"
+ Then I click on "New name" "link" in the "page-content" "region"
+ And I should see "New name" in the "page" "region"
+ And I should see "Subactivity" in the "region-main" "region"
+
+ Scenario: Renaming the activity using the settings form rename the subsection name
+ Given I should see "Subsection activity" in the "page-content" "region"
+ When I click on "Edit settings" "link" in the "Subsection activity" "activity"
+ And I set the following fields to these values:
+ | Name | New name |
+ And I press "Save and display"
+ Then I should see "New name" in the "page" "region"
+ And I should see "Subactivity" in the "region-main" "region"
+ And I am on "Course 1" course homepage
+ And I should see "New name" in the "page-content" "region"
+
+ @javascript
+ Scenario: Renaming the subsection renames the subsection activity name
+ Given I click on "Subsection activity" "link" in the "page-content" "region"
+ And I should see "Subsection activity" in the "page" "region"
+ And I should see "Subactivity" in the "region-main" "region"
+ When I set the field "Edit section name" in the "page" "region" to "New name"
+ Then I should see "New name" in the "page" "region"
+ And I am on "Course 1" course homepage
+ And I should see "New name" in the "page-content" "region"
diff --git a/mod/subsection/tests/courseformat/sectiondelegate_test.php b/mod/subsection/tests/courseformat/sectiondelegate_test.php
new file mode 100644
index 00000000000..13129dd3753
--- /dev/null
+++ b/mod/subsection/tests/courseformat/sectiondelegate_test.php
@@ -0,0 +1,75 @@
+.
+
+namespace mod_subsection\courseformat;
+
+/**
+ * Subsection delegated section tests.
+ *
+ * @package mod_subsection
+ * @copyright 2024 Sara Arjona
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \mod_subsection\courseformat\sectiondelegate
+ * @coversDefaultClass \mod_subsection\courseformat\sectiondelegate
+ */
+final class sectiondelegate_test extends \advanced_testcase {
+
+ /**
+ * Test has_delegate_class().
+ *
+ * @covers ::has_delegate_class
+ */
+ public function test_has_delegate_class(): void {
+ $this->assertTrue(sectiondelegate::has_delegate_class('mod_subsection'));
+ }
+
+ /**
+ * Test get_section_action_menu().
+ *
+ * @covers ::get_section_action_menu
+ */
+ public function test_get_section_action_menu(): void {
+ global $PAGE;
+
+ $this->resetAfterTest();
+ $this->setAdminUser();
+
+ $manager = \core_plugin_manager::resolve_plugininfo_class('mod');
+ $manager::enable_plugin('subsection', 1);
+
+ $course = $this->getDataGenerator()->create_course(['format' => 'topics', 'numsections' => 1]);
+ $this->getDataGenerator()->create_module('subsection', ['course' => $course->id, 'section' => 1]);
+
+ $modinfo = get_fast_modinfo($course->id);
+ $sectioninfos = $modinfo->get_section_info_all();
+ // Get the section info for the delegated section.
+ $sectioninfo = $sectioninfos[2];
+ $delegated = sectiondelegate::instance($sectioninfo);
+ $format = course_get_format($course);
+
+ $outputclass = $format->get_output_classname('content\\section\\controlmenu');
+ $controlmenu = new $outputclass($format, $sectioninfo);
+ $renderer = $PAGE->get_renderer('format_' . $course->format);
+
+ // The default section menu should be different for the delegated section menu.
+ $result = $delegated->get_section_action_menu($format, $controlmenu, $renderer);
+ foreach ($result->get_secondary_actions() as $secondaryaction) {
+ // Highlight and Permalink are only present in section menu (not module), so they shouldn't be find in the result.
+ $this->assertNotEquals(get_string('highlight'), $secondaryaction->text);
+ $this->assertNotEquals(get_string('sectionlink', 'course'), $secondaryaction->text);
+ }
+ }
+}
diff --git a/mod/subsection/tests/courseformat/sectiondelegatemodule_test.php b/mod/subsection/tests/courseformat/sectiondelegatemodule_test.php
new file mode 100644
index 00000000000..16c1a8aad67
--- /dev/null
+++ b/mod/subsection/tests/courseformat/sectiondelegatemodule_test.php
@@ -0,0 +1,112 @@
+.
+
+namespace mod_subsection\courseformat;
+
+use mod_subsection\courseformat\sectiondelegate as testsectiondelegatemodule;
+use section_info;
+use cm_info;
+use stdClass;
+
+/**
+ * Section delegate module tests.
+ *
+ * @package mod_subsection
+ * @copyright 2024 Mikel MartÃn
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \core_courseformat\sectiondelegatemodule
+ * @coversDefaultClass \core_courseformat\sectiondelegatemodule
+ */
+final class sectiondelegatemodule_test extends \advanced_testcase {
+
+ /**
+ * Test get_parent_section.
+ *
+ * @covers ::get_parent_section
+ */
+ public function test_get_parent_section(): void {
+ $this->resetAfterTest();
+
+ $manager = \core_plugin_manager::resolve_plugininfo_class('mod');
+ $manager::enable_plugin('subsection', 1);
+
+ $course = $this->getDataGenerator()->create_course(['format' => 'topics', 'numsections' => 2]);
+ $module = $this->getDataGenerator()->create_module('subsection', (object)['course' => $course->id, 'section' => 2]);
+
+ // Get the section info for the delegated section.
+ $sectioninfo = get_fast_modinfo($course)->get_section_info_by_component('mod_subsection', $module->id);
+
+ /** @var testsectiondelegatemodule */
+ $delegated = sectiondelegate::instance($sectioninfo);
+
+ $parentsectioninfo = $delegated->get_parent_section();
+
+ $this->assertInstanceOf(section_info::class, $parentsectioninfo);
+ $this->assertEquals(2, $parentsectioninfo->sectionnum);
+ }
+
+ /**
+ * Test get_cm.
+ *
+ * @covers ::get_cm
+ */
+ public function test_get_cm(): void {
+ $this->resetAfterTest();
+
+ $manager = \core_plugin_manager::resolve_plugininfo_class('mod');
+ $manager::enable_plugin('subsection', 1);
+
+ $course = $this->getDataGenerator()->create_course(['format' => 'topics', 'numsections' => 1]);
+ $module = $this->getDataGenerator()->create_module('subsection', (object)['course' => $course->id, 'section' => 1]);
+
+ // Get the section info for the delegated section.
+ $sectioninfo = get_fast_modinfo($course)->get_section_info_by_component('mod_subsection', $module->id);
+
+ /** @var testsectiondelegatemodule */
+ $delegated = sectiondelegate::instance($sectioninfo);
+
+ $delegatedsectioncm = $delegated->get_cm();
+
+ $this->assertInstanceOf(cm_info::class, $delegatedsectioncm);
+ $this->assertEquals($module->id, $delegatedsectioncm->instance);
+ }
+
+ /**
+ * Test get_course.
+ *
+ * @covers ::get_course
+ */
+ public function test_get_course(): void {
+ $this->resetAfterTest();
+
+ $manager = \core_plugin_manager::resolve_plugininfo_class('mod');
+ $manager::enable_plugin('subsection', 1);
+
+ $course = $this->getDataGenerator()->create_course(['format' => 'topics', 'numsections' => 1]);
+ $module = $this->getDataGenerator()->create_module('subsection', (object)['course' => $course->id, 'section' => 1]);
+
+ // Get the section info for the delegated section.
+ $sectioninfo = get_fast_modinfo($course)->get_section_info_by_component('mod_subsection', $module->id);
+
+ /** @var testsectiondelegatemodule */
+ $delegated = sectiondelegate::instance($sectioninfo);
+
+ $delegatedsectioncourse = $delegated->get_course();
+
+ $this->assertInstanceOf(stdClass::class, $delegatedsectioncourse);
+ $this->assertEquals($course->id, $delegatedsectioncourse->id);
+ }
+}
diff --git a/mod/subsection/tests/generator/lib.php b/mod/subsection/tests/generator/lib.php
new file mode 100644
index 00000000000..6379bf6e3bc
--- /dev/null
+++ b/mod/subsection/tests/generator/lib.php
@@ -0,0 +1,26 @@
+.
+
+/**
+ * Data generator class for mod_subsection.
+ *
+ * @package mod_subsection
+ * @category test
+ * @copyright 2023 Ferran Recio
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class mod_subsection_generator extends testing_module_generator {
+}
diff --git a/mod/subsection/version.php b/mod/subsection/version.php
new file mode 100644
index 00000000000..cf5b1647a32
--- /dev/null
+++ b/mod/subsection/version.php
@@ -0,0 +1,31 @@
+.
+
+/**
+ * Plugin version and other meta-data are defined here.
+ *
+ * @package mod_subsection
+ * @copyright 2023 Amaia Anabitarte
+ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$plugin->component = 'mod_subsection';
+$plugin->release = '0.1.0';
+$plugin->version = 2024070100;
+$plugin->requires = 2024070500;
+$plugin->maturity = MATURITY_ALPHA;
diff --git a/mod/subsection/view.php b/mod/subsection/view.php
new file mode 100644
index 00000000000..82f97a7bede
--- /dev/null
+++ b/mod/subsection/view.php
@@ -0,0 +1,57 @@
+.
+
+/**
+ * Prints an instance of mod_subsection.
+ *
+ * @package mod_subsection
+ * @copyright 2023 Amaia Anabitarte
+ * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+use mod_subsection\manager;
+use core_courseformat\formatactions;
+
+require(__DIR__.'/../../config.php');
+require_once(__DIR__.'/lib.php');
+
+// Course module id.
+$id = required_param('id', PARAM_INT);
+$cm = get_coursemodule_from_id('subsection', $id, 0, false, MUST_EXIST);
+$manager = manager::create_from_coursemodule($cm);
+$course = $DB->get_record('course', ['id' => $cm->course], '*', MUST_EXIST);
+$moduleinstance = $manager->get_instance();
+
+require_login($course, true, $cm);
+
+$modulecontext = $manager->get_context();
+$manager->set_module_viewed($course);
+
+$modinfo = get_fast_modinfo($course);
+
+$delegatesection = $modinfo->get_section_info_by_component(manager::PLUGINNAME, $moduleinstance->id);
+if (!$delegatesection) {
+ // Some restorations can produce a situation where the section is not found.
+ // In that case, we create a new one.
+ formatactions::section($course)->create_delegated(
+ manager::PLUGINNAME,
+ $id,
+ (object) [
+ 'name' => $moduleinstance->name,
+ ]
+ );
+}
+redirect(new moodle_url('/course/section.php', ['id' => $delegatesection->id]));
diff --git a/report/outline/tests/behat/subsection_reports.feature b/report/outline/tests/behat/subsection_reports.feature
new file mode 100644
index 00000000000..29e4dd1b17d
--- /dev/null
+++ b/report/outline/tests/behat/subsection_reports.feature
@@ -0,0 +1,32 @@
+@mod @mod_subsection @report
+Feature: Subsections are shown in reports
+ In order to use reports
+ As an teacher
+ I need to see sections and subsections structure in reports
+
+ Background:
+ Given I enable "subsection" "mod" plugin
+ And the following "users" exist:
+ | username | firstname | lastname | email |
+ | teacher1 | Teacher | 1 | teacher1@example.com |
+ And the following "courses" exist:
+ | fullname | shortname | category | numsections | initsections |
+ | Course 1 | C1 | 0 | 1 | 1 |
+ And the following "course enrolments" exist:
+ | user | course | role |
+ | teacher1 | C1 | editingteacher |
+ And the following "activities" exist:
+ | activity | name | course | idnumber | section |
+ | page | First page | C1 | page1 | 1 |
+ | subsection | Subsection 1 | C1 | subsection1 | 1 |
+ | page | Last page | C1 | last | 1 |
+ | page | Page in Subsection 1 | C1 | subpage | 2 |
+ And I log in as "teacher1"
+
+ @report_outline
+ Scenario: Course Activity report show subsections' information
+ Given I am on "Course 1" course homepage
+ When I navigate to "Reports > Activity report" in current page administration
+ Then I should see "First page" in the "generaltable" "table"
+ And "Subsection" "table_row" should appear before "Last page" "table_row"
+ And "Page in Subsection 1" "table_row" should appear before "Last page" "table_row"
diff --git a/theme/boost/scss/moodle/courseindex.scss b/theme/boost/scss/moodle/courseindex.scss
index fbb098eb33d..499fa85460a 100644
--- a/theme/boost/scss/moodle/courseindex.scss
+++ b/theme/boost/scss/moodle/courseindex.scss
@@ -158,6 +158,11 @@ $courseindex-item-current: $primary !default;
.current-badge {
display: inline-block;
}
+
+ /* Skip current badges in delegated sections. */
+ .delegated-section .current-badge {
+ display: none;
+ }
}
&.dropready .courseindex-item-content {
diff --git a/theme/boost/style/moodle.css b/theme/boost/style/moodle.css
index f0bc8c06201..e018963bac2 100644
--- a/theme/boost/style/moodle.css
+++ b/theme/boost/style/moodle.css
@@ -38450,10 +38450,14 @@ div.editor_atto_toolbar button .icon {
}
.courseindex .courseindex-section.current {
border-left: solid 3px #0f6cbf;
+ /* Skip current badges in delegated sections. */
}
.courseindex .courseindex-section.current .current-badge {
display: inline-block;
}
+.courseindex .courseindex-section.current .delegated-section .current-badge {
+ display: none;
+}
.courseindex .courseindex-section.dropready .courseindex-item-content {
/* Extra dropzone space */
padding-bottom: 1em;
diff --git a/theme/classic/style/moodle.css b/theme/classic/style/moodle.css
index 2551635fa50..8dce6f612b0 100644
--- a/theme/classic/style/moodle.css
+++ b/theme/classic/style/moodle.css
@@ -38384,10 +38384,14 @@ div.editor_atto_toolbar button .icon {
}
.courseindex .courseindex-section.current {
border-left: solid 3px #0f6cbf;
+ /* Skip current badges in delegated sections. */
}
.courseindex .courseindex-section.current .current-badge {
display: inline-block;
}
+.courseindex .courseindex-section.current .delegated-section .current-badge {
+ display: none;
+}
.courseindex .courseindex-section.dropready .courseindex-item-content {
/* Extra dropzone space */
padding-bottom: 1em;