From df56188eb1bbb38037f5fda25dd42616cbea6585 Mon Sep 17 00:00:00 2001 From: ferran Date: Tue, 28 Oct 2025 18:34:30 +0100 Subject: [PATCH] MDL-87467 core_course: add course navigation controller Co-authored-by: Ferran Recio --- .upgradenotes/MDL-87467-2026020210414599.yml | 9 + .../route/controller/course_navigation.php | 121 ++++++++ .../controller/course_navigation_test.php | 271 ++++++++++++++++++ 3 files changed, 401 insertions(+) create mode 100644 .upgradenotes/MDL-87467-2026020210414599.yml create mode 100644 public/course/classes/route/controller/course_navigation.php create mode 100644 public/course/tests/route/controller/course_navigation_test.php diff --git a/.upgradenotes/MDL-87467-2026020210414599.yml b/.upgradenotes/MDL-87467-2026020210414599.yml new file mode 100644 index 00000000000..6973b51f850 --- /dev/null +++ b/.upgradenotes/MDL-87467-2026020210414599.yml @@ -0,0 +1,9 @@ +issueNumber: MDL-87467 +notes: + core_course: + - message: >- + Add new routes for course module, so we can predict what would be the + next page to show when using course linear navigation. The route url + will be like course/cms//next. If there is not next page, this + will return a 404 error. + type: improved diff --git a/public/course/classes/route/controller/course_navigation.php b/public/course/classes/route/controller/course_navigation.php new file mode 100644 index 00000000000..9a27b5e5bac --- /dev/null +++ b/public/course/classes/route/controller/course_navigation.php @@ -0,0 +1,121 @@ +. + +namespace core_course\route\controller; + +use core\router\route; +use core\router\require_login; +use core_course\cm_info; +use core_course\modinfo; +use core_course\section_info; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; + +/** + * Class course_navigation + * + * @package core_course + * @copyright 2025 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class course_navigation { + use \core\router\route_controller; + + /** + * Go to the next element of the course. + * + * @param ServerRequestInterface $request + * @param ResponseInterface $response + * @param \stdClass $cm + * @return ResponseInterface + */ + #[route( + path: '/cms/{cm}/next', + pathtypes: [ + new \core\router\parameters\path_module(), + ], + requirelogin: new require_login( + requirelogin: true, + courseattributename: 'course', + ), + )] + public function cm_next_element( + ServerRequestInterface $request, + ResponseInterface $response, + \stdClass $cm, + ): ResponseInterface { + // The pathinfo module returns a stdClass and not a cm_info, so we need to + // get the cm_info instance from the course modinfo. + $cm = cm_info::create($cm); + $allcms = $this->get_all_cms($cm->get_modinfo()); + // Note here: we don't check if the cmindex is false because the the path_module parameter will return a 404 + // if the cm is not found in the course, so we can assume that it is always found. + $cmindex = array_search($cm, $allcms, true); + $cmcount = count($allcms); + // Search for the next CM that has got an URL, skipping the ones that don't have it (like labels). + for ($cmindex++; $cmindex < $cmcount; $cmindex++) { + $nextcm = $allcms[$cmindex]; + if (!empty($nextcm->get_url())) { + return $this->redirect($response, $nextcm->get_url()); + } + } + return $this->page_not_found($request, $response); + } + + /** + * Get all course modules in the course in order, including also activities inside sub-sections. + * + * @param \core_course\modinfo $modinfo + * @return cm_info[] + */ + private function get_all_cms(modinfo $modinfo): array { + $cms = []; + $sections = $modinfo->get_section_info_all(); + foreach ($sections as $section) { + if ($section->is_delegated()) { + continue; + } + $cms = array_merge( + $cms, + $this->get_all_section_cms($modinfo, $section), + ); + } + return $cms; + } + + /** + * Get all course modules in a section in order, including also activities inside sub-sections. + * + * @param modinfo $modinfo + * @param section_info $section + * @return cm_info[] + */ + private function get_all_section_cms(modinfo $modinfo, section_info $section): array { + $sectioncms = []; + foreach ($section->get_sequence_cm_infos() as $cm) { + $delegatedsection = $cm->get_delegated_section_info(); + if ($delegatedsection) { + $sectioncms = array_merge( + $sectioncms, + $this->get_all_section_cms($modinfo, $delegatedsection), + ); + } else { + $sectioncms[] = $cm; + } + } + return $sectioncms; + } +} diff --git a/public/course/tests/route/controller/course_navigation_test.php b/public/course/tests/route/controller/course_navigation_test.php new file mode 100644 index 00000000000..36427c81d37 --- /dev/null +++ b/public/course/tests/route/controller/course_navigation_test.php @@ -0,0 +1,271 @@ +. + +namespace core_course\route\controller; + +use core\router\route_loader_interface; +use core\tests\router\route_testcase; +use core\url; +use core_course\modinfo; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; + +/** + * Course navigation controller tests. + * + * @package core_course + * @copyright 2025 Laurent David + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + **/ +#[CoversClass(\core_course\route\controller\course_navigation::class)] +final class course_navigation_test extends route_testcase { + /** + * Test the course navigation course module next route. + * + * @param array $cmsdef + * @param string $currentmodule + * @param array $expectednextelement + * @param string $role + */ + #[DataProvider('cm_next_provider')] + public function test_cm_next( + array $cmsdef, + string $currentmodule, + array $expectednextelement, + string $role = 'student', + ): void { + $this->resetAfterTest(); + $generator = $this->getDataGenerator(); + $course = $generator->create_course(['numsections' => 2]); + $user = $generator->create_and_enrol($course, $role); + $cms = []; + foreach ($cmsdef as $cmdef) { + $cms[$cmdef['name']] = $this->create_module_or_subsection( + courseid: $course->id, + key: $cmdef['name'], + type: $cmdef['type'] ?? 'assign', + options: $cmdef['options'] ?? [], + ); + } + $cmid = $cms[$currentmodule]->cmid ?? 9999; // If we cannot find it we will test the error case of not found. + $this->setUser($user); + $response = $this->process_request( + 'GET', + "course/cms/{$cmid}/next", + route_loader_interface::ROUTE_GROUP_PAGE + ); + if ($expectednextelement['type'] === 'error') { + $this->assertEquals( + $expectednextelement['statuscode'], + $response->getStatusCode(), + ); + return; + } + $this->assert_valid_response($response, 302); + $nextlocation = $response->getHeader('Location'); // Just to consume the header if any. + + $this->assertNotEmpty($nextlocation, 'The redirection header should be present.'); + $this->assert_redirected_url( + $expectednextelement['type'], + $expectednextelement['id'] ?? '', + $course->id, + $nextlocation[0] + ); + } + + /** + * Data provider for test_cm_next. + * + * @return \Generator + */ + public static function cm_next_provider(): \Generator { + yield 'Simple case (teacher)' => [ + 'cmsdef' => [ + ['name' => 'cm1'], + ['name' => 'cm2'], + ], + 'currentmodule' => 'cm1', + 'expectednextelement' => [ + 'type' => 'cm', + 'id' => 'cm2', + ], + 'role' => 'teacher', + ]; + yield 'Simple case (student)' => [ + 'cmsdef' => [ + ['name' => 'cm1'], + ['name' => 'cm2'], + ], + 'currentmodule' => 'cm1', + 'expectednextelement' => [ + 'type' => 'cm', + 'id' => 'cm2', + ], + ]; + yield 'Simple case with hidden module (student)' => [ + 'cmsdef' => [ + ['name' => 'cm1'], + ['name' => 'cm2', 'options' => ['visible' => false]], + ], + 'currentmodule' => 'cm1', + 'expectednextelement' => [ + 'type' => 'cm', + 'id' => 'cm2', + ], + ]; + yield 'Simple case with hidden module (teacher)' => [ + 'cmsdef' => [ + ['name' => 'cm1'], + ['name' => 'cm2', 'options' => ['visible' => false]], + ], + 'currentmodule' => 'cm1', + 'expectednextelement' => [ + 'type' => 'cm', + 'id' => 'cm2', + ], + 'role' => 'teacher', + ]; + yield 'Simple case last activity of a course (student)' => [ + 'cmsdef' => [ + ['name' => 'cm1'], + ['name' => 'cm2', 'options' => ['visible' => false]], + ], + 'currentmodule' => 'cm2', + 'expectednextelement' => [ + 'type' => 'error', + 'statuscode' => 404, + ], + ]; + yield 'With next module being a subsection (student)' => [ + 'cmsdef' => [ + ['name' => 'cm1', 'options' => ['section' => 2]], + ['name' => 'subsection1', 'type' => 'subsection', 'options' => ['section' => 2]], + ['name' => 'cm2', 'options' => ['section' => 'subsection1']], + ], + 'currentmodule' => 'cm1', + 'expectednextelement' => [ + 'type' => 'cm', + 'id' => 'cm2', + ], + ]; + yield 'With next module being a label and subsections (student)' => [ + 'cmsdef' => [ + ['name' => 'cm1'], + ['name' => 'cm2', 'type' => 'label'], + ['name' => 'subsection1', 'type' => 'subsection'], + ['name' => 'cm3', 'options' => ['section' => 'subsection1']], + ], + 'currentmodule' => 'cm1', + 'expectednextelement' => [ + 'type' => 'cm', + 'id' => 'cm3', + ], + ]; + yield 'With last module without url (student)' => [ + 'cmsdef' => [ + ['name' => 'cm1', 'options' => ['section' => 2]], + ['name' => 'cm2', 'type' => 'label'], + ], + 'currentmodule' => 'cm1', + 'expectednextelement' => [ + 'type' => 'error', + 'statuscode' => 404, + ], + ]; + yield 'With module that does not exist (student)' => [ + 'cmsdef' => [ + ['name' => 'cm0'], + ['name' => 'cm1'], + ], + 'currentmodule' => 'cmthatdoesnotexist', + 'expectednextelement' => [ + 'type' => 'error', + 'statuscode' => 404, + ], + ]; + } + + /** + * Helper function to assert that the redirection URL matches the expected next element. + * + * @param string $elementtype + * @param string $elementid + * @param int $courseid + * @param string $location + * @return void + */ + protected function assert_redirected_url( + string $elementtype, + string $elementid, + int $courseid, + string $location + ): void { + $coursemodinfo = modinfo::instance($courseid); + if ($elementtype === 'cm') { + $cms = $coursemodinfo->get_cms(); + $cm = null; + foreach ($cms as $activitycm) { + if ($activitycm->get_name() == $elementid) { + $cm = $activitycm; + break; + } + } + $this->assertNotEmpty($cm, "The course module with name {$elementid} should be found."); + $this->assertEquals( + $cm->url, + new url($location) + ); + } else { + $this->fail('Unknown expected next element type ' . $elementtype); + } + } + + /** + * Helper to create a course module or a subsection based on the given definition. + * This will handle the case where we want to point to a section by name instead of id for easier test writing. + * + * @param int $courseid + * @param string $key The name/key of the module + * @param string $type The module type (default: 'assign') + * @param array $options Additional options for the module + * @return \stdClass The newly created course module. + */ + protected function create_module_or_subsection( + int $courseid, + string $key, + string $type = 'assign', + array $options = [], + ): \stdClass { + $generator = $this->getDataGenerator(); + $moduleoptions = [ + 'course' => $courseid, + 'name' => $key, + ]; + if (isset($options['section']) && !is_int($options['section'])) { + // We are pointing to a subsection module. + $modinfo = modinfo::instance($courseid); + $delegatedcms = $modinfo->get_sections_delegated_by_cm(); + foreach ($delegatedcms as $cminfosection) { + if ($cminfosection->name == $options['section']) { + $options['section'] = $cminfosection->sectionnum; + break; + } + } + } + $moduleoptions = array_merge($moduleoptions, $options); + return $generator->create_module($type, $moduleoptions); + } +}