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