From e89205eca2db31bb451dcc0c7b8b1b796d3ff7af Mon Sep 17 00:00:00 2001 From: Andrew Nicols Date: Mon, 19 Jan 2026 10:23:14 +0800 Subject: [PATCH] MDL-87264 router: Add course and coursemodule query parameters --- .upgradenotes/MDL-87264-2026012116260358.yml | 10 ++ .../route/controller/course_navigation.php | 16 +- .../route/controller/restricted_module.php | 22 +-- .../parameters/course_parameter_trait.php | 143 ++++++++++++++++++ .../coursemodule_parameter_trait.php | 142 +++++++++++++++++ .../classes/router/parameters/path_course.php | 112 +------------- .../router/parameters/path_coursemodule.php | 32 ++++ .../classes/router/parameters/path_module.php | 77 ---------- .../router/parameters/query_course.php | 31 ++++ .../router/parameters/query_coursemodule.php | 31 ++++ .../schema/parameters/query_parameter.php | 8 +- ...le_test.php => path_coursemodule_test.php} | 17 +-- 12 files changed, 427 insertions(+), 214 deletions(-) create mode 100644 .upgradenotes/MDL-87264-2026012116260358.yml create mode 100644 public/lib/classes/router/parameters/course_parameter_trait.php create mode 100644 public/lib/classes/router/parameters/coursemodule_parameter_trait.php create mode 100644 public/lib/classes/router/parameters/path_coursemodule.php delete mode 100644 public/lib/classes/router/parameters/path_module.php create mode 100644 public/lib/classes/router/parameters/query_course.php create mode 100644 public/lib/classes/router/parameters/query_coursemodule.php rename public/lib/tests/router/parameters/{path_module_test.php => path_coursemodule_test.php} (86%) diff --git a/.upgradenotes/MDL-87264-2026012116260358.yml b/.upgradenotes/MDL-87264-2026012116260358.yml new file mode 100644 index 00000000000..8522cc73563 --- /dev/null +++ b/.upgradenotes/MDL-87264-2026012116260358.yml @@ -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 diff --git a/public/course/classes/route/controller/course_navigation.php b/public/course/classes/route/controller/course_navigation.php index 16c761a639a..2f125eaf47e 100644 --- a/public/course/classes/route/controller/course_navigation.php +++ b/public/course/classes/route/controller/course_navigation.php @@ -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); diff --git a/public/course/classes/route/controller/restricted_module.php b/public/course/classes/route/controller/restricted_module.php index bd36eb4838b..4ebeaa9ee39 100644 --- a/public/course/classes/route/controller/restricted_module.php +++ b/public/course/classes/route/controller/restricted_module.php @@ -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); diff --git a/public/lib/classes/router/parameters/course_parameter_trait.php b/public/lib/classes/router/parameters/course_parameter_trait.php new file mode 100644 index 00000000000..bc386b4f696 --- /dev/null +++ b/public/lib/classes/router/parameters/course_parameter_trait.php @@ -0,0 +1,143 @@ +. + +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 + * @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'] = <<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; + } +} diff --git a/public/lib/classes/router/parameters/coursemodule_parameter_trait.php b/public/lib/classes/router/parameters/coursemodule_parameter_trait.php new file mode 100644 index 00000000000..309f6e01d56 --- /dev/null +++ b/public/lib/classes/router/parameters/coursemodule_parameter_trait.php @@ -0,0 +1,142 @@ +. + +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 + * @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'] = <<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; + } +} diff --git a/public/lib/classes/router/parameters/path_course.php b/public/lib/classes/router/parameters/path_course.php index 8db8f9d1f83..bf4e1784a29 100644 --- a/public/lib/classes/router/parameters/path_course.php +++ b/public/lib/classes/router/parameters/path_course.php @@ -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 + * @copyright Andrew Lyons * @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'] = <<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; } diff --git a/public/lib/classes/router/parameters/path_coursemodule.php b/public/lib/classes/router/parameters/path_coursemodule.php new file mode 100644 index 00000000000..e057a02ae49 --- /dev/null +++ b/public/lib/classes/router/parameters/path_coursemodule.php @@ -0,0 +1,32 @@ +. + +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 + * @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; +} diff --git a/public/lib/classes/router/parameters/path_module.php b/public/lib/classes/router/parameters/path_module.php deleted file mode 100644 index 42c4c636578..00000000000 --- a/public/lib/classes/router/parameters/path_module.php +++ /dev/null @@ -1,77 +0,0 @@ -. - -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 - * @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'] = <<withAttribute($this->name, $cm) - ->withAttribute("{$this->name}context", \core\context\module::instance($cm->id)); - } -} diff --git a/public/lib/classes/router/parameters/query_course.php b/public/lib/classes/router/parameters/query_course.php new file mode 100644 index 00000000000..7321015b0ed --- /dev/null +++ b/public/lib/classes/router/parameters/query_course.php @@ -0,0 +1,31 @@ +. + +namespace core\router\parameters; + +/** + * A query parameter referencing a course. + * + * @package core + * @copyright Andrew Lyons + * @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; +} diff --git a/public/lib/classes/router/parameters/query_coursemodule.php b/public/lib/classes/router/parameters/query_coursemodule.php new file mode 100644 index 00000000000..02adf2e55d7 --- /dev/null +++ b/public/lib/classes/router/parameters/query_coursemodule.php @@ -0,0 +1,31 @@ +. + +namespace core\router\parameters; + +/** + * A query parameter referencing a course module. + * + * @package core + * @copyright Andrew Lyons + * @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; +} diff --git a/public/lib/classes/router/schema/parameters/query_parameter.php b/public/lib/classes/router/schema/parameters/query_parameter.php index e3f7c402206..1581e76fae9 100644 --- a/public/lib/classes/router/schema/parameters/query_parameter.php +++ b/public/lib/classes/router/schema/parameters/query_parameter.php @@ -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, diff --git a/public/lib/tests/router/parameters/path_module_test.php b/public/lib/tests/router/parameters/path_coursemodule_test.php similarity index 86% rename from public/lib/tests/router/parameters/path_module_test.php rename to public/lib/tests/router/parameters/path_coursemodule_test.php index 95b8f3b4d53..170ee35eef5 100644 --- a/public/lib/tests/router/parameters/path_module_test.php +++ b/public/lib/tests/router/parameters/path_coursemodule_test.php @@ -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 * @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;