From f6698b7567e51b9f863b51a02073674090dc8a4e Mon Sep 17 00:00:00 2001 From: ferran Date: Wed, 15 Jan 2025 16:29:51 +0100 Subject: [PATCH] MDL-83871 course: activity overview page The old activities block will be fully replaced by a new course activities overview page in the course secondary navigation. This commit has the initial implementation of the page. --- badges/tests/behat/badge_navigation.feature | 2 +- .../output/local/overview/overviewpage.php | 192 ++++++++++++++++++ course/lib.php | 6 + course/overview.php | 54 +++++ .../local/overview/overviewpage.mustache | 67 ++++++ course/tests/behat/behat_course.php | 3 + course/tests/behat/course_overview.feature | 130 ++++++++++++ course/tests/externallib_test.php | 4 +- lang/en/course.php | 5 + lang/en/role.php | 1 + lib/classes/navigation/views/secondary.php | 29 +-- lib/db/access.php | 11 + lib/navigationlib.php | 10 + version.php | 2 +- 14 files changed, 499 insertions(+), 17 deletions(-) create mode 100644 course/classes/output/local/overview/overviewpage.php create mode 100644 course/overview.php create mode 100644 course/templates/local/overview/overviewpage.mustache create mode 100644 course/tests/behat/course_overview.feature diff --git a/badges/tests/behat/badge_navigation.feature b/badges/tests/behat/badge_navigation.feature index c2dff101ec1..2d8d5c588e6 100644 --- a/badges/tests/behat/badge_navigation.feature +++ b/badges/tests/behat/badge_navigation.feature @@ -61,7 +61,7 @@ Feature: Test tertiary navigation as various users | user | createbutton | activetab | | admin | should exist | More | | teacher | should exist | More | - | nonediting | should not exist | Badges | + | nonediting | should not exist | More | Scenario: Check navigation as a student Given I am on the "C1" "Course" page logged in as "student1" diff --git a/course/classes/output/local/overview/overviewpage.php b/course/classes/output/local/overview/overviewpage.php new file mode 100644 index 00000000000..59dafed2f9e --- /dev/null +++ b/course/classes/output/local/overview/overviewpage.php @@ -0,0 +1,192 @@ +. + +namespace core_course\output\local\overview; + +use core\context\course as context_course; +use core\output\named_templatable; +use core\output\renderable; +use core\output\notification; +use core\plugin_manager; +use core\url; +use core_collator; +use stdClass; + +/** + * Class overview page + * + * @package core_course + * @copyright 2025 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class overviewpage implements renderable, named_templatable { + /** @var context_course the context. */ + protected context_course $context; + + /** + * Constructor. + * + * @param stdClass $course the course object. + */ + public function __construct( + /** @var stdClass the course object */ + protected stdClass $course, + ) { + $this->context = context_course::instance($this->course->id); + } + + #[\Override] + public function export_for_template(\renderer_base $output): stdClass { + $modfullnames = $this->get_course_activities_overview_list(); + + $elements = []; + foreach ($modfullnames as $modname => $modfullname) { + $elements[] = $this->export_activity_overview_section_data($output, $modname, $modfullname); + } + + return (object) [ + 'elements' => $elements, + 'courseid' => $this->course->id, + 'contextid' => $this->context->id, + ]; + } + + /** + * Retrieves a list of course activities overview. + * + * @return string[] An associative array module name => module plural name. + */ + private function get_course_activities_overview_list(): array { + $modinfo = get_fast_modinfo($this->course); + $modfullnames = []; + $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)) { + continue; + } + if (array_key_exists($cm->modname, $modfullnames)) { + continue; + } + if (!array_key_exists($cm->modname, $archetypes)) { + $archetypes[$cm->modname] = plugin_supports( + type: 'mod', + name: $cm->modname, + feature: FEATURE_MOD_ARCHETYPE, + default: MOD_ARCHETYPE_OTHER + ); + } + if ($archetypes[$cm->modname] == MOD_ARCHETYPE_RESOURCE) { + if (!array_key_exists('resource', $modfullnames)) { + $modfullnames['resource'] = get_string('resources'); + } + } else { + $modfullnames[$cm->modname] = $cm->modplural; + } + } + + core_collator::asort($modfullnames); + return $modfullnames; + } + + /** + * Exports the data for the activity overview section. + * + * This function checks if the activity has an overview integration, + * and return the data accordingly. + * + * @param \renderer_base $output + * @param string $modname The name of the module. + * @param string $modfullname The full name of the module. + * @return stdClass The exported data for the activity overview section. + */ + private function export_activity_overview_section_data( + \renderer_base $output, + string $modname, + string $modfullname + ): stdClass { + // For now the activity overview is only a links to the activity index.php file. + // In the next issue (MDL-83872) activities can implement their own overview integration. + return $this->export_legacy_overview($output, $modname, $modfullname); + } + + /** + * Generates the activity overview icon for a given module. + * + * @param \renderer_base $output + * @param string $modname The name of the module for which the icon is being generated. + * @return string The HTML string for the activity overview icon. + */ + private function get_activity_overview_icon(\renderer_base $output, string $modname): string { + if ($modname === 'resource') { + return $output->pix_icon('monologo', '', 'mod_page', ['class' => 'icon iconsize-medium']); + } + return $output->pix_icon('monologo', '', "mod_$modname", ['class' => 'icon iconsize-medium']); + } + + /** + * Exports the legacy overview for a given module. + * + * This export only applies to modules that do not have an overview integration. + * + * @param \renderer_base $output + * @param string $modname + * @param string $modfullname + * @return stdClass + */ + private function export_legacy_overview( + \renderer_base $output, + string $modname, + string $modfullname + ): stdClass { + if ($modname === 'resource') { + $legacyoverview = 'resources.php'; + $message = get_string('overview_missing_title', 'core_course', get_string('resource')); + } else { + $legacyoverview = '/mod/' . $modname . '/index.php'; + $pluginman = plugin_manager::instance(); + $message = get_string('overview_missing_title', 'core_course', $pluginman->plugin_name($modname)); + } + + $notification = new notification( + message: get_string('overview_missing_notice', 'core_course'), + messagetype: \core\notification::INFO, + closebutton: false, + title: $message, + titleicon: 'i/circleinfo', + ); + + return (object)[ + 'overviewurl' => new url($legacyoverview, ['id' => $this->course->id]), + 'icon' => $this->get_activity_overview_icon($output, $modname), + 'name' => $modfullname, + 'shortname' => $modname, + 'notification' => $notification->export_for_template($output), + ]; + } + + /** + * Get the name of the template to use for this templatable. + * + * @param \renderer_base $renderer The renderer requesting the template name + * @return string + */ + public function get_template_name(\renderer_base $renderer): string { + return 'core_course/local/overview/overviewpage'; + } +} diff --git a/course/lib.php b/course/lib.php index eec33e6e1ad..fd8e4d26eee 100644 --- a/course/lib.php +++ b/course/lib.php @@ -3682,6 +3682,7 @@ function course_get_user_navigation_options($context, $course = null) { 'search' => false, 'tags' => false, 'communication' => false, + 'overview' => false, ]; $options->blogs = !empty($CFG->enableblogs) && @@ -3761,6 +3762,11 @@ function course_get_user_navigation_options($context, $course = null) { $capabilities = array('moodle/competency:coursecompetencyview', 'moodle/competency:coursecompetencymanage'); $options->competencies = has_any_capability($capabilities, $context); } + + if ($isloggedin && !$isfrontpage) { + $options->overview = has_capability('moodle/course:viewoverview', $context); + } + return $options; } diff --git a/course/overview.php b/course/overview.php new file mode 100644 index 00000000000..ec71feea4a1 --- /dev/null +++ b/course/overview.php @@ -0,0 +1,54 @@ +. + +/** + * Course activities overview page. + * + * @package core_course + * @copyright 2025 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +require_once('../config.php'); +require_once('lib.php'); +require_once($CFG->libdir . '/completionlib.php'); + +$courseid = required_param('id', PARAM_INT); + +$PAGE->set_url('/course/overview.php', ['id' => $courseid]); + +$course = get_course($courseid); + +$context = context_course::instance($course->id, MUST_EXIST); + +require_login($course); +require_capability('moodle/course:viewoverview', $context); + +$output = $PAGE->get_renderer('format_' . $course->format); +$overview = new core_course\output\local\overview\overviewpage($course); + +$PAGE->set_pagelayout('incourse'); + +$PAGE->set_title(get_string('overview_page_title', 'course', $course->fullname)); +$PAGE->set_heading($course->fullname); +echo $output->header(); + +echo $output->heading(get_string('activities'), 2, 'h4'); +echo $output->paragraph(get_string('overview_info', 'course')); + +echo $output->render($overview); + +echo $OUTPUT->footer(); diff --git a/course/templates/local/overview/overviewpage.mustache b/course/templates/local/overview/overviewpage.mustache new file mode 100644 index 00000000000..72c039dbcad --- /dev/null +++ b/course/templates/local/overview/overviewpage.mustache @@ -0,0 +1,67 @@ +{{! + 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 . +}} +{{! + @template core_course/local/overview/overviewpage + + Course activity overview page template. + + Example context (json): + { + "elements": [ + { + "name": "Activity 1", + "shortname": "forum", + "icon": "", + "overviewurl": "https://moodle.org" + }, + { + "name": "Activity 2", + "shortname": "activity2", + "icon": "", + "overviewurl": "https://moodle.org" + } + ], + "contextid": 1, + "courseid": 1 + } +}} +
+ {{#elements}} + {{ + {{#notification}} + {{>core/notification_info}} + {{/notification}} + +
+ {{/sectioncontent}} + {{/core/local/collapsable_section}} + {{/elements}} + diff --git a/course/tests/behat/behat_course.php b/course/tests/behat/behat_course.php index 3c2c1a9f5cf..2e5addd6f4e 100644 --- a/course/tests/behat/behat_course.php +++ b/course/tests/behat/behat_course.php @@ -205,6 +205,9 @@ class behat_course extends behat_base { throw new Exception("The specified section $identifier does not exist."); } return new moodle_url('/course/' . $sectionpage, ['id' => $section->id]); + case 'activities': + $courseid = $this->get_course_id($identifier); + return new moodle_url('/course/overview.php', ['id' => $courseid]); } throw new Exception('Unrecognised core page type "' . $type . '."'); } diff --git a/course/tests/behat/course_overview.feature b/course/tests/behat/course_overview.feature new file mode 100644 index 00000000000..28f1eff5944 --- /dev/null +++ b/course/tests/behat/course_overview.feature @@ -0,0 +1,130 @@ +@core @core_course +Feature: Users can access the course activities overview page + In order to access the course activities overview page + As a user + I should be able to navigate to the course activities overview page + + Background: + Given the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | 1 | teacher1@example.com | + | student1 | Student | 1 | student1@example.com | + And the following "courses" exist: + | fullname | shortname | format | numsections | initsections | + | Course 1 | C1 | topics | 1 | 1 | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + | student1 | C1 | student | + And the following "activities" exist: + | activity | course | section | idnumber | name | + | assign | C1 | 1 | 1 | Test assignment name | + + Scenario: Teacher can access the course overview page + Given I am on the "C1" "Course" page logged in as "teacher1" + When I follow "Activities" + Then I should see "Activities" + And I should see "Go to Assignments overview" + And I follow "Go to Assignments overview" + And I should see "Test assignment name" + And I should see "Needs grading: 0" + + Scenario: Student can access the course overview page + Given I am on the "C1" "Course" page logged in as "student1" + When I follow "Activities" + Then I should see "Activities" + And I should see "Go to Assignments overview" + And I follow "Go to Assignments overview" + And I should see "Test assignment name" + And I should see "No submission" + + Scenario: The activities overview shows only the type of activities present in the course + Given the following "activities" exist: + | activity | course | section | idnumber | name | visible | + | forum | C1 | 1 | 1 | Test forum name | 1 | + | choice | C1 | 1 | 2 | Test choice name | 0 | + # Teacher can see all activities. + When I am on the "Course 1" "course > activities" page logged in as "teacher1" + And I should see "Assignments" in the "region-main" "region" + And I should see "Forums" in the "region-main" "region" + And I should see "Choices" in the "region-main" "region" + And I should not see "Databases" in the "region-main" "region" + And I should not see "Feedback" in the "region-main" "region" + And I should not see "Glossaries" in the "region-main" "region" + And I should not see "Lessons" in the "region-main" "region" + And I should not see "External tools" in the "region-main" "region" + And I should not see "Quizzes" in the "region-main" "region" + And I should not see "SCORM packages" in the "region-main" "region" + And I should not see "Wikis" in the "region-main" "region" + And I should not see "Workshops" in the "region-main" "region" + And I should not see "Resources" in the "region-main" "region" + # Student can see only visible activities. + And I am on the "Course 1" "course > activities" page logged in as "student1" + And I should see "Assignments" in the "region-main" "region" + And I should see "Forums" in the "region-main" "region" + And I should not see "Choices" in the "region-main" "region" + And I should not see "Databases" in the "region-main" "region" + And I should not see "Feedback" in the "region-main" "region" + And I should not see "Glossaries" in the "region-main" "region" + And I should not see "Lessons" in the "region-main" "region" + And I should not see "External tools" in the "region-main" "region" + And I should not see "Quizzes" in the "region-main" "region" + And I should not see "SCORM packages" in the "region-main" "region" + And I should not see "Wikis" in the "region-main" "region" + And I should not see "Workshops" in the "region-main" "region" + And I should not see "Resources" in the "region-main" "region" + + @javascript + Scenario: Teacher can expand and collapse course overview items + # Create another activity to test each activity type could be expanded independently. + Given the following "activities" exist: + | activity | course | section | idnumber | name | + | forum | C1 | 1 | 1 | Test forum name | + And I am on the "Course 1" "course > activities" page logged in as "teacher1" + And I should see "Assignments" in the "assign_overview_collapsible" "region" + And I should see "Forums" in the "forum_overview_collapsible" "region" + And I should not see "Go to Assignments overview" in the "assign_overview_collapsible" "region" + And I should not see "Go to Forums overview" in the "forum_overview_collapsible" "region" + When I click on "Expand" "link" in the "assign_overview_collapsible" "region" + Then I should see "Go to Assignments overview" in the "assign_overview_collapsible" "region" + And I should not see "Go to Forums overview" in the "forum_overview_collapsible" "region" + And I click on "Collapse" "link" in the "assign_overview_collapsible" "region" + And I should not see "Go to Assignments overview" in the "assign_overview_collapsible" "region" + And I should not see "Go to Forums overview" in the "forum_overview_collapsible" "region" + + Scenario: Course overview shows the course present activity types + Given the following "activities" exist: + | activity | course | name | + | book | C1 | Activity 2 | + | choice | C1 | Activity 3 | + | data | C1 | Activity 4 | + | feedback | C1 | Activity 5 | + | folder | C1 | Activity 6 | + | forum | C1 | Activity 7 | + | glossary | C1 | Activity 8 | + | imscp | C1 | Activity 10 | + | label | C1 | Activity 11 | + | lesson | C1 | Activity 12 | + | lti | C1 | Activity 13 | + | page | C1 | Activity 14 | + | quiz | C1 | Activity 15 | + | resource | C1 | Activity 16 | + | scorm | C1 | Activity 17 | + | url | C1 | Activity 18 | + | wiki | C1 | Activity 19 | + | workshop | C1 | Activity 20 | + Given I am on the "Course 1" "course > activities" page logged in as "teacher1" + And I should see "Assignments" in the "assign_overview_collapsible" "region" + And I should see "Choices" in the "choice_overview_collapsible" "region" + And I should see "Databases" in the "data_overview_collapsible" "region" + And I should see "Feedback" in the "feedback_overview_collapsible" "region" + And I should see "Forums" in the "forum_overview_collapsible" "region" + And I should see "Glossaries" in the "glossary_overview_collapsible" "region" + And I should see "Lessons" in the "lesson_overview_collapsible" "region" + And I should see "External tools" in the "lti_overview_collapsible" "region" + And I should see "Quizzes" in the "quiz_overview_collapsible" "region" + And I should see "SCORM packages" in the "scorm_overview_collapsible" "region" + And I should see "Wikis" in the "wiki_overview_collapsible" "region" + And I should see "Workshops" in the "workshop_overview_collapsible" "region" + # All resources are grouped. + And I should see "Resources" in the "resource_overview_collapsible" "region" diff --git a/course/tests/externallib_test.php b/course/tests/externallib_test.php index 538a3477c99..9f56c38f62f 100644 --- a/course/tests/externallib_test.php +++ b/course/tests/externallib_test.php @@ -2847,7 +2847,7 @@ final class externallib_test extends externallib_advanced_testcase { foreach ($course['options'] as $option) { $navoptions->{$option['name']} = $option['available']; } - $this->assertCount(9, $course['options']); + $this->assertCount(10, $course['options']); if ($course['id'] == SITEID) { $this->assertTrue($navoptions->blogs); $this->assertFalse($navoptions->notes); @@ -2858,6 +2858,7 @@ final class externallib_test extends externallib_advanced_testcase { $this->assertFalse($navoptions->search); $this->assertTrue($navoptions->competencies); $this->assertFalse($navoptions->communication); + $this->assertFalse($navoptions->overview); } else { $this->assertTrue($navoptions->blogs); $this->assertFalse($navoptions->notes); @@ -2868,6 +2869,7 @@ final class externallib_test extends externallib_advanced_testcase { $this->assertFalse($navoptions->search); $this->assertTrue($navoptions->competencies); $this->assertFalse($navoptions->communication); + $this->assertTrue($navoptions->overview); } } } diff --git a/lang/en/course.php b/lang/en/course.php index 66db85dba71..e45be4b189d 100644 --- a/lang/en/course.php +++ b/lang/en/course.php @@ -100,6 +100,7 @@ $string['filterbothactive'] = 'First ({$a->first}) Last ({$a->last})'; $string['filterbyname'] = 'Filter by name'; $string['filterfirstactive'] = 'First ({$a->first})'; $string['filterlastactive'] = 'Last ({$a->last})'; +$string['goto_overview'] = 'Go to {$a} overview'; $string['gradetopassnotset'] = 'This course does not have a grade to pass set. It may be set in the grade item of the course (Gradebook setup).'; $string['hideendedcoursestask'] = 'Hide courses on end date'; $string['informationformodule'] = 'Information about the {$a} activity'; @@ -115,6 +116,10 @@ $string['norecentaccessesinfomessage'] = 'Hi {$a->userfirstname},

