diff --git a/classes/autoupdate.php b/classes/autoupdate.php index 3811733..0ce1499 100644 --- a/classes/autoupdate.php +++ b/classes/autoupdate.php @@ -85,6 +85,7 @@ public static function update_from_delete_event(\core\event\base $event): void { foreach ($placestore->places as $p) { if ($p->linkedActivity == $data['objectid']) { $p->linkedActivity = null; + cachemanager::remove_cmid($data['objectid']); $changed = true; } } @@ -105,13 +106,7 @@ public static function update_from_delete_event(\core\event\base $event): void { */ public static function reset_backlink_cache(\core\event\base $event): void { if (isset($data['courseid']) && $data['courseid'] > 0) { - $course = $data['courseid']; - $cache = \cache::make('mod_learningmap', 'backlinks'); - $modinfo = get_fast_modinfo($course); - $cms = $modinfo->get_cms(); - foreach ($cms as $cm) { - $cache->delete($cm->id); - } + cachemanager::reset_backlink_cache($data['courseid']); } } } diff --git a/classes/cachemanager.php b/classes/cachemanager.php new file mode 100644 index 0000000..f58dfa1 --- /dev/null +++ b/classes/cachemanager.php @@ -0,0 +1,106 @@ +. + +namespace mod_learningmap; + +/** + * Cache manager class for mod_learningmap + * + * @package mod_learningmap + * @copyright 2021-2024, ISB Bayern + * @author Stefan Hanauska + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class cachemanager { + /** + * Resets and rebuilds the backlink cache for the whole instance. + * + * @return void + */ + public static function rebuild_backlink_cache(): void { + $cache = \cache::make('mod_learningmap', 'backlinks'); + $cache->purge(); + self::build_backlink_cache(); + } + + /** + * Reset the backlink cache for a course (includes rebuilding it). + * + * @param int $courseid The id of the course. + * @return void + */ + public static function reset_backlink_cache(int $courseid): void { + $cache = \cache::make('mod_learningmap', 'backlinks'); + $modinfo = get_fast_modinfo($courseid); + $cms = $modinfo->get_cms(); + $cache->delete_many(array_keys($cms)); + self::build_backlink_cache($courseid); + } + + /** + * Builds the backlink cache for a course or for the whole instance (e.g. after purging the cache). + * + * @param int $courseid Id of the course, if 0 the cache will be built for the whole instance. + * @return void + */ + public static function build_backlink_cache(int $courseid = 0) { + global $DB; + $backlinks = []; + $cache = \cache::make('mod_learningmap', 'backlinks'); + + $conditions = ['backlink' => 1]; + if (!empty($courseid)) { + $conditions['course'] = $courseid; + } + + $records = $DB->get_records('learningmap', $conditions, '', 'id, placestore, backlink'); + foreach ($records as $record) { + $module = get_coursemodule_from_instance('learningmap', $record->id, 0, true); + $placestore = json_decode($record->placestore); + $coursepageurl = course_get_format($module->course)->get_view_url($module->sectionnum); + $coursepageurl->set_anchor('module-' . $module->id); + foreach ($placestore->places as $place) { + $url = !empty($module->showdescription) ? + $coursepageurl->out() : + new \moodle_url('/mod/learningmap/view.php', ['id' => $module->id]); + $backlinks[$place->linkedActivity][$module->id] = [ + 'url' => $url, + 'name' => $module->name, + 'cmid' => $module->id, + ]; + } + } + + foreach ($backlinks as $cmid => $backlink) { + $cache->set($cmid, $backlink); + } + + if (empty($courseid)) { + $cache->set('fillstate', time()); + } + } + + /** + * Removes a cmid from the backlink cache (e.g. when the course module was deleted). + * + * @param int $cmid Course module id + * @return void + */ + public static function remove_cmid(int $cmid) { + $cache = \cache::make('mod_learningmap', 'backlinks'); + $cache->delete($cmid); + } +} diff --git a/classes/task/fill_backlink_cache.php b/classes/task/fill_backlink_cache.php new file mode 100644 index 0000000..f362892 --- /dev/null +++ b/classes/task/fill_backlink_cache.php @@ -0,0 +1,53 @@ +. + +namespace mod_learningmap\task; +use mod_learningmap\cachemanager; + +/** + * Task to fill backlink cache. + * + * @package mod_learningmap + * @copyright 2021-2024, ISB Bayern + * @author Stefan Hanauska + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class fill_backlink_cache extends \core\task\scheduled_task { + /** + * Get a descriptive name for this task (shown to admins). + * + * @return string + */ + public function get_name() { + return get_string('fill_backlink_cache_task', 'mod_learningmap'); + } + + /** + * Fill backlink cache. + */ + public function execute() { + $cache = \cache::make('mod_learningmap', 'backlinks'); + + $fillstate = $cache->get('fillstate'); + + // If the cache is filled within the last 24 hours, do nothing. + if (!empty($fillstate) && $fillstate < time() - 60 * 60 * 24) { + return; + } + + cachemanager::build_backlink_cache(); + } +} diff --git a/db/tasks.php b/db/tasks.php new file mode 100644 index 0000000..ea21b6a --- /dev/null +++ b/db/tasks.php @@ -0,0 +1,39 @@ +. + +/** + * Scheduled task definitions for mod_learningmap + * + * @package mod_learningmap + * @copyright 2021-2024, ISB Bayern + * @author Stefan Hanauska + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$tasks = [ + [ + 'classname' => 'mod_learningmap\task\fill_backlink_cache', + 'blocking' => 1, + 'minute' => '*/5', + 'hour' => '*', + 'day' => '*', + 'month' => '*', + 'dayofweek' => '*', + 'faildelay' => 1, + ], +]; diff --git a/lang/en/learningmap.php b/lang/en/learningmap.php index 9ca9b93..d7697bc 100644 --- a/lang/en/learningmap.php +++ b/lang/en/learningmap.php @@ -42,6 +42,7 @@ $string['completiontype'] = 'Type of completion'; $string['editorhelp'] = 'How to use the editor'; $string['editplace'] = 'Edit place'; +$string['fill_backlink_cache_task'] = 'Fill learningmap backlink cache'; $string['freetype_required'] = 'FreeType extension to GD is required to run mod_learningmap.'; $string['groupmode'] = 'Group mode'; $string['groupmode_help'] = 'When group mode is active, it is sufficient that one member of the group has completed an activity to be able to have the connected places available.'; diff --git a/lib.php b/lib.php index c2653bd..1adc964 100644 --- a/lib.php +++ b/lib.php @@ -23,6 +23,8 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +use mod_learningmap\cachemanager; + /** * Array with all features the plugin supports for advanced settings. Might be moved * to another place when in use somewhere else. @@ -330,7 +332,7 @@ function learningmap_reset_userdata($data) { * @return void */ function learningmap_before_http_headers() { - global $PAGE, $DB, $OUTPUT; + global $PAGE, $OUTPUT; if ($PAGE->context->contextlevel != CONTEXT_MODULE) { return ''; @@ -338,41 +340,24 @@ function learningmap_before_http_headers() { try { $cache = cache::make('mod_learningmap', 'backlinks'); + $cachekey = $PAGE->cm->id; - $key = $cache->get($cachekey); - $modinfo = get_fast_modinfo($PAGE->course); - - if (!$key) { - $instances = $modinfo->get_instances_of('learningmap'); - if (count($instances) > 0) { - $backlinks = []; - foreach ($instances as $i) { - $record = $DB->get_record('learningmap', ['id' => $i->instance], 'name, placestore, backlink'); - if ($record->backlink == 1) { - $placestore = json_decode($record->placestore); - $coursepageurl = course_get_format($PAGE->course->id)->get_view_url($i->sectionnum); - $coursepageurl->set_anchor('module-' . $i->id); - foreach ($placestore->places as $place) { - $url = !empty($i->showdescription) ? - $coursepageurl->out() : - new moodle_url('/mod/learningmap/view.php', ['id' => $i->id]); - $backlinks[$place->linkedActivity][$i->id] = ['url' => $url, 'name' => $record->name, 'cmid' => $i->id]; - } - } - } - foreach ($backlinks as $cmid => $backlink) { - $cache->set($cmid, $backlink); - } - } else { - $cache->set($cachekey, []); + $backlinks = $cache->get($cachekey); + + if ($backlinks === false) { + // If the cache is not yet filled, fill it for the current course. + if (!$cache->get('fillstate')) { + cachemanager::build_backlink_cache($PAGE->course->id); } - } else { - $backlinks[$cachekey] = $key; + // Try again to get the backlinks. + $backlinks = $cache->get($cachekey); } + $backlinktext = ''; - if (!empty($backlinks[$PAGE->cm->id])) { - foreach ($backlinks[$PAGE->cm->id] as $backlink) { + if (!empty($backlinks)) { + $modinfo = get_fast_modinfo($PAGE->course); + foreach ($backlinks as $backlink) { $cminfo = $modinfo->get_cm($backlink['cmid']); if ($cminfo->available != 0 && $cminfo->uservisible) { $backlinktext .= $OUTPUT->render_from_template('learningmap/backtomap', $backlink); diff --git a/tests/mod_learningmap_backlink_cache_test.php b/tests/mod_learningmap_backlink_cache_test.php new file mode 100644 index 0000000..d1db74f --- /dev/null +++ b/tests/mod_learningmap_backlink_cache_test.php @@ -0,0 +1,248 @@ +. + +namespace mod_learningmap; + +use stdClass; + +/** + * Unit test for mod_learningmap backlink cache + * + * @package mod_learningmap + * @copyright 2021-2024, ISB Bayern + * @author Stefan Hanauska + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @group mod_learningmap + * @group mebis + * @covers ::learningmap_before_http_headers() + * @covers \mod_learningmap\cachemanager + */ +class mod_learningmap_backlink_cache_test extends \advanced_testcase { + /** + * Courses used for testing + * @var array + */ + private array $courses; + + /** + * Learning maps used for testing + * @var array + */ + private array $learningmaps; + + /** + * Activities used for testing + * @var array + */ + private array $activities; + + /** + * User used for testing + * @var stdClass + */ + private stdClass $user; + + /** + * Prepare testing environment. + * @return void + */ + public function setup(): void { + global $DB; + $this->setAdminUser(); + $this->courses[0] = $this->getDataGenerator()->create_course(['enablecompletion' => 1]); + $this->courses[1] = $this->getDataGenerator()->create_course(['enablecompletion' => 1]); + // Set up the learning maps for this test. First one has backlink enabled, second one has backlink disabled, + // third one has backlink enabled but is not available. + $this->learningmaps[0] = $this->getDataGenerator()->create_module( + 'learningmap', + ['course' => $this->courses[0], 'backlink' => 1] + ); + $this->learningmaps[1] = $this->getDataGenerator()->create_module( + 'learningmap', + ['course' => $this->courses[0], 'backlink' => 0] + ); + $this->learningmaps[2] = $this->getDataGenerator()->create_module( + 'learningmap', + ['course' => $this->courses[0], 'backlink' => 1, 'visible' => 0, 'visibleold' => 1] + ); + + $this->activities = []; + // Create activities for this test. The first 10 activities are in the first learning map, the next 10 in the second. + for ($i = 0; $i < 27; $i++) { + $this->activities[] = $this->getDataGenerator()->create_module( + 'page', + [ + 'name' => 'A', + 'content' => 'B', + 'course' => $this->courses[0], + 'completion' => COMPLETION_TRACKING_AUTOMATIC, + 'completionview' => COMPLETION_VIEW_REQUIRED, + ] + ); + $this->learningmaps[(int)($i / 9)]->placestore = str_replace( + 99990 + ($i % 9), + $this->activities[$i]->cmid, + $this->learningmaps[(int)($i / 9)]->placestore + ); + } + $this->activities[] = $this->getDataGenerator()->create_module( + 'page', + [ + 'name' => 'A', + 'content' => 'B', + 'course' => $this->courses[1], + ] + ); + $DB->set_field('learningmap', 'placestore', $this->learningmaps[0]->placestore, ['id' => $this->learningmaps[0]->id]); + $DB->set_field('learningmap', 'placestore', $this->learningmaps[1]->placestore, ['id' => $this->learningmaps[1]->id]); + $DB->set_field('learningmap', 'placestore', $this->learningmaps[2]->placestore, ['id' => $this->learningmaps[2]->id]); + + $studentrole = $DB->get_record('role', ['shortname' => 'student']); + + $this->user = $this->getDataGenerator()->create_user( + [ + 'email' => 'user1@example.com', + 'username' => 'user1', + ] + ); + // Enrol user in both courses. + $this->getDataGenerator()->enrol_user($this->user->id, $this->courses[0]->id, $studentrole->id); + $this->getDataGenerator()->enrol_user($this->user->id, $this->courses[1]->id, $studentrole->id); + } + + /** + * Test the rebuild_backlink_cache() method. + * + * @return void + */ + public function test_rebuild_backlink_cache(): void { + $this->resetAfterTest(); + $cache = \cache::make('mod_learningmap', 'backlinks'); + // Set invalid cache key. + $cache->set('test', 'test'); + cachemanager::rebuild_backlink_cache(); + + $this->assertNotEquals(false, $cache->get('fillstate')); + for ($i = 0; $i < 28; $i++) { + // Only activities 0-8 and 18-26 have cached backlinks. Be aware that availability checking is not done here. + if ((int)($i / 9) % 2 == 0) { + $this->assertNotEquals(false, $cache->get($this->activities[$i]->cmid)); + } else { + $this->assertEquals(false, $cache->get($this->activities[$i]->cmid)); + } + } + // Invalid key should be deleted. + $this->assertEquals(false, $cache->get('test')); + } + + /** + * Test the reset_backlink_cache() method. + * + * @return void + */ + public function test_reset_backlink_cache(): void { + global $DB; + $this->resetAfterTest(); + $cache = \cache::make('mod_learningmap', 'backlinks'); + cachemanager::build_backlink_cache(); + $DB->set_field('learningmap', 'backlink', 0, ['id' => $this->learningmaps[0]->id]); + cachemanager::reset_backlink_cache($this->courses[0]->id); + $this->assertNotEquals(false, $cache->get('fillstate')); + // There are no backlinks anymore for the first learning map. + for ($i = 0; $i < 18; $i++) { + $this->assertEquals(false, $cache->get($this->activities[$i]->cmid)); + } + // There are still cached backlinks for the third learning map. + for ($i = 18; $i < 26; $i++) { + $this->assertNotEquals(false, $cache->get($this->activities[$i]->cmid)); + } + } + + /** + * Test the build_backlink_cache() method. + * + * @return void + */ + public function test_build_backlink_cache(): void { + $this->resetAfterTest(); + $cache = \cache::make('mod_learningmap', 'backlinks'); + // Cache should be empty. + $this->assertEquals(false, $cache->get('fillstate')); + + cachemanager::build_backlink_cache(); + + $this->assertNotEquals(false, $cache->get('fillstate')); + } + + /** + * Test the learningmap_before_http_headers() function, along with on demand backlink generation if cache is not yet filled. + * + * @return void + */ + public function test_backlink_generation(): void { + global $PAGE, $OUTPUT; + $this->setUser($this->user); + $this->resetAfterTest(); + + $modinfo = get_fast_modinfo($this->courses[0]); + + // Test an activity that is part of the first learning map (with backlink enabled). + $PAGE->set_cm($modinfo->get_cm($this->activities[0]->cmid)); + $PAGE->set_activity_record($this->activities[0]); + + $descriptionbefore = $PAGE->activityheader->export_for_template($OUTPUT)['description']; + learningmap_before_http_headers(); + + $descriptionafter = $PAGE->activityheader->export_for_template($OUTPUT)['description']; + $this->assertNotEquals($descriptionbefore, $descriptionafter); + $this->assertTrue(str_contains($descriptionafter, $this->learningmaps[0]->cmid)); + + // Test an activity that is part of the second learning map (with backlink disabled). + $PAGE = new \moodle_page(); + $PAGE->set_cm($modinfo->get_cm($this->activities[10]->cmid)); + $PAGE->set_activity_record($this->activities[10]); + + $descriptionbefore = $PAGE->activityheader->export_for_template($OUTPUT)['description']; + learningmap_before_http_headers(); + + $descriptionafter = $PAGE->activityheader->export_for_template($OUTPUT)['description']; + $this->assertEquals($descriptionbefore, $descriptionafter); + + // Test an activity that is part of the second course (without any learning maps). + $PAGE = new \moodle_page(); + $modinfo = get_fast_modinfo($this->courses[1]); + $PAGE->set_cm($modinfo->get_cm($this->activities[27]->cmid)); + $PAGE->set_activity_record($this->activities[27]); + + $descriptionbefore = $PAGE->activityheader->export_for_template($OUTPUT)['description']; + learningmap_before_http_headers(); + + $descriptionafter = $PAGE->activityheader->export_for_template($OUTPUT)['description']; + $this->assertEquals($descriptionbefore, $descriptionafter); + + // Learningmap is invisible for the user. Backlink should not be generated. + $PAGE = new \moodle_page(); + $modinfo = get_fast_modinfo($this->courses[0]); + $PAGE->set_cm($modinfo->get_cm($this->activities[18]->cmid)); + $PAGE->set_activity_record($this->activities[18]); + + $descriptionbefore = $PAGE->activityheader->export_for_template($OUTPUT)['description']; + learningmap_before_http_headers(); + + $descriptionafter = $PAGE->activityheader->export_for_template($OUTPUT)['description']; + $this->assertEquals($descriptionbefore, $descriptionafter); + } +} diff --git a/version.php b/version.php index cc59fda..870b65d 100644 --- a/version.php +++ b/version.php @@ -25,8 +25,8 @@ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'mod_learningmap'; -$plugin->release = '0.9.6'; -$plugin->version = 2024012601; +$plugin->release = '0.9.7'; +$plugin->version = 2024013102; $plugin->requires = 2020061500; $plugin->supported = [401, 404]; $plugin->maturity = MATURITY_STABLE;