From b54ba900d0bb6bc9c15a775c6b769986ef824111 Mon Sep 17 00:00:00 2001 From: Ivo Oskamp Date: Tue, 6 Jan 2026 11:04:28 +0100 Subject: [PATCH] Auto-commit local changes before build (2026-01-06 11:04:28) --- .last-branch | 2 +- .../src/backend/app/main/routes_daily_jobs.py | 1 + .../src/backend/app/main/routes_jobs.py | 84 +++++++++++++++++++ .../backend/app/main/routes_reporting_api.py | 9 +- .../src/backend/app/main/routes_run_checks.py | 3 +- .../src/backend/app/migrations.py | 41 +++++++++ .../backupchecks/src/backend/app/models.py | 6 ++ .../src/templates/layout/base.html | 5 ++ .../src/templates/main/archived_jobs.html | 45 ++++++++++ .../backupchecks/src/templates/main/jobs.html | 18 +++- docs/changelog.md | 9 ++ 11 files changed, 215 insertions(+), 8 deletions(-) create mode 100644 containers/backupchecks/src/templates/main/archived_jobs.html diff --git a/.last-branch b/.last-branch index dbd7510..89f73d5 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260106-03-veeam-full-job-name-merge +v20260106-04-jobs-archive diff --git a/containers/backupchecks/src/backend/app/main/routes_daily_jobs.py b/containers/backupchecks/src/backend/app/main/routes_daily_jobs.py index 189d0b5..e1b325b 100644 --- a/containers/backupchecks/src/backend/app/main/routes_daily_jobs.py +++ b/containers/backupchecks/src/backend/app/main/routes_daily_jobs.py @@ -76,6 +76,7 @@ def daily_jobs(): jobs = ( Job.query.join(Customer, isouter=True) + .filter(Job.archived.is_(False)) .order_by(Customer.name.asc().nullslast(), Job.backup_software.asc(), Job.backup_type.asc(), Job.job_name.asc()) .all() ) diff --git a/containers/backupchecks/src/backend/app/main/routes_jobs.py b/containers/backupchecks/src/backend/app/main/routes_jobs.py index 8c559f6..7d96507 100644 --- a/containers/backupchecks/src/backend/app/main/routes_jobs.py +++ b/containers/backupchecks/src/backend/app/main/routes_jobs.py @@ -16,6 +16,7 @@ def jobs(): # Join with customers for display jobs = ( Job.query + .filter(Job.archived.is_(False)) .outerjoin(Customer, Customer.id == Job.customer_id) .add_columns( Job.id, @@ -55,6 +56,89 @@ def jobs(): ) +@main_bp.route("/jobs//archive", methods=["POST"]) +@login_required +@roles_required("admin", "operator") +def archive_job(job_id: int): + job = Job.query.get_or_404(job_id) + + if job.archived: + flash("Job is already archived.", "info") + return redirect(url_for("main.jobs")) + + job.archived = True + job.archived_at = datetime.utcnow() + job.archived_by_user_id = current_user.id + db.session.commit() + + try: + log_admin_event("job_archived", f"Archived job {job.id}", details=f"job_name={job.job_name}") + except Exception: + pass + + flash("Job archived.", "success") + return redirect(url_for("main.jobs")) + + +@main_bp.route("/archived-jobs") +@login_required +@roles_required("admin") +def archived_jobs(): + rows = ( + Job.query + .filter(Job.archived.is_(True)) + .outerjoin(Customer, Customer.id == Job.customer_id) + .add_columns( + Job.id, + Job.backup_software, + Job.backup_type, + Job.job_name, + Job.archived_at, + Customer.name.label("customer_name"), + ) + .order_by(Customer.name.asc().nullslast(), Job.backup_software.asc(), Job.backup_type.asc(), Job.job_name.asc()) + .all() + ) + + out = [] + for row in rows: + out.append( + { + "id": row.id, + "customer_name": getattr(row, "customer_name", "") or "", + "backup_software": row.backup_software or "", + "backup_type": row.backup_type or "", + "job_name": row.job_name or "", + "archived_at": _format_datetime(row.archived_at), + } + ) + + return render_template("main/archived_jobs.html", jobs=out) + + +@main_bp.route("/jobs//unarchive", methods=["POST"]) +@login_required +@roles_required("admin") +def unarchive_job(job_id: int): + job = Job.query.get_or_404(job_id) + if not job.archived: + flash("Job is not archived.", "info") + return redirect(url_for("main.archived_jobs")) + + job.archived = False + job.archived_at = None + job.archived_by_user_id = None + db.session.commit() + + try: + log_admin_event("job_unarchived", f"Unarchived job {job.id}", details=f"job_name={job.job_name}") + except Exception: + pass + + flash("Job restored.", "success") + return redirect(url_for("main.archived_jobs")) + + @main_bp.route("/jobs/") @login_required @roles_required("admin", "operator", "viewer") diff --git a/containers/backupchecks/src/backend/app/main/routes_reporting_api.py b/containers/backupchecks/src/backend/app/main/routes_reporting_api.py index 43c4968..8193bce 100644 --- a/containers/backupchecks/src/backend/app/main/routes_reporting_api.py +++ b/containers/backupchecks/src/backend/app/main/routes_reporting_api.py @@ -331,14 +331,17 @@ def build_report_columns_meta(): def build_report_job_filters_meta(): """Build job filter metadata for reporting UIs. - Provides available backup_softwares and backup_types derived from active jobs. + Provides available backup_softwares and backup_types derived from jobs that have runs. """ - # Distinct values across active jobs (exclude known informational jobs). + # Distinct values across jobs with runs (exclude known informational jobs). info_backup_types = {"license key"} + # Distinct values across jobs that actually have at least one run. rows = ( db.session.query(Job.backup_software, Job.backup_type) - .filter(Job.active.is_(True)) + .select_from(Job) + .join(JobRun, JobRun.job_id == Job.id) + .distinct() .all() ) diff --git a/containers/backupchecks/src/backend/app/main/routes_run_checks.py b/containers/backupchecks/src/backend/app/main/routes_run_checks.py index 3571dce..0ec069b 100644 --- a/containers/backupchecks/src/backend/app/main/routes_run_checks.py +++ b/containers/backupchecks/src/backend/app/main/routes_run_checks.py @@ -195,7 +195,7 @@ def run_checks_page(): ) last_reviewed_map = {int(jid): (dt if dt else None) for jid, dt in last_reviewed_rows} - jobs = Job.query.all() + jobs = Job.query.filter(Job.archived.is_(False)).all() today_local = _to_amsterdam_date(datetime.utcnow()) or datetime.utcnow().date() for job in jobs: @@ -222,6 +222,7 @@ def run_checks_page(): ) .select_from(Job) .outerjoin(Customer, Customer.id == Job.customer_id) + .filter(Job.archived.is_(False)) ) # Runs to show in the overview: unreviewed (or all if admin toggle enabled) diff --git a/containers/backupchecks/src/backend/app/migrations.py b/containers/backupchecks/src/backend/app/migrations.py index 49e304d..94f376f 100644 --- a/containers/backupchecks/src/backend/app/migrations.py +++ b/containers/backupchecks/src/backend/app/migrations.py @@ -777,12 +777,53 @@ def run_migrations() -> None: migrate_overrides_match_columns() migrate_job_runs_review_tracking() migrate_job_runs_override_metadata() + migrate_jobs_archiving() migrate_news_tables() migrate_reporting_tables() migrate_reporting_report_config() print("[migrations] All migrations completed.") +def migrate_jobs_archiving() -> None: + """Add archiving columns to jobs if missing. + + Columns: + - jobs.archived (BOOLEAN NOT NULL DEFAULT FALSE) + - jobs.archived_at (TIMESTAMP NULL) + - jobs.archived_by_user_id (INTEGER NULL) + """ + + table = "jobs" + + try: + engine = db.get_engine() + except Exception as exc: + print(f"[migrations] Could not get engine for jobs archiving migration: {exc}") + return + + inspector = inspect(engine) + try: + existing_columns = {col["name"] for col in inspector.get_columns(table)} + except Exception as exc: + print(f"[migrations] {table} table not found for jobs archiving migration, skipping: {exc}") + return + + with engine.begin() as conn: + if "archived" not in existing_columns: + print('[migrations] Adding jobs.archived column...') + conn.execute(text('ALTER TABLE "jobs" ADD COLUMN archived BOOLEAN NOT NULL DEFAULT FALSE')) + + if "archived_at" not in existing_columns: + print('[migrations] Adding jobs.archived_at column...') + conn.execute(text('ALTER TABLE "jobs" ADD COLUMN archived_at TIMESTAMP')) + + if "archived_by_user_id" not in existing_columns: + print('[migrations] Adding jobs.archived_by_user_id column...') + conn.execute(text('ALTER TABLE "jobs" ADD COLUMN archived_by_user_id INTEGER')) + + print("[migrations] migrate_jobs_archiving completed.") + + def migrate_reporting_report_config() -> None: """Add report_definitions.report_config column if missing. diff --git a/containers/backupchecks/src/backend/app/models.py b/containers/backupchecks/src/backend/app/models.py index a66644c..c508649 100644 --- a/containers/backupchecks/src/backend/app/models.py +++ b/containers/backupchecks/src/backend/app/models.py @@ -196,6 +196,12 @@ class Job(db.Model): auto_approve = db.Column(db.Boolean, nullable=False, default=True) active = db.Column(db.Boolean, nullable=False, default=True) + # Archived jobs are excluded from Daily Jobs and Run Checks. + # JobRuns remain in the database and are still included in reporting. + archived = db.Column(db.Boolean, nullable=False, default=False) + archived_at = db.Column(db.DateTime, nullable=True) + archived_by_user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column( db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False diff --git a/containers/backupchecks/src/templates/layout/base.html b/containers/backupchecks/src/templates/layout/base.html index 5ae1890..a0cc910 100644 --- a/containers/backupchecks/src/templates/layout/base.html +++ b/containers/backupchecks/src/templates/layout/base.html @@ -82,6 +82,11 @@ + {% if active_role == 'admin' %} + + {% endif %} diff --git a/containers/backupchecks/src/templates/main/archived_jobs.html b/containers/backupchecks/src/templates/main/archived_jobs.html new file mode 100644 index 0000000..479fdb7 --- /dev/null +++ b/containers/backupchecks/src/templates/main/archived_jobs.html @@ -0,0 +1,45 @@ +{% extends "layout/base.html" %} +{% block content %} +

