Auto-commit local changes before build (2026-01-06 20:50:50)

This commit is contained in:
Ivo Oskamp 2026-01-06 20:50:50 +01:00
parent 92716dc13c
commit 71752fb926
7 changed files with 121 additions and 57 deletions

View File

@ -1 +1 @@
v20260106-22-ticket-link-multiple-jobs v20260106-24-ticket-scope-resolve-popup

View File

@ -22,14 +22,19 @@ def api_job_run_alerts(run_id: int):
db.session.execute( db.session.execute(
text( text(
""" """
SELECT t.id, t.ticket_code, t.description, t.start_date, t.resolved_at, t.active_from_date SELECT t.id,
t.ticket_code,
t.description,
t.start_date,
COALESCE(ts.resolved_at, t.resolved_at) AS resolved_at,
t.active_from_date
FROM tickets t FROM tickets t
JOIN ticket_scopes ts ON ts.ticket_id = t.id JOIN ticket_scopes ts ON ts.ticket_id = t.id
WHERE ts.job_id = :job_id WHERE ts.job_id = :job_id
AND t.active_from_date <= :run_date AND t.active_from_date <= :run_date
AND ( AND (
t.resolved_at IS NULL COALESCE(ts.resolved_at, t.resolved_at) IS NULL
OR ((t.resolved_at AT TIME ZONE 'UTC' AT TIME ZONE :ui_tz)::date) >= :run_date OR ((COALESCE(ts.resolved_at, t.resolved_at) AT TIME ZONE 'UTC' AT TIME ZONE :ui_tz)::date) >= :run_date
) )
ORDER BY t.start_date DESC ORDER BY t.start_date DESC
""" """
@ -169,7 +174,7 @@ def api_tickets():
"active_from_date": str(getattr(t, "active_from_date", "") or ""), "active_from_date": str(getattr(t, "active_from_date", "") or ""),
"start_date": _format_datetime(t.start_date), "start_date": _format_datetime(t.start_date),
"resolved_at": _format_datetime(t.resolved_at) if t.resolved_at else "", "resolved_at": _format_datetime(t.resolved_at) if t.resolved_at else "",
"active": t.resolved_at is None, "active": (t.resolved_at is None and TicketScope.query.filter_by(ticket_id=t.id, resolved_at=None).first() is not None),
} }
) )
return jsonify({"status": "ok", "tickets": items}) return jsonify({"status": "ok", "tickets": items})
@ -205,42 +210,12 @@ def api_tickets():
return jsonify({"status": "error", "message": "Invalid ticket_code format. Expected TYYYYMMDD.####."}), 400 return jsonify({"status": "error", "message": "Invalid ticket_code format. Expected TYYYYMMDD.####."}), 400
existing = Ticket.query.filter_by(ticket_code=ticket_code).first() existing = Ticket.query.filter_by(ticket_code=ticket_code).first()
is_new = existing is None
# If the ticket already exists, link it to this job/run (a single ticket number can apply to multiple jobs). # Create new ticket if it doesn't exist; otherwise reuse the existing one so the same
# ticket number can be linked to multiple jobs and job runs.
if existing: if existing:
ticket = existing ticket = existing
try:
# Ensure there is at least one job scope for this ticket/job combination.
if job and job.id:
scope = TicketScope.query.filter_by(
ticket_id=ticket.id,
scope_type="job",
job_id=job.id,
).first()
if not scope:
scope = TicketScope(
ticket_id=ticket.id,
scope_type="job",
customer_id=job.customer_id,
backup_software=job.backup_software,
backup_type=job.backup_type,
job_id=job.id,
job_name_match=job.job_name,
job_name_match_mode="exact",
)
db.session.add(scope)
# Link the ticket to this specific run (idempotent due to unique constraint).
existing_link = TicketJobRun.query.filter_by(ticket_id=ticket.id, job_run_id=run.id).first()
if not existing_link:
link = TicketJobRun(ticket_id=ticket.id, job_run_id=run.id, link_source="manual")
db.session.add(link)
db.session.commit()
except Exception as exc:
db.session.rollback()
return jsonify({"status": "error", "message": str(exc) or "Failed to link ticket."}), 500
else: else:
ticket = Ticket( ticket = Ticket(
ticket_code=ticket_code, ticket_code=ticket_code,
@ -251,11 +226,17 @@ def api_tickets():
resolved_at=None, resolved_at=None,
) )
try: try:
if is_new:
db.session.add(ticket) db.session.add(ticket)
db.session.flush() db.session.flush()
# Minimal scope from job # Ensure a scope exists for this job so alerts/popups can show the ticket code.
scope = None
if job and job.id:
scope = TicketScope.query.filter_by(ticket_id=ticket.id, scope_type="job", job_id=job.id).first()
if not scope:
scope = TicketScope( scope = TicketScope(
ticket_id=ticket.id, ticket_id=ticket.id,
scope_type="job", scope_type="job",
@ -265,16 +246,27 @@ def api_tickets():
job_id=job.id if job else None, job_id=job.id if job else None,
job_name_match=job.job_name if job else None, job_name_match=job.job_name if job else None,
job_name_match_mode="exact", job_name_match_mode="exact",
resolved_at=None,
) )
db.session.add(scope) db.session.add(scope)
else:
# Re-open this ticket for this job if it was previously resolved for this scope.
scope.resolved_at = None
scope.customer_id = job.customer_id if job else scope.customer_id
scope.backup_software = job.backup_software if job else scope.backup_software
scope.backup_type = job.backup_type if job else scope.backup_type
scope.job_name_match = job.job_name if job else scope.job_name_match
scope.job_name_match_mode = "exact"
# Link ticket to this job run (idempotent)
if not TicketJobRun.query.filter_by(ticket_id=ticket.id, job_run_id=run.id).first():
link = TicketJobRun(ticket_id=ticket.id, job_run_id=run.id, link_source="manual") link = TicketJobRun(ticket_id=ticket.id, job_run_id=run.id, link_source="manual")
db.session.add(link) db.session.add(link)
db.session.commit() db.session.commit()
except Exception as exc: except Exception as exc:
db.session.rollback() db.session.rollback()
return jsonify({"status": "error", "message": str(exc) or "Failed to create ticket."}), 500 return jsonify({"status": "error", "message": str(exc) or "Failed to create ticket."}), 500
return jsonify( return jsonify(
{ {
@ -285,8 +277,8 @@ def api_tickets():
"description": ticket.description or "", "description": ticket.description or "",
"start_date": _format_datetime(ticket.start_date), "start_date": _format_datetime(ticket.start_date),
"active_from_date": str(ticket.active_from_date) if getattr(ticket, "active_from_date", None) else "", "active_from_date": str(ticket.active_from_date) if getattr(ticket, "active_from_date", None) else "",
"resolved_at": _format_datetime(ticket.resolved_at) if getattr(ticket, "resolved_at", None) else "", "resolved_at": "",
"active": getattr(ticket, "resolved_at", None) is None, "active": True,
}, },
} }
) )
@ -308,9 +300,68 @@ def api_ticket_resolve(ticket_id: int):
return jsonify({"status": "error", "message": "Forbidden."}), 403 return jsonify({"status": "error", "message": "Forbidden."}), 403
ticket = Ticket.query.get_or_404(ticket_id) ticket = Ticket.query.get_or_404(ticket_id)
if ticket.resolved_at is None: now = datetime.utcnow()
ticket.resolved_at = datetime.utcnow()
payload = request.get_json(silent=True) if request.is_json else {}
try: try:
run_id = int((payload or {}).get("job_run_id") or 0)
except Exception:
run_id = 0
# Job-scoped resolve (from popups / job details): resolve only for the job of the provided job_run_id
if run_id > 0:
run = JobRun.query.get(run_id)
if not run:
return jsonify({"status": "error", "message": "Job run not found."}), 404
job = Job.query.get(run.job_id) if run else None
job_id = job.id if job else None
try:
scope = None
if job_id:
scope = TicketScope.query.filter_by(ticket_id=ticket.id, scope_type="job", job_id=job_id).first()
if not scope:
scope = TicketScope(
ticket_id=ticket.id,
scope_type="job",
customer_id=job.customer_id if job else None,
backup_software=job.backup_software if job else None,
backup_type=job.backup_type if job else None,
job_id=job_id,
job_name_match=job.job_name if job else None,
job_name_match_mode="exact",
resolved_at=now,
)
db.session.add(scope)
else:
if scope.resolved_at is None:
scope.resolved_at = now
# Keep the audit link to the run (idempotent)
if not TicketJobRun.query.filter_by(ticket_id=ticket.id, job_run_id=run.id).first():
db.session.add(TicketJobRun(ticket_id=ticket.id, job_run_id=run.id, link_source="manual"))
# If all scopes are resolved, also resolve the ticket globally (so the central list shows it as resolved)
open_scope = TicketScope.query.filter_by(ticket_id=ticket.id, resolved_at=None).first()
if open_scope is None and ticket.resolved_at is None:
ticket.resolved_at = now
db.session.commit()
except Exception as exc:
db.session.rollback()
return jsonify({"status": "error", "message": str(exc) or "Failed to resolve ticket."}), 500
return jsonify({"status": "ok", "resolved_at": _format_datetime(now)})
# Global resolve (from central ticket list): resolve ticket and all scopes
if ticket.resolved_at is None:
ticket.resolved_at = now
try:
# Resolve any still-open scopes
TicketScope.query.filter_by(ticket_id=ticket.id, resolved_at=None).update({"resolved_at": now})
db.session.commit() db.session.commit()
except Exception as exc: except Exception as exc:
db.session.rollback() db.session.rollback()
@ -319,11 +370,9 @@ def api_ticket_resolve(ticket_id: int):
# If this endpoint is called from a regular HTML form submit (e.g. Tickets/Remarks page), # If this endpoint is called from a regular HTML form submit (e.g. Tickets/Remarks page),
# redirect back instead of showing raw JSON in the browser. # redirect back instead of showing raw JSON in the browser.
if not request.is_json and "application/json" not in (request.headers.get("Accept") or ""): if not request.is_json and "application/json" not in (request.headers.get("Accept") or ""):
return redirect(request.referrer or url_for("main.tickets_page")) return redirect(request.referrer or url_for("main.tickets"))
return jsonify({"status": "ok", "resolved_at": _format_datetime(ticket.resolved_at)})
return jsonify({"status": "ok", "resolved_at": _format_datetime(now)})
@main_bp.route("/api/tickets/<int:ticket_id>/link-run", methods=["POST"]) @main_bp.route("/api/tickets/<int:ticket_id>/link-run", methods=["POST"])
@login_required @login_required
@roles_required("admin", "operator", "viewer") @roles_required("admin", "operator", "viewer")

