From 71752fb9263abf5eb33bccd50af66a0cd99b6196 Mon Sep 17 00:00:00 2001 From: Ivo Oskamp Date: Tue, 6 Jan 2026 20:50:50 +0100 Subject: [PATCH] Auto-commit local changes before build (2026-01-06 20:50:50) --- .last-branch | 2 +- .../src/backend/app/main/routes_api.py | 151 ++++++++++++------ .../src/backend/app/main/routes_jobs.py | 4 +- .../src/backend/app/main/routes_run_checks.py | 4 +- .../src/backend/app/migrations.py | 7 + .../backupchecks/src/backend/app/models.py | 3 +- docs/changelog.md | 7 + 7 files changed, 121 insertions(+), 57 deletions(-) diff --git a/.last-branch b/.last-branch index 2133819..43a2923 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260106-22-ticket-link-multiple-jobs +v20260106-24-ticket-scope-resolve-popup diff --git a/containers/backupchecks/src/backend/app/main/routes_api.py b/containers/backupchecks/src/backend/app/main/routes_api.py index 87f2a65..0da7b4f 100644 --- a/containers/backupchecks/src/backend/app/main/routes_api.py +++ b/containers/backupchecks/src/backend/app/main/routes_api.py @@ -22,14 +22,19 @@ def api_job_run_alerts(run_id: int): db.session.execute( 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 JOIN ticket_scopes ts ON ts.ticket_id = t.id WHERE ts.job_id = :job_id AND t.active_from_date <= :run_date AND ( - t.resolved_at IS NULL - OR ((t.resolved_at AT TIME ZONE 'UTC' AT TIME ZONE :ui_tz)::date) >= :run_date + COALESCE(ts.resolved_at, t.resolved_at) IS NULL + 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 """ @@ -169,7 +174,7 @@ def api_tickets(): "active_from_date": str(getattr(t, "active_from_date", "") or ""), "start_date": _format_datetime(t.start_date), "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}) @@ -205,42 +210,12 @@ def api_tickets(): return jsonify({"status": "error", "message": "Invalid ticket_code format. Expected TYYYYMMDD.####."}), 400 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: 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: ticket = Ticket( ticket_code=ticket_code, @@ -251,11 +226,17 @@ def api_tickets(): resolved_at=None, ) - try: + try: + if is_new: db.session.add(ticket) 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( ticket_id=ticket.id, scope_type="job", @@ -265,16 +246,27 @@ def api_tickets(): job_id=job.id if job else None, job_name_match=job.job_name if job else None, job_name_match_mode="exact", + resolved_at=None, ) 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") db.session.add(link) - db.session.commit() - except Exception as exc: - db.session.rollback() - return jsonify({"status": "error", "message": str(exc) or "Failed to create ticket."}), 500 + db.session.commit() + except Exception as exc: + db.session.rollback() + return jsonify({"status": "error", "message": str(exc) or "Failed to create ticket."}), 500 return jsonify( { @@ -285,8 +277,8 @@ def api_tickets(): "description": ticket.description or "", "start_date": _format_datetime(ticket.start_date), "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 "", - "active": getattr(ticket, "resolved_at", None) is None, + "resolved_at": "", + "active": True, }, } ) @@ -308,9 +300,68 @@ def api_ticket_resolve(ticket_id: int): return jsonify({"status": "error", "message": "Forbidden."}), 403 ticket = Ticket.query.get_or_404(ticket_id) - if ticket.resolved_at is None: - ticket.resolved_at = datetime.utcnow() + now = datetime.utcnow() + + payload = request.get_json(silent=True) if request.is_json else {} 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() except Exception as exc: 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), # 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 ""): - return redirect(request.referrer or url_for("main.tickets_page")) - - return jsonify({"status": "ok", "resolved_at": _format_datetime(ticket.resolved_at)}) - + return redirect(request.referrer or url_for("main.tickets")) + return jsonify({"status": "ok", "resolved_at": _format_datetime(now)}) @main_bp.route("/api/tickets//link-run", methods=["POST"]) @login_required @roles_required("admin", "operator", "viewer") diff --git a/containers/backupchecks/src/backend/app/main/routes_jobs.py b/containers/backupchecks/src/backend/app/main/routes_jobs.py index 7d96507..2c4dc54 100644 --- a/containers/backupchecks/src/backend/app/main/routes_jobs.py +++ b/containers/backupchecks/src/backend/app/main/routes_jobs.py @@ -198,8 +198,8 @@ def job_detail(job_id: int): WHERE ts.job_id = :job_id AND t.active_from_date <= :max_date AND ( - t.resolved_at IS NULL - OR ((t.resolved_at AT TIME ZONE 'UTC' AT TIME ZONE :ui_tz)::date) >= :min_date + COALESCE(ts.resolved_at, t.resolved_at) IS NULL + OR ((COALESCE(ts.resolved_at, t.resolved_at) AT TIME ZONE 'UTC' AT TIME ZONE :ui_tz)::date) >= :min_date ) """ ), 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 12908f3..2ecfb7f 100644 --- a/containers/backupchecks/src/backend/app/main/routes_run_checks.py +++ b/containers/backupchecks/src/backend/app/main/routes_run_checks.py @@ -505,8 +505,8 @@ def run_checks_page(): WHERE ts.job_id = :job_id AND t.active_from_date <= :run_date AND ( - t.resolved_at IS NULL - OR ((t.resolved_at AT TIME ZONE 'UTC' AT TIME ZONE :ui_tz)::date) >= :run_date + COALESCE(ts.resolved_at, t.resolved_at) IS NULL + OR ((COALESCE(ts.resolved_at, t.resolved_at) AT TIME ZONE 'UTC' AT TIME ZONE :ui_tz)::date) >= :run_date ) LIMIT 1 """ diff --git a/containers/backupchecks/src/backend/app/migrations.py b/containers/backupchecks/src/backend/app/migrations.py index b431a75..3387793 100644 --- a/containers/backupchecks/src/backend/app/migrations.py +++ b/containers/backupchecks/src/backend/app/migrations.py @@ -1351,6 +1351,7 @@ def migrate_object_persistence_tables() -> None: job_id INTEGER REFERENCES jobs(id), job_name_match VARCHAR(255), job_name_match_mode VARCHAR(32), + resolved_at TIMESTAMP, created_at TIMESTAMP NOT NULL ); """)) @@ -1364,6 +1365,12 @@ def migrate_object_persistence_tables() -> None: 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(""" CREATE TABLE IF NOT EXISTS remarks ( id SERIAL PRIMARY KEY, diff --git a/containers/backupchecks/src/backend/app/models.py b/containers/backupchecks/src/backend/app/models.py index 7b0f26a..bdcfa50 100644 --- a/containers/backupchecks/src/backend/app/models.py +++ b/containers/backupchecks/src/backend/app/models.py @@ -397,6 +397,7 @@ class TicketScope(db.Model): job_name_match = db.Column(db.String(255)) job_name_match_mode = db.Column(db.String(32)) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + resolved_at = db.Column(db.DateTime) class TicketJobRun(db.Model): @@ -649,4 +650,4 @@ class ReportObjectSummary(db.Model): report = db.relationship( "ReportDefinition", backref=db.backref("object_summaries", lazy="dynamic", cascade="all, delete-orphan"), - ) + ) \ No newline at end of file diff --git a/docs/changelog.md b/docs/changelog.md index aa7fa27..66c9e20 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -4,6 +4,13 @@ - 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. +## 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 -- 2.45.2