diff --git a/course/format/classes/base.php b/course/format/classes/base.php index 2c7df4624b6..9807cd0baf1 100644 --- a/course/format/classes/base.php +++ b/course/format/classes/base.php @@ -177,7 +177,7 @@ abstract class base { * * @param int|stdClass $courseorid either course id or * an object that has the property 'format' and may contain property 'id' - * @return course_format + * @return base */ public static final function instance($courseorid) { global $DB; diff --git a/course/format/classes/formatactions.php b/course/format/classes/formatactions.php new file mode 100644 index 00000000000..a841a708e8b --- /dev/null +++ b/course/format/classes/formatactions.php @@ -0,0 +1,150 @@ +. + +namespace core_courseformat; + +use core_courseformat\local\courseactions; +use core_courseformat\local\sectionactions; +use core_courseformat\local\cmactions; +use coding_exception; +use stdClass; + +/** + * Class to instantiate course format actions. + * + * This class is used to access course content actions. + * + * All course actions are divided into three main clases: + * - course: actions related to the course. + * - section: actions related to the sections. + * - cm: actions related to the course modules. + * + * Format plugin can provide their own actions classes by extending the actions classes + * with the following namespaces: + * - course: format_{PLUGINNAME}\courseformat\courseactions + * - section: format_{PLUGINNAME}\courseformat\sectionactions + * - cm: format_{PLUGINNAME}\courseformat\cmactions + * + * There a static method to get the general formatactions instance: + * - formatactions::instance($courseorid): returns an instance to access all available actions. + * + * The class also provides some convenience methods to get specific actions level on a specific course: + * - formatactions::course($courseorid): returns an instance of the course actions class. + * - formatactions::section($courseorid): returns an instance of the section actions class. + * - formatactions::cm($courseorid): returns an instance of the cm actions class. + * + * There are two ways of executing actions. For example, to execute a section action + * called "move_after" the options are: + * + * Option A: ideal for executing only one action. + * + * formatactions::section($courseid)->move_after($sectioninfo, $aftersectioninfo); + * + * Option B: when actions in the same course are going to be executed at different levels. + * + * $actions = formatactions::instance($courseid); + * $actions->section->move_after($sectioninfo, $aftersectioninfo); + * + * @package core_courseformat + * @copyright 2023 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +final class formatactions { + /** + * @var courseactions|null courseactions instance. + */ + public courseactions $course; + + /** + * @var sectionactions sectionactions instance. + */ + public sectionactions $section; + + /** + * @var cmactions cmactions instance. + */ + public cmactions $cm; + + /** + * Returns an instance of the actions class for the given course format. + * + * @param base $format the course format. + */ + protected function __construct(base $format) { + $actionclasses = [ + 'course' => courseactions::class, + 'section' => sectionactions::class, + 'cm' => cmactions::class, + ]; + foreach ($actionclasses as $action => $classname) { + $formatalternative = 'format_' . $format->get_format() . '\\courseformat\\' . $action . 'actions'; + if (class_exists($formatalternative)) { + if (!is_subclass_of($formatalternative, $classname)) { + throw new coding_exception("The \"$formatalternative\" must extend \"$classname\""); + } + $actionclasses[$action] = $formatalternative; + } + $this->$action = new $actionclasses[$action]($format->get_course()); + } + } + + /** + * Returns an instance of the actions class for the given course format. + * @param int|stdClass $courseorid course id or record. + * @return courseactions + */ + public static function course($courseorid): courseactions { + return self::instance($courseorid)->course; + } + + /** + * Returns an instance of the actions class for the given course format. + * + * @param int|stdClass $courseorid course id or record. + * @return sectionactions + */ + public static function section($courseorid): sectionactions { + return self::instance($courseorid)->section; + } + + /** + * Returns an instance of the actions class for the given course format. + * @param int|stdClass $courseorid course id or record. + * @return cmactions + */ + public static function cm($courseorid): cmactions { + return self::instance($courseorid)->cm; + } + + /** + * Get a course action loader instance. + * @param int|stdClass $courseorid course id or course. + * @return self + */ + public static function instance(int|stdClass $courseorid): self { + $coursesectionscache = \cache::make('core', 'courseactionsinstances'); + $format = base::instance($courseorid); + $courseid = $format->get_courseid(); + $cachekey = "{$courseid}_{$format->get_format()}"; + $cachedinstance = $coursesectionscache->get($cachekey); + if ($cachedinstance) { + return $cachedinstance; + } + $result = new self($format); + $coursesectionscache->set($cachekey, $result); + return $result; + } +} diff --git a/course/format/classes/local/baseactions.php b/course/format/classes/local/baseactions.php new file mode 100644 index 00000000000..b0ed45c6a47 --- /dev/null +++ b/course/format/classes/local/baseactions.php @@ -0,0 +1,87 @@ +. + +namespace core_courseformat\local; + +use core_courseformat\base as course_format; +use section_info; +use cm_info; +use stdClass; + +/** + * Format base actions. + * + * This class defined the format actions base class extended by the course, section and cm actions. + * + * It also provides helpers to get the most recent modinfo and format information. Those + * convenience methods are meant to improve the actions readability and prevent excessive + * message chains. + * + * @package core_courseformat + * @copyright 2023 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class baseactions { + /** + * @var stdClass the course object. + */ + protected stdClass $course; + + /** + * Constructor. + * @param stdClass $course the course object. + */ + public function __construct(stdClass $course) { + $this->course = $course; + } + + /** + * Get the course. + * @return stdClass the course object. + */ + protected function get_course(): stdClass { + return $this->course; + } + + /** + * Get the course format. + * @return course_format the course format. + */ + protected function get_format(): course_format { + return course_format::instance($this->course); + } + + /** + * Get the section info. + * @param int $sectionid the section id. + * @param int $strictness Use MUST_EXIST to throw exception if it doesn't + * @return section_info|null Information for numbered section or null if not found + */ + protected function get_section_info($sectionid, int $strictness = IGNORE_MISSING): ?section_info { + // Course actions must always get the most recent version of the section info. + return get_fast_modinfo($this->course->id)->get_section_info_by_id($sectionid, $strictness); + } + + /** + * Get the cm info. + * @param int $cmid the cm id. + * @return cm_info|null Information for numbered cm or null if not found + */ + protected function get_cm_info($cmid): ?cm_info { + // Course actions must always get the most recent version of the cm info. + return get_fast_modinfo($this->course->id)->get_cm($cmid); + } +} diff --git a/course/format/classes/local/cmactions.php b/course/format/classes/local/cmactions.php new file mode 100644 index 00000000000..3bfd71845e8 --- /dev/null +++ b/course/format/classes/local/cmactions.php @@ -0,0 +1,28 @@ +. + +namespace core_courseformat\local; + +/** + * Course module course format actions. + * + * @package core_courseformat + * @copyright 2023 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class cmactions extends baseactions { + // All course module actions will go here. +} diff --git a/course/format/classes/local/courseactions.php b/course/format/classes/local/courseactions.php new file mode 100644 index 00000000000..8bfa6f4696a --- /dev/null +++ b/course/format/classes/local/courseactions.php @@ -0,0 +1,28 @@ +. + +namespace core_courseformat\local; + +/** + * Course actions. + * + * @package core_courseformat + * @copyright 2023 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class courseactions extends baseactions { + // All general course actions will go here. +} diff --git a/course/format/classes/local/sectionactions.php b/course/format/classes/local/sectionactions.php new file mode 100644 index 00000000000..7d55ff774e3 --- /dev/null +++ b/course/format/classes/local/sectionactions.php @@ -0,0 +1,28 @@ +. + +namespace core_courseformat\local; + +/** + * Section course format actions. + * + * @package core_courseformat + * @copyright 2023 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class sectionactions extends baseactions { + // All section actions will go here. +} diff --git a/course/format/tests/fixtures/format_theunittest_cmactions.php b/course/format/tests/fixtures/format_theunittest_cmactions.php new file mode 100644 index 00000000000..82419fc0df9 --- /dev/null +++ b/course/format/tests/fixtures/format_theunittest_cmactions.php @@ -0,0 +1,29 @@ +. + +namespace format_theunittest\courseformat; + +use core_courseformat\local\cmactions as core_cm_actions; + +/** + * Fixture for fake course module actions testing. + * + * @package core_course + * @copyright 2023 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class cmactions extends core_cm_actions { +} diff --git a/course/format/tests/fixtures/format_theunittest_courseactions.php b/course/format/tests/fixtures/format_theunittest_courseactions.php new file mode 100644 index 00000000000..07607f3be08 --- /dev/null +++ b/course/format/tests/fixtures/format_theunittest_courseactions.php @@ -0,0 +1,29 @@ +. + +namespace format_theunittest\courseformat; + +use core_courseformat\local\courseactions as core_course_actions; + +/** + * Fixture for fake course actions testing. + * + * @package core_course + * @copyright 2023 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class courseactions extends core_course_actions { +} diff --git a/course/format/tests/fixtures/format_theunittest_sectionactions.php b/course/format/tests/fixtures/format_theunittest_sectionactions.php new file mode 100644 index 00000000000..609c8a257e8 --- /dev/null +++ b/course/format/tests/fixtures/format_theunittest_sectionactions.php @@ -0,0 +1,29 @@ +. + +namespace format_theunittest\courseformat; + +use core_courseformat\local\sectionactions as core_section_actions; + +/** + * Fixture for fake section actions testing. + * + * @package core_course + * @copyright 2023 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class sectionactions extends core_section_actions { +} diff --git a/course/format/tests/formatactions_test.php b/course/format/tests/formatactions_test.php new file mode 100644 index 00000000000..ec0b4e77296 --- /dev/null +++ b/course/format/tests/formatactions_test.php @@ -0,0 +1,146 @@ +. + +namespace core_courseformat; + +/** + * Course format actions class tests. + * + * @package core_courseformat + * @copyright 2023 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @coversDefaultClass \core_courseformat\base + */ +class formatactions_test extends \advanced_testcase { + + /** + * Setup to ensure that fixtures are loaded. + */ + public static function setUpBeforeClass(): void { + global $CFG; + require_once($CFG->dirroot . '/course/format/tests/fixtures/format_theunittest.php'); + require_once($CFG->dirroot . '/course/format/tests/fixtures/format_theunittest_courseactions.php'); + require_once($CFG->dirroot . '/course/format/tests/fixtures/format_theunittest_sectionactions.php'); + require_once($CFG->dirroot . '/course/format/tests/fixtures/format_theunittest_cmactions.php'); + } + + /** + * Test for get_instance static method. + * @dataProvider provider_classname_action + * @covers ::instance + * @param string $format + * @param array $classnames + */ + public function test_instance(string $format, array $classnames): void { + $this->resetAfterTest(); + $course = $this->getDataGenerator()->create_course(['format' => $format]); + + $instance1 = formatactions::instance($course); + $this->assertInstanceOf('\core_courseformat\formatactions', $instance1); + + $instance2 = formatactions::instance($course->id); + $this->assertInstanceOf('\core_courseformat\formatactions', $instance2); + + // Validate the method is caching the result. + $this->assertEquals($instance1, $instance2); + + // Validate public attribute classes. + $this->assertInstanceOf($classnames['course'], $instance1->course); + $this->assertInstanceOf($classnames['section'], $instance1->section); + $this->assertInstanceOf($classnames['cm'], $instance1->cm); + } + + /** + * Test that the course action instance is created correctly. + * @dataProvider provider_classname_action + * @covers ::course + * @param string $format + * @param array $classnames + */ + public function test_course_action_instance(string $format, array $classnames): void { + $this->resetAfterTest(); + $course = $this->getDataGenerator()->create_course(['format' => $format]); + + $instance1 = formatactions::course($course); + $this->assertInstanceOf($classnames['course'], $instance1); + + $instance2 = formatactions::course($course->id); + $this->assertInstanceOf($classnames['course'], $instance2); + } + + /** + * Test that the section action instance is created correctly. + * @dataProvider provider_classname_action + * @covers ::section + * + * @param string $format + * @param array $classnames + */ + public function test_static_sectionactions_instance(string $format, array $classnames): void { + $this->resetAfterTest(); + $course = $this->getDataGenerator()->create_course(['format' => $format]); + + $instance1 = formatactions::section($course); + $this->assertInstanceOf($classnames['section'], $instance1); + + $instance2 = formatactions::section($course->id); + $this->assertInstanceOf($classnames['section'], $instance2); + } + + /** + * Test that the cm action instance is created correctly. + * @dataProvider provider_classname_action + * @covers ::cm + * + * @param string $format + * @param array $classnames + */ + public function test_static_cmactions_instance(string $format, array $classnames): void { + $this->resetAfterTest(); + $course = $this->getDataGenerator()->create_course(['format' => $format]); + + $instance1 = formatactions::cm($course); + $this->assertInstanceOf($classnames['cm'], $instance1); + + $instance2 = formatactions::cm($course->id); + $this->assertInstanceOf($classnames['cm'], $instance2); + } + + /** + * Data provider for format class names scenarios. + * @return array + */ + public static function provider_classname_action(): array { + return [ + 'Topics format' => [ + 'format' => 'topics', + 'classnames' => [ + 'course' => '\core_courseformat\local\courseactions', + 'section' => '\core_courseformat\local\sectionactions', + 'cm' => '\core_courseformat\local\cmactions', + ], + ], + 'The unit test fixture format' => [ + 'format' => 'theunittest', + 'classnames' => [ + 'course' => '\format_theunittest\courseformat\courseactions', + 'section' => '\format_theunittest\courseformat\sectionactions', + 'cm' => '\format_theunittest\courseformat\cmactions', + ], + ], + ]; + } +} diff --git a/course/format/tests/local/baseactions_test.php b/course/format/tests/local/baseactions_test.php new file mode 100644 index 00000000000..65bc80d9732 --- /dev/null +++ b/course/format/tests/local/baseactions_test.php @@ -0,0 +1,149 @@ +. + +namespace core_courseformat\local; +use ReflectionMethod; +use section_info; +use cm_info; + +/** + * Base format actions class tests. + * + * @package core_courseformat + * @copyright 2023 Ferran Recio + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @coversDefaultClass \core_courseformat\base + */ +class baseactions_test extends \advanced_testcase { + /** + * Setup to ensure that fixtures are loaded. + */ + public static function setUpBeforeClass(): void { + global $CFG; + require_once($CFG->dirroot . '/course/lib.php'); + } + + /** + * Get the reflection method for a base class instance. + * @param baseactions $baseinstance + * @param string $methodname + * @return ReflectionMethod + */ + private function get_base_reflection_method(baseactions $baseinstance, string $methodname): ReflectionMethod { + $reflectionclass = new \reflectionclass($baseinstance); + $method = $reflectionclass->getMethod($methodname); + $method->setAccessible(true); + return $method; + } + + /** + * Test for get_instance static method. + * @covers ::get_format + */ + public function test_get_format(): void { + global $DB; + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(['format' => 'topics']); + + $baseactions = new baseactions($course); + $method = $this->get_base_reflection_method($baseactions, 'get_format'); + + $format = $method->invoke($baseactions); + $this->assertEquals('topics', $format->get_format()); + $this->assertEquals('format_topics', $format::class); + + // Format should be always the most updated one. + $course->format = 'weeks'; + $DB->update_record('course', $course); + + $format = $method->invoke($baseactions); + $this->assertEquals('weeks', $format->get_format()); + $this->assertEquals('format_weeks', $format::class); + } + + /** + * Test for get_instance static method. + * @covers ::get_section_info + */ + public function test_get_section_info(): void { + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course( + ['format' => 'topics', 'numsections' => 4], + ['createsections' => true] + ); + + $modinfo = get_fast_modinfo($course->id); + $originalsection = $modinfo->get_section_info(1); + + $baseactions = new baseactions($course); + $method = $this->get_base_reflection_method($baseactions, 'get_section_info'); + + $sectioninfo = $method->invoke($baseactions, $originalsection->id); + $this->assertInstanceOf(section_info::class, $sectioninfo); + $this->assertEquals($originalsection->id, $sectioninfo->id); + $this->assertEquals($originalsection->section, $sectioninfo->section); + + // Section info should be always the most updated one. + course_update_section($course, $originalsection, (object)['name' => 'New name']); + move_section_to($course, 1, 3); + + $sectioninfo = $method->invoke($baseactions, $originalsection->id); + $this->assertInstanceOf(section_info::class, $sectioninfo); + $this->assertEquals($originalsection->id, $sectioninfo->id); + $this->assertEquals(3, $sectioninfo->section); + + $this->assertEquals('New name', $sectioninfo->name); + } + + /** + * Test for get_instance static method. + * @covers ::get_cm_info + */ + public function test_get_cm_info(): void { + global $DB; + $this->resetAfterTest(); + $this->setAdminUser(); + + $course = $this->getDataGenerator()->create_course( + ['format' => 'topics', 'numsections' => 4], + ['createsections' => true] + ); + $activity = $this->getDataGenerator()->create_module('label', ['course' => $course->id]); + + $modinfo = get_fast_modinfo($course->id); + $destinationsection = $modinfo->get_section_info(3); + $originalcm = $modinfo->get_cm($activity->cmid); + + $baseactions = new baseactions($course); + $method = $this->get_base_reflection_method($baseactions, 'get_cm_info'); + + $cm = $method->invoke($baseactions, $originalcm->id); + $this->assertInstanceOf(cm_info::class, $cm); + $this->assertEquals($originalcm->id, $cm->id); + $this->assertEquals($originalcm->sectionnum, $cm->sectionnum); + $this->assertEquals($originalcm->name, $cm->name); + + // CM info should be always the most updated one. + moveto_module($originalcm, $destinationsection); + + $cm = $method->invoke($baseactions, $originalcm->id); + $this->assertInstanceOf(cm_info::class, $cm); + $this->assertEquals($originalcm->id, $cm->id); + $this->assertEquals($destinationsection->section, $cm->sectionnum); + } +} diff --git a/course/upgrade.txt b/course/upgrade.txt index 524d9ab47a9..4b0b4831dac 100644 --- a/course/upgrade.txt +++ b/course/upgrade.txt @@ -12,6 +12,9 @@ information provided here is intended especially for developers. - `course_purge_section_cache` - `course_purge_module_cache` - `get_array_of_activities` +* New format actions classes. Those classes will eventually replace all course/lib.php content editing functions. + All new methods are distributed in three classes formats can extend. Method can be accessed using static + methods (see doc block of core_courseformat\formatactions for more information). === 4.3 === * The `core_course_renderer::course_section_cm_completion` method has been removed, and can no longer be used diff --git a/lang/en/cache.php b/lang/en/cache.php index 7304371d53a..c7632027880 100644 --- a/lang/en/cache.php +++ b/lang/en/cache.php @@ -45,6 +45,7 @@ $string['cachedef_contentbank_allowed_categories'] = 'Allowed content bank cours $string['cachedef_contentbank_allowed_courses'] = 'Allowed content bank courses for current user'; $string['cachedef_contentbank_enabled_extensions'] = 'Allowed extensions and its supporter plugins in content bank'; $string['cachedef_contentbank_context_extensions'] = 'Allowed extensions and its supporter plugins in a content bank context'; +$string['cachedef_courseactionsinstances'] = 'Loaded course actions instances'; $string['cachedef_coursecat'] = 'Course categories lists for particular user'; $string['cachedef_coursecatrecords'] = 'Course categories records'; $string['cachedef_coursesectionspreferences'] = 'Course section preferences'; diff --git a/lib/db/caches.php b/lib/db/caches.php index 7fcd2562fb8..5a066730485 100644 --- a/lib/db/caches.php +++ b/lib/db/caches.php @@ -238,6 +238,16 @@ $definitions = array( 'simplekeys' => true, 'simpledata' => true, ], + // Course actions instances cache. + 'courseactionsinstances' => [ + 'mode' => cache_store::MODE_REQUEST, + 'simplekeys' => true, + 'simpledata' => false, + 'staticacceleration' => true, + // Executing actions in more than 10 courses usually means executing the same action on each course + // so there is no need for caching individual course instances. + 'staticaccelerationsize' => 10, + ], // Used to store data for repositories to avoid repetitive DB queries within one request. 'repositories' => array( 'mode' => cache_store::MODE_REQUEST,