From a4a6a60d457280307a3b90b9e2c580b165c71d7f Mon Sep 17 00:00:00 2001 From: Ivo Oskamp Date: Tue, 3 Feb 2026 14:13:08 +0100 Subject: [PATCH] Auto-commit local changes before build (2026-02-03 14:13:08) --- .last-branch | 2 +- .../app/integrations/autotask/client.py | 147 +++++++++++------- .../src/backend/app/main/routes_run_checks.py | 2 +- docs/changelog.md | 7 + 4 files changed, 104 insertions(+), 54 deletions(-) diff --git a/.last-branch b/.last-branch index 88cd114..dce2111 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260203-06-autotask-ticketnotes-child-endpoint +v20260203-07-autotask-notes-endpoint-fix diff --git a/containers/backupchecks/src/backend/app/integrations/autotask/client.py b/containers/backupchecks/src/backend/app/integrations/autotask/client.py index 494b231..417433c 100644 --- a/containers/backupchecks/src/backend/app/integrations/autotask/client.py +++ b/containers/backupchecks/src/backend/app/integrations/autotask/client.py @@ -111,6 +111,7 @@ class AutotaskClient: path: str, params: Optional[Dict[str, Any]] = None, json_body: Optional[Dict[str, Any]] = None, + parse_json: bool = True, ) -> Any: zone = self.get_zone_info() base = zone.api_url.rstrip("/") @@ -161,12 +162,48 @@ class AutotaskClient: if resp.status_code == 404: raise AutotaskError(f"Resource not found (HTTP 404) for path: {path}", status_code=404) if resp.status_code >= 400: - raise AutotaskError(f"Autotask API error (HTTP {resp.status_code}).", status_code=resp.status_code) + snippet = (resp.text or "").strip().replace("\r", " ").replace("\n", " ") + if len(snippet) > 500: + snippet = snippet[:500] + "..." + msg = f"Autotask API error (HTTP {resp.status_code})." + if snippet: + msg += f" Body: {snippet}" + raise AutotaskError(msg, status_code=resp.status_code) + + # Successful responses may return an empty body (204) or non-JSON content for some write endpoints. + body_text = resp.text or "" + content_type = (resp.headers.get("Content-Type") or "").lower() + + if not parse_json: + if resp.status_code == 204 or body_text.strip() == "": + return None + if "json" in content_type: + try: + return resp.json() + except Exception: + return body_text + return body_text + + if resp.status_code == 204 or body_text.strip() == "": + return None + + if "json" not in content_type: + raise AutotaskError( + f"Autotask API response is not JSON (Content-Type: {content_type or 'unknown'}).", + status_code=resp.status_code, + ) try: return resp.json() except Exception as exc: - raise AutotaskError("Autotask API response is not valid JSON.") from exc + snippet = body_text.strip().replace("\r", " ").replace("\n", " ") + if len(snippet) > 500: + snippet = snippet[:500] + "..." + raise AutotaskError( + "Autotask API response is not valid JSON." + (f" Body: {snippet}" if snippet else ""), + status_code=resp.status_code, + ) from exc + def _as_items_list(self, payload: Any) -> List[Dict[str, Any]]: """Normalize common Autotask REST payload shapes to a list of dicts.""" @@ -522,68 +559,74 @@ class AutotaskClient: def create_ticket_note(self, note_payload: Dict[str, Any]) -> Dict[str, Any]: - """Create a TicketNote for a Ticket. + """Create a user-visible note on a Ticket. - Autotask TicketNotes are a child collection of Tickets. In some tenants, creating notes via the - root entity endpoint (POST /TicketNotes) is not supported, while creating via the parent ticket - child URL may work (POST /Tickets/{id}/TicketNotes). + Preferred route (validated in Postman for this tenant): + - POST /Tickets/{id}/Notes - Callers can keep a fallback (for example updating the Ticket description) if both routes fail. - """ + Some tenants return an empty body (204) or non-JSON content on success; callers should verify + creation via TicketNotes queries if needed. + """ - if not isinstance(note_payload, dict): - raise AutotaskError("Invalid ticket note payload.") + if not isinstance(note_payload, dict): + raise AutotaskError("Invalid ticket note payload.") - try: - tid = int(note_payload.get("ticketID") or note_payload.get("ticketId") or 0) - except Exception: - tid = 0 - if tid <= 0: - raise AutotaskError("Invalid ticketID in ticket note payload.") - - title = str(note_payload.get("title") or "Backupchecks") - description = str( - note_payload.get("description") - or note_payload.get("note") - or note_payload.get("body") - or "" - ) - - pub_val = note_payload.get("publish") - # REST uses an integer picklist; in practice '1' corresponds to "ALL" / all Autotask users. - if isinstance(pub_val, bool): - publish = 1 if pub_val else 1 - else: try: - publish = int(pub_val) if pub_val is not None else 1 + tid = int(note_payload.get("ticketID") or note_payload.get("ticketId") or 0) except Exception: - publish = 1 + tid = 0 + if tid <= 0: + raise AutotaskError("Invalid ticketID in ticket note payload.") - child_payload = { - "title": title, - "description": description, - "publish": publish, - } + title = str(note_payload.get("title") or "Backupchecks") + description = str( + note_payload.get("description") + or note_payload.get("note") + or note_payload.get("body") + or "" + ) - # Preferred: parent-child URL - data = self._request("POST", f"Tickets/{tid}/TicketNotes", json_body=child_payload) + pub_val = note_payload.get("publish") + # REST uses an integer picklist; in practice "1" corresponds to publishing to all users. + if isinstance(pub_val, bool): + publish = 1 if pub_val else 1 + else: + try: + publish = int(pub_val) if pub_val is not None else 1 + except Exception: + publish = 1 - 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 - if "id" in data: - return data + child_payload = { + "title": title, + # Some tenants expect "description", others expect "note". We send both. + "description": description, + "note": description, + "noteType": 1, + "publish": publish, + } - items = self._as_items_list(data) - if items: - return items[0] + # Validated: parent-child Notes endpoint + data = self._request("POST", f"Tickets/{tid}/Notes", json_body=child_payload, parse_json=False) - raise AutotaskError("Autotask did not return a created ticket note object.") + # Successful note creation may return no body; allow callers to verify via queries. + if data is None or isinstance(data, str): + return {} + 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 + if "id" in data: + return data + + items = self._as_items_list(data) + if items: + return items[0] + + return {} def get_ticket_note(self, note_id: int) -> Dict[str, Any]: """Retrieve a TicketNote by ID via GET /TicketNotes/{id}.""" 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 036b276..0b8a7ea 100644 --- a/containers/backupchecks/src/backend/app/main/routes_run_checks.py +++ b/containers/backupchecks/src/backend/app/main/routes_run_checks.py @@ -1815,7 +1815,7 @@ def api_run_checks_autotask_resolve_note(): """Post a user-visible 'should be resolved' update to an existing Autotask ticket. This step does NOT close the ticket in Autotask. - Primary behaviour: create a TicketNote via POST /TicketNotes so the message is clearly visible. + Primary behaviour: create a Ticket note via POST /Tickets/{id}/Notes so the message is clearly visible. Fallback behaviour: if TicketNote create is not supported (HTTP 404), append the marker text to the Ticket description via PUT /Tickets and verify persistence. """ diff --git a/docs/changelog.md b/docs/changelog.md index 11e0491..a2a66df 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -493,6 +493,13 @@ Changes: - Removed usage of the unsupported POST /TicketNotes endpoint. - Ensured the created note is user-visible in Autotask and clearly marks the ticket as resolved by Backupchecks. +## v20260203-07-autotask-notes-endpoint-fix + +- Fixed Autotask ticket note creation using the POST /Tickets/{TicketID}/Notes endpoint. +- Updated response handling to support empty or non-JSON success responses without JSON parsing errors. +- Improved backend error handling so Autotask write errors return valid JSON instead of breaking the request. +- Made the ticket note payload more robust to support tenant-specific field requirements. + *** ## v0.1.21