Skip to content
17 changes: 15 additions & 2 deletions classes/local/entity/process.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
25 changes: 23 additions & 2 deletions classes/local/manager/process_manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -176,16 +176,37 @@ 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) {
global $DB;
$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;
Expand Down
24 changes: 22 additions & 2 deletions db/upgrade.php
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,6 @@ function xmldb_tool_lifecycle_upgrade($oldversion) {
}

// Lifecycle savepoint reached.

upgrade_plugin_savepoint(true, 2019082200, 'tool', 'lifecycle');
}

Expand All @@ -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');
}

Expand Down Expand Up @@ -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');

Expand Down Expand Up @@ -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;
}
18 changes: 14 additions & 4 deletions errors.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand All @@ -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');
}
Expand Down
29 changes: 29 additions & 0 deletions step/uclcontextfreeze/lang/en/lifecyclestep_uclcontextfreeze.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php
// 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 <http://www.gnu.org/licenses/>.

/**
* 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.';

83 changes: 83 additions & 0 deletions step/uclcontextfreeze/lib.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php
// 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 <http://www.gnu.org/licenses/>.

/**
* 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';
}
}
14 changes: 14 additions & 0 deletions step/uclcontextfreeze/version.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php
defined('MOODLE_INTERNAL') || die();

$plugin->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,
];

39 changes: 39 additions & 0 deletions trigger/coursedelete/classes/privacy/provider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php
// 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 <http://www.gnu.org/licenses/>.

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';
}
}
45 changes: 45 additions & 0 deletions trigger/coursedelete/lang/en/lifecycletrigger_coursedelete.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php
// 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 <http://www.gnu.org/licenses/>.

/**
* 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.';
Loading