Auto-commit local changes before build (2026-01-06 11:04:28) #38

Merged
ivooskamp merged 1 commits from v20260106-04-jobs-archive into main 2026-01-13 11:06:43 +01:00
11 changed files with 215 additions and 8 deletions

View File

@ -1 +1 @@
v20260106-03-veeam-full-job-name-merge
v20260106-04-jobs-archive

View File

@ -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()
)

View File

@ -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/<int:job_id>/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/<int:job_id>/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/<int:job_id>")
@login_required
@roles_required("admin", "operator", "viewer")

View File

@ -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()
)

View File

@ -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)

View File

@ -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.

View File

@ -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

View File

@ -82,6 +82,11 @@
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.jobs') }}">Jobs</a>
</li>
{% if active_role == 'admin' %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.archived_jobs') }}">Archived Jobs</a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.daily_jobs') }}">Daily Jobs</a>
</li>

View File

@ -0,0 +1,45 @@
{% extends "layout/base.html" %}
{% block content %}
<h2 class="mb-3">Archived Jobs</h2>
<div class="table-responsive">
<table class="table table-sm table-hover align-middle">
<thead class="table-light">
<tr>
<th scope="col">Customer</th>
<th scope="col">Backup</th>
<th scope="col">Type</th>
<th scope="col">Job name</th>
<th scope="col">Archived at</th>
<th scope="col" class="text-end">Actions</th>
</tr>
</thead>
<tbody>
{% if jobs %}
{% for j in jobs %}
<tr>
<td>{{ j.customer_name }}</td>
<td>{{ j.backup_software }}</td>
<td>{{ j.backup_type }}</td>
<td>
<a class="text-decoration-none" href="{{ url_for('main.job_detail', job_id=j.id) }}">{{ j.job_name }}</a>
</td>
<td>{{ j.archived_at }}</td>
<td class="text-end">
<form method="post" action="{{ url_for('main.unarchive_job', job_id=j.id) }}" style="display:inline;">
<button type="submit" class="btn btn-sm btn-outline-secondary">Restore</button>
</form>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="6" class="text-center text-muted py-3">
No archived jobs found.
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -10,21 +10,33 @@
<th scope="col">Backup</th>
<th scope="col">Type</th>
<th scope="col">Job name</th>
{% if can_manage_jobs %}
<th scope="col" class="text-end">Actions</th>
{% endif %}
</tr>
</thead>
<tbody>
{% if jobs %}
{% for j in jobs %}
<tr style="cursor: pointer;" onclick="window.location='{{ url_for('main.job_detail', job_id=j.id) }}'">
<tr>
<td>{{ j.customer_name }}</td>
<td>{{ j.backup_software }}</td>
<td>{{ j.backup_type }}</td>
<td>{{ j.job_name }}</td>
<td>
<a class="text-decoration-none" href="{{ url_for('main.job_detail', job_id=j.id) }}">{{ j.job_name }}</a>
</td>
{% if can_manage_jobs %}
<td class="text-end">
<form method="post" action="{{ url_for('main.archive_job', job_id=j.id) }}" style="display:inline;">
<button type="submit" class="btn btn-sm btn-outline-secondary">Archive</button>
</form>
</td>
{% endif %}
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="4" class="text-center text-muted py-3">
<td colspan="{% if can_manage_jobs %}5{% else %}4{% endif %}" class="text-center text-muted py-3">
No jobs found.
</td>
</tr>

View File

@ -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