From 76683736caa773091fef364a6c3d8f5711e197e3 Mon Sep 17 00:00:00 2001 From: "ccaewan@ucl.ac.uk" Date: Tue, 31 Mar 2026 16:51:23 +0100 Subject: [PATCH 1/9] Add coursefreeze, coursedelete triggers and uclcontextfreeze step --- .../en/lifecyclestep_uclcontextfreeze.php | 29 +++ step/uclcontextfreeze/lib.php | 83 ++++++++ step/uclcontextfreeze/version.php | 14 ++ .../coursedelete/classes/privacy/provider.php | 39 ++++ .../lang/en/lifecycletrigger_coursedelete.php | 45 +++++ trigger/coursedelete/lib.php | 154 +++++++++++++++ trigger/coursedelete/version.php | 32 +++ .../coursefreeze/classes/privacy/provider.php | 39 ++++ .../lang/en/lifecycletrigger_coursefreeze.php | 57 ++++++ trigger/coursefreeze/lib.php | 185 ++++++++++++++++++ trigger/coursefreeze/version.php | 32 +++ 11 files changed, 709 insertions(+) create mode 100644 step/uclcontextfreeze/lang/en/lifecyclestep_uclcontextfreeze.php create mode 100644 step/uclcontextfreeze/lib.php create mode 100644 step/uclcontextfreeze/version.php create mode 100644 trigger/coursedelete/classes/privacy/provider.php create mode 100644 trigger/coursedelete/lang/en/lifecycletrigger_coursedelete.php create mode 100644 trigger/coursedelete/lib.php create mode 100644 trigger/coursedelete/version.php create mode 100644 trigger/coursefreeze/classes/privacy/provider.php create mode 100644 trigger/coursefreeze/lang/en/lifecycletrigger_coursefreeze.php create mode 100644 trigger/coursefreeze/lib.php create mode 100644 trigger/coursefreeze/version.php diff --git a/step/uclcontextfreeze/lang/en/lifecyclestep_uclcontextfreeze.php b/step/uclcontextfreeze/lang/en/lifecyclestep_uclcontextfreeze.php new file mode 100644 index 00000000..1b886307 --- /dev/null +++ b/step/uclcontextfreeze/lang/en/lifecyclestep_uclcontextfreeze.php @@ -0,0 +1,29 @@ +. + +/** + * Lang strings for delete course step + * + * @package lifecyclestep_uclcontextfreeze + * @copyright 2025 UCL + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +$string['stepname'] = 'Archive/Freeze course step'; +$string['plugindescription'] = 'Calls UCLs contextfreeze (block) to archive courses'; +$string['pluginname'] = 'UCL contextfreeze'; +$string['privacy:metadata'] = 'This subplugin does not store any personal data.'; + diff --git a/step/uclcontextfreeze/lib.php b/step/uclcontextfreeze/lib.php new file mode 100644 index 00000000..80335795 --- /dev/null +++ b/step/uclcontextfreeze/lib.php @@ -0,0 +1,83 @@ +. + + /** + * Step subplugin to freeze a course context using UCL block_lifecycle manager. + * + * @package lifecyclestep_uclcontextfreeze + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace tool_lifecycle\step; + +use stdClass; +use tool_lifecycle\local\response\step_response; + +defined('MOODLE_INTERNAL') || die(); + +require_once(__DIR__ . '/../lib.php'); + +/** + * Step subplugin to freeze a course context using UCL block_lifecycle. + * + * @package lifecyclestep_uclcontextfreeze + */ +class uclcontextfreeze extends libbase { + + /** + * Processes the course and returns a response. + * + * @param int $processid of the respective process. + * @param int $instanceid of the step instance. + * @param stdClass $course to be processed. + * @return step_response + */ + public function process_course($processid, $instanceid, $course) { + + if (!class_exists('\block_lifecycle\manager')) { + return step_response::rollback(); + } + + try { + \block_lifecycle\manager::freeze_course((int)$course->id); + } catch (\Exception $e) { + return step_response::rollback(); + } + + return step_response::proceed(); + } + + /** + * Processes the course in status waiting and returns a response. + * + * @param int $processid + * @param int $instanceid + * @param stdClass $course + * @return step_response + */ + public function process_waiting_course($processid, $instanceid, $course) { + return $this->process_course($processid, $instanceid, $course); + } + + /** + * The return value should be equivalent with the name of the subplugin folder. + * + * @return string + */ + public function get_subpluginname() { + return 'uclcontextfreeze'; + } +} diff --git a/step/uclcontextfreeze/version.php b/step/uclcontextfreeze/version.php new file mode 100644 index 00000000..5980d5c0 --- /dev/null +++ b/step/uclcontextfreeze/version.php @@ -0,0 +1,14 @@ +component = 'lifecyclestep_uclcontextfreeze'; +$plugin->version = 2025102300; +$plugin->requires = 2022112800; // Requires Moodle 4.1+. +$plugin->maturity = MATURITY_STABLE; +$plugin->release = '0.1'; + +// Requires UCL's lifecycle bloxk (so the manager class exists) +$plugin->dependencies = [ + 'block_lifecycle' => ANY_VERSION, +]; + diff --git a/trigger/coursedelete/classes/privacy/provider.php b/trigger/coursedelete/classes/privacy/provider.php new file mode 100644 index 00000000..5b41dfd4 --- /dev/null +++ b/trigger/coursedelete/classes/privacy/provider.php @@ -0,0 +1,39 @@ +. + +namespace lifecycletrigger_coursedelete\privacy; + +use core_privacy\local\metadata\null_provider; + +/** + * Privacy subsystem implementation for lifecycletrigger_coursefreeze. + * + * @package lifecycletrigger_coursefreeze + * @copyright 2025 Gifty Wanzola (ccaewan) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class provider implements null_provider { + + /** + * Get the language string identifier with the component's language + * file to explain why this plugin stores no data. + * + * @return string + */ + public static function get_reason(): string { + return 'privacy:metadata'; + } +} diff --git a/trigger/coursedelete/lang/en/lifecycletrigger_coursedelete.php b/trigger/coursedelete/lang/en/lifecycletrigger_coursedelete.php new file mode 100644 index 00000000..6bd66051 --- /dev/null +++ b/trigger/coursedelete/lang/en/lifecycletrigger_coursedelete.php @@ -0,0 +1,45 @@ +. + +/** + * Lang strings for course freeze trigger + * + * @package lifecycletrigger_coursedelete + * @copyright 2025 Gifty (ccaewan) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + $string['pluginname'] = 'Select long-term archived courses for deletion'; + // Description shown on the workflow configuration page. + + $string['plugindescription'] = + 'Selects courses that have been archived (frozen), have not been accessed for a prolonged period, ' + . 'and were created sufficiently long ago. These courses are considered end-of-life and may be ' + . 'safely removed using a delete step in the workflow.'; + + $string['inactivitydelay'] = 'Last access threshold'; + $string['inactivitydelay_help'] = + 'Only delete courses where the most recent user activity is older than this period. ' + . 'Set to 48 months by default to target courses with no access for at least 4 years.'; + + $string['creationdelay'] = 'Minimum course age'; + + $string['creationdelay_help'] = + 'The minimum age of a course based on its creation date. ' + . 'Courses created more recently than this threshold will not be selected for deletion.' + . 'Set to 60 months by default to target courses with older than at least 5 years.'; + + $string['privacy:metadata'] = 'The Course deletion trigger does not store or process personal data.'; \ No newline at end of file diff --git a/trigger/coursedelete/lib.php b/trigger/coursedelete/lib.php new file mode 100644 index 00000000..342d28a6 --- /dev/null +++ b/trigger/coursedelete/lib.php @@ -0,0 +1,154 @@ +. + + /** + * Trigger subplugin to delete frozen courses based on long inactivity + age. + * + * @package lifecycletrigger_coursedelete + * @copyright 2025 + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + namespace tool_lifecycle\trigger; + + use tool_lifecycle\local\manager\settings_manager; + use tool_lifecycle\local\response\trigger_response; + use tool_lifecycle\settings_type; + use tool_lifecycle\trigger\instance_setting; + + defined('MOODLE_INTERNAL') || die(); + require_once(__DIR__ . '/../lib.php'); + + /** + * Class which implements a trigger for deleting frozen courses. + * + * Logic (workaround while no "frozen since" timestamp exists): + * - Course context is locked (frozen): {context}.locked = 1 for course contextlevel 50 + * - Last access (enrolled users) older than inactivitydelay (default 48 months) + * - Course creation older than creationdelay (default 60 months) + */ + class coursedelete extends base_automatic { + + public function check_course($course, $triggerid) { + return trigger_response::trigger(); + } + + /** + * Instance settings for this trigger. + * + * @return instance_setting[] + */ + public function instance_settings() { + return [ + // Last access must be older than this (default 48 months). + new instance_setting('inactivitydelay', PARAM_INT), + + // Course creation must be older than this (default 60 months). + new instance_setting('creationdelay', PARAM_INT), + ]; + } + + /** + * Returns the WHERE clause and params selecting courses to be deleted. + * + * @param int $triggerid + * @return array [string $where, array $params] + * @throws \coding_exception + * @throws \dml_exception + */ + public function get_course_recordset_where($triggerid) { + // Load instance settings. + $settings = settings_manager::get_settings($triggerid, settings_type::TRIGGER); + + // Defaults (approx months as 365-day years for now): + // - 48 months ≈ 4 years + // - 60 months ≈ 5 years + $inactivitydelay = isset($settings['inactivitydelay']) ? (int)$settings['inactivitydelay'] : (4 * 365 * DAYSECS); + $creationdelay = isset($settings['creationdelay']) ? (int)$settings['creationdelay'] : (5 * 365 * DAYSECS); + + $now = time(); + $lastaccessthreshold = $now - $inactivitydelay; + $creationthreshold = $now - $creationdelay; + + // Frozen courses: course context locked + // Inactive courses: most recent recorded course access is older than threshold + // Old courses: timecreated older than threshold + $where = "c.timecreated < :creationthreshold + AND EXISTS ( + SELECT 1 + FROM {context} ctx + WHERE ctx.contextlevel = 50 + AND ctx.instanceid = c.id + AND ctx.locked = 1 + ) + AND c.id IN ( + SELECT la.courseid + FROM {user_lastaccess} la + GROUP BY la.courseid + HAVING MAX(la.timeaccess) < :lastaccessthreshold + )"; + + + $params = [ + 'creationthreshold' => $creationthreshold, + 'lastaccessthreshold' => $lastaccessthreshold, + ]; + + return [$where, $params]; + } + + /** + * Add instance settings elements to the add-instance form. + * + * @param \MoodleQuickForm $mform + * @return void + * @throws \coding_exception + */ + public function extend_add_instance_form_definition($mform) { + + $elementname = 'inactivitydelay'; + $mform->addElement('duration', $elementname, get_string($elementname, 'lifecycletrigger_coursedelete')); + $mform->addHelpButton($elementname, $elementname, 'lifecycletrigger_coursedelete'); + $mform->setDefault($elementname, 4 * 365 * DAYSECS); // ~48 months. + + $elementname = 'creationdelay'; + $mform->addElement('duration', $elementname, get_string($elementname, 'lifecycletrigger_coursedelete')); + $mform->addHelpButton($elementname, $elementname, 'lifecycletrigger_coursedelete'); + $mform->setDefault($elementname, 5 * 365 * DAYSECS); // ~60 months. + } + + /** + * After data is loaded, set defaults from existing settings if present. + * + * @param \MoodleQuickForm $mform + * @param array $settings + * @return void + */ + public function extend_add_instance_form_definition_after_data($mform, $settings) { + if (!is_array($settings)) { + return; + } + if (array_key_exists('inactivitydelay', $settings)) { + $mform->setDefault('inactivitydelay', $settings['inactivitydelay']); + } + if (array_key_exists('creationdelay', $settings)) { + $mform->setDefault('creationdelay', $settings['creationdelay']); + } + } + + public function get_subpluginname() { + return 'coursedelete'; + } + } diff --git a/trigger/coursedelete/version.php b/trigger/coursedelete/version.php new file mode 100644 index 00000000..a25e801e --- /dev/null +++ b/trigger/coursedelete/version.php @@ -0,0 +1,32 @@ +. + +/** + * Version info for lifecycle deletion trigger. + * + * @package lifecycletrigger_coursedelete + * @copyright 2025 Gifty Wanzola (ccaewan) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$plugin->version = 2025121802; +$plugin->component = 'lifecycletrigger_coursedelete'; +$plugin->maturity = MATURITY_STABLE; +$plugin->requires = 2022112800; // Requires Moodle 4.1+. +$plugin->supported = [405, 500]; +$plugin->release = 'v5.0-r1'; diff --git a/trigger/coursefreeze/classes/privacy/provider.php b/trigger/coursefreeze/classes/privacy/provider.php new file mode 100644 index 00000000..5c90fd63 --- /dev/null +++ b/trigger/coursefreeze/classes/privacy/provider.php @@ -0,0 +1,39 @@ +. + +namespace lifecycletrigger_lastaccess\privacy; + +use core_privacy\local\metadata\null_provider; + +/** + * Privacy subsystem implementation for lifecycletrigger_lastaccess. + * + * @package lifecycletrigger_lastaccess + * @copyright 2023 Justus Dieckmann WWU Münster + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class provider implements null_provider { + + /** + * Get the language string identifier with the component's language + * file to explain why this plugin stores no data. + * + * @return string + */ + public static function get_reason(): string { + return 'privacy:metadata'; + } +} diff --git a/trigger/coursefreeze/lang/en/lifecycletrigger_coursefreeze.php b/trigger/coursefreeze/lang/en/lifecycletrigger_coursefreeze.php new file mode 100644 index 00000000..6593f028 --- /dev/null +++ b/trigger/coursefreeze/lang/en/lifecycletrigger_coursefreeze.php @@ -0,0 +1,57 @@ +. + +/** + * Lang strings for course freeze trigger + * + * @package lifecycletrigger_coursefreeze + * @copyright 2025 Gifty (ccaewan) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +// Short name shown in the trigger dropdown +$string['pluginname'] = 'Select courses to archive'; + + +// Description shown on the workflow config page + +$string['plugindescription'] = + 'Selects courses for archiving based on how long ago they were last accessed ' + . 'and when they were created. Only courses that are older than both thresholds ' + . 'will be passed to the next step.'; + + +// Last access threshold setting + +$string['lastaccessdelay'] = 'Time since last access'; +$string['lastaccessdelay_help'] = + 'Only include courses where the most recent user activity is older than this period. ' + . 'For example, set this to 12 months to target courses with no access for at least 1 year.'; + + +// Creation date setting + +$string['creationdelay'] = 'Course age'; +$string['creationdelay_help'] = + 'Only include courses that were created earlier than this period. ' + . 'For example, set this to 24 months so that only courses at least 2 years old are selected.'; + + +// Privacy + +$string['privacy:metadata'] = + 'The Course freeze trigger does not store any personal data.'; \ No newline at end of file diff --git a/trigger/coursefreeze/lib.php b/trigger/coursefreeze/lib.php new file mode 100644 index 00000000..5ee53c96 --- /dev/null +++ b/trigger/coursefreeze/lib.php @@ -0,0 +1,185 @@ +. + +/** + * Interface for the subplugintype trigger + * It has to be implemented by all subplugins. + * + * @package lifecycletrigger_coursefreeze + * @copyright 2025 Gifty Wanzola (ccaewan) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace tool_lifecycle\trigger; + +use tool_lifecycle\local\manager\settings_manager; +use tool_lifecycle\local\response\trigger_response; +use tool_lifecycle\settings_type; +use tool_lifecycle\trigger\instance_setting; + + +defined('MOODLE_INTERNAL') || die(); +require_once(__DIR__ . '/../lib.php'); + +/** + * Class which implements the trigger for freezing courses. + * + * @package lifecycletrigger_coursefreeze + */ +class coursefreeze extends base_automatic { + /** + * If check_course_code() returns true, code to check the given course is placed here + * @param \stdClass $course + * @param int $triggerid + * @return trigger_response + */ + public function check_course($course, $triggerid) { + // Every decision is already in the where statement. + return trigger_response::trigger(); + } + +/** + * Instance settings for this trigger. + * + * @return instance_setting[] + */ +public function instance_settings() { + return [ + // Last access must be older than this (12 months) + new instance_setting('lastaccessdelay', PARAM_INT), + + // Course creation must be older than this (24 months) + new instance_setting('creationdelay', PARAM_INT), + ]; +} + + /** + * Returns the where statement for all courses that should be triggered, + * meaning timestamp of the course freeze / interaction with this course is older than delay + * (only counting interactions of users who are enrolled in the course) + * + * @param int $triggerid id of the trigger instance. + * @return string[] + * @throws \coding_exception + * @throws \dml_exception + */ + public function get_course_recordset_where($triggerid) { + global $DB; + + // Get trigger settings. + $settings = settings_manager::get_settings($triggerid, settings_type::TRIGGER); + + // Fallback defaults if not set. + $lastaccessdelay = isset($settings['lastaccessdelay']) ? $settings['lastaccessdelay'] : DAYSECS * 365; // 12 months. + $creationdelay = isset($settings['creationdelay']) ? $settings['creationdelay'] : DAYSECS * 365 * 2; // 24 months. + + $now = time(); + $lastaccessthreshold = $now - $lastaccessdelay; + $creationthreshold = $now - $creationdelay; + + // Only courses that: + // - have last access older than lastaccessthreshold + // or have never been accessed + // - AND were created before creationthreshold + // not including the front page course + $where = 'c.id <> 1 + AND c.timecreated < :creationthreshold + AND EXISTS ( + SELECT 1 + FROM {context} ctx + WHERE ctx.contextlevel = 50 + AND ctx.instanceid = c.id + AND ctx.locked = 0 + ) + AND ( + NOT EXISTS ( + SELECT 1 + FROM {user_lastaccess} la + WHERE la.courseid = c.id + ) + OR + EXISTS ( + SELECT 1 + FROM {user_lastaccess} la + WHERE la.courseid = c.id + GROUP BY la.courseid + HAVING MAX(la.timeaccess) < :lastaccessthreshold + ) + )'; + + $params = [ + 'creationthreshold' => $creationthreshold, + 'lastaccessthreshold' => $lastaccessthreshold, + ]; + + return [$where, $params]; + } + + + /** + * Add elements to add instance form + * + * @param \MoodleQuickForm $mform + * @return void + * @throws \coding_exception + */ + public function extend_add_instance_form_definition($mform) { + + // Check last access + $elementname = 'lastaccessdelay'; + $mform->addElement('duration', $elementname, + get_string($elementname, 'lifecycletrigger_coursefreeze')); + $mform->addHelpButton($elementname, $elementname, 'lifecycletrigger_coursefreeze'); + $mform->setDefault($elementname, DAYSECS * 365); // default = 12 months + + + // Check course creation age. + $elementname = 'creationdelay'; + $mform->addElement('duration', $elementname, + get_string($elementname, 'lifecycletrigger_coursefreeze')); + $mform->addHelpButton($elementname, $elementname, 'lifecycletrigger_coursefreeze'); + $mform->setDefault($elementname, DAYSECS * 365 * 2); // default = 24 months + } + + + /** + * Extend add instance form + * + * @param \MoodleQuickForm $mform + * @param array $settings + * @return void + */ + public function extend_add_instance_form_definition_after_data($mform, $settings) { + if (is_array($settings)) { + if (array_key_exists('lastaccessdelay', $settings)) { + $mform->setDefault('lastaccessdelay', $settings['lastaccessdelay']); + } + if (array_key_exists('creationdelay', $settings)) { + $mform->setDefault('creationdelay', $settings['creationdelay']); + } + } + } + + /** + * Return subplugin name + * + * @return string + */ + public function get_subpluginname() { + return 'coursefreeze'; + } + +} diff --git a/trigger/coursefreeze/version.php b/trigger/coursefreeze/version.php new file mode 100644 index 00000000..d81539d6 --- /dev/null +++ b/trigger/coursefreeze/version.php @@ -0,0 +1,32 @@ +. + +/** + * Life Cycle course freeze Trigger + * + * @package lifecycletrigger_coursefreeze + * @copyright 2025 Gifty (ccaewan) + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die; + +$plugin->component = 'lifecycletrigger_coursefreeze'; +$plugin->maturity = MATURITY_STABLE; +$plugin->version = 2025103000; +$plugin->requires = 2022112800; // Requires Moodle 4.1+. +$plugin->supported = [405, 500]; +$plugin->release = 'v5.0-r1'; From 941d3207b29275c1d9e437b9d4adbb6eaddc5087 Mon Sep 17 00:00:00 2001 From: Gifty Wanzola Date: Wed, 1 Apr 2026 17:37:17 +0100 Subject: [PATCH 2/9] Comment out dependencies in version.php Comment out plugin dependencies for future reference. --- trigger/customfieldsemester/version.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/trigger/customfieldsemester/version.php b/trigger/customfieldsemester/version.php index ba4c87d6..8dd3cff6 100644 --- a/trigger/customfieldsemester/version.php +++ b/trigger/customfieldsemester/version.php @@ -32,5 +32,5 @@ $plugin->supported = [405, 501]; $plugin->release = 'v5.1-r5'; -$plugin->dependencies = ['tool_lifecycle' => 2025050403, - 'customfield_semester' => 2025043001, ]; +// $plugin->dependencies = ['tool_lifecycle' => 2025050403, + // 'customfield_semester' => 2025043001, ]; From c917b13717f24382cc8d347e670fcfa1d45b158e Mon Sep 17 00:00:00 2001 From: Gifty Wanzola Date: Mon, 13 Apr 2026 15:28:58 +0100 Subject: [PATCH 3/9] Improve context handling for deleted courses Updated context retrieval to handle missing courses gracefully. --- classes/local/entity/process.php | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/classes/local/entity/process.php b/classes/local/entity/process.php index bdda89ac..3cc66a31 100644 --- a/classes/local/entity/process.php +++ b/classes/local/entity/process.php @@ -114,10 +114,23 @@ public static function from_record($record, $coursedeleted = false) { } if (!$coursedeleted) { - $context = \context_course::instance($record->courseid); + /* Use IGNORE_MISSING so that if a course has been deleted outside of lifecycle + (e.g. manually or by a previous failed cron run) this never throws a + dml_missing_record_exception. Callers that need a valid context should check + that $process->context is not empty before proceeding */ + $context = \context_course::instance($record->courseid, IGNORE_MISSING); + if ($context === false) { + debugging( + 'tool_lifecycle process::from_record: course ' . $record->courseid . + ' has no context — treating as deleted.', + DEBUG_DEVELOPER + ); + $context = ''; + } } else { - $context = ""; + $context = ''; } + $instance = new self($record->id, $record->workflowid, $record->courseid, From 6ee5951e0edddbdf2cfb4ec9e5fd8e4ea646ee4f Mon Sep 17 00:00:00 2001 From: Gifty Wanzola Date: Mon, 13 Apr 2026 15:31:42 +0100 Subject: [PATCH 4/9] Improve documentation for get_processes_by_workflow Updated documentation for get_processes_by_workflow method to clarify orphaned process handling and fixed typos. --- classes/local/manager/process_manager.php | 25 +++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/classes/local/manager/process_manager.php b/classes/local/manager/process_manager.php index 21e3692d..2cc37004 100644 --- a/classes/local/manager/process_manager.php +++ b/classes/local/manager/process_manager.php @@ -176,9 +176,13 @@ public static function count_process_errors_by_workflow($workflowid) { } /** - * Returns all processes for given workflow id + * Returns all processes for given workflow id. + * Orphaned process records (where the course no longer exists) are detected here + * and moved to the proc_error table rather than being returned, preventing a fatal + * dml_missing_record_exception when lifecycle later tries to load the course context + * (e.g. during abortprocesses()). * @param int $workflowid id of the workflow - * @return array of proccesses initiated by specifed workflow id + * @return array of processes initiated by specified workflow id * @throws \dml_exception */ public static function get_processes_by_workflow($workflowid) { @@ -186,6 +190,23 @@ public static function get_processes_by_workflow($workflowid) { $records = $DB->get_records('tool_lifecycle_process', ['workflowid' => $workflowid]); $processes = []; foreach ($records as $record) { + /* + Detect orphaned process records pointing to courses that no longer exist + and route them to the error table, consistent with how get_processes() handles + the same situation. Without this guard,= abortprocesses() could fatally crash when + process::from_record() calls context_course::instance() on a deleted course + */ + if (!$DB->record_exists('course', ['id' => $record->courseid])) { + debugging( + 'tool_lifecycle get_processes_by_workflow: course ' . $record->courseid . + ' no longer exists — moving process ' . $record->id . ' to error table.', + DEBUG_DEVELOPER + ); + $process = process::from_record($record, true); + $e = new \Exception(get_string('process_withnotexistingcourse', 'tool_lifecycle')); + self::insert_process_error($process, $e); + continue; + } $processes[] = process::from_record($record); } return $processes; From ed2fec112f70307585462befd68038297e5ddc7f Mon Sep 17 00:00:00 2001 From: Gifty Wanzola Date: Mon, 13 Apr 2026 16:02:29 +0100 Subject: [PATCH 5/9] Add upgrade steps for new plugin versions Added new upgrade steps for version 2026012004 and 2026012005, including cleanup of orphaned process records. --- db/upgrade.php | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/db/upgrade.php b/db/upgrade.php index eb0c54bb..10796df2 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -435,7 +435,6 @@ function xmldb_tool_lifecycle_upgrade($oldversion) { } // Lifecycle savepoint reached. - upgrade_plugin_savepoint(true, 2019082200, 'tool', 'lifecycle'); } @@ -448,7 +447,6 @@ function xmldb_tool_lifecycle_upgrade($oldversion) { set_config('duration', $duration, 'tool_lifecycle'); // Lifecycle savepoint reached. - upgrade_plugin_savepoint(true, 2019082300, 'tool', 'lifecycle'); } @@ -613,6 +611,7 @@ function xmldb_tool_lifecycle_upgrade($oldversion) { upgrade_plugin_savepoint(true, 2025050405, 'tool', 'lifecycle'); } + if ($oldversion < 2025102302) { $table = new xmldb_table('tool_lifecycle_workflow'); @@ -715,5 +714,26 @@ function xmldb_tool_lifecycle_upgrade($oldversion) { upgrade_plugin_savepoint(true, 2026012003, 'tool', 'lifecycle'); } + if ($oldversion < 2026012004) { + + // Lifecycle savepoint reached. + upgrade_plugin_savepoint(true, 2026012004, 'tool', 'lifecycle'); + } + + if ($oldversion < 2026012005) { + + // Remove orphaned process records pointing to courses that no longer exist. + // These cause a fatal dml_missing_record_exception in abortprocesses() when + // lifecycle tries to load the course context via process::from_record(). + // This mirrors the cleanup already done at upgrade 2020091800 but uses a + // direct DELETE for efficiency and to avoid calling abort_process() on records + // whose course context cannot be loaded. + $DB->execute('DELETE FROM {tool_lifecycle_process} + WHERE courseid NOT IN (SELECT id FROM {course})'); + + // Lifecycle savepoint reached. + upgrade_plugin_savepoint(true, 2026012005, 'tool', 'lifecycle'); + } + return true; } From 1b3c42fce6643f988e217583c708236216bad56b Mon Sep 17 00:00:00 2001 From: Gifty Wanzola Date: Mon, 13 Apr 2026 16:03:01 +0100 Subject: [PATCH 6/9] Update version to 2026012005 and maintain release --- version.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/version.php b/version.php index 256c9d02..94bce3d0 100644 --- a/version.php +++ b/version.php @@ -13,7 +13,6 @@ // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . - /** * Version details. * @@ -26,7 +25,7 @@ $plugin->component = 'tool_lifecycle'; $plugin->maturity = MATURITY_STABLE; -$plugin->version = 2026012004; +$plugin->version = 2026012005; $plugin->requires = 2024100700; // Requires Moodle 4.5+. $plugin->supported = [405, 501]; $plugin->release = 'v5.1-r5'; From b78427f4430802c53a0906d8b6150392191b56c1 Mon Sep 17 00:00:00 2001 From: Gifty Wanzola Date: Mon, 13 Apr 2026 16:39:31 +0100 Subject: [PATCH 7/9] improve error handling for deleted courses fix to prevent error message when proceeding or rolling back deleted course --- errors.php | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/errors.php b/errors.php index ec1b6420..fb0d5e04 100644 --- a/errors.php +++ b/errors.php @@ -50,8 +50,13 @@ if ($action == 'proceed') { foreach ($ids as $id) { if ($courseid = $DB->get_field('tool_lifecycle_proc_error', 'courseid', ['id' => $id])) { - $course = get_course($courseid); - $coursename = get_course_display_name_for_list($course); + // Course may have been deleted — guard against get_course() throwing. + try { + $course = get_course($courseid); + $coursename = get_course_display_name_for_list($course); + } catch (\dml_missing_record_exception $e) { + $coursename = get_string('coursenotfound', 'tool_lifecycle') . ' (ID: ' . $courseid . ')'; + } } else { $coursename = get_string('coursenotfound', 'tool_lifecycle'); } @@ -67,8 +72,13 @@ } else if ($action == 'rollback') { foreach ($ids as $id) { if ($courseid = $DB->get_field('tool_lifecycle_proc_error', 'courseid', ['id' => $id])) { - $course = get_course($courseid); - $coursename = get_course_display_name_for_list($course); + // Course may have been deleted — guard against get_course() throwing. + try { + $course = get_course($courseid); + $coursename = get_course_display_name_for_list($course); + } catch (\dml_missing_record_exception $e) { + $coursename = get_string('coursenotfound', 'tool_lifecycle') . ' (ID: ' . $courseid . ')'; + } } else { $coursename = get_string('coursenotfound', 'tool_lifecycle'); } From c89755cddf6e3e9e2b4bca93ffdfd125f9a0c9a7 Mon Sep 17 00:00:00 2001 From: Gifty Wanzola Date: Mon, 13 Apr 2026 18:27:37 +0100 Subject: [PATCH 8/9] Fix to prevent course exclusions --- trigger/coursedelete/lib.php | 317 ++++++++++++++++++----------------- 1 file changed, 164 insertions(+), 153 deletions(-) diff --git a/trigger/coursedelete/lib.php b/trigger/coursedelete/lib.php index 342d28a6..d0480405 100644 --- a/trigger/coursedelete/lib.php +++ b/trigger/coursedelete/lib.php @@ -1,154 +1,165 @@ . - - /** - * Trigger subplugin to delete frozen courses based on long inactivity + age. - * - * @package lifecycletrigger_coursedelete - * @copyright 2025 - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - namespace tool_lifecycle\trigger; - - use tool_lifecycle\local\manager\settings_manager; - use tool_lifecycle\local\response\trigger_response; - use tool_lifecycle\settings_type; - use tool_lifecycle\trigger\instance_setting; - - defined('MOODLE_INTERNAL') || die(); - require_once(__DIR__ . '/../lib.php'); - - /** - * Class which implements a trigger for deleting frozen courses. - * - * Logic (workaround while no "frozen since" timestamp exists): - * - Course context is locked (frozen): {context}.locked = 1 for course contextlevel 50 - * - Last access (enrolled users) older than inactivitydelay (default 48 months) - * - Course creation older than creationdelay (default 60 months) - */ - class coursedelete extends base_automatic { - - public function check_course($course, $triggerid) { - return trigger_response::trigger(); - } - - /** - * Instance settings for this trigger. - * - * @return instance_setting[] - */ - public function instance_settings() { - return [ - // Last access must be older than this (default 48 months). - new instance_setting('inactivitydelay', PARAM_INT), - - // Course creation must be older than this (default 60 months). - new instance_setting('creationdelay', PARAM_INT), - ]; - } - - /** - * Returns the WHERE clause and params selecting courses to be deleted. - * - * @param int $triggerid - * @return array [string $where, array $params] - * @throws \coding_exception - * @throws \dml_exception - */ - public function get_course_recordset_where($triggerid) { - // Load instance settings. - $settings = settings_manager::get_settings($triggerid, settings_type::TRIGGER); - - // Defaults (approx months as 365-day years for now): - // - 48 months ≈ 4 years - // - 60 months ≈ 5 years - $inactivitydelay = isset($settings['inactivitydelay']) ? (int)$settings['inactivitydelay'] : (4 * 365 * DAYSECS); - $creationdelay = isset($settings['creationdelay']) ? (int)$settings['creationdelay'] : (5 * 365 * DAYSECS); - - $now = time(); - $lastaccessthreshold = $now - $inactivitydelay; - $creationthreshold = $now - $creationdelay; - - // Frozen courses: course context locked - // Inactive courses: most recent recorded course access is older than threshold - // Old courses: timecreated older than threshold - $where = "c.timecreated < :creationthreshold - AND EXISTS ( - SELECT 1 - FROM {context} ctx - WHERE ctx.contextlevel = 50 - AND ctx.instanceid = c.id - AND ctx.locked = 1 - ) - AND c.id IN ( - SELECT la.courseid - FROM {user_lastaccess} la - GROUP BY la.courseid - HAVING MAX(la.timeaccess) < :lastaccessthreshold - )"; - - - $params = [ - 'creationthreshold' => $creationthreshold, - 'lastaccessthreshold' => $lastaccessthreshold, - ]; - - return [$where, $params]; - } - - /** - * Add instance settings elements to the add-instance form. - * - * @param \MoodleQuickForm $mform - * @return void - * @throws \coding_exception - */ - public function extend_add_instance_form_definition($mform) { - - $elementname = 'inactivitydelay'; - $mform->addElement('duration', $elementname, get_string($elementname, 'lifecycletrigger_coursedelete')); - $mform->addHelpButton($elementname, $elementname, 'lifecycletrigger_coursedelete'); - $mform->setDefault($elementname, 4 * 365 * DAYSECS); // ~48 months. - - $elementname = 'creationdelay'; - $mform->addElement('duration', $elementname, get_string($elementname, 'lifecycletrigger_coursedelete')); - $mform->addHelpButton($elementname, $elementname, 'lifecycletrigger_coursedelete'); - $mform->setDefault($elementname, 5 * 365 * DAYSECS); // ~60 months. - } - - /** - * After data is loaded, set defaults from existing settings if present. - * - * @param \MoodleQuickForm $mform - * @param array $settings - * @return void - */ - public function extend_add_instance_form_definition_after_data($mform, $settings) { - if (!is_array($settings)) { - return; - } - if (array_key_exists('inactivitydelay', $settings)) { - $mform->setDefault('inactivitydelay', $settings['inactivitydelay']); - } - if (array_key_exists('creationdelay', $settings)) { - $mform->setDefault('creationdelay', $settings['creationdelay']); - } - } - - public function get_subpluginname() { - return 'coursedelete'; - } - } +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/** + * Trigger subplugin to delete frozen courses based on long inactivity + age. + * + * @package lifecycletrigger_coursedelete + * @copyright 2025 + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace tool_lifecycle\trigger; + +use tool_lifecycle\local\manager\settings_manager; +use tool_lifecycle\local\response\trigger_response; +use tool_lifecycle\settings_type; +use tool_lifecycle\trigger\instance_setting; + +defined('MOODLE_INTERNAL') || die(); +require_once(__DIR__ . '/../lib.php'); + +/** + * Class which implements a trigger for deleting frozen courses. + * + * Logic: + * - Course context is locked (frozen): {context}.locked = 1 for course contextlevel 50 + * - Last access (enrolled users) older than inactivitydelay (default 48 months), + * OR course has never been accessed at all (no rows in user_lastaccess) + * - Course creation older than creationdelay (default 60 months) + */ +class coursedelete extends base_automatic { + + public function check_course($course, $triggerid) { + return trigger_response::trigger(); + } + + /** + * Instance settings for this trigger. + * + * @return instance_setting[] + */ + public function instance_settings() { + return [ + // Last access must be older than this (default 48 months). + new instance_setting('inactivitydelay', PARAM_INT), + + // Course creation must be older than this (default 60 months). + new instance_setting('creationdelay', PARAM_INT), + ]; + } + + /** + * Returns the WHERE clause and params selecting courses to be deleted. + * + * Matches frozen courses that are either: + * - Old enough AND have never been accessed (no user_lastaccess rows), OR + * - Old enough AND last access is older than inactivitydelay + * + * This mirrors the same NOT EXISTS / OR EXISTS pattern used in the + * coursefreeze trigger so that courses with zero access records are + * correctly included rather than silently excluded. + * + * @param int $triggerid + * @return array [string $where, array $params] + * @throws \coding_exception + * @throws \dml_exception + */ + public function get_course_recordset_where($triggerid) { + $settings = settings_manager::get_settings($triggerid, settings_type::TRIGGER); + + $inactivitydelay = isset($settings['inactivitydelay']) ? (int)$settings['inactivitydelay'] : (4 * 365 * DAYSECS); + $creationdelay = isset($settings['creationdelay']) ? (int)$settings['creationdelay'] : (5 * 365 * DAYSECS); + + $now = time(); + $lastaccessthreshold = $now - $inactivitydelay; + $creationthreshold = $now - $creationdelay; + // Include NOT EXISTS / OR EXISTS to prevent silently excluded courses (enroled users without any LA records) + $where = "c.id <> 1 + AND c.timecreated < :creationthreshold + AND EXISTS ( + SELECT 1 + FROM {context} ctx + WHERE ctx.contextlevel = 50 + AND ctx.instanceid = c.id + AND ctx.locked = 1 + ) + AND ( + NOT EXISTS ( + SELECT 1 + FROM {user_lastaccess} la + WHERE la.courseid = c.id + ) + OR + EXISTS ( + SELECT 1 + FROM {user_lastaccess} la + WHERE la.courseid = c.id + GROUP BY la.courseid + HAVING MAX(la.timeaccess) < :lastaccessthreshold + ) + )"; + + $params = [ + 'creationthreshold' => $creationthreshold, + 'lastaccessthreshold' => $lastaccessthreshold, + ]; + + return [$where, $params]; + } + + /** + * Add instance settings elements to the add-instance form. + * + * @param \MoodleQuickForm $mform + * @return void + * @throws \coding_exception + */ + public function extend_add_instance_form_definition($mform) { + + $elementname = 'inactivitydelay'; + $mform->addElement('duration', $elementname, get_string($elementname, 'lifecycletrigger_coursedelete')); + $mform->addHelpButton($elementname, $elementname, 'lifecycletrigger_coursedelete'); + $mform->setDefault($elementname, 4 * 365 * DAYSECS); // ~48 months. + + $elementname = 'creationdelay'; + $mform->addElement('duration', $elementname, get_string($elementname, 'lifecycletrigger_coursedelete')); + $mform->addHelpButton($elementname, $elementname, 'lifecycletrigger_coursedelete'); + $mform->setDefault($elementname, 5 * 365 * DAYSECS); // ~60 months. + } + + /** + * After data is loaded, set defaults from existing settings if present. + * + * @param \MoodleQuickForm $mform + * @param array $settings + * @return void + */ + public function extend_add_instance_form_definition_after_data($mform, $settings) { + if (!is_array($settings)) { + return; + } + if (array_key_exists('inactivitydelay', $settings)) { + $mform->setDefault('inactivitydelay', $settings['inactivitydelay']); + } + if (array_key_exists('creationdelay', $settings)) { + $mform->setDefault('creationdelay', $settings['creationdelay']); + } + } + + public function get_subpluginname() { + return 'coursedelete'; + } +} From 0e73ca4679b0ecf9350e16a402c9123f28224a5c Mon Sep 17 00:00:00 2001 From: Gifty Wanzola Date: Tue, 14 Apr 2026 16:59:30 +0100 Subject: [PATCH 9/9] Uncomment plugin dependencies for version.php Initially commented out to prevent error during instance upgrade --- trigger/customfieldsemester/version.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/trigger/customfieldsemester/version.php b/trigger/customfieldsemester/version.php index 8dd3cff6..ba4c87d6 100644 --- a/trigger/customfieldsemester/version.php +++ b/trigger/customfieldsemester/version.php @@ -32,5 +32,5 @@ $plugin->supported = [405, 501]; $plugin->release = 'v5.1-r5'; -// $plugin->dependencies = ['tool_lifecycle' => 2025050403, - // 'customfield_semester' => 2025043001, ]; +$plugin->dependencies = ['tool_lifecycle' => 2025050403, + 'customfield_semester' => 2025043001, ];