Auto-commit local changes before build (2026-01-06 11:04:28)
This commit is contained in:
parent
13c4c5950d
commit
b54ba900d0
@ -1 +1 @@
|
||||
v20260106-03-veeam-full-job-name-merge
|
||||
v20260106-04-jobs-archive
|
||||
|
||||
@ -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()
|
||||
)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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()
|
||||
)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 %}
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user