View File

@ -198,8 +198,8 @@ def job_detail(job_id: int):
WHERE ts.job_id = :job_id WHERE ts.job_id = :job_id
AND t.active_from_date <= :max_date AND t.active_from_date <= :max_date
AND ( AND (
t.resolved_at IS NULL COALESCE(ts.resolved_at, t.resolved_at) IS NULL
OR ((t.resolved_at AT TIME ZONE 'UTC' AT TIME ZONE :ui_tz)::date) >= :min_date OR ((COALESCE(ts.resolved_at, t.resolved_at) AT TIME ZONE 'UTC' AT TIME ZONE :ui_tz)::date) >= :min_date
) )
""" """
), ),

View File

@ -505,8 +505,8 @@ def run_checks_page():
WHERE ts.job_id = :job_id WHERE ts.job_id = :job_id
AND t.active_from_date <= :run_date AND t.active_from_date <= :run_date
AND ( AND (
t.resolved_at IS NULL COALESCE(ts.resolved_at, t.resolved_at) IS NULL
OR ((t.resolved_at AT TIME ZONE 'UTC' AT TIME ZONE :ui_tz)::date) >= :run_date OR ((COALESCE(ts.resolved_at, t.resolved_at) AT TIME ZONE 'UTC' AT TIME ZONE :ui_tz)::date) >= :run_date
) )
LIMIT 1 LIMIT 1
""" """

