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;