diff --git a/.last-branch b/.last-branch index ded7de3..cfed06c 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260119-12-autotask-ticket-state-sync +v20260119-13-autotask-psa-resolved-recreate diff --git a/containers/backupchecks/src/backend/app/main/routes_api.py b/containers/backupchecks/src/backend/app/main/routes_api.py index 0da7b4f..395968a 100644 --- a/containers/backupchecks/src/backend/app/main/routes_api.py +++ b/containers/backupchecks/src/backend/app/main/routes_api.py @@ -347,6 +347,8 @@ def api_ticket_resolve(ticket_id: int): 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 + if getattr(ticket, "resolved_origin", None) is None: + ticket.resolved_origin = "backupchecks" db.session.commit() except Exception as exc: @@ -358,6 +360,8 @@ def api_ticket_resolve(ticket_id: int): # Global resolve (from central ticket list): resolve ticket and all scopes if ticket.resolved_at is None: ticket.resolved_at = now + if getattr(ticket, "resolved_origin", None) is None: + ticket.resolved_origin = "backupchecks" try: # Resolve any still-open scopes 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 bb07517..9e67216 100644 --- a/containers/backupchecks/src/backend/app/main/routes_run_checks.py +++ b/containers/backupchecks/src/backend/app/main/routes_run_checks.py @@ -111,6 +111,8 @@ def _resolve_internal_ticket_for_job( if ticket.resolved_at is None: ticket.resolved_at = now + if getattr(ticket, "resolved_origin", None) is None: + ticket.resolved_origin = "psa" # Resolve all still-open scopes. try: @@ -999,6 +1001,20 @@ def run_checks_details(): runs = q.order_by(func.coalesce(JobRun.run_at, JobRun.created_at).desc(), JobRun.id.desc()).limit(400).all() + # Prefetch internal ticket resolution info for Autotask-linked runs (Phase 2 UI). + autotask_codes = set() + for _r in runs: + code = (getattr(_r, "autotask_ticket_number", None) or "").strip() + if code: + autotask_codes.add(code) + ticket_by_code = {} + if autotask_codes: + try: + for _t in Ticket.query.filter(Ticket.ticket_code.in_(list(autotask_codes))).all(): + ticket_by_code[_t.ticket_code] = _t + except Exception: + ticket_by_code = {} + runs_payload = [] for run in runs: msg = MailMessage.query.get(run.mail_message_id) if run.mail_message_id else None @@ -1104,6 +1120,20 @@ def run_checks_details(): except Exception: pass + # Autotask ticket resolution info (derived from internal Ticket) + at_resolved = False + at_resolved_origin = "" + at_resolved_at = "" + try: + _code = (getattr(run, "autotask_ticket_number", None) or "").strip() + if _code and _code in ticket_by_code: + _t = ticket_by_code[_code] + at_resolved = getattr(_t, "resolved_at", None) is not None + at_resolved_origin = (getattr(_t, "resolved_origin", None) or "") + at_resolved_at = _format_datetime(getattr(_t, "resolved_at", None)) if getattr(_t, "resolved_at", None) else "" + except Exception: + pass + status_display = run.status or "-" try: status_display, _, _, _ov_id, _ov_reason = _apply_overrides_to_run(job, run) @@ -1127,6 +1157,9 @@ def run_checks_details(): "objects": objects_payload, "autotask_ticket_id": getattr(run, "autotask_ticket_id", None), "autotask_ticket_number": getattr(run, "autotask_ticket_number", None) or "", + "autotask_ticket_is_resolved": bool(at_resolved), + "autotask_ticket_resolved_origin": at_resolved_origin, + "autotask_ticket_resolved_at": at_resolved_at, } ) @@ -1165,9 +1198,27 @@ def api_run_checks_create_autotask_ticket(): if not run: return jsonify({"status": "error", "message": "Run not found."}), 404 - # Idempotent: if already created, return existing linkage. + # If a ticket is already linked we normally prevent duplicate creation. + # Exception: if the linked ticket is resolved (e.g. resolved by PSA), allow creating a new ticket. if getattr(run, "autotask_ticket_id", None): - return jsonify( + already_resolved = False + try: + code = (getattr(run, "autotask_ticket_number", None) or "").strip() + if code: + t = Ticket.query.filter_by(ticket_code=code).first() + already_resolved = bool(getattr(t, "resolved_at", None)) if t else False + except Exception: + already_resolved = False + if not already_resolved: + return jsonify( + { + "status": "ok", + "ticket_id": int(run.autotask_ticket_id), + "ticket_number": getattr(run, "autotask_ticket_number", None) or "", + "already_exists": True, + } + ) + # resolved -> continue, create a new Autotask ticket and overwrite current linkage. { "status": "ok", "ticket_id": int(run.autotask_ticket_id), diff --git a/containers/backupchecks/src/backend/app/migrations.py b/containers/backupchecks/src/backend/app/migrations.py index b1d1405..69fa355 100644 --- a/containers/backupchecks/src/backend/app/migrations.py +++ b/containers/backupchecks/src/backend/app/migrations.py @@ -894,6 +894,7 @@ def run_migrations() -> None: migrate_feedback_tables() migrate_feedback_replies_table() migrate_tickets_active_from_date() + migrate_tickets_resolved_origin() migrate_remarks_active_from_date() migrate_overrides_match_columns() migrate_job_runs_review_tracking() @@ -1253,6 +1254,33 @@ def migrate_tickets_active_from_date() -> None: + +def migrate_tickets_resolved_origin() -> None: + """Add tickets.resolved_origin column if missing. + + Used to show whether a ticket was resolved by PSA polling or manually inside Backupchecks. + """ + + table = "tickets" + try: + engine = db.get_engine() + except Exception as exc: + print(f"[migrations] Could not get engine for tickets resolved_origin migration: {exc}") + return + + try: + with engine.connect() as conn: + cols = _get_table_columns(conn, table) + if not cols: + return + if "resolved_origin" not in cols: + print("[migrations] Adding tickets.resolved_origin column...") + conn.execute(text('ALTER TABLE "tickets" ADD COLUMN resolved_origin VARCHAR(32)')) + except Exception as exc: + print(f"[migrations] tickets resolved_origin migration failed (continuing): {exc}") + + print("[migrations] migrate_tickets_resolved_origin completed.") + def migrate_mail_messages_overall_message() -> None: """Add overall_message column to mail_messages if missing.""" table = "mail_messages" diff --git a/containers/backupchecks/src/backend/app/models.py b/containers/backupchecks/src/backend/app/models.py index 4ecba7d..b799cc4 100644 --- a/containers/backupchecks/src/backend/app/models.py +++ b/containers/backupchecks/src/backend/app/models.py @@ -421,6 +421,8 @@ class Ticket(db.Model): # Audit timestamp: when the ticket was created (UTC, naive) start_date = db.Column(db.DateTime, nullable=False) resolved_at = db.Column(db.DateTime) + # Resolution origin for audit/UI: psa | backupchecks + resolved_origin = db.Column(db.String(32)) 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) diff --git a/containers/backupchecks/src/templates/main/run_checks.html b/containers/backupchecks/src/templates/main/run_checks.html index dcf56ca..b06bd5b 100644 --- a/containers/backupchecks/src/templates/main/run_checks.html +++ b/containers/backupchecks/src/templates/main/run_checks.html @@ -216,12 +216,21 @@