This commit is contained in:
Amaia Anabitarte
2026-02-18 16:37:26 +01:00
3 changed files with 401 additions and 0 deletions
@@ -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/<cmid>/next. If there is not next page, this
will return a 404 error.
type: improved
@@ -0,0 +1,121 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
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 <[email protected]>
* @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;
}
}
@@ -0,0 +1,271 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
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 <[email protected]>
* @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);
}
}