From 60c7e89dc250bc62ac29c506e4ad3180ad961b24 Mon Sep 17 00:00:00 2001 From: Ivo Oskamp Date: Mon, 9 Feb 2026 13:45:05 +0100 Subject: [PATCH] Add cleanup orphaned jobs maintenance option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added new maintenance option in Settings → Maintenance to delete jobs that are no longer linked to an existing customer (customer_id is NULL or customer doesn't exist). Features: - Finds all jobs without valid customer link - Deletes jobs, runs, and related emails permanently - Cleans up auxiliary tables (ticket_job_runs, remark_job_runs, scopes, overrides) - Provides feedback on deleted items count - Logs action to audit log Use case: When customers are removed, their jobs and emails should be completely removed from the database. Co-Authored-By: Claude Sonnet 4.5 --- .../src/backend/app/main/routes_settings.py | 117 ++++++++++++++++++ .../src/templates/main/settings.html | 12 ++ docs/changelog-claude.md | 3 + 3 files changed, 132 insertions(+) diff --git a/containers/backupchecks/src/backend/app/main/routes_settings.py b/containers/backupchecks/src/backend/app/main/routes_settings.py index 2a16256..bb19ea7 100644 --- a/containers/backupchecks/src/backend/app/main/routes_settings.py +++ b/containers/backupchecks/src/backend/app/main/routes_settings.py @@ -124,6 +124,123 @@ def settings_jobs_delete_all(): return redirect(url_for("main.settings")) +@main_bp.route("/settings/jobs/delete-orphaned", methods=["POST"]) +@login_required +@roles_required("admin") +def settings_jobs_delete_orphaned(): + """Delete jobs that have no customer (customer_id is NULL or customer does not exist). + + Also deletes all related emails from the database since the customer is gone. + """ + try: + # Find jobs without valid customer + orphaned_jobs = Job.query.outerjoin(Customer, Job.customer_id == Customer.id).filter( + db.or_( + Job.customer_id.is_(None), + Customer.id.is_(None) + ) + ).all() + + if not orphaned_jobs: + flash("No orphaned jobs found.", "info") + return redirect(url_for("main.settings", section="maintenance")) + + job_count = len(orphaned_jobs) + mail_count = 0 + run_count = 0 + + # Collect mail message ids and run ids for cleanup + mail_message_ids = [] + run_ids = [] + job_ids = [job.id for job in orphaned_jobs] + + for job in orphaned_jobs: + for run in job.runs: + if run.id is not None: + run_ids.append(run.id) + run_count += 1 + if run.mail_message_id: + mail_message_ids.append(run.mail_message_id) + + # Delete related mails permanently (customer is gone) + if mail_message_ids: + mail_count = len(mail_message_ids) + MailMessage.query.filter(MailMessage.id.in_(mail_message_ids)).delete(synchronize_session=False) + + # Helper function for safe SQL execution + def _safe_execute(stmt, params): + try: + db.session.execute(stmt, params) + except Exception: + pass + + # Clean up auxiliary tables that may not have ON DELETE CASCADE + if run_ids: + from sqlalchemy import text, bindparam + _safe_execute( + text("DELETE FROM ticket_job_runs WHERE job_run_id IN :run_ids").bindparams( + bindparam("run_ids", expanding=True) + ), + {"run_ids": run_ids}, + ) + _safe_execute( + text("DELETE FROM remark_job_runs WHERE job_run_id IN :run_ids").bindparams( + bindparam("run_ids", expanding=True) + ), + {"run_ids": run_ids}, + ) + + if job_ids: + from sqlalchemy import text, bindparam + # Clean up scopes + _safe_execute( + text("DELETE FROM ticket_scopes WHERE job_id IN :job_ids").bindparams( + bindparam("job_ids", expanding=True) + ), + {"job_ids": job_ids}, + ) + _safe_execute( + text("DELETE FROM remark_scopes WHERE job_id IN :job_ids").bindparams( + bindparam("job_ids", expanding=True) + ), + {"job_ids": job_ids}, + ) + # Clean up overrides + _safe_execute( + text("DELETE FROM overrides WHERE job_id IN :job_ids").bindparams( + bindparam("job_ids", expanding=True) + ), + {"job_ids": job_ids}, + ) + + # Delete all orphaned jobs (runs/objects are cascaded via ORM relationships) + for job in orphaned_jobs: + db.session.delete(job) + + db.session.commit() + + flash( + f"Deleted {job_count} orphaned job(s), {run_count} run(s), and {mail_count} email(s).", + "success" + ) + + _log_admin_event( + event_type="maintenance_delete_orphaned_jobs", + details={ + "jobs_deleted": job_count, + "runs_deleted": run_count, + "mails_deleted": mail_count, + }, + ) + + except Exception as exc: + db.session.rollback() + print(f"[settings-jobs] Failed to delete orphaned jobs: {exc}") + flash("Failed to delete orphaned jobs.", "danger") + + return redirect(url_for("main.settings", section="maintenance")) + + @main_bp.route("/settings/objects/backfill", methods=["POST"]) @login_required @roles_required("admin") diff --git a/containers/backupchecks/src/templates/main/settings.html b/containers/backupchecks/src/templates/main/settings.html index 301d790..d6bc75a 100644 --- a/containers/backupchecks/src/templates/main/settings.html +++ b/containers/backupchecks/src/templates/main/settings.html @@ -549,6 +549,18 @@ +
+
+
Cleanup orphaned jobs
+
+

Delete jobs that are no longer linked to an existing customer. Related emails and runs will be permanently deleted from the database.

+
+ +
+
+
+
+
Jobs maintenance
diff --git a/docs/changelog-claude.md b/docs/changelog-claude.md index cd5e1f3..0798f7c 100644 --- a/docs/changelog-claude.md +++ b/docs/changelog-claude.md @@ -4,6 +4,9 @@ This file documents all changes made to this project via Claude Code. ## [2026-02-09] +### Added +- Added "Cleanup orphaned jobs" maintenance option in Settings → Maintenance to delete jobs without valid customer links and their associated emails/runs permanently from database (useful when customers are removed) + ### Changed - Removed customer name from Autotask ticket title to keep titles concise (format changed from "[Backupchecks] Customer - Job Name - Status" to "[Backupchecks] Job Name - Status")