diff --git a/README.md b/README.md index 099984b..0c79570 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ This has been tested to cover the following course_delete_module adhoc task fail - course_module table record absent. - module's table (e.g. quiz) record absent. - context table record absent. +- **course module flagged with `deletioninprogress=1` but no matching adhoc task in the queue ("orphaned" course module).** ## Check ## - If the task(s) contains multiple course modules, it will recommend @@ -18,6 +19,13 @@ This has been tested to cover the following course_delete_module adhoc task fail course modules. For example, the course module record or the assign table record might be missing. See "Resolve" for information about how the plugin can resolve these. +- If there are **orphaned course modules** — rows in `course_modules` flagged + with `deletioninprogress=1` but with no matching `course_delete_modules` + adhoc task in the queue — the plugin will list them in a separate "Orphaned + course modules" section. This can happen when a worker process is killed + mid-execute (for example by a worker time-out, OOM, or container restart) + before the framework can mark the task as failed, leaving the cm flagged + but no longer queued. ## Resolve ## - Separate "clustered" adhoc tasks: @@ -29,6 +37,12 @@ This has been tested to cover the following course_delete_module adhoc task fail If a task is incomplete, the plugin can delete the remnant data records and clear off the course_delete_adhoc task. Although this process will NOT backup the module to the recycle bin. +- Re-queue orphaned course modules: + For each orphaned cm (and the bulk action for all of them), the plugin + re-queues a fresh `course_delete_modules` adhoc task via Moodle's standard + `course_delete_module($cmid, true)` helper. This is the same code path the + activity Delete button uses, so all normal deletion hooks, observers and + events fire as expected. The next cron run completes the deletion as normal. ## GUI and CLI ## There is a GUI side and a CLI to the plugin. @@ -44,6 +58,14 @@ Instructions of how to use the CLI can be found in the help page: $ php admin/tool/fix_delete_modules/cli/fix_course_delete_modules.php --help +The CLI also supports the orphan-cm scan via the `--requeue-orphans` flag: + + $ # List orphaned course modules without making changes: + $ php admin/tool/fix_delete_modules/cli/fix_course_delete_modules.php --requeue-orphans + + $ # Re-queue them via course_delete_module() so cron will complete the deletion: + $ php admin/tool/fix_delete_modules/cli/fix_course_delete_modules.php --requeue-orphans --fix + ## Branches ## | Moodle version | Branch | diff --git a/classes/check/orphan_modules.php b/classes/check/orphan_modules.php new file mode 100644 index 0000000..027f6d6 --- /dev/null +++ b/classes/check/orphan_modules.php @@ -0,0 +1,112 @@ +. + +/** + * Check API entry for orphaned course modules. + * + * @package tool_fix_delete_modules + * @category check + * @copyright 2026 Catalyst IT + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace tool_fix_delete_modules\check; + +use core\check\check; +use core\check\result; + +/** + * Status check: reports orphaned course modules. + * + * An "orphaned" course module is a row in course_modules with deletioninprogress=1 + * that has no matching \core_course\task\course_delete_modules adhoc task queued — + * typically left behind when a worker is killed mid-execute (time-out, OOM, container + * restart) before the framework can mark the task as failed. With no task left in the + * queue, cron will never finish the deletion and the activity stays "deletion in + * progress" on its course page indefinitely. + * + * Surfacing this on /report/status/index.php (core) and on the croncheck page + * provided by tool_heartbeat means external monitoring picks it up automatically. + * + * @package tool_fix_delete_modules + * @category check + * @copyright 2026 Catalyst IT + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class orphan_modules extends check { + /** + * Localised check name. + * + * @return string + */ + public function get_name(): string { + return get_string('check_orphan_modules_name', 'tool_fix_delete_modules'); + } + + /** + * Action link — drops the admin straight at the orphaned-modules section + * of the plugin's index page, where the re-queue buttons live. + * + * @return \action_link|null + */ + public function get_action_link(): ?\action_link { + $url = new \moodle_url('/admin/tool/fix_delete_modules/index.php', null, 'orphan-modules'); + return new \action_link($url, get_string('pluginname', 'tool_fix_delete_modules')); + } + + /** + * Run the check. + * + * Uses the same orphan_module_list scan the GUI/CLI flows use, so the result + * is always consistent with what the plugin's report page shows. + * + * @return result + */ + public function get_result(): result { + global $DB; + + $list = new \tool_fix_delete_modules\orphan_module_list(); + $orphans = $list->get_orphan_modules(); + $count = count($orphans); + + if ($count === 0) { + return new result( + result::OK, + get_string('check_orphan_modules_ok', 'tool_fix_delete_modules') + ); + } + + // The list is bounded (worst observed in production: 43) and the underlying + // scan is the same one the report page runs, so we don't need a separate + // lazy result class — just build the bullet list inline. + $rows = []; + foreach ($orphans as $cm) { + $modname = $DB->get_field('modules', 'name', ['id' => $cm->module]) ?: ('module#' . $cm->module); + $rows[] = \html_writer::tag( + 'li', + "cmid {$cm->id} — course {$cm->course} — {$modname} (instance {$cm->instance})" + ); + } + $details = \html_writer::tag('p', get_string('check_orphan_modules_details_intro', 'tool_fix_delete_modules')) + . \html_writer::tag('ul', implode('', $rows)); + + return new result( + result::WARNING, + get_string('check_orphan_modules_warning', 'tool_fix_delete_modules', $count), + $details + ); + } +} diff --git a/classes/orphan_module_list.php b/classes/orphan_module_list.php new file mode 100644 index 0000000..4abc168 --- /dev/null +++ b/classes/orphan_module_list.php @@ -0,0 +1,117 @@ +. + +/** + * Class to enumerate orphaned course modules: rows in course_modules with + * deletioninprogress = 1 that have no matching \core_course\task\course_delete_modules + * adhoc task queued. + * + * @package tool_fix_delete_modules + * @category admin + * @copyright 2026 Catalyst IT + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace tool_fix_delete_modules; + +/** + * List of orphaned course modules — flagged for deletion but no queued task. + * + * @package tool_fix_delete_modules + * @category admin + * @copyright 2026 Catalyst IT + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class orphan_module_list { + /** @var \stdClass[] $orphanmodules course_modules rows that are stuck without a queued task, keyed by cmid. */ + private $orphanmodules; + + /** + * Constructor — populates the orphan list immediately. + */ + public function __construct() { + $this->set_orphan_modules(); + } + + /** + * Get the array of orphaned course module records, keyed by cmid. + * + * @return \stdClass[] + */ + public function get_orphan_modules() { + return $this->orphanmodules; + } + + /** + * Get just the cmids of orphaned modules. + * + * @return int[] + */ + public function get_cmids() { + return array_keys($this->orphanmodules); + } + + /** + * Whether any orphaned modules exist. + * + * @return bool + */ + public function has_orphans() { + return !empty($this->orphanmodules); + } + + /** + * Build the list: + * 1. Find every cm with deletioninprogress=1. + * 2. Walk every queued course_delete_modules adhoc task and collect referenced cmids. + * 3. Stuck cms not referenced by any task = orphans. + * + * Avoids JSON_EXTRACT for cross-DB portability (MySQL/PostgreSQL). + */ + private function set_orphan_modules() { + global $DB; + + $this->orphanmodules = []; + + // Step 1: stuck cms. + $stuck = $DB->get_records('course_modules', ['deletioninprogress' => 1], 'course, id'); + if (empty($stuck)) { + return; + } + + // Step 2: cmids referenced by any queued task. + $referenced = []; + $tasks = \core\task\manager::get_adhoc_tasks('\core_course\task\course_delete_modules'); + foreach ($tasks as $task) { + $customdata = $task->get_custom_data(); + if (empty($customdata) || empty($customdata->cms)) { + continue; + } + foreach ($customdata->cms as $cm) { + if (isset($cm->id)) { + $referenced[(int)$cm->id] = true; + } + } + } + + // Step 3: subtract. + foreach ($stuck as $cm) { + if (!isset($referenced[(int)$cm->id])) { + $this->orphanmodules[(int)$cm->id] = $cm; + } + } + } +} diff --git a/classes/reporter.php b/classes/reporter.php index 51ebbba..fce5ec2 100644 --- a/classes/reporter.php +++ b/classes/reporter.php @@ -33,9 +33,11 @@ require_once("diagnoser.php"); require_once("outcome.php"); require_once("surgeon.php"); +require_once("orphan_module_list.php"); require_once(__DIR__."/../form.php"); -use html_table, html_writer, moodle_url, separate_delete_modules_form, fix_delete_modules_form; +use html_table, html_writer, moodle_url, separate_delete_modules_form, fix_delete_modules_form, + requeue_orphan_module_form, requeue_orphan_modules_all_form; /** * controller class which liases between the user facing (GUI/CLI files) and the model classes (diagnoser/surgeon). * @@ -728,4 +730,219 @@ private function format_message(array $data, string $templatename) { return $this->ishtmloutput ? $OUTPUT->render_from_template("tool_fix_delete_modules/$templatename", $data) : $data['title'].PHP_EOL.$data['body'].PHP_EOL; } + + /** + * Build the orphan-modules report section — table of stuck cms with no queued task, + * plus per-row Re-queue buttons and a Re-queue-all bulk button. + * + * Renders as HTML when $this->ishtmloutput is true, plain text otherwise. + * + * @return string + */ + public function get_orphan_modules_report() { + global $DB, $OUTPUT; + + $orphans = (new orphan_module_list())->get_orphan_modules(); + + if (empty($orphans)) { + if ($this->ishtmloutput) { + // Match the catalyst pattern for "all good" — green text under the heading. + $heading = html_writer::tag( + 'h4', + get_string('heading_orphan_modules', 'tool_fix_delete_modules'), + ['class' => 'fix-orphans-heading'] + ); + $body = html_writer::tag( + 'p', + get_string('orphan_modules_none_found', 'tool_fix_delete_modules'), + ['class' => 'text-success'] + ); + return $heading . $body; + } + return get_string('orphan_modules_none_found', 'tool_fix_delete_modules') . PHP_EOL; + } + + // Decorate each orphan row with course shortname, module type, and activity + // name (from the module-specific table — every standard activity has a 'name' column). + $rows = []; + foreach ($orphans as $cm) { + $modname = $DB->get_field('modules', 'name', ['id' => $cm->module]); + $course = $DB->get_field('course', 'shortname', ['id' => $cm->course]); + $activity = '-'; + if ($modname) { + try { + $activity = $DB->get_field($modname, 'name', ['id' => $cm->instance]) ?: '-'; + } catch (\dml_exception $e) { + $activity = '-'; + } + } + $rows[] = (object)[ + 'cmid' => $cm->id, + 'courseid' => $cm->course, + 'shortname' => $course ?: '-', + 'modname' => $modname ?: '-', + 'instance' => $cm->instance, + 'activity' => $activity, + 'requeueform' => $this->ishtmloutput ? $this->get_requeue_orphan_button($cm, $modname) : '', + ]; + } + + $count = count($rows); + $data = [ + 'heading' => get_string('heading_orphan_modules', 'tool_fix_delete_modules'), + 'count' => $count, + 'countlabel' => get_string('orphan_modules_count', 'tool_fix_delete_modules', $count), + 'explanationintro' => get_string('orphan_modules_explanation_intro', 'tool_fix_delete_modules'), + 'explanationaction' => get_string('orphan_modules_explanation_action', 'tool_fix_delete_modules'), + 'rows' => $rows, + 'requeueall' => $this->ishtmloutput ? $this->get_requeue_all_orphans_button() : '', + ]; + + if ($this->ishtmloutput) { + return $OUTPUT->render_from_template('tool_fix_delete_modules/orphan_modules_table', $data); + } + + // Plain-text rendering for CLI. + $out = $data['heading'] . PHP_EOL . $data['countlabel'] . PHP_EOL + . $data['explanationintro'] . PHP_EOL . PHP_EOL . $data['explanationaction'] . PHP_EOL; + $out .= sprintf( + "%-8s %-22s %-25s %-40s%s", + 'cmid', + 'course', + 'module', + 'activity', + PHP_EOL + ); + foreach ($rows as $row) { + $course = $row->courseid . ' ' . $row->shortname; + $module = $row->modname . ' #' . $row->instance; + $out .= sprintf( + "%-8d %-22s %-25s %-40s%s", + $row->cmid, + substr($course, 0, 22), + substr($module, 0, 25), + substr($row->activity, 0, 40), + PHP_EOL + ); + } + return $out; + } + + /** + * Render a single "Permanently delete orphaned module #N" button for an orphan cm. + * + * @param \stdClass $cm course_modules row. + * @param string $modname module type name (e.g. 'assign', 'quiz'). + * @return string + */ + private function get_requeue_orphan_button(\stdClass $cm, string $modname) { + $actionurl = new moodle_url('/admin/tool/fix_delete_modules/fix_module.php'); + $params = ['cmid' => $cm->id, 'cmname' => $modname]; + $form = new requeue_orphan_module_form($actionurl, $params); + return $form->render(); + } + + /** + * Render the "Permanently delete all orphaned modules" bulk button. + * + * @return string + */ + private function get_requeue_all_orphans_button() { + $actionurl = new moodle_url('/admin/tool/fix_delete_modules/fix_module.php'); + $form = new requeue_orphan_modules_all_form($actionurl, []); + return $form->render(); + } + + /** + * Re-queue a list of orphaned course modules and return rendered outcome. + * + * If $cmids is empty, re-queue every orphan currently present. + * + * For each cm: render the surgeon's per-cm messages in a fix_results block, + * then append a final aggregate summary block (matching the catalyst pattern of + * outcome_module_fix_successful / outcome_module_fix_fail at the end of the + * existing flow). + * + * @param int[] $cmids list of course_modules.id values to re-queue (empty = all orphans). + * @return string rendered outcome (HTML or plain text). + */ + public function requeue_orphan_modules(array $cmids = []) { + if (empty($cmids)) { + $cmids = (new orphan_module_list())->get_cmids(); + } + + $output = ''; + $okcount = 0; + $failcount = 0; + $noopcount = 0; + + foreach ($cmids as $cmid) { + $cmid = (int)$cmid; + $messages = surgeon::requeue_orphan_module($cmid); + + // Classify the outcome by checking which language string was emitted — + // mirrors how catalyst's existing surgeon code communicates state via + // pre-built lang strings rather than a separate status flag. + $okstring = get_string('outcome_orphan_module_requeued', 'tool_fix_delete_modules', $cmid); + $gonestring = get_string('outcome_orphan_module_already_gone', 'tool_fix_delete_modules', $cmid); + $unstuck = get_string('outcome_orphan_module_not_stuck', 'tool_fix_delete_modules', $cmid); + + if (in_array($okstring, $messages, true)) { + $okcount++; + } else if (in_array($gonestring, $messages, true) || in_array($unstuck, $messages, true)) { + $noopcount++; + } else { + $failcount++; + } + + $data = [ + 'title' => get_string('button_requeue_orphan_module', 'tool_fix_delete_modules') . " (cmid {$cmid})", + 'outcomemessages' => $messages, + ]; + $output .= $this->format_message($data, 'task_fix_results'); + } + + // Append the aggregate summary block. + $total = $okcount + $failcount + $noopcount; + if ($total > 0) { + $output .= $this->format_message([ + 'title' => get_string('orphan_modules_summary_heading', 'tool_fix_delete_modules'), + 'outcomemessages' => [$this->build_summary_message($okcount, $failcount, $noopcount, $total)], + ], 'task_fix_results'); + } + + return $output; + } + + /** + * Pick the right summary lang string based on the per-cm outcome counts. + * + * @param int $ok number of cms successfully re-queued. + * @param int $fail number of cms that failed. + * @param int $noop number of cms that needed no action (already gone / no longer flagged). + * @param int $total total cms processed. + * @return string + */ + private function build_summary_message(int $ok, int $fail, int $noop, int $total) { + $suffix = $total === 1 ? 'single' : 'plural'; + + if ($ok === $total) { + $key = 'outcome_orphan_modules_summary_all_ok_' . $suffix; + return get_string($key, 'tool_fix_delete_modules', $total); + } + if ($noop === $total) { + $key = 'outcome_orphan_modules_summary_all_noop_' . $suffix; + return get_string($key, 'tool_fix_delete_modules', $total); + } + if ($fail === $total) { + $key = 'outcome_orphan_modules_summary_all_fail_' . $suffix; + return get_string($key, 'tool_fix_delete_modules', $total); + } + return get_string('outcome_orphan_modules_summary_partial', 'tool_fix_delete_modules', (object)[ + 'ok' => $ok, + 'fail' => $fail, + 'noop' => $noop, + 'total' => $total, + ]); + } } diff --git a/classes/surgeon.php b/classes/surgeon.php index 3f18150..859bea9 100644 --- a/classes/surgeon.php +++ b/classes/surgeon.php @@ -485,4 +485,55 @@ private static function get_queued_adhoc_task_record($task) { } return $DB->get_record_select('task_adhoc', $sql, $params); } + + /** + * Re-queue a course_delete_modules adhoc task for a single orphaned course module — + * a cm with deletioninprogress=1 that has no matching task in the queue. + * + * Uses Moodle's own course_delete_module($cmid, true) helper, which is the same code + * path the activity Delete button uses. This means the deletion goes through every + * normal hook, observer and event the platform expects. + * + * The deletioninprogress flag is cleared first because course_delete_module() will + * set it back; running the helper against an already-flagged cm is otherwise a noop + * shape that can short-circuit on some Moodle versions. + * + * @param int $cmid course_modules.id of the orphan to re-queue. + * @return string[] outcome messages (one or more lines, language-string-formatted). + */ + public static function requeue_orphan_module(int $cmid): array { + global $CFG, $DB; + require_once($CFG->dirroot . '/course/lib.php'); + + $messages = []; + + if (!$cm = $DB->get_record('course_modules', ['id' => $cmid])) { + // The cm has already been removed — most commonly because cron drained + // the freshly-queued task between this click and the previous one (back + // button, double-click, or two tabs). Treat as a benign info outcome. + $messages[] = get_string('outcome_orphan_module_already_gone', 'tool_fix_delete_modules', $cmid); + return $messages; + } + + if (empty($cm->deletioninprogress)) { + $messages[] = get_string('outcome_orphan_module_not_stuck', 'tool_fix_delete_modules', $cmid); + return $messages; + } + + try { + // Note: course_delete_module() will set deletioninprogress=1 itself when queueing. + $DB->set_field('course_modules', 'deletioninprogress', 0, ['id' => $cmid]); + course_delete_module($cmid, true); + $messages[] = get_string('outcome_orphan_module_requeued', 'tool_fix_delete_modules', $cmid); + } catch (\Throwable $e) { + // Restore the flag so the cm is still visible to a future re-queue attempt. + $DB->set_field('course_modules', 'deletioninprogress', 1, ['id' => $cmid]); + $messages[] = get_string( + 'outcome_orphan_module_requeue_failed', + 'tool_fix_delete_modules', + ['cmid' => $cmid, 'error' => $e->getMessage()] + ); + } + return $messages; + } } diff --git a/cli/fix_course_delete_modules.php b/cli/fix_course_delete_modules.php index 75589fd..c25628e 100644 --- a/cli/fix_course_delete_modules.php +++ b/cli/fix_course_delete_modules.php @@ -35,15 +35,17 @@ // Get the cli options. list($options, $unrecognized) = cli_get_params(array( - 'fix' => false, + 'fix' => false, 'minimumfaildelay' => false, - 'taskids' => false, - 'help' => false + 'taskids' => false, + 'requeue-orphans' => false, + 'help' => false, ), array( 'f' => 'fix', 'm' => 'minimumfaildelay', 't' => 'taskids', + 'r' => 'requeue-orphans', 'h' => 'help' )); @@ -65,11 +67,17 @@ -f, --fix Fix the incomplete course_delete_module adhoc tasks. To fix tasks '--taskids' must be explicitly specified which modules. +-r, --requeue-orphans List orphaned course modules: cms with deletioninprogress=1 + but no queued course_delete_modules adhoc task. + Use with --fix to actually re-queue them via Moodle's + course_delete_module(\$cmid, true) helper. -h, --help Print out this help Example: \$sudo -u www-data /usr/bin/php admin/tool/fix_delete_modules/cli/fix_course_delete_modules.php --taskids=* \$sudo -u www-data /usr/bin/php admin/tool/fix_delete_modules/cli/fix_course_delete_modules.php --taskids=2,3,4 --fix +\$sudo -u www-data /usr/bin/php admin/tool/fix_delete_modules/cli/fix_course_delete_modules.php --requeue-orphans +\$sudo -u www-data /usr/bin/php admin/tool/fix_delete_modules/cli/fix_course_delete_modules.php --requeue-orphans --fix "; if ($unrecognized) { @@ -82,6 +90,52 @@ die(); } +// The --requeue-orphans flag short-circuits the task-driven flow: list (or re-queue) +// orphaned cms — those flagged deletioninprogress=1 with no matching task. +if ($options['requeue-orphans']) { + require_once(__DIR__ . '/../classes/orphan_module_list.php'); + require_once(__DIR__ . '/../classes/surgeon.php'); + + $orphanlist = new \tool_fix_delete_modules\orphan_module_list(); + $orphans = $orphanlist->get_orphan_modules(); + + if (empty($orphans)) { + cli_writeln(get_string('orphan_modules_none_found', 'tool_fix_delete_modules')); + die(); + } + + cli_writeln(count($orphans) . ' orphaned course module(s) found:'); + cli_writeln(sprintf('%-8s %-10s %-10s %-15s', 'cmid', 'courseid', 'instance', 'modid')); + foreach ($orphans as $cm) { + cli_writeln(sprintf('%-8d %-10d %-10d %-15d', $cm->id, $cm->course, $cm->instance, $cm->module)); + } + + if (!$options['fix']) { + cli_writeln(''); + cli_writeln('Add --fix to re-queue these via course_delete_module($cmid, true).'); + die(); + } + + cli_writeln(''); + cli_writeln('Re-queueing...'); + + // Moodle's course_delete_module() writes $USER->id into the task customdata as + // userid/realuserid. In a fresh CLI context $USER is the noreply user (id=0), + // and the adhoc task runner skips userid=0 tasks silently. Set up the main + // admin so the queued tasks have a runnable owner. + \core\cron::setup_user(get_admin()); + + foreach ($orphans as $cm) { + $messages = \tool_fix_delete_modules\surgeon::requeue_orphan_module((int)$cm->id); + foreach ($messages as $msg) { + cli_writeln(' ' . $msg); + } + } + cli_writeln('Done. Run cron (or "php admin/cli/adhoc_task.php --execute'); + cli_writeln('--classname=\'\\\\core_course\\\\task\\\\course_delete_modules\'") to drain the queue.'); + die(); +} + $minimumfaildelay = 60; // Default to 60 seconds to exclude any tasks which haven't run yet. if ($options['minimumfaildelay'] !== false) { if (is_numeric($options['minimumfaildelay'])) { diff --git a/fix_module.php b/fix_module.php index 3cf8b15..23dc8a0 100644 --- a/fix_module.php +++ b/fix_module.php @@ -24,43 +24,60 @@ */ require_once(__DIR__ . '/../../../config.php'); +require_once($CFG->libdir . '/adminlib.php'); require_once(__DIR__.'/classes/reporter.php'); -require_login(); + +// Gate: every action below mutates state (deletes modules, queues delete tasks, +// re-queues orphans). Restrict to admins by reusing the same admin_externalpage +// hook index.php uses; this enforces the moodle/site:config capability declared +// in settings.php and also calls require_login() internally. +admin_externalpage_setup('tool_fix_delete_modules'); use tool_fix_delete_modules\reporter; -// Retrieve parameters. +// Retrieve parameters. taskid is optional because the orphan-requeue actions +// have no associated adhoc task — there is no row in mdl_task_adhoc to point at. $action = required_param('action', PARAM_ALPHANUMEXT); $cmid = required_param('cmid', PARAM_INT); $modulename = required_param('cmname', PARAM_ALPHAEXT); -$taskid = required_param('taskid', PARAM_INT); +$taskid = optional_param('taskid', 0, PARAM_INT); $prevurl = new moodle_url('/admin/tool/fix_delete_modules/index.php'); $mainurl = $prevurl; -if ($action == 'fix_module') { - require_sesskey(); +$validactions = ['fix_module', 'requeue_orphan_module', 'requeue_orphan_modules_all']; +if (!in_array($action, $validactions, true)) { + throw new moodle_exception('error_actionnotfound', 'tool_fix_delete_modules', $prevurl, $action); +} - $url = new moodle_url('/admin/tool/fix_delete_modules/fix_module.php'); - $PAGE->set_url($url); - $PAGE->set_context(context_system::instance()); - $PAGE->set_title(get_string('pluginname', 'tool_fix_delete_modules')); - $PAGE->set_heading(get_string('pluginname', 'tool_fix_delete_modules'). " - deleting module"); - $renderer = $PAGE->get_renderer('core'); +require_sesskey(); - echo $OUTPUT->header(); +$url = new moodle_url('/admin/tool/fix_delete_modules/fix_module.php'); +$PAGE->set_url($url); +$PAGE->set_context(context_system::instance()); +$PAGE->set_title(get_string('pluginname', 'tool_fix_delete_modules')); - $minimumfaildelay = intval(get_config('tool_fix_delete_modules', 'minimumfaildelay')); - $reporter = new reporter(true, $minimumfaildelay); +$minimumfaildelay = intval(get_config('tool_fix_delete_modules', 'minimumfaildelay')); +$reporter = new reporter(true, $minimumfaildelay); - // Output template rendered results of fixing the course module. - echo $reporter->fix_tasks(array($taskid)); +if ($action === 'fix_module') { + $PAGE->set_heading(get_string('pluginname', 'tool_fix_delete_modules') . " - deleting module"); + echo $OUTPUT->header(); + echo $reporter->fix_tasks([$taskid]); +} else if ($action === 'requeue_orphan_module') { + $PAGE->set_heading(get_string('pluginname', 'tool_fix_delete_modules') + . ' - ' . get_string('button_requeue_orphan_module', 'tool_fix_delete_modules')); + echo $OUTPUT->header(); + echo $reporter->requeue_orphan_modules([$cmid]); +} else { // Action: requeue_orphan_modules_all. + $PAGE->set_heading(get_string('pluginname', 'tool_fix_delete_modules') + . ' - ' . get_string('button_requeue_orphan_modules_all', 'tool_fix_delete_modules')); + echo $OUTPUT->header(); + echo $reporter->requeue_orphan_modules(); +} - // Return to main page link. - $urlstring = html_writer::link($mainurl, get_string('returntomainlinklabel', 'tool_fix_delete_modules')); - echo get_string('deletemodule_returntomainpagesentence', 'tool_fix_delete_modules', $urlstring); +// Return to main page link. +$urlstring = html_writer::link($mainurl, get_string('returntomainlinklabel', 'tool_fix_delete_modules')); +echo get_string('deletemodule_returntomainpagesentence', 'tool_fix_delete_modules', $urlstring); - echo $OUTPUT->footer(); -} else { - throw new moodle_exception('error_actionnotfound', 'tool_fix_delete_modules', $prevurl, $action); -} +echo $OUTPUT->footer(); diff --git a/form.php b/form.php index 1990f6d..29b1c2f 100644 --- a/form.php +++ b/form.php @@ -62,6 +62,71 @@ public function definition() { } +/** + * requeue_orphan_module_form — per-row "Permanently delete orphaned module #N" button. + * + * Used by the orphan-modules report section. Posts to fix_module.php with + * action=requeue_orphan_module, which re-queues a fresh course_delete_modules + * adhoc task for the given cmid via Moodle's course_delete_module() API. + * + * @package tool_fix_delete_modules + * @copyright 2026 Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class requeue_orphan_module_form extends moodleform { + /** + * Define the form. + */ + public function definition() { + $mform = $this->_form; + + $cmid = $this->_customdata['cmid']; + $label = get_string('button_requeue_orphan_module', 'tool_fix_delete_modules'); + $icon = ' '; + $btnhtml = ''; + $mform->addElement('html', $btnhtml); + + $mform->addElement('hidden', 'action', 'requeue_orphan_module'); + $mform->setType('action', PARAM_ALPHANUMEXT); + $mform->addElement('hidden', 'cmid', $cmid); + $mform->setType('cmid', PARAM_INT); + $mform->addElement('hidden', 'cmname', $this->_customdata['cmname']); + $mform->setType('cmname', PARAM_ALPHAEXT); + $mform->addElement('hidden', 'taskid', 0); + $mform->setType('taskid', PARAM_INT); + } +} + +/** + * requeue_orphan_modules_all_form — "Permanently delete all orphaned modules" bulk button. + * + * @package tool_fix_delete_modules + * @copyright 2026 Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class requeue_orphan_modules_all_form extends moodleform { + /** + * Define the form. + */ + public function definition() { + $mform = $this->_form; + + $label = get_string('button_requeue_orphan_modules_all', 'tool_fix_delete_modules'); + $icon = ' '; + $btnhtml = ''; + $mform->addElement('html', $btnhtml); + + $mform->addElement('hidden', 'action', 'requeue_orphan_modules_all'); + $mform->setType('action', PARAM_ALPHANUMEXT); + $mform->addElement('hidden', 'cmid', 0); + $mform->setType('cmid', PARAM_INT); + $mform->addElement('hidden', 'cmname', 'all'); + $mform->setType('cmname', PARAM_ALPHAEXT); + $mform->addElement('hidden', 'taskid', 0); + $mform->setType('taskid', PARAM_INT); + } +} + /** * separate_delete_modules_form Form Class. * diff --git a/index.php b/index.php index 8147f40..24a2c22 100644 --- a/index.php +++ b/index.php @@ -53,6 +53,7 @@ $pagesubtitle = get_string('displaypage-subtitle', 'tool_fix_delete_modules'); $reports = $reporter->get_tables_report(); $diagnoses = $reporter->get_diagnosis(); +$orphans = $reporter->get_orphan_modules_report(); if ($reports == '') { // No report means no adhoc tasks in queue. $diagnoses = html_writer::tag('p', get_string('success_none_found', 'tool_fix_delete_modules'), @@ -65,7 +66,8 @@ } $maindata = ['pagesubtitle' => $pagesubtitle, 'reports' => $reports, - 'diagnoses' => $diagnoses]; + 'diagnoses' => $diagnoses, + 'orphans' => $orphans]; $output = $OUTPUT->render_from_template('tool_fix_delete_modules/main_elements', $maindata); if ($output == '') { diff --git a/lang/en/tool_fix_delete_modules.php b/lang/en/tool_fix_delete_modules.php index 1daba82..c9f81a2 100644 --- a/lang/en/tool_fix_delete_modules.php +++ b/lang/en/tool_fix_delete_modules.php @@ -135,4 +135,30 @@ $string['symptom_context_table_record_missing'] = 'There is no context table record for this course module'; $string['symptom_good_no_issues'] = 'GOOD! No issues.'; $string['symptoms'] = 'Symptoms'; - +$string['button_requeue_orphan_module'] = 'Re-queue stalled deletion'; +$string['button_requeue_orphan_modules_all'] = 'Re-queue all stalled deletions'; +$string['cli_requeue_orphans_help'] = 'Re-queue orphaned course modules — those with deletioninprogress=1 but no queued course_delete_modules adhoc task. Use --fix to actually re-queue.'; +$string['heading_orphan_modules'] = 'Orphaned course modules'; +$string['orphan_modules_count'] = '{$a} orphaned module(s) found'; +$string['orphan_modules_explanation_action'] = 'Click Re-queue stalled deletion to queue a fresh course_delete_module() task; the next cron run will process the deletion normally.'; +$string['orphan_modules_explanation_intro'] = 'Each row below is an activity that is stuck showing "deletion in progress" on its course page — the deletion was started but no adhoc task remains to complete it.'; +$string['orphan_modules_none_found'] = 'No orphaned course modules — every flagged module has a queued task or is being processed normally.'; +$string['orphan_modules_summary_heading'] = 'Re-queue summary'; +$string['outcome_orphan_module_already_gone'] = 'Course module {$a} had already been removed — no further action required.'; +$string['outcome_orphan_module_fix_fail'] = 'The course module could NOT be re-queued.'; +$string['outcome_orphan_module_fix_successful'] = 'The course module was successfully re-queued for deletion.'; +$string['outcome_orphan_module_not_stuck'] = 'Course module {$a} was no longer flagged for deletion — no further action required.'; +$string['outcome_orphan_module_requeue_failed'] = 'Course module {$a->cmid} could NOT be re-queued. Error: {$a->error}'; +$string['outcome_orphan_module_requeued'] = 'Course module {$a} was successfully re-queued for deletion.'; +$string['outcome_orphan_modules_summary_all_fail_plural'] = 'None of the {$a} course modules were successfully re-queued.'; +$string['outcome_orphan_modules_summary_all_fail_single'] = 'The course module could NOT be re-queued.'; +$string['outcome_orphan_modules_summary_all_noop_plural'] = 'None of the {$a} course modules required action.'; +$string['outcome_orphan_modules_summary_all_noop_single'] = 'The course module required no action.'; +$string['outcome_orphan_modules_summary_all_ok_plural'] = 'All {$a} course modules were successfully re-queued for deletion.'; +$string['outcome_orphan_modules_summary_all_ok_single'] = 'The course module was successfully re-queued for deletion.'; +$string['outcome_orphan_modules_summary_partial'] = '{$a->ok} of {$a->total} course modules were successfully re-queued. {$a->fail} could NOT be re-queued and {$a->noop} required no action.'; +$string['table_title_orphan_modules'] = 'Orphaned course modules table'; +$string['check_orphan_modules_name'] = 'Orphaned course modules'; +$string['check_orphan_modules_ok'] = 'No orphaned course modules detected.'; +$string['check_orphan_modules_warning'] = '{$a} orphaned course module(s) detected. Re-queue them from the Fix Delete Modules page.'; +$string['check_orphan_modules_details_intro'] = 'The following course modules are flagged with deletioninprogress=1 but have no queued course_delete_modules adhoc task. They were most likely left behind when a worker process was killed mid-execute. Open the plugin\'s report page to re-queue them.'; diff --git a/lib.php b/lib.php new file mode 100644 index 0000000..684e12b --- /dev/null +++ b/lib.php @@ -0,0 +1,39 @@ +. + +/** + * Plugin callbacks for tool_fix_delete_modules. + * + * @package tool_fix_delete_modules + * @copyright 2026 Catalyst IT + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +/** + * Register status checks with the Moodle Check API. + * + * Discovered automatically by \core\check\manager::get_status_checks() (M3.9+) so + * the orphan-cm check appears on /report/status/index.php and is picked up by + * tool_heartbeat's croncheck.php. On Moodle <3.9 the Check API doesn't exist + * and this callback is simply never invoked. + * + * @return \core\check\check[] + */ +function tool_fix_delete_modules_status_checks(): array { + return [ + new \tool_fix_delete_modules\check\orphan_modules(), + ]; +} diff --git a/separate_module.php b/separate_module.php index aa3689f..3dd0ccb 100644 --- a/separate_module.php +++ b/separate_module.php @@ -24,8 +24,13 @@ */ require_once(__DIR__ . '/../../../config.php'); +require_once($CFG->libdir . '/adminlib.php'); -require_login(); +// Gate: this endpoint mutates state (separates clustered course_delete_modules +// adhoc tasks). Restrict to admins by reusing the same admin_externalpage hook +// index.php uses; this enforces the moodle/site:config capability declared in +// settings.php and also calls require_login() internally. +admin_externalpage_setup('tool_fix_delete_modules'); use tool_fix_delete_modules\reporter; diff --git a/styles.css b/styles.css index c8a2f6b..ed8a310 100644 --- a/styles.css +++ b/styles.css @@ -96,3 +96,147 @@ left: -999px; max-width: 0; } + +/* Orphaned-modules section — light pink box matching catalyst's .fix-diagnosis, + toolbar layout, outlined #A32D2D buttons with refresh icon. */ +.fix-orphans { + margin-top: 1.5rem; + border-radius: 10px; + padding: 1rem 1.25rem; + background-color: #fee; + width: 100%; +} +.fix-orphans-heading { + color: #511; + margin-bottom: 0.5rem; +} +.fix-orphans-explanation { + margin-bottom: 1rem; + border-radius: 10px; + padding: 10px; + background-color: #ffb1b1; + color: #511; +} +.fix-orphans-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.75rem; +} +.fix-orphans-count { + font-weight: 500; + color: #511; +} +.fix-orphans-bulk .mform, +.fix-orphans-bulk .fitem, +.fix-orphans-bulk .felement, +.fix-orphans-table td .mform, +.fix-orphans-table td .fitem, +.fix-orphans-table td .felement { + margin: 0; + padding: 0; + text-align: left; +} +.fix-orphans-bulk form, +.fix-orphans-table td form { + margin: 0; +} +.fix-orphans-table { + background-color: #fff; + border-radius: 6px; + overflow: hidden; + margin-bottom: 0; + border-collapse: collapse; + width: 100%; +} +.fix-orphans-table th { + background-color: #fff; + border-bottom: 1px solid #e0d4d4; +} +.fix-orphans-table tbody tr:nth-child(odd) td { + background-color: #fff; +} +.fix-orphans-table tbody tr:nth-child(even) td { + background-color: #f5f5f5; +} +.fix-orphans-table td, +.fix-orphans-table th { + vertical-align: middle; + padding: 0.6rem 0.75rem; + line-height: 1.5; +} +.fix-orphans-table .fix-orphans-action-col { + text-align: right; + white-space: nowrap; +} +/* Flatten moodleform wrappers inside the action cell so the button can sit + on the same vertical centre as the row's text. moodleform normally renders + the submit element inside
<.mform><.fitem><.felement> — each of these + contributes its own line-height + margin and pushes the button below the + row's text baseline. Strip those defaults so the inline-flex centring wins. */ +.fix-orphans-table td.fix-orphans-action-col { + line-height: 1; +} +.fix-orphans-table td .btn { + display: inline-block; + vertical-align: middle; + margin: 0; + line-height: 1.4; +} +.fix-orphans-table td form, +.fix-orphans-table td .mform, +.fix-orphans-table td .fitem, +.fix-orphans-table td .felement, +.fix-orphans-table td .fcontainer { + display: inline; + margin: 0; + padding: 0; + line-height: inherit; + border: 0; + background: transparent; +} +/* The Bootstrap grid columns moodleform wraps each form element in + (col-md-3 = label, col-md-9 = input). The label is empty for our submit-only + element, so collapse the label column entirely; let the input column behave inline. */ +.fix-orphans-table td .fitem .col-md-3 { + display: none; +} +.fix-orphans-table td .fitem .col-md-9 { + flex: 0 0 auto; + max-width: none; + width: auto; + padding: 0; + margin: 0; + display: inline; +} +.fix-orphans-modpill { + display: inline-block; + padding: 0.15em 0.55em; + border-radius: 0.5rem; + background-color: #f3e7e7; + color: #511; + font-family: monospace; + font-size: 0.9em; + line-height: 1.3; + vertical-align: baseline; + margin: 0 0.15em; +} +.fix-orphan-requeue-icon { + margin-right: 0.4rem; +} +/* Outlined burgundy button — neutral fill, #A32D2D border + text. + Higher specificity than Bootstrap btn-primary defaults. */ +#page-admin-tool-fix_delete_modules-index .fix-orphans .btn.btn-outline-orphan-requeue, +#page-admin-tool-fix_delete_modules-index .fix-orphans-bulk .btn.btn-outline-orphan-requeue { + background-color: transparent; + border: 1px solid #A32D2D; + color: #A32D2D; + font-weight: 500; +} +#page-admin-tool-fix_delete_modules-index .fix-orphans .btn.btn-outline-orphan-requeue:hover, +#page-admin-tool-fix_delete_modules-index .fix-orphans .btn.btn-outline-orphan-requeue:focus, +#page-admin-tool-fix_delete_modules-index .fix-orphans-bulk .btn.btn-outline-orphan-requeue:hover, +#page-admin-tool-fix_delete_modules-index .fix-orphans-bulk .btn.btn-outline-orphan-requeue:focus { + background-color: #A32D2D; + color: #fff; +} diff --git a/templates/main_elements.mustache b/templates/main_elements.mustache index 48a8470..8d1674d 100644 --- a/templates/main_elements.mustache +++ b/templates/main_elements.mustache @@ -29,16 +29,19 @@ * pagesubtitle string - The page subtitle of the index.php page. * reports string - HTML content for the reports related course_module_delete tasks. * diagnoses string - HTML content for the diagnoses of course_module_delete tasks. + * orphans string - HTML content for the orphaned-course-modules section (may be empty). Example context (json): { "pagesubtitle": "Check Modules", "reports": "
....
", - "diagnoses": "
....
" + "diagnoses": "
....
", + "orphans": "
...
" } }}

