From 07e6630a899e451b4d734c2bd25eeb464474d1c4 Mon Sep 17 00:00:00 2001 From: Ivo Oskamp Date: Mon, 19 Jan 2026 12:53:24 +0100 Subject: [PATCH] Auto-commit local changes before build (2026-01-19 12:53:24) --- .last-branch | 2 +- .../app/integrations/autotask/client.py | 37 +++++ .../src/backend/app/main/routes_run_checks.py | 133 ++++++++++++++---- docs/changelog.md | 10 ++ 4 files changed, 152 insertions(+), 30 deletions(-) diff --git a/.last-branch b/.last-branch index a9c74c3..efacb08 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260119-03-autotask-reference-data-auto-refresh +v20260119-04-autotask-ticket-registration diff --git a/containers/backupchecks/src/backend/app/integrations/autotask/client.py b/containers/backupchecks/src/backend/app/integrations/autotask/client.py index 59a62f0..0382e41 100644 --- a/containers/backupchecks/src/backend/app/integrations/autotask/client.py +++ b/containers/backupchecks/src/backend/app/integrations/autotask/client.py @@ -430,3 +430,40 @@ class AutotaskClient: return items[0] raise AutotaskError("Autotask did not return a created ticket object.") + + + def get_ticket(self, ticket_id: int) -> Dict[str, Any]: + """Retrieve a Ticket by Autotask Ticket ID. + + Uses GET /Tickets/{id}. + + This is the authoritative retrieval method and is mandatory after creation, + because the create response does not reliably include the human-facing + ticket number. + """ + + try: + tid = int(ticket_id) + except Exception: + raise AutotaskError("Invalid ticket id.") + + if tid <= 0: + raise AutotaskError("Invalid ticket id.") + + data = self._request("GET", f"Tickets/{tid}") + if isinstance(data, dict): + if "item" in data and isinstance(data.get("item"), dict): + return data["item"] + if "items" in data and isinstance(data.get("items"), list) and data.get("items"): + first = data.get("items")[0] + if isinstance(first, dict): + return first + # Some environments return the raw object + if "id" in data or "ticketNumber" in data or "number" in data: + return data + + items = self._as_items_list(data) + if items: + return items[0] + + raise AutotaskError("Autotask did not return a ticket object.") 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 b66350c..fb4120d 100644 --- a/containers/backupchecks/src/backend/app/main/routes_run_checks.py +++ b/containers/backupchecks/src/backend/app/main/routes_run_checks.py @@ -35,6 +35,7 @@ from ..models import ( Override, User, ) +from ..ticketing_utils import ensure_internal_ticket_for_job, ensure_ticket_jobrun_links def _build_autotask_client_from_settings(): @@ -894,16 +895,7 @@ 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 getattr(run, "autotask_ticket_id", None): - return jsonify( - { - "status": "ok", - "ticket_id": int(run.autotask_ticket_id), - "ticket_number": getattr(run, "autotask_ticket_number", None) or "", - "already_exists": True, - } - ) + already_exists = bool(getattr(run, "autotask_ticket_id", None)) job = Job.query.get(run.job_id) if not job: @@ -1002,42 +994,125 @@ def api_run_checks_create_autotask_ticket(): if priority_id: payload["priority"] = int(priority_id) + client = None try: client = _build_autotask_client_from_settings() - created = client.create_ticket(payload) except Exception as exc: - return jsonify({"status": "error", "message": f"Autotask ticket creation failed: {exc}"}), 400 + return jsonify({"status": "error", "message": f"Autotask client setup failed: {exc}"}), 400 - ticket_id = created.get("id") if isinstance(created, dict) else None - ticket_number = None - if isinstance(created, dict): - ticket_number = created.get("ticketNumber") or created.get("number") or created.get("ticket_number") + ticket_id = getattr(run, "autotask_ticket_id", None) + ticket_number = getattr(run, "autotask_ticket_number", None) + # Create ticket only when missing. if not ticket_id: - return jsonify({"status": "error", "message": "Autotask did not return a ticket id."}), 400 + try: + created = client.create_ticket(payload) + except Exception as exc: + return jsonify({"status": "error", "message": f"Autotask ticket creation failed: {exc}"}), 400 + ticket_id = created.get("id") if isinstance(created, dict) else None + if isinstance(created, dict): + ticket_number = created.get("ticketNumber") or created.get("number") or created.get("ticket_number") + + if not ticket_id: + return jsonify({"status": "error", "message": "Autotask did not return a ticket id."}), 400 + + try: + run.autotask_ticket_id = int(ticket_id) + except Exception: + run.autotask_ticket_id = None + + run.autotask_ticket_number = (str(ticket_number).strip() if ticket_number is not None else "") or None + run.autotask_ticket_created_at = datetime.utcnow() + run.autotask_ticket_created_by_user_id = current_user.id + + try: + db.session.add(run) + db.session.commit() + except Exception as exc: + db.session.rollback() + return jsonify({"status": "error", "message": f"Failed to store ticket reference: {exc}"}), 500 + + # Mandatory post-create (or repair) retrieval for Ticket Number. + if ticket_id and not (ticket_number or "").strip(): + try: + fetched = client.get_ticket(int(ticket_id)) + ticket_number = None + if isinstance(fetched, dict): + ticket_number = fetched.get("ticketNumber") or fetched.get("number") or fetched.get("ticket_number") + ticket_number = (str(ticket_number).strip() if ticket_number is not None else "") or None + except Exception as exc: + # Ticket ID is persisted, but internal propagation must not proceed without the ticket number. + return jsonify({"status": "error", "message": f"Autotask ticket created but ticket number retrieval failed: {exc}"}), 400 + + if not ticket_number: + return jsonify({"status": "error", "message": "Autotask ticket created but ticket number is not available."}), 400 + + try: + run = JobRun.query.get(run_id) + if not run: + return jsonify({"status": "error", "message": "Run not found."}), 404 + run.autotask_ticket_number = ticket_number + db.session.add(run) + db.session.commit() + except Exception as exc: + db.session.rollback() + return jsonify({"status": "error", "message": f"Failed to store ticket number: {exc}"}), 500 + + # Internal ticket + linking propagation (required for UI parity) try: - run.autotask_ticket_id = int(ticket_id) - except Exception: - run.autotask_ticket_id = None + run = JobRun.query.get(run_id) + if not run: + return jsonify({"status": "error", "message": "Run not found."}), 404 - run.autotask_ticket_number = (str(ticket_number).strip() if ticket_number is not None else "") or None - run.autotask_ticket_created_at = datetime.utcnow() - run.autotask_ticket_created_by_user_id = current_user.id + ticket_id_int = int(getattr(run, "autotask_ticket_id", None) or 0) + ticket_number_str = (getattr(run, "autotask_ticket_number", None) or "").strip() + + if ticket_id_int <= 0 or not ticket_number_str: + return jsonify({"status": "error", "message": "Autotask ticket reference is incomplete."}), 400 + + # Create/reuse internal ticket (code == Autotask Ticket Number) + internal_ticket = ensure_internal_ticket_for_job( + ticket_code=ticket_number_str, + title=subject, + description=description, + job=job, + active_from_dt=getattr(run, "run_at", None) or datetime.utcnow(), + start_dt=getattr(run, "autotask_ticket_created_at", None) or datetime.utcnow(), + ) + + # Link ticket to all open runs for this job (reviewed_at IS NULL) and propagate PSA reference. + open_runs = JobRun.query.filter(JobRun.job_id == job.id, JobRun.reviewed_at.is_(None)).all() + run_ids_to_link: list[int] = [] + + for r in open_runs: + # Never overwrite an existing different Autotask ticket for a run. + existing_id = getattr(r, "autotask_ticket_id", None) + if existing_id and int(existing_id) != ticket_id_int: + continue + + if not existing_id: + r.autotask_ticket_id = ticket_id_int + r.autotask_ticket_number = ticket_number_str + r.autotask_ticket_created_at = getattr(run, "autotask_ticket_created_at", None) + r.autotask_ticket_created_by_user_id = getattr(run, "autotask_ticket_created_by_user_id", None) + db.session.add(r) + + run_ids_to_link.append(int(r.id)) + + ensure_ticket_jobrun_links(ticket_id=int(internal_ticket.id), run_ids=run_ids_to_link, link_source="autotask") - try: - db.session.add(run) db.session.commit() except Exception as exc: db.session.rollback() - return jsonify({"status": "error", "message": f"Failed to store ticket reference: {exc}"}), 500 + return jsonify({"status": "error", "message": f"Failed to propagate internal ticket linkage: {exc}"}), 500 return jsonify( { "status": "ok", - "ticket_id": int(run.autotask_ticket_id) if run.autotask_ticket_id else None, - "ticket_number": run.autotask_ticket_number or "", - "already_exists": False, + "ticket_id": int(getattr(run, "autotask_ticket_id", None) or 0) or None, + "ticket_number": getattr(run, "autotask_ticket_number", None) or "", + "already_exists": already_exists, } ) diff --git a/docs/changelog.md b/docs/changelog.md index d7a846d..08516c9 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -223,6 +223,16 @@ Changes: - Aligned Autotask ticket creation and polling paths with the legacy manual ticket creation flow, without changing any UI behavior. - Ensured solution works consistently with Autotask integration enabled or disabled by relying exclusively on internal Ticket and TicketJobRun structures. +## v20260119-04-autotask-ticket-registration + +### Changes: +- Implemented reliable Autotask ticket number retrieval by enforcing a post-create GET on the created ticket, avoiding incomplete create responses. +- Added automatic creation or reuse of an internal Ticket based on the Autotask ticket number to preserve legacy ticket behavior. +- Ensured idempotent linking of the internal Ticket to all open job runs (reviewed_at IS NULL) for the same job, matching manual ticket functionality. +- Propagated Autotask ticket references (autotask_ticket_id and autotask_ticket_number) to all related open runs when a ticket is created. +- Added repair/propagation logic so runs that already have an Autotask ticket ID but lack internal linking are corrected automatically. +- Guaranteed that future runs for the same job inherit the existing Autotask and internal ticket associations. + *** ## v0.1.21