Merge pull request 'Auto-commit local changes before build (2026-01-06 11:04:28)' (#38) from v20260106-04-jobs-archive into main
Reviewed-on: #38
This commit is contained in:
commit
b5183f23f0
@ -1 +1 @@
|
|||||||
v20260106-03-veeam-full-job-name-merge
|
v20260106-04-jobs-archive
|
||||||
|
|||||||
@ -76,6 +76,7 @@ def daily_jobs():
|
|||||||
|
|
||||||
jobs = (
|
jobs = (
|
||||||
Job.query.join(Customer, isouter=True)
|
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())
|
.order_by(Customer.name.asc().nullslast(), Job.backup_software.asc(), Job.backup_type.asc(), Job.job_name.asc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|||||||
@ -16,6 +16,7 @@ def jobs():
|
|||||||
# Join with customers for display
|
# Join with customers for display
|
||||||
jobs = (
|
jobs = (
|
||||||
Job.query
|
Job.query
|
||||||
|
.filter(Job.archived.is_(False))
|
||||||
.outerjoin(Customer, Customer.id == Job.customer_id)
|
.outerjoin(Customer, Customer.id == Job.customer_id)
|
||||||
.add_columns(
|
.add_columns(
|
||||||
Job.id,
|
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>")
|
@main_bp.route("/jobs/<int:job_id>")
|
||||||
@login_required
|
@login_required
|
||||||
@roles_required("admin", "operator", "viewer")
|
@roles_required("admin", "operator", "viewer")
|
||||||
|
|||||||
@ -331,14 +331,17 @@ def build_report_columns_meta():
|
|||||||
def build_report_job_filters_meta():
|
def build_report_job_filters_meta():
|
||||||
"""Build job filter metadata for reporting UIs.
|
"""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"}
|
info_backup_types = {"license key"}
|
||||||
|
|
||||||
|
# Distinct values across jobs that actually have at least one run.
|
||||||
rows = (
|
rows = (
|
||||||
db.session.query(Job.backup_software, Job.backup_type)
|
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()
|
.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}
|
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()
|
today_local = _to_amsterdam_date(datetime.utcnow()) or datetime.utcnow().date()
|
||||||
|
|
||||||
for job in jobs:
|
for job in jobs:
|
||||||
@ -222,6 +222,7 @@ def run_checks_page():
|
|||||||
)
|
)
|
||||||
.select_from(Job)
|
.select_from(Job)
|
||||||
.outerjoin(Customer, Customer.id == Job.customer_id)
|
.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)
|
# 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_overrides_match_columns()
|
||||||
migrate_job_runs_review_tracking()
|
migrate_job_runs_review_tracking()
|
||||||
migrate_job_runs_override_metadata()
|
migrate_job_runs_override_metadata()
|
||||||
|
migrate_jobs_archiving()
|
||||||
migrate_news_tables()
|
migrate_news_tables()
|
||||||
migrate_reporting_tables()
|
migrate_reporting_tables()
|
||||||
migrate_reporting_report_config()
|
migrate_reporting_report_config()
|
||||||
print("[migrations] All migrations completed.")
|
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:
|
def migrate_reporting_report_config() -> None:
|
||||||
"""Add report_definitions.report_config column if missing.
|
"""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)
|
auto_approve = db.Column(db.Boolean, nullable=False, default=True)
|
||||||
active = 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)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||||
updated_at = db.Column(
|
updated_at = db.Column(
|
||||||
db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
||||||
|
|||||||
@ -82,6 +82,11 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{{ url_for('main.jobs') }}">Jobs</a>
|
<a class="nav-link" href="{{ url_for('main.jobs') }}">Jobs</a>
|
||||||
</li>
|
</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">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{{ url_for('main.daily_jobs') }}">Daily Jobs</a>
|
<a class="nav-link" href="{{ url_for('main.daily_jobs') }}">Daily Jobs</a>
|
||||||
</li>
|
</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">Backup</th>
|
||||||
<th scope="col">Type</th>
|
<th scope="col">Type</th>
|
||||||
<th scope="col">Job name</th>
|
<th scope="col">Job name</th>
|
||||||
|
{% if can_manage_jobs %}
|
||||||
|
<th scope="col" class="text-end">Actions</th>
|
||||||
|
{% endif %}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% if jobs %}
|
{% if jobs %}
|
||||||
{% for j in 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.customer_name }}</td>
|
||||||
<td>{{ j.backup_software }}</td>
|
<td>{{ j.backup_software }}</td>
|
||||||
<td>{{ j.backup_type }}</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>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<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.
|
No jobs found.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -20,6 +20,15 @@
|
|||||||
- Jobs containing "(Full)" are now treated as the same job as those without "(Full)".
|
- 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.
|
- 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
|
## v0.1.16
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user