diff --git a/course/classes/cache/course_image.php b/course/classes/cache/course_image.php new file mode 100644 index 00000000000..e01b31c93ec --- /dev/null +++ b/course/classes/cache/course_image.php @@ -0,0 +1,102 @@ +. + +namespace core_course\cache; + +use cache_data_source; +use cache_definition; +use moodle_url; +use core_course_list_element; + +/** + * Class to describe cache data source for course image. + * + * @package core + * @subpackage course + * @author Dmitrii Metelkin + * @copyright 2021 Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class course_image implements cache_data_source { + + /** @var course_image */ + protected static $instance = null; + + /** + * Returns an instance of the data source class that the cache can use for loading data using the other methods + * specified by this interface. + * + * @param cache_definition $definition + * @return \core_course\cache\course_image + */ + public static function get_instance_for_cache(cache_definition $definition): course_image { + if (is_null(self::$instance)) { + self::$instance = new course_image(); + } + return self::$instance; + } + + /** + * Loads the data for the key provided ready formatted for caching. + * + * @param string|int $key The key to load. + * @return string|bool Returns course image url as a string or false if the image is not exist + */ + public function load_for_cache($key) { + $course = get_fast_modinfo($key)->get_course(); + return $this->get_image_url_from_overview_files($course); + } + + /** + * Returns image URL from course overview files. + * + * @param \stdClass $course Course object. + * @return null|string Image URL or null if it's not exists. + */ + protected function get_image_url_from_overview_files(\stdClass $course): ?string { + $courseinlist = new core_course_list_element($course); + foreach ($courseinlist->get_course_overviewfiles() as $file) { + if ($file->is_valid_image()) { + return moodle_url::make_pluginfile_url( + $file->get_contextid(), + $file->get_component(), + $file->get_filearea(), + null, + $file->get_filepath(), + $file->get_filename() + )->out(); + } + } + + // Returning null if no image found to let it be cached + // as false is what cache API returns then a data is not found in cache. + return null; + } + + /** + * Loads several keys for the cache. + * + * @param array $keys An array of keys each of which will be string|int. + * @return array An array of matching data items. + */ + public function load_many_for_cache(array $keys): array { + $records = []; + foreach ($keys as $key) { + $records[$key] = $this->load_for_cache($key); + } + return $records; + } +} diff --git a/course/classes/external/course_summary_exporter.php b/course/classes/external/course_summary_exporter.php index 65471f7298d..3ac98879ac5 100644 --- a/course/classes/external/course_summary_exporter.php +++ b/course/classes/external/course_summary_exporter.php @@ -165,24 +165,16 @@ class course_summary_exporter extends \core\external\exporter { * Get the course image if added to course. * * @param object $course - * @return string url of course image + * @return string|false url of course image or false if it's not exist. */ public static function get_course_image($course) { - global $CFG; - $courseinlist = new \core_course_list_element($course); - foreach ($courseinlist->get_course_overviewfiles() as $file) { - if ($file->is_valid_image()) { - $pathcomponents = [ - '/pluginfile.php', - $file->get_contextid(), - $file->get_component(), - $file->get_filearea() . $file->get_filepath() . $file->get_filename() - ]; - $path = implode('/', $pathcomponents); - return (new moodle_url($path))->out(); - } + $image = \cache::make('core', 'course_image')->get($course->id); + + if (is_null($image)) { + $image = false; } - return false; + + return $image; } /** diff --git a/course/lib.php b/course/lib.php index d1b676ef53c..098162d1a55 100644 --- a/course/lib.php +++ b/course/lib.php @@ -2551,6 +2551,9 @@ function update_course($data, $editoroptions = NULL) { // make sure the modinfo cache is reset rebuild_course_cache($data->id); + // Purge course image cache in case if course image has been updated. + \cache::make('core', 'course_image')->delete($data->id); + // update course format options with full course data course_get_format($data->id)->update_course_format_options($data, $oldcourse); diff --git a/course/tests/course_image_cache_test.php b/course/tests/course_image_cache_test.php new file mode 100644 index 00000000000..f8f98596e49 --- /dev/null +++ b/course/tests/course_image_cache_test.php @@ -0,0 +1,176 @@ +. + +namespace tests\core_course; + +use context_user; +use context_course; +use ReflectionMethod; +use cache_definition; +use core_course\cache\course_image; + +/** + * Functional test for class course_image + * + * @package core + * @subpackage course + * @author Dmitrii Metelkin + * @copyright 2021 Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class course_image_cache_testcase extends \advanced_testcase { + + /** + * Initial setup. + */ + protected function setUp(): void { + global $CFG; + + parent::setUp(); + $this->resetAfterTest(); + $this->setAdminUser(); + + // Allow multiple overview files. + $CFG->courseoverviewfileslimit = 3; + } + + /** + * Helper method to create a draft area for current user and fills it with fake files + * + * @param array $files array of files that need to be added to filearea, filename => filecontents + * @return int draftid for the filearea + */ + protected function fill_draft_area(array $files): int { + global $USER; + + $draftid = file_get_unused_draft_itemid(); + foreach ($files as $filename => $filecontents) { + // Add actual file there. + $filerecord = [ + 'component' => 'user', + 'filearea' => 'draft', + 'contextid' => context_user::instance($USER->id)->id, 'itemid' => $draftid, + 'filename' => $filename, 'filepath' => '/' + ]; + $fs = get_file_storage(); + $fs->create_file_from_string($filerecord, $filecontents); + } + return $draftid; + } + + /** + * A helper method to generate expected file URL. + * + * @param \stdClass $course Course object. + * @param string $filename File name. + * @return string + */ + protected function build_expected_course_image_url(\stdClass $course, string $filename): string { + $contextid = context_course::instance($course->id)->id; + return 'https://www.example.com/moodle/pluginfile.php/' . $contextid. '/course/overviewfiles/' . $filename; + } + + /** + * Test exception if try to get an image for non existing course. + */ + public function test_getting_data_if_course_is_not_exist() { + $this->expectException('dml_missing_record_exception'); + $this->expectExceptionMessageMatches("/Can't find data record in database table course./"); + $this->assertFalse(\cache::make('core', 'course_image')->get(999)); + } + + /** + * Test get_image_url_from_overview_files when no summary files in the course. + */ + public function test_get_image_url_from_overview_files_return_null_if_no_summary_files_in_the_course() { + $method = new ReflectionMethod(course_image::class, 'get_image_url_from_overview_files'); + $cache = course_image::get_instance_for_cache(new cache_definition()); + $method->setAccessible(true); + + // Create course without files. + $course = $this->getDataGenerator()->create_course(); + $this->assertNull($method->invokeArgs($cache, [$course])); + } + + /** + * Test get_image_url_from_overview_files when no summary images in the course. + */ + public function test_get_image_url_from_overview_files_returns_null_if_no_summary_images_in_the_course() { + $method = new ReflectionMethod(course_image::class, 'get_image_url_from_overview_files'); + $cache = course_image::get_instance_for_cache(new cache_definition()); + $method->setAccessible(true); + + // Create course without image files. + $draftid2 = $this->fill_draft_area(['filename2.zip' => 'Test file contents2']); + $course2 = $this->getDataGenerator()->create_course(['overviewfiles_filemanager' => $draftid2]); + $this->assertNull($method->invokeArgs($cache, [$course2])); + } + + /** + * Test get_image_url_from_overview_files when no summary images in the course. + */ + public function test_get_image_url_from_overview_files_returns_url_if_there_is_a_summary_image() { + $method = new ReflectionMethod(course_image::class, 'get_image_url_from_overview_files'); + $cache = course_image::get_instance_for_cache(new cache_definition()); + $method->setAccessible(true); + + // Create course without one image. + $draftid1 = $this->fill_draft_area(['filename1.jpg' => file_get_contents(__DIR__ . '/fixtures/image.jpg')]); + $course1 = $this->getDataGenerator()->create_course(['overviewfiles_filemanager' => $draftid1]); + $expected = $this->build_expected_course_image_url($course1, 'filename1.jpg'); + $this->assertEquals($expected, $method->invokeArgs($cache, [$course1])); + } + + /** + * Test get_image_url_from_overview_files when several summary images in the course. + */ + public function test_get_image_url_from_overview_files_returns_url_of_the_first_image_if_there_are_many_summary_images() { + $method = new ReflectionMethod(course_image::class, 'get_image_url_from_overview_files'); + $cache = course_image::get_instance_for_cache(new cache_definition()); + $method->setAccessible(true); + + // Create course with two image files. + $draftid1 = $this->fill_draft_area([ + 'filename1.jpg' => file_get_contents(__DIR__ . '/fixtures/image.jpg'), + 'filename2.jpg' => file_get_contents(__DIR__ . '/fixtures/image.jpg'), + ]); + $course1 = $this->getDataGenerator()->create_course(['overviewfiles_filemanager' => $draftid1]); + + $expected = $this->build_expected_course_image_url($course1, 'filename1.jpg'); + $this->assertEquals($expected, $method->invokeArgs($cache, [$course1])); + } + + /** + * Test get_image_url_from_overview_files when several summary files in the course. + */ + public function test_get_image_url_from_overview_files_returns_url_of_the_first_image_if_there_are_many_summary_files() { + $method = new ReflectionMethod(course_image::class, 'get_image_url_from_overview_files'); + $cache = course_image::get_instance_for_cache(new cache_definition()); + $method->setAccessible(true); + + // Create course with two image files and one zip file. + $draftid1 = $this->fill_draft_area([ + 'filename1.zip' => 'Test file contents2', + 'filename2.jpg' => file_get_contents(__DIR__ . '/fixtures/image.jpg'), + 'filename3.jpg' => file_get_contents(__DIR__ . '/fixtures/image.jpg'), + ]); + $course1 = $this->getDataGenerator()->create_course(['overviewfiles_filemanager' => $draftid1]); + + $expected = $this->build_expected_course_image_url($course1, 'filename2.jpg'); + $this->assertEquals($expected, $method->invokeArgs($cache, [$course1])); + } + +} diff --git a/course/tests/course_summary_exporter_test.php b/course/tests/course_summary_exporter_test.php new file mode 100644 index 00000000000..7f6503d2a95 --- /dev/null +++ b/course/tests/course_summary_exporter_test.php @@ -0,0 +1,73 @@ +. + +namespace tests\core_course; + +use core_course\external\course_summary_exporter; +use context_user; +use context_course; + +/** + * Functional test for class course_summary_exporter + * + * @package core + * @subpackage course + * @author Dmitrii Metelkin + * @copyright 2021 Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class course_summary_exporter_testcase extends \advanced_testcase { + + /** + * Test that if no course overview images uploaded get_course_image returns false. + */ + public function test_get_course_image_when_no_overview_images_uploaded() { + $this->resetAfterTest(true); + $this->setAdminUser(); + $course = $this->getDataGenerator()->create_course(); + + $this->assertFalse(course_summary_exporter::get_course_image($course)); + } + + /** + * Test that if course overview images uploaded get_course_image returns an image URL. + */ + public function test_get_course_image_when_overview_images_are_uploaded() { + global $USER; + + $this->resetAfterTest(true); + $this->setAdminUser(); + + $draftid = file_get_unused_draft_itemid(); + $filerecord = [ + 'component' => 'user', + 'filearea' => 'draft', + 'contextid' => context_user::instance($USER->id)->id, + 'itemid' => $draftid, + 'filename' => 'image.jpg', + 'filepath' => '/', + ]; + $fs = get_file_storage(); + $fs->create_file_from_string($filerecord, file_get_contents(__DIR__ . '/fixtures/image.jpg')); + $course = $this->getDataGenerator()->create_course(['overviewfiles_filemanager' => $draftid]); + $coursecontext = context_course::instance($course->id); + + $expected = 'https://www.example.com/moodle/pluginfile.php/' . $coursecontext->id . '/course/overviewfiles/image.jpg'; + $actual = course_summary_exporter::get_course_image($course); + $this->assertSame($expected, $actual); + } + +} diff --git a/course/tests/fixtures/image.jpg b/course/tests/fixtures/image.jpg new file mode 100755 index 00000000000..91cc0d3cfe4 Binary files /dev/null and b/course/tests/fixtures/image.jpg differ diff --git a/lang/en/cache.php b/lang/en/cache.php index a0ff0c3cbb4..d4eac728c9e 100644 --- a/lang/en/cache.php +++ b/lang/en/cache.php @@ -49,6 +49,7 @@ $string['cachedef_coursecattree'] = 'Course categories tree'; $string['cachedef_coursecompletion'] = 'Course completion status'; $string['cachedef_coursecontacts'] = 'List of course contacts'; $string['cachedef_coursemodinfo'] = 'Accumulated information about modules and sections for each course'; +$string['cachedef_course_image'] = 'Course images'; $string['cachedef_course_user_dates'] = 'The user dates for courses set to relative dates mode'; $string['cachedef_completion'] = 'Activity completion status'; $string['cachedef_databasemeta'] = 'Database meta information'; diff --git a/lib/db/caches.php b/lib/db/caches.php index e7b352dad18..fac98b7aa63 100644 --- a/lib/db/caches.php +++ b/lib/db/caches.php @@ -484,4 +484,13 @@ $definitions = array( 'staticacceleration' => true, 'staticaccelerationsize' => 100 ], + + // Course image cache. + 'course_image' => [ + 'mode' => cache_store::MODE_APPLICATION, + 'simplekeys' => true, + 'simpledata' => true, + 'staticacceleration' => true, + 'datasource' => '\core_course\cache\course_image', + ], );