MDL-87264 router: Add course and coursemodule query parameters

This commit is contained in:
Andrew Nicols
2026-02-27 14:43:56 +00:00
committed by Mark Johnson
parent dcf942624d
commit e89205eca2
12 changed files with 427 additions and 214 deletions
@@ -0,0 +1,10 @@
issueNumber: MDL-87264
notes:
core:
- message: >
Added new `core\router\parameter\query_course` and
`core\router\parameter\query_coursemodule` parameter types, to allow
course IDs and course module IDs to be accepted as query string
parameters for routes, and automatically converted into the
corresponding objects and contexts.
type: improved
@@ -40,13 +40,13 @@ class course_navigation {
*
* @param ServerRequestInterface $request
* @param ResponseInterface $response
* @param \stdClass $cm
* @param \stdClass $cmdata
* @return ResponseInterface
*/
#[route(
path: '/cms/{cm}/next',
pathtypes: [
new \core\router\parameters\path_module(),
new \core\router\parameters\path_coursemodule(name: 'cm'),
],
requirelogin: new require_login(
requirelogin: true,
@@ -56,11 +56,11 @@ class course_navigation {
public function cm_next_element(
ServerRequestInterface $request,
ResponseInterface $response,
\stdClass $cm,
\stdClass $cmdata,
): 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);
$cm = cm_info::create($cmdata);
$modinfo = $cm->get_modinfo();
$section = $this->get_section($cm);
$allsectioncms = $this->get_all_section_cms($modinfo, $section);
@@ -89,13 +89,13 @@ class course_navigation {
*
* @param ServerRequestInterface $request
* @param ResponseInterface $response
* @param \stdClass $cm
* @param \stdClass $cmdata
* @return ResponseInterface
*/
#[route(
path: '/cms/{cm}/previous',
pathtypes: [
new \core\router\parameters\path_module(),
new \core\router\parameters\path_coursemodule(name: 'cm'),
],
requirelogin: new require_login(
requirelogin: true,
@@ -105,11 +105,11 @@ class course_navigation {
public function cm_previous_element(
ServerRequestInterface $request,
ResponseInterface $response,
\stdClass $cm,
\stdClass $cmdata,
): 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);
$cm = cm_info::create($cmdata);
$modinfo = $cm->get_modinfo();
$section = $this->get_section($cm);
$allsectioncms = $this->get_all_section_cms($modinfo, $section);
@@ -36,13 +36,13 @@ class restricted_module {
* Restricted module.
*
* @param ResponseInterface $response
* @param modinfo $cm
* @param \stdClass $cmdata
* @return ResponseInterface
*/
#[route(
path: '/cms/{cm}/restricted',
pathtypes: [
new \core\router\parameters\path_module(),
new \core\router\parameters\path_coursemodule(name: 'cm'),
],
requirelogin: new require_login(
requirelogin: true,
@@ -52,28 +52,28 @@ class restricted_module {
public function restricted_module_page(
ServerRequestInterface $request,
ResponseInterface $response,
\stdClass $cm,
\stdClass $cmdata,
): ResponseInterface {
global $OUTPUT, $PAGE;
$context = \context_module::instance($cm->id);
$context = \context_module::instance($cmdata->id);
$course = get_course($cm->course);
$course = get_course($cmdata->course);
$modinfo = get_fast_modinfo($course);
$cminfo = $modinfo->get_cm($cm->id);
$sectioninfo = $modinfo->get_section_info_by_id($cm->section);
$cminfo = $modinfo->get_cm($cmdata->id);
$sectioninfo = $modinfo->get_section_info_by_id($cmdata->section);
$format = course_get_format($course);
$course->format = $format->get_format();
$url = \core\router\util::get_path_for_callable(
[self::class, 'restricted_module_page'],
['cm' => $cm->id],
['cm' => $cmdata->id],
);
$PAGE->set_url($url, ['cm' => $cm->id]);
$PAGE->set_url($url, ['cm' => $cmdata->id]);
$PAGE->add_body_class('limitedwidth');
$PAGE->set_context($context);
$PAGE->set_pagetype('mod-' . $cm->modname . '-restricted');
$PAGE->set_pagetype('mod-' . $cminfo->modname . '-restricted');
$strtitle = get_string('restrictedtitle', 'course', $cminfo->get_name());
$PAGE->set_title($strtitle . \moodle_page::TITLE_SEPARATOR . $course->shortname);
$PAGE->set_heading($course->fullname);
@@ -94,7 +94,7 @@ class restricted_module {
$response->getBody()->write($OUTPUT->footer());
$eventdata = [
'objectid' => $cm->id,
'objectid' => $cmdata->id,
'context' => $context,
];
$event = \core\event\course_restricted_module_viewed::create($eventdata);
@@ -0,0 +1,143 @@
<?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\router\parameters;
use core\exception\not_found_exception;
use core\param;
use core\router\schema\example;
use Psr\Http\Message\ServerRequestInterface;
/**
* Trait for route parameters that pass a course identifier, to attach the course data and context to the request.
*
* This holds common code used in {@see path_course} and {@see query_course}.
*
* @package core
* @copyright 2026 Andrew Lyons <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
trait course_parameter_trait {
/**
* Create a new course parameter.
*
* @param string $name The name of the parameter to use for the course identifier
* @param mixed ...$extra Additional arguments
*/
public function __construct(
string $name = 'course',
...$extra,
) {
$extra['name'] = $name;
$extra['type'] = param::RAW;
$extra['description'] = <<<EOF
The course identifier.
This can be the id of the course, the idnumber of the course, or the shortname of the course.
If specifying a course idnumber, the value should be in the format `idnumber:[idnumber]`.
If specifying a course shortname, the value should be in the format `name:[shortname]`.
EOF;
$extra['examples'] = [
new example(
name: 'A course id',
value: 54,
),
new example(
name: 'A course specified by its idnumber',
value: 'idnumber:000117-physics-101-1',
),
new example(
name: 'A course specified by its shortname',
value: 'name:000117-phys101-0',
),
];
parent::__construct(...$extra);
}
/**
* Get the course object for the given identifier.
*
* @param string $value A course id, idnumber, or shortname
* @return object
* @throws not_found_exception If the course cannot be found
*/
protected function get_course_for_value(string $value): mixed {
global $DB;
$data = false;
if (is_numeric($value)) {
$data = $DB->get_record('course', [
'id' => $value,
]);
} else if (str_starts_with($value, 'idnumber:')) {
$data = $DB->get_record('course', [
'idnumber' => substr($value, strlen('idnumber:')),
]);
} else if (str_starts_with($value, 'name:')) {
$data = $DB->get_record('course', [
'shortname' => substr($value, strlen('name:')),
]);
}
if ($data) {
return $data;
}
throw new not_found_exception('course', $value);
}
/**
* Add course and course context parameters to the request.
*
* @param ServerRequestInterface $request
* @param string $value
* @return ServerRequestInterface
*/
public function add_attributes_for_parameter_value(
ServerRequestInterface $request,
string $value,
): ServerRequestInterface {
$course = $this->get_course_for_value($value);
return $request
->withAttribute($this->name, $course)
->withAttribute("{$this->name}context", \core\context\course::instance($course->id));
}
/**
* Get the schema for the parameter type, with valid patterns.
*
* @param param $type
* @return \stdClass
*/
public function get_schema_from_type(param $type): \stdClass {
$schema = parent::get_schema_from_type($type);
$schema->pattern = "^(";
$schema->pattern .= implode("|", [
'\d+',
'idnumber:.+',
'name:.+',
]);
$schema->pattern .= ")$";
return $schema;
}
}
@@ -0,0 +1,142 @@
<?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\router\parameters;
use core\exception\not_found_exception;
use core\param;
use core\router\schema\example;
use Psr\Http\Message\ServerRequestInterface;
/**
* Trait for route parameters that pass a course module identifier, to attach the course module data and context to the request.
*
* This code used in {@see query_coursemodule} that can be reused for other parameter types referencing a course module.
*
* @package core
* @copyright Andrew Lyons <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
trait coursemodule_parameter_trait {
/**
* Create a new coursemodule parameter.
*
* @param string $name The name of the parameter to use for the course identifier
* @param mixed ...$extra Additional arguments
*/
public function __construct(
string $name = 'coursemodule',
...$extra,
) {
$extra['name'] = $name;
$extra['type'] = param::RAW;
$extra['description'] = <<<EOF
The course module identifier.
This can be the id of the course module or the idnumber of the course module.
If specifying a course module idnumber, the value should be in the format `idnumber:[idnumber]`.
EOF;
$extra['examples'] = [
new example(
name: 'A course module id',
value: 54,
),
new example(
name: 'A course module specified by its idnumber',
value: 'idnumber:000117-physics-101-1',
),
];
parent::__construct(...$extra);
}
/**
* Get the course module record for the given identifier.
*
* @param string $value A course module id or idnumber
* @return object Course module record.
* @throws not_found_exception If the course module cannot be found
*/
protected function get_data_for_value(string $value): mixed {
global $DB;
$data = false;
if (is_numeric($value)) {
$data = $DB->get_record('course_modules', [
'id' => $value,
]);
} else if (str_starts_with($value, 'idnumber:')) {
$data = $DB->get_record('course_modules', [
'idnumber' => substr($value, strlen('idnumber:')),
]);
}
if ($data) {
return $data;
}
throw new not_found_exception('coursemodule', $value);
}
/**
* Add course module data and course module context parameters to the request.
*
* @param ServerRequestInterface $request
* @param string $value
* @return ServerRequestInterface
*/
public function add_attributes_for_parameter_value(
ServerRequestInterface $request,
string $value,
): ServerRequestInterface {
$data = $this->get_data_for_value($value);
return $request
->withAttribute($this->get_value_name('data'), $data)
->withAttribute($this->get_value_name('context'), \core\context\module::instance($data->id));
}
/**
* Get the schema for the parameter type, with valid patterns.
*
* @param param $type
* @return \stdClass
*/
public function get_schema_from_type(param $type): \stdClass {
$schema = parent::get_schema_from_type($type);
$schema->pattern = "^(";
$schema->pattern .= implode("|", [
'\d+',
'idnumber:.+',
]);
$schema->pattern .= ")$";
return $schema;
}
/**
* Get the name of the parameter with the given suffix.
*
* @param string $suffix
* @return string
*/
protected function get_value_name(string $suffix): string {
return $this->name . $suffix;
}
}
@@ -16,120 +16,16 @@
namespace core\router\parameters;
use core\exception\not_found_exception;
use core\param;
use core\router\schema\example;
use core\router\schema\parameters\mapped_property_parameter;
use core\router\schema\referenced_object;
use Psr\Http\Message\ServerRequestInterface;
/**
* A Moodle parameter referenced in the path.
*
* @package core
* @copyright 2023 Andrew Lyons <[email protected]>
* @copyright Andrew Lyons <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class path_course extends \core\router\schema\parameters\path_parameter implements
mapped_property_parameter,
referenced_object
\core\router\schema\parameters\mapped_property_parameter,
\core\router\schema\referenced_object
{
/**
* Create a new path_course parameter.
*
* @param string $name The name of the parameter to use for the course identifier
* @param mixed ...$extra Additional arguments
*/
public function __construct(
string $name = 'course',
...$extra,
) {
$extra['name'] = $name;
$extra['type'] = param::RAW;
$extra['description'] = <<<EOF
The course identifier.
This can be the id of the course, the idnumber of the course, or the shortname of the course.
If specifying a course idnumber, the value should be in the format `idnumber:[idnumber]`.
If specifying a course shortname, the value should be in the format `name:[shortname]`.
EOF;
$extra['examples'] = [
new example(
name: 'A course id',
value: 54,
),
new example(
name: 'A course specified by its idnumber',
value: 'idnumber:000117-physics-101-1',
),
new example(
name: 'A course specified by its shortname',
value: 'name:000117-phys101-0',
),
];
parent::__construct(...$extra);
}
/**
* Get the course object for the given identifier.
*
* @param string $value A course id, idnumber, or shortname
* @return object
* @throws not_found_exception If the course cannot be found
*/
protected function get_course_for_value(string $value): mixed {
global $DB;
$data = false;
if (is_numeric($value)) {
$data = $DB->get_record('course', [
'id' => $value,
]);
} else if (str_starts_with($value, 'idnumber:')) {
$data = $DB->get_record('course', [
'idnumber' => substr($value, strlen('idnumber:')),
]);
} else if (str_starts_with($value, 'name:')) {
$data = $DB->get_record('course', [
'shortname' => substr($value, strlen('name:')),
]);
}
if ($data) {
return $data;
}
throw new not_found_exception('course', $value);
}
#[\Override]
public function add_attributes_for_parameter_value(
ServerRequestInterface $request,
string $value,
): ServerRequestInterface {
$course = $this->get_course_for_value($value);
return $request
->withAttribute($this->name, $course)
->withAttribute("{$this->name}context", \core\context\course::instance($course->id));
}
#[\Override]
public function get_schema_from_type(param $type): \stdClass {
$schema = parent::get_schema_from_type($type);
$schema->pattern = "^(";
$schema->pattern .= implode("|", [
'\d+',
'idnumber:.+',
'name:.+',
]);
$schema->pattern .= ")$";
return $schema;
}
use course_parameter_trait;
}
@@ -0,0 +1,32 @@
<?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\router\parameters;
/**
* A path parameter referencing a course module ID. This will add the course module data object and context to the request.
*
* @package core
* @copyright Catalyst IT Europe Ltd.
* @author Mark Johnson <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class path_coursemodule extends \core\router\schema\parameters\path_parameter implements
\core\router\schema\parameters\mapped_property_parameter,
\core\router\schema\referenced_object
{
use coursemodule_parameter_trait;
}
@@ -1,77 +0,0 @@
<?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\router\parameters;
use core\exception\not_found_exception;
use core\param;
use core\router\schema\example;
use core\router\schema\parameters\mapped_property_parameter;
use core\router\schema\referenced_object;
use Psr\Http\Message\ServerRequestInterface;
/**
* A Moodle parameter referenced in the path.
*
* @package core
* @copyright 2026 Amaia Anabitarte <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class path_module extends \core\router\schema\parameters\path_parameter implements
mapped_property_parameter,
referenced_object
{
/**
* Create a new path_module parameter.
*
* @param string $name The name of the parameter to use for the module identifier
* @param mixed ...$extra Additional arguments
*/
public function __construct(
string $name = 'cm',
...$extra,
) {
$extra['name'] = $name;
$extra['type'] = param::RAW;
$extra['description'] = <<<EOF
The module identifier.
This can be the id of the module.
EOF;
$extra['examples'] = [
new example(
name: 'A module id',
value: 54,
),
];
parent::__construct(...$extra);
}
#[\Override]
public function add_attributes_for_parameter_value(
ServerRequestInterface $request,
string $value,
): ServerRequestInterface {
if (!$cm = get_coursemodule_from_id('', $value)) {
throw new not_found_exception('course_module', $value);
}
return $request
->withAttribute($this->name, $cm)
->withAttribute("{$this->name}context", \core\context\module::instance($cm->id));
}
}
@@ -0,0 +1,31 @@
<?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\router\parameters;
/**
* A query parameter referencing a course.
*
* @package core
* @copyright Andrew Lyons <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class query_course extends \core\router\schema\parameters\query_parameter implements
\core\router\schema\parameters\mapped_property_parameter,
\core\router\schema\referenced_object
{
use course_parameter_trait;
}
@@ -0,0 +1,31 @@
<?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\router\parameters;
/**
* A query parameter referencing a course module.
*
* @package core
* @copyright Andrew Lyons <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class query_coursemodule extends \core\router\schema\parameters\query_parameter implements
\core\router\schema\parameters\mapped_property_parameter,
\core\router\schema\referenced_object
{
use coursemodule_parameter_trait;
}
@@ -74,7 +74,13 @@ class query_parameter extends parameter {
default => throw new \ValueError('Invalid boolean value.'),
};
}
$this->type->validate_param($params[$this->name]);
$value = $params[$this->name];
$this->type->validate_param($value);
if ($value !== null && is_a($this, mapped_property_parameter::class)) {
$request = $this->add_attributes_for_parameter_value($request, $value);
}
return $this->update_request_params(
$request,
@@ -17,7 +17,6 @@
namespace core\router\parameters;
use core\exception\not_found_exception;
use core\router\parameters\path_module;
use core\tests\router\route_testcase;
use GuzzleHttp\Psr7\ServerRequest;
use stdClass;
@@ -28,9 +27,9 @@ use stdClass;
* @package core
* @copyright Amaia Anabitarte <amaia@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @covers \core\router\parameters\path_module
* @covers \core\router\parameters\path_coursemodule
*/
final class path_module_test extends route_testcase {
final class path_coursemodule_test extends route_testcase {
public function test_module_id(): void {
$this->resetAfterTest();
@@ -38,15 +37,15 @@ final class path_module_test extends route_testcase {
$mod = $this->getDataGenerator()->create_module('page', ['course' => $course->id]);
$modcontext = \context_module::instance($mod->cmid);
$param = new path_module();
$param = new path_coursemodule();
$request = new ServerRequest('GET', '/course/cms/' . $mod->cmid . '/restricted');
$newrequest = $param->add_attributes_for_parameter_value($request, $mod->cmid);
$this->assertInstanceOf(stdClass::class, $newrequest->getAttribute('cm'));
$this->assertInstanceOf(\core\context\module::class, $newrequest->getAttribute('cmcontext'));
$this->assertInstanceOf(stdClass::class, $newrequest->getAttribute('coursemoduledata'));
$this->assertInstanceOf(\core\context\module::class, $newrequest->getAttribute('coursemodulecontext'));
$this->assertEquals($mod->cmid, $newrequest->getAttribute('cm')->id);
$this->assertEquals($modcontext->id, $newrequest->getAttribute('cmcontext')->id);
$this->assertEquals($mod->cmid, $newrequest->getAttribute('coursemoduledata')->id);
$this->assertEquals($modcontext->id, $newrequest->getAttribute('coursemodulecontext')->id);
}
/**
@@ -55,7 +54,7 @@ final class path_module_test extends route_testcase {
public function test_module_not_found(): void {
$this->resetAfterTest();
$param = new path_module();
$param = new path_coursemodule();
$course = $this->getDataGenerator()->create_course();
$modid = 9999;