View File

@ -1351,6 +1351,7 @@ def migrate_object_persistence_tables() -> None:
job_id INTEGER REFERENCES jobs(id), job_id INTEGER REFERENCES jobs(id),
job_name_match VARCHAR(255), job_name_match VARCHAR(255),
job_name_match_mode VARCHAR(32), job_name_match_mode VARCHAR(32),
resolved_at TIMESTAMP,
created_at TIMESTAMP NOT NULL created_at TIMESTAMP NOT NULL
); );
""")) """))
@ -1364,6 +1365,12 @@ def migrate_object_persistence_tables() -> None:
UNIQUE(ticket_id, job_run_id) UNIQUE(ticket_id, job_run_id)
); );
""")) """))
# Ensure scope-level resolution exists for per-job ticket resolving
conn.execute(text("ALTER TABLE ticket_scopes ADD COLUMN IF NOT EXISTS resolved_at TIMESTAMP"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_ticket_scopes_ticket_id ON ticket_scopes (ticket_id)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_ticket_scopes_job_id ON ticket_scopes (job_id)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_ticket_scopes_resolved_at ON ticket_scopes (resolved_at)"))
conn.execute(text(""" conn.execute(text("""
CREATE TABLE IF NOT EXISTS remarks ( CREATE TABLE IF NOT EXISTS remarks (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,

View File

@ -397,6 +397,7 @@ class TicketScope(db.Model):
job_name_match = db.Column(db.String(255)) job_name_match = db.Column(db.String(255))
job_name_match_mode = db.Column(db.String(32)) job_name_match_mode = db.Column(db.String(32))
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
resolved_at = db.Column(db.DateTime)
class TicketJobRun(db.Model): class TicketJobRun(db.Model):

View File

@ -4,6 +4,13 @@
- Prevented duplicate ticket creation errors when reusing an existing ticket_code. - Prevented duplicate ticket creation errors when reusing an existing ticket_code.
- Ensured tickets are reused and linked instead of rejected when already present in the system. - Ensured tickets are reused and linked instead of rejected when already present in the system.
## v20260106-24-ticket-scope-resolve-popup
- Fixed missing ticket number display in job and run popups by always creating or reusing a ticket scope when linking an existing ticket to a job.
- Updated ticket resolution logic to support per-job resolution when resolving from a job or run context.
- Ensured resolving a ticket from the central Tickets view resolves the ticket globally and closes all associated job scopes.
- Updated ticket active status determination to be based on open job scopes, allowing the same ticket number to remain active for other jobs when applicable.
================================================================================================================================================ ================================================================================================================================================
## v0.1.17 ## v0.1.17