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/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/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/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 3f0590c1765..9dc3e919612 100644 --- a/course/upgrade.txt +++ b/course/upgrade.txt @@ -43,6 +43,10 @@ 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. +* 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