A number of students in {$a->coursename} have not accessed the course recently.

'; $string['noteachinginfomessage'] = 'Hi {$a->userfirstname},

Courses with start dates in the next week have been identified as having no teacher or student enrolments.

'; +$string['overview_info'] = 'View all the activities in this course, including key details like due date and updates.'; +$string['overview_missing_notice'] = 'Check the overview page for more details.'; +$string['overview_missing_title'] = 'Information not available here for {$a} activities'; +$string['overview_page_title'] = 'Course activities: {$a}'; $string['participants:perpage'] = 'Number of participants per page'; $string['participants:perpage_help'] = 'The number of users shown per page on the participants page in each course.'; $string['participantsnavigation'] = 'Participants tertiary navigation.'; diff --git a/lang/en/role.php b/lang/en/role.php index e56b7c5202a..5cb88358ac7 100644 --- a/lang/en/role.php +++ b/lang/en/role.php @@ -219,6 +219,7 @@ $string['course:viewhiddencourses'] = 'View hidden courses'; $string['course:viewhiddengroups'] = 'View hidden groups'; $string['course:viewhiddensections'] = 'View hidden sections'; $string['course:viewhiddenuserfields'] = 'View hidden user fields'; +$string['course:viewoverview'] = 'View course activities overview page'; $string['course:viewparticipants'] = 'View participants'; $string['course:viewscales'] = 'View scales'; $string['course:visibility'] = 'Hide/show courses'; diff --git a/lib/classes/navigation/views/secondary.php b/lib/classes/navigation/views/secondary.php index b99e16981c1..9cd8ad24142 100644 --- a/lib/classes/navigation/views/secondary.php +++ b/lib/classes/navigation/views/secondary.php @@ -57,8 +57,8 @@ class secondary extends view { $nodes = []; $nodes['settings'] = [ self::TYPE_CONTAINER => [ - 'coursereports' => 3, - 'questionbank' => 4, + 'coursereports' => 4, + 'questionbank' => 5, ], self::TYPE_SETTING => [ 'editsettings' => 0, @@ -71,28 +71,29 @@ class secondary extends view { 'otherusers' => 1.7, 'gradebooksetup' => 2.1, 'outcomes' => 2.2, - 'coursecompletion' => 6, - 'coursebadges' => 7.1, - 'newbadge' => 7.2, - 'filtermanagement' => 9, - 'unenrolself' => 10, - 'coursetags' => 11, - 'download' => 12, - 'contextlocking' => 13, + 'coursecompletion' => 7, + 'coursebadges' => 8.1, + 'newbadge' => 8.2, + 'filtermanagement' => 10, + 'unenrolself' => 11, + 'coursetags' => 12, + 'download' => 13, + 'contextlocking' => 14, ], ]; $nodes['navigation'] = [ self::TYPE_CONTAINER => [ 'participants' => 1, + 'courseoverview' => 3, ], self::TYPE_SETTING => [ 'grades' => 2, - 'badgesview' => 7, - 'competencies' => 8, - 'communication' => 14, + 'badgesview' => 8, + 'competencies' => 9, + 'communication' => 15, ], self::TYPE_CUSTOM => [ - 'contentbank' => 5, + 'contentbank' => 6, 'participants' => 1, // In site home, 'participants' is classified differently. ], ]; diff --git a/lib/db/access.php b/lib/db/access.php index 9a9f680c051..780796962d0 100644 --- a/lib/db/access.php +++ b/lib/db/access.php @@ -1025,6 +1025,17 @@ $capabilities = array( ) ), + 'moodle/course:viewoverview' => [ + 'captype' => 'read', + 'contextlevel' => CONTEXT_COURSE, + 'archetypes' => [ + 'student' => CAP_ALLOW, + 'teacher' => CAP_ALLOW, + 'editingteacher' => CAP_ALLOW, + 'manager' => CAP_ALLOW, + ], + ], + 'moodle/course:changefullname' => array( 'riskbitmask' => RISK_XSS, diff --git a/lib/navigationlib.php b/lib/navigationlib.php index e28095301d3..76c75922316 100644 --- a/lib/navigationlib.php +++ b/lib/navigationlib.php @@ -3162,6 +3162,16 @@ class global_navigation extends navigation_node { navigation_node::TYPE_SETTING, null, 'communication'); } + if ($navoptions->overview) { + $coursenode->add( + text: get_string('activities'), + action: new moodle_url('/course/overview.php', ['id' => $course->id]), + type: self::TYPE_CONTAINER, + key: 'courseoverview', + icon: new pix_icon('i/info', ''), + ); + } + return true; } /** diff --git a/version.php b/version.php index 2e218ac513f..5e4479ac7af 100644 --- a/version.php +++ b/version.php @@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2025013100.00; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2025013100.01; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes. $release = '5.0dev (Build: 20250131)'; // Human-friendly version name