Archived Jobs

+ +
+ + + + + + + + + + + + + {% if jobs %} + {% for j in jobs %} + + + + + + + + + {% endfor %} + {% else %} + + + + {% endif %} + +
CustomerBackupTypeJob nameArchived atActions
{{ j.customer_name }}{{ j.backup_software }}{{ j.backup_type }} + {{ j.job_name }} + {{ j.archived_at }} +
+ +
+
+ No archived jobs found. +
+
+{% endblock %} diff --git a/containers/backupchecks/src/templates/main/jobs.html b/containers/backupchecks/src/templates/main/jobs.html index 1d7ddde..0fc8d31 100644 --- a/containers/backupchecks/src/templates/main/jobs.html +++ b/containers/backupchecks/src/templates/main/jobs.html @@ -10,21 +10,33 @@ Backup Type Job name + {% if can_manage_jobs %} + Actions + {% endif %} {% if jobs %} {% for j in jobs %} - + {{ j.customer_name }} {{ j.backup_software }} {{ j.backup_type }} - {{ j.job_name }} + + {{ j.job_name }} + + {% if can_manage_jobs %} + +
+ +
+ + {% endif %} {% endfor %} {% else %} - + No jobs found. diff --git a/docs/changelog.md b/docs/changelog.md index 67bcac2..fccf903 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -20,6 +20,15 @@ - Jobs containing "(Full)" are now treated as the same job as those without "(Full)". - This prevents duplicate job entries and ensures correct aggregation and reporting. +--- + +## v20260106-04-jobs-archive + +- Added job archiving support, allowing Operator and Admin roles to archive jobs that no longer produce runs. +- Archived jobs are excluded from Daily Jobs and Run Checks to keep active views clean. +- Introduced an Admin-only Archived Jobs page to view and restore archived jobs. +- Archived job runs remain fully included in all reports and statistics to preserve historical accuracy. + ================================================================================================================================================ ## v0.1.16 -- 2.45.2