diff --git a/.last-branch b/.last-branch index df72be1..ec32f49 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260119-18-fix-legacy-ticketnumber-sync +v20260120-01-autotask-deleted-ticket-detection diff --git a/containers/backupchecks/src/backend/app/integrations/autotask/client.py b/containers/backupchecks/src/backend/app/integrations/autotask/client.py index 4aaaa8a..c3d21b4 100644 --- a/containers/backupchecks/src/backend/app/integrations/autotask/client.py +++ b/containers/backupchecks/src/backend/app/integrations/autotask/client.py @@ -483,6 +483,37 @@ class AutotaskClient: raise AutotaskError("Autotask did not return a ticket object.") + def query_deleted_ticket_logs_by_ticket_ids(self, ticket_ids: List[int]) -> List[Dict[str, Any]]: + """Query DeletedTicketLogs for a set of ticket IDs. + + Uses POST /DeletedTicketLogs/query. + + Returns list items including ticketID, ticketNumber, deletedByResourceID, deletedDateTime. + """ + + ids: List[int] = [] + for x in ticket_ids or []: + try: + v = int(x) + except Exception: + continue + if v > 0: + ids.append(v) + + if not ids: + return [] + + # Field name differs across docs/tenants (ticketID vs ticketId). + # Autotask query field matching is case-insensitive in most tenants; we use the common ticketID. + payload = { + "filter": [ + {"op": "in", "field": "ticketID", "value": ids}, + ] + } + + data = self._request("POST", "DeletedTicketLogs/query", json_body=payload) + return self._as_items_list(data) + def query_tickets_by_ids( self, ticket_ids: List[int], 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 75f8a90..845e5ef 100644 --- a/containers/backupchecks/src/backend/app/main/routes_run_checks.py +++ b/containers/backupchecks/src/backend/app/main/routes_run_checks.py @@ -109,13 +109,14 @@ def _resolve_internal_ticket_for_job( job: Job | None, run_ids: list[int], now: datetime, + origin: str = "psa", ) -> None: """Resolve the ticket (and its job scope) as PSA-driven, best-effort.""" if ticket.resolved_at is None: ticket.resolved_at = now if getattr(ticket, "resolved_origin", None) is None: - ticket.resolved_origin = "psa" + ticket.resolved_origin = origin # Resolve all still-open scopes. try: @@ -191,6 +192,89 @@ def _poll_autotask_ticket_states_for_runs(*, run_ids: list[int]) -> None: now = datetime.utcnow() ticket_ids = sorted(ticket_to_runs.keys()) + # Deleted tickets: check DeletedTicketLogs first (authoritative). + deleted_map: dict[int, dict] = {} + try: + deleted_items = client.query_deleted_ticket_logs_by_ticket_ids(ticket_ids) + except Exception: + deleted_items = [] + + for it in deleted_items or []: + if not isinstance(it, dict): + continue + raw_tid = it.get("ticketID") if "ticketID" in it else it.get("ticketId") + try: + tid_int = int(raw_tid) if raw_tid is not None else 0 + except Exception: + tid_int = 0 + if tid_int <= 0: + continue + deleted_map[tid_int] = it + + # Persist deleted audit fields on runs and resolve internal ticket as PSA-deleted. + for tid, item in deleted_map.items(): + runs_for_ticket = ticket_to_runs.get(tid) or [] + if not runs_for_ticket: + continue + deleted_by = item.get("deletedByResourceID") if "deletedByResourceID" in item else item.get("deletedByResourceId") + deleted_dt_raw = item.get("deletedDateTime") or item.get("deletedDatetime") or item.get("deletedAt") + deleted_dt = None + if deleted_dt_raw: + try: + s = str(deleted_dt_raw).replace("Z", "+00:00") + deleted_dt = datetime.fromisoformat(s) + if deleted_dt.tzinfo is not None: + deleted_dt = deleted_dt.astimezone(timezone.utc).replace(tzinfo=None) + except Exception: + deleted_dt = None + try: + deleted_by_int = int(deleted_by) if deleted_by is not None else None + except Exception: + deleted_by_int = None + + # Backfill ticket number (if present in log) + ticket_number = item.get("ticketNumber") or item.get("ticket_number") + for rr in runs_for_ticket: + if deleted_dt and getattr(rr, "autotask_ticket_deleted_at", None) is None: + rr.autotask_ticket_deleted_at = deleted_dt + if deleted_by_int and getattr(rr, "autotask_ticket_deleted_by_resource_id", None) is None: + rr.autotask_ticket_deleted_by_resource_id = deleted_by_int + if ticket_number and not (getattr(rr, "autotask_ticket_number", None) or "").strip(): + rr.autotask_ticket_number = str(ticket_number).strip() + db.session.add(rr) + + # Resolve internal ticket with origin psa_deleted (best-effort) + tn = "" + if ticket_number: + tn = str(ticket_number).strip() + if not tn: + for rr in runs_for_ticket: + if (getattr(rr, "autotask_ticket_number", None) or "").strip(): + tn = rr.autotask_ticket_number.strip() + break + job = Job.query.get(runs_for_ticket[0].job_id) if runs_for_ticket else None + active_from_dt = None + try: + dts = [getattr(x, "run_at", None) for x in runs_for_ticket if getattr(x, "run_at", None)] + active_from_dt = min(dts) if dts else None + except Exception: + active_from_dt = None + internal_ticket = _ensure_internal_ticket_for_autotask( + ticket_number=tn, + job=job, + run_ids=[int(x.id) for x in runs_for_ticket if getattr(x, "id", None)], + now=now, + active_from_dt=active_from_dt, + ) + if internal_ticket is not None: + _resolve_internal_ticket_for_job( + ticket=internal_ticket, + job=job, + run_ids=[int(x.id) for x in runs_for_ticket if getattr(x, "id", None)], + now=deleted_dt or now, + origin="psa_deleted", + ) + # Optimization: query non-terminal tickets first; fallback to GET by id for missing. try: active_items = client.query_tickets_by_ids(ticket_ids, exclude_status_ids=sorted(AUTOTASK_TERMINAL_STATUS_IDS)) @@ -206,7 +290,7 @@ def _poll_autotask_ticket_states_for_runs(*, run_ids: list[int]) -> None: if iid > 0: active_map[iid] = it - missing_ids = [tid for tid in ticket_ids if tid not in active_map] + missing_ids = [tid for tid in ticket_ids if tid not in active_map and tid not in deleted_map] # Process active tickets: backfill ticket numbers + ensure internal ticket link. try: @@ -1178,6 +1262,9 @@ def run_checks_details(): "autotask_ticket_is_resolved": bool(at_resolved), "autotask_ticket_resolved_origin": at_resolved_origin, "autotask_ticket_resolved_at": at_resolved_at, + "autotask_ticket_is_deleted": bool(getattr(run, "autotask_ticket_deleted_at", None)), + "autotask_ticket_deleted_at": _format_datetime(getattr(run, "autotask_ticket_deleted_at", None)) if getattr(run, "autotask_ticket_deleted_at", None) else "", + "autotask_ticket_deleted_by_resource_id": getattr(run, "autotask_ticket_deleted_by_resource_id", None), } ) diff --git a/containers/backupchecks/src/backend/app/migrations.py b/containers/backupchecks/src/backend/app/migrations.py index 8dfca71..aafa9e9 100644 --- a/containers/backupchecks/src/backend/app/migrations.py +++ b/containers/backupchecks/src/backend/app/migrations.py @@ -924,6 +924,7 @@ def run_migrations() -> None: migrate_job_runs_review_tracking() migrate_job_runs_override_metadata() migrate_job_runs_autotask_ticket_fields() + migrate_job_runs_autotask_ticket_deleted_fields() migrate_jobs_archiving() migrate_news_tables() migrate_reporting_tables() @@ -993,6 +994,46 @@ def migrate_job_runs_autotask_ticket_fields() -> None: print("[migrations] migrate_job_runs_autotask_ticket_fields completed.") +def migrate_job_runs_autotask_ticket_deleted_fields() -> None: + """Add Autotask deleted ticket audit fields to job_runs if missing. + + Columns: + - job_runs.autotask_ticket_deleted_at (TIMESTAMP NULL) + - job_runs.autotask_ticket_deleted_by_resource_id (INTEGER NULL) + """ + + table = "job_runs" + try: + engine = db.get_engine() + except Exception as exc: + print(f"[migrations] Could not get engine for job_runs Autotask ticket deleted fields migration: {exc}") + return + + try: + with engine.begin() as conn: + cols = _get_table_columns(conn, table) + if not cols: + print("[migrations] job_runs table not found; skipping migrate_job_runs_autotask_ticket_deleted_fields.") + return + + if "autotask_ticket_deleted_at" not in cols: + print("[migrations] Adding job_runs.autotask_ticket_deleted_at column...") + conn.execute(text('ALTER TABLE "job_runs" ADD COLUMN autotask_ticket_deleted_at TIMESTAMP')) + + if "autotask_ticket_deleted_by_resource_id" not in cols: + print("[migrations] Adding job_runs.autotask_ticket_deleted_by_resource_id column...") + conn.execute(text('ALTER TABLE "job_runs" ADD COLUMN autotask_ticket_deleted_by_resource_id INTEGER')) + + conn.execute(text('CREATE INDEX IF NOT EXISTS idx_job_runs_autotask_ticket_deleted_by_resource_id ON "job_runs" (autotask_ticket_deleted_by_resource_id)')) + + conn.execute(text('CREATE INDEX IF NOT EXISTS idx_job_runs_autotask_ticket_deleted_at ON "job_runs" (autotask_ticket_deleted_at)')) + except Exception as exc: + print(f"[migrations] migrate_job_runs_autotask_ticket_deleted_fields failed (continuing): {exc}") + return + + print("[migrations] migrate_job_runs_autotask_ticket_deleted_fields completed.") + + def migrate_jobs_archiving() -> None: """Add archiving columns to jobs if missing. diff --git a/containers/backupchecks/src/backend/app/models.py b/containers/backupchecks/src/backend/app/models.py index b799cc4..703fe6d 100644 --- a/containers/backupchecks/src/backend/app/models.py +++ b/containers/backupchecks/src/backend/app/models.py @@ -281,6 +281,9 @@ class JobRun(db.Model): autotask_ticket_number = db.Column(db.String(64), nullable=True) autotask_ticket_created_at = db.Column(db.DateTime, nullable=True) autotask_ticket_created_by_user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) + autotask_ticket_deleted_at = db.Column(db.DateTime, nullable=True) + autotask_ticket_deleted_by_resource_id = db.Column(db.Integer, nullable=True) + created_at = db.Column(db.DateTime, default=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 93c94ba..977b3d8 100644 --- a/containers/backupchecks/src/templates/main/run_checks.html +++ b/containers/backupchecks/src/templates/main/run_checks.html @@ -884,10 +884,18 @@ table.addEventListener('change', function (e) { var num = (run && run.autotask_ticket_number) ? String(run.autotask_ticket_number) : ''; var isResolved = !!(run && run.autotask_ticket_is_resolved); var origin = (run && run.autotask_ticket_resolved_origin) ? String(run.autotask_ticket_resolved_origin) : ''; + var isDeleted = !!(run && run.autotask_ticket_is_deleted); + var deletedAt = (run && run.autotask_ticket_deleted_at) ? String(run.autotask_ticket_deleted_at) : ''; + var deletedBy = (run && run.autotask_ticket_deleted_by_resource_id) ? String(run.autotask_ticket_deleted_by_resource_id) : ''; if (num) { var extra = ''; - if (isResolved && origin === 'psa') { + if (isDeleted) { + var meta = ''; + if (deletedAt) meta += '