MDL-86660 course_format: Display restricted activities in Overview

This commit is contained in:
Sara Arjona
2025-11-21 19:31:37 +01:00
parent a280753085
commit ebf42ca7a7
12 changed files with 572 additions and 24 deletions
@@ -0,0 +1,7 @@
issueNumber: MDL-86660
notes:
core_courseformat:
- message: >-
The `$cm` attribute in `activityoverviewbase` has been updated to public
visibility, allowing direct access to the course module instance
type: changed
@@ -0,0 +1,8 @@
issueNumber: MDL-86660
notes:
core_courseformat:
- message: >-
A new `available` attribute has been added to `activityname_exporter`
class. It allows the external API to return the activity's availability
status relative to the current user.
type: changed
@@ -0,0 +1,9 @@
issueNumber: MDL-86660
notes:
core_courseformat:
- message: >-
Two new public static methods have been added to the `overviewtable`
class:
- `is_cm_displayable`: Determines if a course module should be listed in the overview table.
- `is_cm_available`: Checks if a course module is accessible to the user (and should therefore be rendered as a link).
type: changed
@@ -75,7 +75,7 @@ abstract class activityoverviewbase {
*/
public function __construct(
/** @var cm_info The course module. */
protected readonly cm_info $cm,
public readonly cm_info $cm,
) {
$this->context = $cm->context;
$this->course = $cm->get_course();
@@ -87,6 +87,11 @@ class activityname_exporter extends exporter {
'multiple' => true,
'default' => [],
],
'available' => [
'type' => PARAM_BOOL,
'null' => NULL_NOT_ALLOWED,
'description' => 'Whether the activity is available.',
],
];
}
@@ -102,6 +107,7 @@ class activityname_exporter extends exporter {
'hidden' => $templatedata->hidden,
'stealth' => $templatedata->stealth,
'sectiontitle' => $templatedata->sectiontitle ?? null,
'available' => $templatedata->available,
'errormessages' => $source->get_error_messages(),
];
}
@@ -75,6 +75,7 @@ class activityname implements externable, named_templatable, renderable {
'hidden' => empty($cm->visible),
'stealth' => $cm->is_stealth(),
'nogroupserror' => $this->nogroupserror,
'available' => overviewtable::is_cm_available($cm),
];
if ($format->uses_sections() && $section->uservisible) {
$result->sectiontitle = $format->get_section_name($section);
@@ -92,9 +92,7 @@ class overviewpage implements renderable, named_templatable {
$archetypes = [];
foreach ($modinfo->cms as $cm) {
// Exclude activities that aren't visible or have no view link (e.g. label).
// Account for folder being displayed inline.
if (!$cm->uservisible || (!$cm->has_view() && strcmp($cm->modname, 'folder') !== 0)) {
if (!overviewtable::is_cm_displayable($cm)) {
continue;
}
if (array_key_exists($cm->modname, $modfullnames)) {
@@ -138,7 +138,7 @@ class overviewtable implements externable, named_templatable, renderable {
private function load_all_overviews_from_each_activity(): array {
$result = [];
foreach ($this->get_related_course_modules() as $cm) {
if (!$this->is_cm_displayable($cm)) {
if (!self::is_cm_displayable($cm)) {
continue;
}
$overview = overviewfactory::create($cm);
@@ -215,13 +215,31 @@ class overviewtable implements externable, named_templatable, renderable {
/**
* Check if the course module is displayable in the overview table.
*
* @param cm_info $cm
* @return bool
* @param cm_info $cm The course module info
* @return bool Whether the course module is displayable in the overview table or not.
*/
private function is_cm_displayable(cm_info $cm): bool {
public static function is_cm_displayable(cm_info $cm): bool {
// Exclude activities that aren't displayed in the course page (except for stealth),
// activities that are not available but availability is hidden
// or activities that have no view link (e.g. label).
// Folder is an exception because it has settings to be displayed in the course
// page without having a view link.
return $cm->uservisible && ($cm->has_view() || strcmp($cm->modname, 'folder') === 0);
return (
(has_capability('moodle/course:viewhiddenactivities', $cm->context)
|| (($cm->is_visible_on_course_page() || $cm->is_stealth())
&& ($cm->available || !empty($cm->availableinfo))))
&& ($cm->has_view() || strcmp($cm->modname, 'folder') === 0)
);
}
/**
* Check if the given course module is available (so linkable) in the overview table.
*
* @param cm_info $cm The course module info
* @return bool Whether the course module is available or not.
*/
public static function is_cm_available(cm_info $cm): bool {
return $cm->uservisible || $cm->available;
}
/**
@@ -263,6 +281,13 @@ class overviewtable implements externable, named_templatable, renderable {
return ['name' => $overview->get_name_overview()];
}
if (!self::is_cm_available($overview->cm)) {
return [
'name' => $overview->get_name_overview(),
'duedate' => $overview->get_due_date_overview(),
];
}
$row = [
'name' => $overview->get_name_overview(),
'duedate' => $overview->get_due_date_overview(),
@@ -26,12 +26,14 @@
"sectiontitle": "Section title",
"visible": true,
"stealth": false,
"nogroupserror": false
"nogroupserror": false,
"available": true
}
}}
<div class="d-flex flex-column">
<div class="fw-bold">
<a href="{{activityurl}}" class="activityname">{{{activityname}}}</a>
{{#available}}<a href="{{activityurl}}" class="activityname">{{{activityname}}}</a>{{/available}}
{{^available}}<span class="activityname">{{{activityname}}}</span>{{/available}}
</div>
{{#sectiontitle}}
<div class="small">
@@ -16,6 +16,8 @@
namespace core_courseformat\external;
use core_courseformat\output\local\overview\overviewtable;
/**
* Tests for activityname_exporter.
*
@@ -57,7 +59,8 @@ final class activityname_exporter_test extends \advanced_testcase {
$this->assertObjectHasProperty('stealth', $data);
$this->assertObjectHasProperty('sectiontitle', $data);
$this->assertObjectHasProperty('errormessages', $data);
$this->assertCount(6, get_object_vars($data));
$this->assertObjectHasProperty('available', $data);
$this->assertCount(7, get_object_vars($data));
$expected = [
'activityname' => \core_external\util::format_string($cm->name, $cm->context, true),
@@ -66,6 +69,7 @@ final class activityname_exporter_test extends \advanced_testcase {
'stealth' => $cm->is_stealth(),
'sectiontitle' => $format->get_section_name($cm->get_section_info()),
'errormessages' => [],
'available' => overviewtable::is_cm_available($cm),
];
foreach ($expected as $property => $value) {
@@ -28,8 +28,15 @@ namespace core_courseformat\output\local\overview;
final class overviewtable_test extends \advanced_testcase {
/**
* Test export_for_external method.
*
* @param string $role The role of the user.
* @param int $expectedactivities The expected number of activities visible to the user.
*/
public function test_export_for_external(): void {
#[\PHPUnit\Framework\Attributes\DataProvider('provider_export_for_external')]
public function test_export_for_external(
string $role,
int $expectedactivities,
): void {
$this->resetAfterTest();
$course = $this->getDataGenerator()->create_course();
@@ -37,32 +44,49 @@ final class overviewtable_test extends \advanced_testcase {
// Ensure the course is fully loaded.
$course = get_course($course->id);
// Create three pages, one of them unavailable and hidden to students.
$mods = [
'assign1' => $this->getDataGenerator()->create_module('assign', ['course' => $course->id]),
'assign2' => $this->getDataGenerator()->create_module('assign', ['course' => $course->id]),
'page1' => $this->getDataGenerator()->create_module('page', ['course' => $course->id]),
'page2' => $this->getDataGenerator()->create_module('page', ['course' => $course->id]),
];
$availabilityjson = json_encode(\core_availability\tree::get_root_json(
[
\availability_date\condition::get_json(
\availability_date\condition::DIRECTION_FROM,
time() + 3600,
),
],
'&',
false,
));
$mods['page3'] = $this->getDataGenerator()->create_module('page', [
'course' => $course->id,
'visible' => true,
'availability' => $availabilityjson,
]);
$user = $this->getDataGenerator()->create_user();
$this->getDataGenerator()->enrol_user($user->id, $course->id, 'editingteacher');
$this->getDataGenerator()->enrol_user($user->id, $course->id, $role);
$this->setUser($user);
$modinfo = get_fast_modinfo($course);
$cms = [
'assign1' => $modinfo->get_cm($mods['assign1']->cmid),
'assign2' => $modinfo->get_cm($mods['assign2']->cmid),
'page1' => $modinfo->get_cm($mods['page1']->cmid),
'page2' => $modinfo->get_cm($mods['page2']->cmid),
'page3' => $modinfo->get_cm($mods['page3']->cmid),
];
$renderer = \core\di::get(\core\output\renderer_helper::class)->get_core_renderer();
$overviewtable = new overviewtable($course, 'assign');
$overviewtable = new overviewtable($course, 'page');
$data = $overviewtable->export_for_external();
$templatedata = $overviewtable->export_for_template($renderer);
$this->assertEquals($course, $data->course);
$this->assertEquals(true, $data->hasintegration);
$this->assertEquals(false, $data->hasintegration);
foreach ($templatedata->headers as $header) {
$this->assertObjectHasProperty('name', $header);
@@ -74,9 +98,9 @@ final class overviewtable_test extends \advanced_testcase {
$this->assertCount(4, get_object_vars($data));
$this->assertCount(2, $data->activities);
$this->assertCount($expectedactivities, $data->activities);
$this->assertEquals($cms['assign1'], $data->activities[0]->cm);
$this->assertEquals($cms['page1'], $data->activities[0]->cm);
$this->assertEquals(false, $data->activities[0]->haserror);
$this->assertCount(count($data->headers), $data->activities[0]->items);
@@ -88,4 +112,442 @@ final class overviewtable_test extends \advanced_testcase {
}
}
}
/**
* Data provider for test_export_for_external.
*
* @return \Generator The data provider array.
*/
public static function provider_export_for_external(): \Generator {
yield 'Editing teacher' => [
'role' => 'editingteacher',
'expectedactivities' => 3,
];
yield 'Non-editing teacher' => [
'role' => 'teacher',
'expectedactivities' => 3,
];
yield 'Student' => [
'role' => 'student',
'expectedactivities' => 2,
];
}
/**
* Test is_cm_displayable method for various visibility and stealth combinations.
*
* @param string $role The role of the user.
* @param bool $visible Whether the activity is visible.
* @param bool $stealth Whether the activity is displayed in the course page or not.
* @param bool $expected The expected result.
*/
#[\PHPUnit\Framework\Attributes\DataProvider('provider_is_cm_displayable_visibility')]
public function test_is_cm_displayable_visibility(
string $role,
bool $visible,
bool $stealth,
bool $expected
): void {
$this->resetAfterTest();
set_config('allowstealth', true);
$course = $this->getDataGenerator()->create_course();
$user = $this->getDataGenerator()->create_user();
$this->getDataGenerator()->enrol_user($user->id, $course->id, $role);
$this->setUser($user);
$mod = $this->getDataGenerator()->create_module('page', [
'course' => $course->id,
'visible' => $visible,
'visibleoncoursepage' => !$stealth,
]);
$modinfo = get_fast_modinfo($course);
$cm = $modinfo->get_cm($mod->cmid);
$this->assertEquals($visible, $cm->visible);
$this->assertEquals($stealth, !$cm->visibleoncoursepage);
$this->assertEquals($expected, overviewtable::is_cm_displayable($cm));
}
/**
* Data provider for test_is_cm_displayable_visibility.
*
* @return \Generator The data provider array.
*/
public static function provider_is_cm_displayable_visibility(): \Generator {
yield 'Editing teacher - Visible' => [
'role' => 'editingteacher',
'visible' => true,
'stealth' => false,
'expected' => true,
];
yield 'Editing teacher - Hidden' => [
'role' => 'editingteacher',
'visible' => false,
'stealth' => false,
'expected' => true,
];
yield 'Editing teacher - Stealth' => [
'role' => 'editingteacher',
'visible' => true,
'stealth' => true,
'expected' => true,
];
yield 'Teacher - Visible' => [
'role' => 'teacher',
'visible' => true,
'stealth' => false,
'expected' => true,
];
yield 'Teacher - Hidden' => [
'role' => 'teacher',
'visible' => false,
'stealth' => false,
'expected' => true,
];
yield 'Teacher - Stealth' => [
'role' => 'teacher',
'visible' => true,
'stealth' => true,
'expected' => true,
];
yield 'Student - Visible' => [
'role' => 'student',
'visible' => true,
'stealth' => false,
'expected' => true,
];
yield 'Student - Hidden' => [
'role' => 'student',
'visible' => false,
'stealth' => false,
'expected' => false,
];
yield 'Student - Stealth' => [
'role' => 'student',
'visible' => true,
'stealth' => true,
'expected' => true,
];
}
/**
* Test is_cm_displayable method for modules without view page (like folder or text&media).
*/
public function test_is_cm_displayable_without_view_page(): void {
$this->resetAfterTest();
$course = $this->getDataGenerator()->create_course();
$user = $this->getDataGenerator()->create_user();
$this->getDataGenerator()->enrol_user($user->id, $course->id, 'editingteacher');
$this->setUser($user);
$modfolder = $this->getDataGenerator()->create_module('folder', [
'course' => $course->id,
'visible' => 1,
]);
$modtext = $this->getDataGenerator()->create_module('label', [
'course' => $course->id,
'visible' => 1,
]);
$modinfo = get_fast_modinfo($course);
$cmfolder = $modinfo->get_cm($modfolder->cmid);
$cmtext = $modinfo->get_cm($modtext->cmid);
$this->assertTrue(overviewtable::is_cm_displayable($cmfolder));
$this->assertFalse(overviewtable::is_cm_displayable($cmtext));
}
/**
* Test is_cm_displayable method for various availability combinations.
*
* @param string $role The role of the user.
* @param bool $visible Whether the activity is visible.
* @param bool $isavailable Whether the activity is available.
* @param bool $availabilityvisible Whether the availability info is shown.
* @param bool $expected The expected result.
*/
#[\PHPUnit\Framework\Attributes\DataProvider('provider_is_cm_displayable_availability')]
public function test_is_cm_displayable_availability(
string $role,
bool $visible,
bool $isavailable,
bool $availabilityvisible,
bool $expected
): void {
$this->resetAfterTest();
$course = $this->getDataGenerator()->create_course();
$user = $this->getDataGenerator()->create_user();
$this->getDataGenerator()->enrol_user($user->id, $course->id, $role);
$this->setUser($user);
// Set up the availability settings.
$operation = \availability_date\condition::DIRECTION_FROM;
$time = $isavailable ? time() - 3600 : time() + 3600;
$availabilityjson = json_encode(\core_availability\tree::get_root_json(
[
\availability_date\condition::get_json($operation, $time),
],
'&',
$availabilityvisible,
));
$mod = $this->getDataGenerator()->create_module('page', [
'course' => $course->id,
'visible' => $visible,
'availability' => $availabilityjson,
]);
$modinfo = get_fast_modinfo($course);
$cm = $modinfo->get_cm($mod->cmid);
$this->assertEquals($visible, $cm->visible);
$this->assertEquals($isavailable, $cm->available);
if ($isavailable || !$visible) {
$this->assertEmpty($cm->availableinfo);
} else {
$this->assertEquals($availabilityvisible, !empty($cm->availableinfo));
}
$this->assertEquals($expected, overviewtable::is_cm_displayable($cm));
}
/**
* Data provider for test_is_cm_displayable_availability.
*
* @return \Generator The data provider array.
*/
public static function provider_is_cm_displayable_availability(): \Generator {
yield 'Teacher - Visible - Available visible' => [
'role' => 'teacher',
'visible' => true,
'isavailable' => true,
'availabilityvisible' => true,
'expected' => true,
];
yield 'Teacher - Visible - Unavailable visible' => [
'role' => 'teacher',
'visible' => true,
'isavailable' => false,
'availabilityvisible' => true,
'expected' => true,
];
yield 'Teacher - Visible - Unavailable hidden' => [
'role' => 'teacher',
'visible' => true,
'isavailable' => false,
'availabilityvisible' => false,
'expected' => true,
];
yield 'Teacher - Hidden - Available visible' => [
'role' => 'teacher',
'visible' => false,
'isavailable' => true,
'availabilityvisible' => true,
'expected' => true,
];
yield 'Teacher - Hidden - Unavailable visible' => [
'role' => 'teacher',
'visible' => false,
'isavailable' => false,
'availabilityvisible' => true,
'expected' => true,
];
yield 'Teacher - Hidden - Unavailable hidden' => [
'role' => 'teacher',
'visible' => false,
'isavailable' => false,
'availabilityvisible' => false,
'expected' => true,
];
yield 'Student - Visible - Available visible' => [
'role' => 'student',
'visible' => true,
'isavailable' => true,
'availabilityvisible' => true,
'expected' => true,
];
yield 'Student - Visible - Unavailable visible' => [
'role' => 'student',
'visible' => true,
'isavailable' => false,
'availabilityvisible' => true,
'expected' => true,
];
yield 'Student - Visible - Unavailable hidden' => [
'role' => 'student',
'visible' => true,
'isavailable' => false,
'availabilityvisible' => false,
'expected' => false,
];
yield 'Student - Hidden - Available visible' => [
'role' => 'student',
'visible' => false,
'isavailable' => true,
'availabilityvisible' => true,
'expected' => false,
];
yield 'Student - Hidden - Unavailable visible' => [
'role' => 'student',
'visible' => false,
'isavailable' => false,
'availabilityvisible' => true,
'expected' => false,
];
yield 'Student - Hidden - Unavailable hidden' => [
'role' => 'student',
'visible' => false,
'isavailable' => false,
'availabilityvisible' => false,
'expected' => false,
];
}
/**
* Test is_cm_available method.
*
* @param string $role The role of the user.
* @param bool $visible Whether the activity is visible.
* @param bool $isavailable Whether the activity is available.
* @param bool $availabilityvisible Whether the availability info is shown.
* @param bool $expected The expected result.
*/
#[\PHPUnit\Framework\Attributes\DataProvider('provider_is_cm_available')]
public function test_is_cm_available(
string $role,
bool $visible,
bool $isavailable,
bool $availabilityvisible,
bool $expected
): void {
$this->resetAfterTest();
$course = $this->getDataGenerator()->create_course();
$user = $this->getDataGenerator()->create_user();
$this->getDataGenerator()->enrol_user($user->id, $course->id, $role);
$this->setUser($user);
// Set up the availability settings.
$operation = \availability_date\condition::DIRECTION_FROM;
$time = $isavailable ? time() - 3600 : time() + 3600;
$availabilityjson = json_encode(\core_availability\tree::get_root_json(
[
\availability_date\condition::get_json($operation, $time),
],
'&',
$availabilityvisible,
));
$mod = $this->getDataGenerator()->create_module('page', [
'course' => $course->id,
'visible' => $visible,
'availability' => $availabilityjson,
]);
$modinfo = get_fast_modinfo($course);
$cm = $modinfo->get_cm($mod->cmid);
$this->assertEquals($visible, $cm->visible);
$this->assertEquals($isavailable, $cm->available);
if ($isavailable || !$visible) {
$this->assertEmpty($cm->availableinfo);
} else {
$this->assertEquals($availabilityvisible, !empty($cm->availableinfo));
}
$this->assertEquals($expected, overviewtable::is_cm_available($cm));
}
/**
* Data provider for test_is_cm_available.
*
* @return \Generator The data provider array.
*/
public static function provider_is_cm_available(): \Generator {
yield 'Teacher - Visible - Available visible' => [
'role' => 'teacher',
'visible' => true,
'isavailable' => true,
'availabilityvisible' => true,
'expected' => true,
];
yield 'Teacher - Visible - Unavailable visible' => [
'role' => 'teacher',
'visible' => true,
'isavailable' => false,
'availabilityvisible' => true,
'expected' => true,
];
yield 'Teacher - Visible - Unavailable hidden' => [
'role' => 'teacher',
'visible' => true,
'isavailable' => false,
'availabilityvisible' => false,
'expected' => true,
];
yield 'Teacher - Hidden - Available visible' => [
'role' => 'teacher',
'visible' => false,
'isavailable' => true,
'availabilityvisible' => true,
'expected' => true,
];
yield 'Teacher - Hidden - Unavailable visible' => [
'role' => 'teacher',
'visible' => false,
'isavailable' => false,
'availabilityvisible' => true,
'expected' => true,
];
yield 'Teacher - Hidden - Unavailable hidden' => [
'role' => 'teacher',
'visible' => false,
'isavailable' => false,
'availabilityvisible' => false,
'expected' => true,
];
yield 'Student - Visible - Available visible' => [
'role' => 'student',
'visible' => true,
'isavailable' => true,
'availabilityvisible' => true,
'expected' => true,
];
yield 'Student - Visible - Unavailable visible' => [
'role' => 'student',
'visible' => true,
'isavailable' => false,
'availabilityvisible' => true,
'expected' => false,
];
yield 'Student - Visible - Unavailable hidden' => [
'role' => 'student',
'visible' => true,
'isavailable' => false,
'availabilityvisible' => false,
'expected' => false,
];
yield 'Student - Hidden - Available visible' => [
'role' => 'student',
'visible' => false,
'isavailable' => true,
'availabilityvisible' => true,
'expected' => true,
];
yield 'Student - Hidden - Unavailable visible' => [
'role' => 'student',
'visible' => false,
'isavailable' => false,
'availabilityvisible' => true,
'expected' => false,
];
yield 'Student - Hidden - Unavailable hidden' => [
'role' => 'student',
'visible' => false,
'isavailable' => false,
'availabilityvisible' => false,
'expected' => false,
];
}
}
@@ -17,8 +17,8 @@ Feature: Users can access the course activities overview page
| teacher1 | C1 | editingteacher |
| student1 | C1 | student |
And the following "activities" exist:
| activity | course | section | idnumber | name |
| assign | C1 | 1 | 1 | Test assignment name |
| activity | course | section | idnumber | name | duedate |
| assign | C1 | 1 | 1 | Test assignment name | ##tomorrow noon## |
Scenario: Teacher can navigate to the course overview page
Given I am on the "C1" "Course" page logged in as "teacher1"
@@ -364,3 +364,29 @@ Feature: Users can access the course activities overview page
# Student should not see the section name.
But I am on the "Course 1" "course > activities > assign" page logged in as "student1"
And I should not see "Section 1" in the "Test assignment name" "table_row"
@javascript
Scenario: Unavailable activities are shown or hidden in the overview based on user capability
# Add a date restriction to an activity, visible to students.
Given I am on the "Test assignment name" "assign activity editing" page logged in as teacher1
And I expand all fieldsets
And I click on "Add restriction..." "button" in the "root" "core_availability > Availability Button Area"
And I click on "Date" "button" in the "Add restriction..." "dialogue"
And I set the field "Direction" in the "1" "availability_date > Date Restriction" to "until"
And I press "Save and return to course"
# Teacher can see the activity and the link in the overview.
When I am on the "Course 1" "course > activities > assign" page logged in as "teacher1"
Then I should see "Test assignment name" in the "assign_overview_collapsible" "region"
And "Test assignment name" "link" should exist in the "assign_overview_collapsible" "region"
And I should see "Name" in the "assign_overview_collapsible" "region"
And I should see "Due date" in the "assign_overview_collapsible" "region"
And I should see "Submissions" in the "assign_overview_collapsible" "region"
And I should see "Actions" in the "assign_overview_collapsible" "region"
# Student can see the activity but not the link in the overview.
But I am on the "Course 1" "course > activities > assign" page logged in as "student1"
And I should see "Test assignment name" in the "assign_overview_collapsible" "region"
And "Test assignment name" "link" should not exist in the "assign_overview_collapsible" "region"
And I should see "Name" in the "assign_overview_collapsible" "region"
And I should see "Due date" in the "assign_overview_collapsible" "region"
And I should not see "Submission status" in the "assign_overview_collapsible" "region"
And I should not see "Grade" in the "assign_overview_collapsible" "region"