diff --git a/.upgradenotes/MDL-87671-2026020509381268.yml b/.upgradenotes/MDL-87671-2026020509381268.yml new file mode 100644 index 00000000000..efbaf8b18ae --- /dev/null +++ b/.upgradenotes/MDL-87671-2026020509381268.yml @@ -0,0 +1,5 @@ +issueNumber: MDL-87671 +notes: + core: + - message: A new path_section parameter type for routing has been created + type: improved diff --git a/.upgradenotes/MDL-87671-2026020509424586.yml b/.upgradenotes/MDL-87671-2026020509424586.yml new file mode 100644 index 00000000000..39d14a3ba0a --- /dev/null +++ b/.upgradenotes/MDL-87671-2026020509424586.yml @@ -0,0 +1,7 @@ +issueNumber: MDL-87671 +notes: + core: + - message: >- + Nodes from sections with visible restrictions have been added to the + navigation tree + type: improved diff --git a/public/availability/tests/behat/display_availability.feature b/public/availability/tests/behat/display_availability.feature index c0ce6dcd9f4..c873cda4426 100644 --- a/public/availability/tests/behat/display_availability.feature +++ b/public/availability/tests/behat/display_availability.feature @@ -181,3 +181,30 @@ Feature: Display availability for activities and sections And I press "Add restriction..." And I click on "Grade" "button" in the "Add restriction..." "dialogue" And the "alt" attribute of ".availability-item .availability-eye img" "css_element" should contain "Item name displayed" + + Scenario: Restricted section page + # Set up. + Given the following config values are set as admin: + | unaddableblocks | | theme_boost| + And the following "groups" exist: + | course | name | idnumber | + | C1 | G1 | GI1 | + And the following "groupings" exist: + | name | course | idnumber | + | GX1 | C1 | GXI1 | + And I am on the "C1" "Course" page logged in as "teacher1" + And I turn editing mode on + And I add the "Navigation" block if not present + # Add a restriction to section 1 (visible to students). + And I edit the section "1" + And I set the following fields to these values: + | Access restrictions | Grouping: GX1 | + And I press "Save changes" + # Change to student view. + And I am on the "Course 1" "Course" page logged in as "student1" + And "Section 1" "link" should appear before "Section 2" "link" in the "Navigation" "block" + When I click on "Section 1" "link" in the "Navigation" "block" + # Section 1 should be visible and show info. + And I should see "Section 1" in the "page-header" "region" + And I should see "Not available unless" in the "region-main" "region" + And "Page 1" "link" should not exist in the "region-main" "region" diff --git a/public/course/classes/route/controller/restricted_section.php b/public/course/classes/route/controller/restricted_section.php new file mode 100644 index 00000000000..533aef54485 --- /dev/null +++ b/public/course/classes/route/controller/restricted_section.php @@ -0,0 +1,87 @@ +. + +namespace core_course\route\controller; + +use core\router\route; +use core\router\require_login; +use core_course\modinfo; +use core_course\section_info; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; + +/** + * Restricted section redirection. + * + * @package core_course + * @copyright 2026 Amaia Anabitarte + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class restricted_section { + use \core\router\route_controller; + + /** + * Restricted section. + * + * @param ResponseInterface $response + * @param section_info $sectioninfo + * @return ResponseInterface + */ + #[route( + path: '/sections/{section}/restricted', + pathtypes: [ + new \core\router\parameters\path_section(), + ], + requirelogin: new require_login( + requirelogin: true, + courseattributename: 'course', + ), + )] + public function restricted_section_page( + ServerRequestInterface $request, + ResponseInterface $response, + \stdClass $section, + ): ResponseInterface { + global $OUTPUT, $PAGE, $CFG; + + require_once($CFG->dirroot . '/course/lib.php'); + + $course = get_course($section->course); + $format = course_get_format($course->id); + $format->set_sectionid($section->id); + $outputclass = $format->get_output_classname('content'); + $sectionoutput = new $outputclass($format); + $PAGE->set_url('/course/section.php', ['id' => $section->id]); + $PAGE->set_course($course); + + $PAGE->set_pagelayout('course'); + $PAGE->add_body_classes(['limitedwidth', 'single-section-page']); + $PAGE->set_pagetype('course-view-section-' . $course->format . '-restricted'); + $PAGE->set_context(\context_course::instance($course->id)); + + $sectiontitle = $format->get_section_name($section); + $strtitle = get_string('restrictedtitle', 'course', $sectiontitle); + $PAGE->set_title($strtitle . \moodle_page::TITLE_SEPARATOR . $course->shortname); + $PAGE->set_heading($sectiontitle); + $PAGE->set_secondary_navigation(false); + $renderer = $format->get_renderer($PAGE); + $response->getBody()->write($OUTPUT->header()); + $response->getBody()->write($renderer->render($sectionoutput)); + $response->getBody()->write($OUTPUT->footer()); + + return $response; + } +} diff --git a/public/course/section.php b/public/course/section.php index eafb7df74db..3c4668b6e75 100644 --- a/public/course/section.php +++ b/public/course/section.php @@ -96,9 +96,11 @@ if (!$sectioninfo->uservisible) { // Check if coursesection has conditions affecting availability and if // so, output availability info. if ($sectioninfo->visible && $sectioninfo->availableinfo) { - $sectionname = get_section_name($course, $sectioninfo); - $message = get_string('notavailablecourse', '', $sectionname); - redirect(course_get_url($course), $message, null, \core\output\notification::NOTIFY_ERROR); + $url = \core\router\util::get_path_for_callable( + [\core_course\route\controller\restricted_section::class, 'restricted_section_page'], + ['section' => $sectioninfo->id], + ); + redirect($url, '', null); } else { // Note: We actually already know they don't have this capability // or uservisible would have been true; this is just to get the diff --git a/public/lib/classes/navigation/global_navigation.php b/public/lib/classes/navigation/global_navigation.php index 909affaa5d6..72e7c41afa3 100644 --- a/public/lib/classes/navigation/global_navigation.php +++ b/public/lib/classes/navigation/global_navigation.php @@ -1027,6 +1027,7 @@ class global_navigation extends navigation_node { unset($sections[$key]->summary); $sections[$key]->hasactivites = false; if (!array_key_exists($section->sectionnum, $modinfo->sections)) { + // It may be because the section is not available for the user. continue; } foreach ($section->get_sequence_cm_infos() as $cm) { @@ -1083,6 +1084,7 @@ class global_navigation extends navigation_node { global $CFG, $DB, $USER, $SITE; require_once($CFG->dirroot . '/course/lib.php'); + $format = course_get_format($course->id); [$sections, $activities] = $this->generate_sections_and_activities($course); $navigationsections = []; @@ -1093,7 +1095,7 @@ class global_navigation extends navigation_node { } if ( - !$section->uservisible + !$format->is_section_visible($section) || ( !$this->showemptysections && !$section->hasactivites diff --git a/public/lib/classes/router/parameters/path_section.php b/public/lib/classes/router/parameters/path_section.php new file mode 100644 index 00000000000..30d6e2bcc84 --- /dev/null +++ b/public/lib/classes/router/parameters/path_section.php @@ -0,0 +1,98 @@ +. + +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 parameter representing a section in the path. + * + * @package core + * @copyright 2026 Amaia Anabitarte + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class path_section extends \core\router\schema\parameters\path_parameter implements + mapped_property_parameter, + referenced_object +{ + /** + * Create a new path_section parameter. + * + * @param string $name The name of the parameter to use for the section identifier + * @param mixed ...$extra Additional arguments + */ + public function __construct( + string $name = 'section', + ...$extra, + ) { + $extra['name'] = $name; + $extra['type'] = param::RAW; + $extra['description'] = <<get_section_for_value($value); + return $request + ->withAttribute($this->name, $section) + ->withAttribute("coursecontext", \context_course::instance($section->course)); + } + + /** + * Get the section object for the given identifier. + * + * @param string $value A section id + * @return object + * @throws not_found_exception If the section cannot be found + */ + protected function get_section_for_value(string $value): mixed { + global $DB; + + $data = false; + + if (is_numeric($value)) { + $data = $DB->get_record('course_sections', [ + 'id' => $value, + ]); + } + if ($data) { + return $data; + } + + throw new not_found_exception('course_sections', $value); + } +} diff --git a/public/lib/tests/router/parameters/path_section_test.php b/public/lib/tests/router/parameters/path_section_test.php new file mode 100644 index 00000000000..eb989a839a8 --- /dev/null +++ b/public/lib/tests/router/parameters/path_section_test.php @@ -0,0 +1,68 @@ +. + +namespace core\router\parameters; + +use core\exception\not_found_exception; +use core\tests\router\route_testcase; +use GuzzleHttp\Psr7\ServerRequest; +use stdClass; + +/** + * Tests for the Section Path parameter. + * + * @package core + * @copyright Amaia Anabitarte + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \core\router\parameters\path_section + */ +final class path_section_test extends route_testcase { + public function test_section_id(): void { + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + $modinfo = get_fast_modinfo($course); + $section = $modinfo->get_section_info(1); + $context = \context_course::instance($course->id); + + $param = new path_section(); + $request = new ServerRequest('GET', '/course/sections/' . $section->id . '/restricted'); + $newrequest = $param->add_attributes_for_parameter_value($request, $section->id); + + $this->assertInstanceOf(stdClass::class, $newrequest->getAttribute('section')); + $this->assertInstanceOf(\core\context\course::class, $newrequest->getAttribute('coursecontext')); + + $this->assertEquals($section->id, $newrequest->getAttribute('section')->id); + $this->assertEquals($context->id, $newrequest->getAttribute('coursecontext')->id); + } + + /** + * Tests for when a section was not found. + */ + public function test_section_not_found(): void { + $this->resetAfterTest(); + + $param = new path_section(); + + $course = $this->getDataGenerator()->create_course(); + $sectionid = 9999; + + $request = new ServerRequest('GET', '/course/sections/' . $sectionid . '/restricted'); + + $this->expectException(not_found_exception::class); + $param->add_attributes_for_parameter_value($request, $sectionid); + } +}