From f7b0960d188aad187d9dae763bedcafd0deada63 Mon Sep 17 00:00:00 2001 From: Sara Arjona Date: Thu, 25 Mar 2021 18:04:57 +0100 Subject: [PATCH 1/2] MDL-71135 course: add legacy_format_renderer support Until Moodle 4.0, renderer.php file was optional (although highly recommended) for course formats. From Moodle 4.0 onwards, renderer is required to support the new course editor implementation. The legacy_format_renderer class has been created for backward compatibility, to avoid some errors with course formats (such as social) without the renderer file. Apart from that, course_format->get_renderer() method has been reviewed to use this legacy_format_renderer when no renderer.php file is found. --- course/classes/course_format.php | 15 +++++++- .../course_format/legacy_format_renderer.php | 38 +++++++++++++++++++ course/upgrade.txt | 3 ++ 3 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 course/classes/output/course_format/legacy_format_renderer.php diff --git a/course/classes/course_format.php b/course/classes/course_format.php index f55b3c97163..1549e41e07d 100644 --- a/course/classes/course_format.php +++ b/course/classes/course_format.php @@ -38,6 +38,7 @@ use lang_string; use completion_info; use external_api; use stdClass; +use core_course\output\course_format\legacy_format_renderer; /** * Base class for course formats @@ -1055,7 +1056,19 @@ abstract class course_format { * @return renderer_base */ public function get_renderer(moodle_page $page) { - return $page->get_renderer('format_'. $this->get_format()); + try { + $renderer = $page->get_renderer('format_'. $this->get_format()); + } catch (moodle_exception $e) { + $formatname = $this->get_format(); + $expectedrenderername = 'format_'. $this->get_format() . '\output\renderer'; + debugging( + "The '{$formatname}' course format does not define the {$expectedrenderername} renderer class. This is required since Moodle 4.0.", + DEBUG_DEVELOPER + ); + $renderer = new legacy_format_renderer($page, null); + } + + return $renderer; } /** diff --git a/course/classes/output/course_format/legacy_format_renderer.php b/course/classes/output/course_format/legacy_format_renderer.php new file mode 100644 index 00000000000..9530becb407 --- /dev/null +++ b/course/classes/output/course_format/legacy_format_renderer.php @@ -0,0 +1,38 @@ +. + +/** + * Legacy course format renderer. + * + * Since Moodle 4.0, renderer.php file was optional (although highly recommended) for course formats. From Moodle 4.0 onwards, + * renderer is required to support the new course editor implementation. + * This legacy class has been created for backward compatibility, to avoid some errors with course formats (such as social) + * without this renderer.php file. + * + * @package core_course + * @copyright 2021 Sara Arjona (sara@moodle.com) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace core_course\output\course_format; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot.'/course/format/renderer.php'); + +class legacy_format_renderer extends \format_section_renderer_base { + +} diff --git a/course/upgrade.txt b/course/upgrade.txt index 3f0590c1765..e59c36ae6bd 100644 --- a/course/upgrade.txt +++ b/course/upgrade.txt @@ -43,6 +43,9 @@ renderer and course format renderer: - start_section_list (integrated in output\course_format) - end_section_list (integrated in output\course_format) - page_title (moved to output\course_format) +* Course formats should have a renderer (until now it was only highly recommended but not mandatory). For backwards +compatibility (to not break third-party plugins without it), legacy_format_renderer has been created and will be used when +course formats don't have their own renderer. === 3.11 === * A new callback xxx_coursemodule_definition_after_data that allows plugins to extend activity forms after the data is set. From 5f91cbb61134f2eb072e8f27f2f9f9ce518631ce Mon Sep 17 00:00:00 2001 From: Sara Arjona Date: Tue, 23 Mar 2021 18:41:00 +0100 Subject: [PATCH 2/2] MDL-71135 course: core_course_get_state external method --- course/classes/external/get_state.php | 132 +++++++++ course/classes/output/cm_format/state.php | 85 ++++++ course/classes/output/course_format/state.php | 71 +++++ .../classes/output/section_format/state.php | 82 ++++++ course/tests/external/get_state_test.php | 268 ++++++++++++++++++ ...theunittest_output_course_format_state.php | 39 +++ course/upgrade.txt | 1 + lib/db/services.php | 6 + version.php | 2 +- 9 files changed, 685 insertions(+), 1 deletion(-) create mode 100644 course/classes/external/get_state.php create mode 100644 course/classes/output/cm_format/state.php create mode 100644 course/classes/output/course_format/state.php create mode 100644 course/classes/output/section_format/state.php create mode 100644 course/tests/external/get_state_test.php create mode 100644 course/tests/fixtures/format_theunittest_output_course_format_state.php diff --git a/course/classes/external/get_state.php b/course/classes/external/get_state.php new file mode 100644 index 00000000000..228df464e6d --- /dev/null +++ b/course/classes/external/get_state.php @@ -0,0 +1,132 @@ +. + +namespace core_course\external; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->libdir . '/externallib.php'); + +use external_api; +use external_function_parameters; +use external_value; + +/** + * Class for exporting a course state. + * + * @package core_course + * @copyright 2021 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @since Moodle 4.0 + */ +class get_state extends external_api { + + /** + * Webservice parameters. + * + * @return external_function_parameters + */ + public static function execute_parameters(): external_function_parameters { + return new external_function_parameters( + [ + 'courseid' => new external_value(PARAM_INT, 'course id', VALUE_REQUIRED), + ] + ); + } + + /** + * This method will load all course, sections and cm states needed to initialize the frontend + * course editor module. The state data of every individual course, section and cm is + * build using the specifics "state" output components. + * + * By default, the states are generated by: + * - core_course\output\course_format\state + * - core_course\output\section_format\state + * - core_course\output\cm_format\state + * + * As the other main course outputs, format plugins can override those output components + * to send more information to the frontend course editor. These extended classes should + * be located in format_XXX\output\course_format\state, format_XXX\output\section_format\state + * or format_XXX\output\cm_format\state. + * + * @param int $courseid the course id + * @return string Course state in JSON + */ + public static function execute(int $courseid): string { + global $PAGE, $CFG; + + require_once($CFG->dirroot.'/course/lib.php'); + + $params = external_api::validate_parameters(self::execute_parameters(), [ + 'courseid' => $courseid, + ]); + $courseid = $params['courseid']; + + self::validate_context(\context_course::instance($courseid)); + + $courseformat = course_get_format($courseid); + $modinfo = $courseformat->get_modinfo(); + + // Get the proper renderer. + $renderer = $courseformat->get_renderer($PAGE); + + $result = (object)[ + 'course' => (object)[], + 'section' => [], + 'cm' => [], + ]; + + // Load the output class names. + $courseclass = $courseformat->get_output_classname('course_format\state'); + $sectionclass = $courseformat->get_output_classname('section_format\state'); + $cmclass = $courseformat->get_output_classname('cm_format\state'); + + // General state. + $coursestate = new $courseclass($courseformat); + $result->course = $coursestate->export_for_template($renderer); + + // Sections and course modules state. + $sections = $modinfo->get_section_info_all(); + foreach ($sections as $section) { + if (!empty($section->uservisible)) { + // Only return this section data if it's visible by current user on the course page. + $sectionstate = new $sectionclass($courseformat, $section); + $result->section[] = $sectionstate->export_for_template($renderer); + } + } + + foreach ($modinfo->cms as $cm) { + if ($cm->is_visible_on_course_page()) { + // Only return this course module data if it's visible by current user on the course page. + $section = $sections[$cm->sectionnum]; + $cmstate = new $cmclass($courseformat, $section, $cm); + $result->cm[] = $cmstate->export_for_template($renderer); + } + } + + return json_encode($result); + } + + /** + * Webservice returns. + * + * @return external_value + */ + public static function execute_returns(): external_value { + return new external_value(PARAM_RAW, 'Encoded course state JSON'); + } +} diff --git a/course/classes/output/cm_format/state.php b/course/classes/output/cm_format/state.php new file mode 100644 index 00000000000..f870145b5bb --- /dev/null +++ b/course/classes/output/cm_format/state.php @@ -0,0 +1,85 @@ +. + +namespace core_course\output\cm_format; + +use core_course\course_format; +use section_info; +use cm_info; +use renderable; +use stdClass; + +/** + * Contains the ajax update course module structure. + * + * @package core_course + * @copyright 2021 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class state implements renderable { + + /** @var course_format the course format class */ + protected $format; + + /** @var section_info the course section class */ + protected $section; + + /** @var bool if cmitem HTML content must be exported as well */ + protected $exportcontent; + + /** @var cm_info the course module to display */ + protected $cm; + + /** + * Constructor. + * + * @param course_format $format the course format + * @param section_info $section the section data + * @param cm_info $cm the course module data + * @param bool $exportcontent = false if pre-rendered cmitem must be exported. + */ + public function __construct(course_format $format, section_info $section, cm_info $cm, bool $exportcontent = false) { + $this->format = $format; + $this->section = $section; + $this->cm = $cm; + $this->exportcontent = $exportcontent; + } + + /** + * Export this data so it can be used as state object in the course editor. + * + * @param renderer_base $output typically, the renderer that's calling this function + * @return stdClass data context for a mustache template + */ + public function export_for_template(\renderer_base $output): stdClass { + + $format = $this->format; + $section = $this->section; + $cm = $this->cm; + + $data = (object)[ + 'id' => $cm->id, + 'name' => $cm->name, + 'visible' => !empty($cm->visible), + ]; + + if ($this->exportcontent) { + $data->content = $output->course_section_updated_cm_item($format, $section, $cm); + } + + return $data; + } +} diff --git a/course/classes/output/course_format/state.php b/course/classes/output/course_format/state.php new file mode 100644 index 00000000000..d0018d3d01e --- /dev/null +++ b/course/classes/output/course_format/state.php @@ -0,0 +1,71 @@ +. + +namespace core_course\output\course_format; + +use core_course\course_format; +use renderable; +use stdClass; + +/** + * Contains the ajax update course structure. + * + * @package core_course + * @copyright 2021 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class state implements renderable { + + /** @var course_format the course format class */ + protected $format; + + /** + * Constructor. + * + * @param course_format $format the course format + */ + public function __construct(course_format $format) { + $this->format = $format; + } + + /** + * Export this data so it can be used as state object in the course editor. + * + * @param renderer_base $output typically, the renderer that's calling this function + * @return stdClass data context for a mustache template + */ + public function export_for_template(\renderer_base $output): stdClass { + $format = $this->format; + $course = $format->get_course(); + $modinfo = $this->format->get_modinfo(); + + $data = (object)[ + 'id' => $course->id, + 'numsections' => $format->get_last_section_number(), + 'sectionlist' => [], + 'editmode' => $format->show_editor(), + ]; + + $sections = $modinfo->get_section_info_all(); + foreach ($sections as $section) { + if (!empty($section->uservisible)) { + $data->sectionlist[] = $section->id; + } + } + + return $data; + } +} diff --git a/course/classes/output/section_format/state.php b/course/classes/output/section_format/state.php new file mode 100644 index 00000000000..7c58c56e089 --- /dev/null +++ b/course/classes/output/section_format/state.php @@ -0,0 +1,82 @@ +. + +namespace core_course\output\section_format; + +use core_course\course_format; +use section_info; +use renderable; +use stdClass; + +/** + * Contains the ajax update section structure. + * + * @package core_course + * @copyright 2021 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class state implements renderable { + + /** @var course_format the course format class */ + protected $format; + + /** @var section_info the course section class */ + protected $section; + + /** + * Constructor. + * + * @param course_format $format the course format + * @param section_info $section the section info + */ + public function __construct(course_format $format, section_info $section) { + $this->format = $format; + $this->section = $section; + } + + /** + * Export this data so it can be used as state object in the course editor. + * + * @param renderer_base $output typically, the renderer that's calling this function + * @return array data context for a mustache template + */ + public function export_for_template(\renderer_base $output): stdClass { + $format = $this->format; + $section = $this->section; + $modinfo = $format->get_modinfo(); + + $data = (object)[ + 'id' => $section->id, + 'section' => $section->section, + 'title' => $format->get_section_name($section), + 'cmlist' => [], + 'visible' => !empty($section->visible), + ]; + + if (empty($modinfo->sections[$section->section])) { + return $data; + } + + foreach ($modinfo->sections[$section->section] as $modnumber) { + $mod = $modinfo->cms[$modnumber]; + if ($mod->is_visible_on_course_page()) { + $data->cmlist[] = $mod->id; + } + } + + return $data; + } +} diff --git a/course/tests/external/get_state_test.php b/course/tests/external/get_state_test.php new file mode 100644 index 00000000000..09e7b5cf921 --- /dev/null +++ b/course/tests/external/get_state_test.php @@ -0,0 +1,268 @@ +. + +namespace core_course\external; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/webservice/tests/helpers.php'); + +use external_api; + +/** + * Tests for the get_state class. + * + * @package core_course + * @category test + * @copyright 2021 Sara Arjona (sara@moodle.com) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @coversDefaultClass \core_course\external\get_state + */ +class get_state_test extends \externallib_advanced_testcase { + + /** @var array Sections in the testing course. */ + private $sections; + + /** @var array Activities in the testing course. */ + private $activities; + + /** + * Setup to ensure that fixtures are loaded. + */ + public static function setupBeforeClass(): void { + global $CFG; + + require_once($CFG->dirroot . '/course/lib.php'); + require_once($CFG->dirroot . '/course/tests/fixtures/format_theunittest.php'); + require_once($CFG->dirroot . '/course/tests/fixtures/format_theunittest_output_course_format_state.php'); + } + + /** + * Setup testcase. + */ + public function setUp(): void { + $this->resetAfterTest(); + + $this->sections = []; + $this->activities = []; + } + + /** + * Test tearDown. + */ + public function tearDown(): void { + unset($this->sections); + unset($this->activities); + } + + /** + * Test the behaviour of get_state::execute(). + * + * @dataProvider get_state_provider + * @covers ::execute + * + * @param string $role The role of the user that will execute the method. + * @param string $format The course format of the course where the method will be executed. + * @param string|null $expectedexception If this call will raise an exception, this is its name. + */ + public function test_get_state(string $role, string $format = 'topics', ?string $expectedexception = null): void { + $this->resetAfterTest(); + + // Create a course. + $numsections = 6; + $visiblesections = $numsections + 1; // Include topic 0. + $course = $this->getDataGenerator()->create_course(['numsections' => $numsections, 'format' => $format]); + $hiddensections = [4, 6]; + foreach ($hiddensections as $section) { + set_section_visible($course->id, $section, 0); + } + + // Create and enrol user. + $isadmin = ($role == 'admin'); + $canedit = $isadmin || ($role == 'editingteacher'); + if ($isadmin) { + $this->setAdminUser(); + } else { + if (!$canedit) { + // User won't see the hidden sections. Remove them from the total. + $visiblesections = $visiblesections - count($hiddensections); + } + $user = $this->getDataGenerator()->create_user(); + if ($role != 'unenroled') { + $this->getDataGenerator()->enrol_user($user->id, $course->id, $role); + } + $this->setUser($user); + } + + // Add some activities to the course. + $this->create_activity($course->id, 'page', 1, true, $canedit); + $this->create_activity($course->id, 'forum', 1, true, $canedit); + $this->create_activity($course->id, 'book', 1, false, $canedit); + $this->create_activity($course->id, 'assign', 2, false, $canedit); + $this->create_activity($course->id, 'glossary', 4, true, $canedit); + $this->create_activity($course->id, 'label', 5, false, $canedit); + $this->create_activity($course->id, 'feedback', 5, true, $canedit); + + if ($expectedexception) { + $this->expectException($expectedexception); + } + + // Get course state. + $result = get_state::execute($course->id); + $result = external_api::clean_returnvalue(get_state::execute_returns(), $result); + $result = json_decode($result); + if ($format == 'social' || $format == 'theunittest') { + // These course format's hasn't the renderer file, so a debugging message will be displayed. + $this->assertDebuggingCalled(); + } + + // Check course information. + $this->assertEquals($numsections, $result->course->numsections); + $this->assertCount($visiblesections, $result->section); + $this->assertCount(count($this->activities), $result->cm); + $this->assertCount(count($result->course->sectionlist), $result->section); + if ($format == 'theunittest') { + $this->assertTrue(property_exists($result->course, 'newfancyelement')); + } else { + $this->assertFalse(property_exists($result->course, 'newfancyelement')); + } + + // Check sections information. + foreach ($result->section as $section) { + if (in_array($section->section, $hiddensections)) { + $this->assertFalse($section->visible); + } else { + $this->assertTrue($section->visible); + } + // Check section is defined in course->sectionlist. + $this->assertContains($section->id, $result->course->sectionlist); + // Check course modules list for this section is the expected. + if (array_key_exists($section->section, $this->sections)) { + $this->assertEquals($this->sections[$section->section], $section->cmlist); + } + } + // Check course modules information. + foreach ($result->cm as $cm) { + $this->assertEquals($this->activities[$cm->id]->name, $cm->name); + $this->assertEquals((bool) $this->activities[$cm->id]->visible, $cm->visible); + } + } + + /** + * Data provider for test_get_state(). + * + * @return array + */ + public function get_state_provider(): array { + return [ + // ROLES. Testing behaviour depending on the user role calling the method. + 'Admin user should work' => [ + 'role' => 'admin', + ], + 'Editing teacher should work' => [ + 'role' => 'editingteacher', + ], + 'Student should work' => [ + 'role' => 'student', + ], + 'Unenroled user should raise an exception' => [ + 'role' => 'unenroled', + 'format' => 'topics', + 'expectedexception' => 'moodle_exception', + ], + + // COURSEFORMAT. Test behaviour depending on course formats. + 'Single activity format should work (admin)' => [ + 'role' => 'admin', + 'format' => 'singleactivity', + ], + 'Social format should work (admin)' => [ + 'role' => 'admin', + 'format' => 'social', + ], + 'Weeks format should work (admin)' => [ + 'role' => 'admin', + 'format' => 'weeks', + ], + 'The unit tests format should work (admin)' => [ + 'role' => 'admin', + 'format' => 'theunittest', + ], + 'Single activity format should work (student)' => [ + 'role' => 'student', + 'format' => 'singleactivity', + ], + 'Social format should work (student)' => [ + 'role' => 'student', + 'format' => 'social', + ], + 'Weeks format should work (student)' => [ + 'role' => 'student', + 'format' => 'weeks', + ], + 'The unit tests format should work (student)' => [ + 'role' => 'student', + 'format' => 'theunittest', + ], + 'Single activity format should raise an exception (unenroled)' => [ + 'role' => 'unenroled', + 'format' => 'singleactivity', + 'expectedexception' => 'moodle_exception', + ], + 'Social format should raise an exception (unenroled)' => [ + 'role' => 'unenroled', + 'format' => 'social', + 'expectedexception' => 'moodle_exception', + ], + 'Weeks format should raise an exception (unenroled)' => [ + 'role' => 'unenroled', + 'format' => 'weeks', + 'expectedexception' => 'moodle_exception', + ], + 'The unit tests format should raise an exception (unenroled)' => [ + 'role' => 'unenroled', + 'format' => 'theunittest', + 'expectedexception' => 'moodle_exception', + ], + ]; + } + + /** + * Helper method to create an activity into a section and add it to the $sections and $activities arrays. + * For non-admin users, only visible activities will be added to the activities and sections arrays. + * + * @param int $courseid Course identifier where the activity will be added. + * @param string $type Activity type ('forum', 'assign', ...). + * @param int $section Section number where the activity will be added. + * @param bool $visible Whether the activity will be visible or not. + * @param bool $canedit Whether the activity will be accessed later by a user with editing capabilities + */ + private function create_activity(int $courseid, string $type, int $section, bool $visible = true, bool $canedit = true): void { + $activity = $this->getDataGenerator()->create_module( + $type, + ['course' => $courseid], + ['section' => $section, 'visible' => $visible] + ); + + list(, $activitycm) = get_course_and_cm_from_instance($activity->id, $type); + + if ($visible || $canedit) { + $this->activities[$activitycm->id] = $activitycm; + $this->sections[$section][] = $activitycm->id; + } + } +} diff --git a/course/tests/fixtures/format_theunittest_output_course_format_state.php b/course/tests/fixtures/format_theunittest_output_course_format_state.php new file mode 100644 index 00000000000..ce49a36f182 --- /dev/null +++ b/course/tests/fixtures/format_theunittest_output_course_format_state.php @@ -0,0 +1,39 @@ +. + +namespace format_theunittest\output\course_format; +/** + * Fixture for fake course format testing course format API. + * + * @package core_course + * @copyright 2021 Sara Arjona (sara@moodle.com) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class state extends \core_course\output\course_format\state { + + /** + * Export this data so it can be used as state object in the course editor. + * + * @param renderer_base $output typically, the renderer that's calling this function + * @return stdClass data context for a mustache template + */ + public function export_for_template(\renderer_base $output): \stdClass { + $data = parent::export_for_template($output); + $data->newfancyelement = 'thatsme'; + + return $data; + } +} diff --git a/course/upgrade.txt b/course/upgrade.txt index e59c36ae6bd..9dc3e919612 100644 --- a/course/upgrade.txt +++ b/course/upgrade.txt @@ -46,6 +46,7 @@ renderer and course format renderer: * Course formats should have a renderer (until now it was only highly recommended but not mandatory). For backwards compatibility (to not break third-party plugins without it), legacy_format_renderer has been created and will be used when course formats don't have their own renderer. +* New external core_course\external\get_state returns current state information for a given course. === 3.11 === * A new callback xxx_coursemodule_definition_after_data that allows plugins to extend activity forms after the data is set. diff --git a/lib/db/services.php b/lib/db/services.php index 4cfda198c3e..f83efc8c8be 100644 --- a/lib/db/services.php +++ b/lib/db/services.php @@ -508,6 +508,12 @@ $functions = array( 'type' => 'read', 'ajax' => true, ), + 'core_course_get_state' => [ + 'classname' => 'core_course\external\get_state', + 'description' => 'Get the current course state.', + 'type' => 'read', + 'ajax' => true, + ], 'core_course_edit_module' => array( 'classname' => 'core_course_external', 'methodname' => 'edit_module', diff --git a/version.php b/version.php index 3edc4c1753f..8982c8342d8 100644 --- a/version.php +++ b/version.php @@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2021060400.00; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2021060400.01; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes. $release = '4.0dev (Build: 20210604)'; // Human-friendly version name