From 66f5a57fe063e18403ea4c716a4774171d46195f Mon Sep 17 00:00:00 2001 From: Ivo Oskamp Date: Thu, 15 Jan 2026 16:17:26 +0100 Subject: [PATCH] Auto-commit local changes before build (2026-01-15 16:17:26) --- .last-branch | 2 +- .../app/integrations/autotask/client.py | 93 ++++++++++++++++--- docs/changelog.md | 5 + 3 files changed, 84 insertions(+), 16 deletions(-) diff --git a/.last-branch b/.last-branch index a30bfd0..a340f78 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260115-15-autotask-default-ticket-status-setting +v20260115-16-autotask-ticket-create-response-fix diff --git a/containers/backupchecks/src/backend/app/integrations/autotask/client.py b/containers/backupchecks/src/backend/app/integrations/autotask/client.py index 59a62f0..a9dab64 100644 --- a/containers/backupchecks/src/backend/app/integrations/autotask/client.py +++ b/containers/backupchecks/src/backend/app/integrations/autotask/client.py @@ -105,19 +105,20 @@ class AutotaskClient: "Accept": "application/json", } - def _request( + def _request_raw( self, method: str, path: str, params: Optional[Dict[str, Any]] = None, json_body: Optional[Dict[str, Any]] = None, - ) -> Any: + ) -> requests.Response: + """Perform an Autotask REST API request and return the raw response.""" zone = self.get_zone_info() base = zone.api_url.rstrip("/") url = f"{base}/v1.0/{path.lstrip('/')}" headers = self._headers() - def do_request(use_basic_auth: bool, extra_headers: Optional[Dict[str, str]] = None): + def do_request(use_basic_auth: bool, extra_headers: Optional[Dict[str, str]] = None) -> requests.Response: h = dict(headers) if extra_headers: h.update(extra_headers) @@ -149,8 +150,7 @@ class AutotaskClient: raise AutotaskError( "Authentication failed (HTTP 401). " "Verify API Username, API Secret, and ApiIntegrationCode. " - f"Environment={self.environment}, ZoneInfoBase={zi_base}, ZoneApiUrl={zone.api_url}." - , + f"Environment={self.environment}, ZoneInfoBase={zi_base}, ZoneApiUrl={zone.api_url}.", status_code=401, ) if resp.status_code == 403: @@ -163,6 +163,19 @@ class AutotaskClient: if resp.status_code >= 400: raise AutotaskError(f"Autotask API error (HTTP {resp.status_code}).", status_code=resp.status_code) + return resp + + def _request( + self, + method: str, + path: str, + params: Optional[Dict[str, Any]] = None, + json_body: Optional[Dict[str, Any]] = None, + ) -> Any: + resp = self._request_raw(method=method, path=path, params=params, json_body=json_body) + if not (resp.content or b""): + return {} + try: return resp.json() except Exception as exc: @@ -404,6 +417,15 @@ class AutotaskClient: """ return self._get_ticket_picklist_values(field_names=["status", "statusid"]) + def get_ticket(self, ticket_id: int) -> Dict[str, Any]: + """Fetch a Ticket by ID via GET /Tickets/.""" + if not isinstance(ticket_id, int) or ticket_id <= 0: + raise AutotaskError("Invalid Autotask ticket id.") + data = self._request("GET", f"Tickets/{ticket_id}") + if isinstance(data, dict) and data: + return data + raise AutotaskError("Autotask did not return a ticket object.") + def create_ticket(self, payload: Dict[str, Any]) -> Dict[str, Any]: """Create a Ticket in Autotask. @@ -413,20 +435,61 @@ class AutotaskClient: if not isinstance(payload, dict) or not payload: raise AutotaskError("Ticket payload is empty.") - data = self._request("POST", "Tickets", json_body=payload) - # Autotask commonly returns the created object or an items list. + resp = self._request_raw("POST", "Tickets", json_body=payload) + + data: Any = {} + if resp.content: + try: + data = resp.json() + except Exception: + # Some tenants return an empty body or a non-JSON body on successful POST. + data = {} + + ticket_id: Optional[int] = None + + # Autotask may return a lightweight create result like {"itemId": 12345}. 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"): + for key in ("itemId", "itemID", "id", "ticketId", "ticketID"): + if key in data and str(data.get(key) or "").isdigit(): + ticket_id = int(data[key]) + break + + # Some variants wrap the created entity. + if ticket_id is None and "item" in data and isinstance(data.get("item"), dict): + item = data.get("item") + if "id" in item and str(item.get("id") or "").isdigit(): + ticket_id = int(item["id"]) + else: + return item + + if ticket_id is None and "items" in data and isinstance(data.get("items"), list) and data.get("items"): first = data.get("items")[0] if isinstance(first, dict): - return first - if "id" in data: - return data - # Fallback: return normalized first item if possible + if "id" in first and str(first.get("id") or "").isdigit(): + ticket_id = int(first["id"]) + else: + return first + + # Location header often contains the created entity URL. + if ticket_id is None: + location = (resp.headers.get("Location") or resp.headers.get("location") or "").strip() + if location: + try: + last = location.rstrip("/").split("/")[-1] + if last.isdigit(): + ticket_id = int(last) + except Exception: + ticket_id = None + + # If we have an ID, fetch the full ticket object so callers can reliably access ticketNumber etc. + if ticket_id is not None: + return self.get_ticket(ticket_id) + + # Last-resort fallback: normalize first item if possible. items = self._as_items_list(data) if items: return items[0] - raise AutotaskError("Autotask did not return a created ticket object.") + raise AutotaskError( + f"Autotask did not return a created ticket object (HTTP {resp.status_code})." + ) diff --git a/docs/changelog.md b/docs/changelog.md index d21635a..6715874 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -112,6 +112,11 @@ Changes: - Extended reference data refresh to include Ticket Statuses and updated diagnostics counters accordingly. - Added database column for cached ticket statuses and included it in migrations for existing installations. +## v20260115-16-autotask-ticket-create-response-fix +- Fixed Autotask ticket creation handling for tenants that return a lightweight or empty POST /Tickets response. +- Added support for extracting the created ticket ID from itemId/id fields and from the Location header. +- Added a follow-up GET /Tickets/{id} to always retrieve the full created ticket object (ensuring ticketNumber/id are available). + *** ## v0.1.21