{{pagesubtitle}}

{{{diagnoses}}} {{{reports}}} + {{{orphans}}}
diff --git a/templates/orphan_modules_table.mustache b/templates/orphan_modules_table.mustache new file mode 100644 index 0000000..38fa6e6 --- /dev/null +++ b/templates/orphan_modules_table.mustache @@ -0,0 +1,86 @@ +{{! + This file is part of Moodle - https://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 . +}} +{{! + @template tool_fix_delete_modules/orphan_modules_table + + Renders the orphaned-course-modules report section: cms with deletioninprogress=1 + that have no queued course_delete_modules adhoc task. Includes a top toolbar + (count summary + bulk re-queue button) and a per-row Re-queue button. + + Context variables required for this template: + * heading string - section heading. + * count int - number of orphan rows. + * countlabel string - localised "{n} orphaned module(s) found". + * explanationintro string - paragraph 1: what these rows are. + * explanationaction string - paragraph 2: how to act on them. + * rows array of objects: + - cmid int + - courseid int + - shortname string (course shortname) + - modname string (e.g. assign / quiz) + - instance int + - activity string (the activity's user-visible name) + - requeueform string (HTML for the per-row re-queue form/button) + * requeueall string - HTML for the bulk re-queue form/button. + + Example context (json): + { + "heading": "Orphaned course modules", + "count": 1, + "countlabel": "1 orphaned module(s) found", + "explanation": "Each row below is an activity ...", + "rows": [ + {"cmid": 32, "courseid": 6, "shortname": "test-course-2", "modname": "assign", "instance": 14, "activity": "Sample assignment 3", "requeueform": ""} + ], + "requeueall": "" + } +}} +
+

{{heading}}

+
+

{{{explanationintro}}}

+

{{{explanationaction}}}

+
+
+ {{countlabel}} + {{{requeueall}}} +
+ + + + + + + + + + + + + {{#rows}} + + + + + + + + + {{/rows}} + +
cmidcourseidcourse shortnameModuleActivity nameAction
{{cmid}}{{courseid}}{{shortname}}{{instance}} ({{modname}}){{activity}}{{{requeueform}}}
+
\ No newline at end of file diff --git a/tests/check_orphan_modules_test.php b/tests/check_orphan_modules_test.php new file mode 100644 index 0000000..56463ed --- /dev/null +++ b/tests/check_orphan_modules_test.php @@ -0,0 +1,95 @@ +. + +namespace tool_fix_delete_modules; + +use core\check\result; +use tool_fix_delete_modules\check\orphan_modules; + +/** + * Tests for the orphan_modules status check. + * + * The check is a Check-API adaptor on top of \tool_fix_delete_modules\orphan_module_list, + * so these tests focus on the OK / WARNING decision and the summary payload — the + * underlying detection rules are covered by orphan_module_list_test. + * + * @package tool_fix_delete_modules + * @category test + * @copyright 2026 Catalyst IT + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +final class check_orphan_modules_test extends \advanced_testcase { + /** + * On a clean site, the check is OK. + * + * @covers \tool_fix_delete_modules\check\orphan_modules + */ + public function test_ok_when_no_orphans(): void { + $this->resetAfterTest(true); + + $check = new orphan_modules(); + $result = $check->get_result(); + + $this->assertSame(result::OK, $result->get_status()); + $this->assertNotEmpty($result->get_summary()); + } + + /** + * A cm flagged deletioninprogress=1 with no queued task triggers WARNING and + * the summary mentions the count. + * + * @covers \tool_fix_delete_modules\check\orphan_modules + */ + public function test_warning_when_orphan_present(): void { + global $DB; + $this->resetAfterTest(true); + + $course = $this->getDataGenerator()->create_course(); + $page = $this->getDataGenerator()->create_module('page', ['course' => $course->id]); + // Flag without queuing a task — this is the orphan state we want to detect. + $DB->set_field('course_modules', 'deletioninprogress', 1, ['id' => $page->cmid]); + + $check = new orphan_modules(); + $result = $check->get_result(); + + $this->assertSame(result::WARNING, $result->get_status()); + $this->assertStringContainsString('1', $result->get_summary()); + // The detail block lists the offending cmid so an operator can act on it. + $this->assertStringContainsString((string)$page->cmid, $result->get_details()); + } + + /** + * A cm flagged AND covered by a queued task is in-flight, not orphaned — + * the check should stay OK in that case. + * + * @covers \tool_fix_delete_modules\check\orphan_modules + */ + public function test_ok_when_flagged_but_task_queued(): void { + $this->resetAfterTest(true); + $this->setAdminUser(); + + $course = $this->getDataGenerator()->create_course(); + $page = $this->getDataGenerator()->create_module('page', ['course' => $course->id]); + // The course_delete_module(..., true) call sets DIP=1 AND queues the task — the + // canonical in-flight state, which is not an orphan. + course_delete_module($page->cmid, true); + + $check = new orphan_modules(); + $result = $check->get_result(); + + $this->assertSame(result::OK, $result->get_status()); + } +} diff --git a/tests/orphan_module_list_test.php b/tests/orphan_module_list_test.php new file mode 100644 index 0000000..eae9a41 --- /dev/null +++ b/tests/orphan_module_list_test.php @@ -0,0 +1,124 @@ +. + +namespace tool_fix_delete_modules; + +/** + * Tests for the orphan_module_list class. + * + * Covers the data-source layer for the orphan-cm cleanup flow: scanning + * mdl_course_modules for deletioninprogress=1 rows and subtracting any + * cmids referenced by a queued course_delete_modules adhoc task. + * + * @package tool_fix_delete_modules + * @category test + * @copyright 2026 Catalyst IT + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +final class orphan_module_list_test extends \advanced_testcase { + /** + * On a clean site, orphan_module_list is empty. + * + * @covers \tool_fix_delete_modules\orphan_module_list + */ + public function test_empty_when_no_stuck_modules(): void { + $this->resetAfterTest(true); + + $list = new orphan_module_list(); + + $this->assertFalse($list->has_orphans()); + $this->assertSame([], $list->get_orphan_modules()); + $this->assertSame([], $list->get_cmids()); + } + + /** + * A cm with deletioninprogress=1 and no matching adhoc task is reported as an orphan. + * + * @covers \tool_fix_delete_modules\orphan_module_list + */ + public function test_flagged_cm_with_no_task_is_orphan(): void { + global $DB; + $this->resetAfterTest(true); + + $course = $this->getDataGenerator()->create_course(); + $page = $this->getDataGenerator()->create_module('page', ['course' => $course->id]); + + // Flag the cm without queuing a task — exactly the state we want to detect. + $DB->set_field('course_modules', 'deletioninprogress', 1, ['id' => $page->cmid]); + + $list = new orphan_module_list(); + + $this->assertTrue($list->has_orphans()); + $this->assertSame([$page->cmid], $list->get_cmids()); + $orphans = $list->get_orphan_modules(); + $this->assertArrayHasKey($page->cmid, $orphans); + $this->assertEquals(1, $orphans[$page->cmid]->deletioninprogress); + } + + /** + * A cm with deletioninprogress=1 AND a matching queued task is NOT an orphan — + * its deletion is in-flight and the existing flow handles it. + * + * @covers \tool_fix_delete_modules\orphan_module_list + */ + public function test_flagged_cm_with_matching_task_is_not_orphan(): void { + $this->resetAfterTest(true); + + $course = $this->getDataGenerator()->create_course(); + $page = $this->getDataGenerator()->create_module('page', ['course' => $course->id]); + + // The course_delete_module(..., true) call sets DIP=1 AND queues the task — the canonical path. + course_delete_module($page->cmid, true); + + $list = new orphan_module_list(); + + $this->assertFalse($list->has_orphans(), 'A cm referenced by a queued task should not be reported as an orphan.'); + } + + /** + * A mix of orphans and in-flight cms returns only the orphans. + * + * @covers \tool_fix_delete_modules\orphan_module_list + */ + public function test_mixed_set_returns_only_orphans(): void { + global $DB; + $this->resetAfterTest(true); + + $course = $this->getDataGenerator()->create_course(); + + // Orphan #1 — flagged, no task. + $orphan1 = $this->getDataGenerator()->create_module('page', ['course' => $course->id]); + $DB->set_field('course_modules', 'deletioninprogress', 1, ['id' => $orphan1->cmid]); + + // Orphan #2 — flagged, no task. + $orphan2 = $this->getDataGenerator()->create_module('label', ['course' => $course->id]); + $DB->set_field('course_modules', 'deletioninprogress', 1, ['id' => $orphan2->cmid]); + + // In-flight — flagged AND task queued via the canonical Moodle helper. + $inflight = $this->getDataGenerator()->create_module('url', ['course' => $course->id]); + course_delete_module($inflight->cmid, true); + + $list = new orphan_module_list(); + $cmids = $list->get_cmids(); + sort($cmids); + + $expected = [$orphan1->cmid, $orphan2->cmid]; + sort($expected); + + $this->assertSame($expected, $cmids); + $this->assertNotContains($inflight->cmid, $cmids); + } +} diff --git a/tests/surgeon_orphan_requeue_test.php b/tests/surgeon_orphan_requeue_test.php new file mode 100644 index 0000000..0d4c6af --- /dev/null +++ b/tests/surgeon_orphan_requeue_test.php @@ -0,0 +1,110 @@ +. + +namespace tool_fix_delete_modules; + +/** + * Tests for surgeon::requeue_orphan_module(). + * + * Covers the fix path for the orphan-cm cleanup flow: re-queueing a fresh + * course_delete_modules adhoc task for a cm that was flagged for deletion + * but lost its queued task. Goes through Moodle's own course_delete_module() + * helper, so the resulting task is identical to one the activity Delete + * button would have produced. + * + * @package tool_fix_delete_modules + * @category test + * @copyright 2026 Catalyst IT + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +final class surgeon_orphan_requeue_test extends \advanced_testcase { + /** + * Re-queueing a flagged orphan cm produces a new course_delete_modules adhoc task + * and returns the success message. + * + * @covers \tool_fix_delete_modules\surgeon::requeue_orphan_module + */ + public function test_requeue_creates_adhoc_task(): void { + global $DB; + $this->resetAfterTest(true); + $this->setAdminUser(); + + $course = $this->getDataGenerator()->create_course(); + $page = $this->getDataGenerator()->create_module('page', ['course' => $course->id]); + + // Set up the orphan state: flagged, no task. + $DB->set_field('course_modules', 'deletioninprogress', 1, ['id' => $page->cmid]); + $this->assertCount(0, \core\task\manager::get_adhoc_tasks('\core_course\task\course_delete_modules')); + + $messages = surgeon::requeue_orphan_module($page->cmid); + + // A task should now be queued. + $tasks = \core\task\manager::get_adhoc_tasks('\core_course\task\course_delete_modules'); + $this->assertCount(1, $tasks); + + // The task's customdata should reference our cmid. Core's course_delete_module() + // writes cms as a 0-indexed array, so we check the cm objects' id fields rather + // than relying on the outer array key. + $task = reset($tasks); + $customdata = $task->get_custom_data(); + $cms = (array)$customdata->cms; + $cmids = array_map(fn($cm) => (int)$cm->id, $cms); + $this->assertContains((int)$page->cmid, $cmids); + + // The success message should be returned. + $expected = get_string('outcome_orphan_module_requeued', 'tool_fix_delete_modules', $page->cmid); + $this->assertContains($expected, $messages); + } + + /** + * Calling requeue against a non-existent cm returns the "already gone" message + * (a benign no-op, not a hard error). + * + * @covers \tool_fix_delete_modules\surgeon::requeue_orphan_module + */ + public function test_requeue_already_gone(): void { + $this->resetAfterTest(true); + $this->setAdminUser(); + + $bogus = 99999999; + $messages = surgeon::requeue_orphan_module($bogus); + + $expected = get_string('outcome_orphan_module_already_gone', 'tool_fix_delete_modules', $bogus); + $this->assertContains($expected, $messages); + $this->assertCount(0, \core\task\manager::get_adhoc_tasks('\core_course\task\course_delete_modules')); + } + + /** + * Calling requeue against a cm that exists but is not flagged returns the + * "not stuck" message and queues nothing. + * + * @covers \tool_fix_delete_modules\surgeon::requeue_orphan_module + */ + public function test_requeue_not_stuck(): void { + $this->resetAfterTest(true); + $this->setAdminUser(); + + $course = $this->getDataGenerator()->create_course(); + $page = $this->getDataGenerator()->create_module('page', ['course' => $course->id]); + // Note: no DIP flag set — cm is healthy. + + $messages = surgeon::requeue_orphan_module($page->cmid); + + $expected = get_string('outcome_orphan_module_not_stuck', 'tool_fix_delete_modules', $page->cmid); + $this->assertContains($expected, $messages); + $this->assertCount(0, \core\task\manager::get_adhoc_tasks('\core_course\task\course_delete_modules')); + } +} diff --git a/version.php b/version.php index 8158e3a..011809f 100644 --- a/version.php +++ b/version.php @@ -26,8 +26,8 @@ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'tool_fix_delete_modules'; -$plugin->release = '0.1.0'; -$plugin->version = 2022081600; +$plugin->release = '0.2.0'; +$plugin->version = 2026051101; $plugin->requires = 2018051700; $plugin->supported = [35, 405]; $plugin->maturity = MATURITY_BETA;