diff --git a/.last-branch b/.last-branch index c22fb62..2ab7240 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260203-12-autotask-resolution-v1-casing-fix +v20260203-13-autotask-resolution-item-wrapper diff --git a/containers/backupchecks/src/backend/app/integrations/autotask/client.py b/containers/backupchecks/src/backend/app/integrations/autotask/client.py index caf2261..b53115f 100644 --- a/containers/backupchecks/src/backend/app/integrations/autotask/client.py +++ b/containers/backupchecks/src/backend/app/integrations/autotask/client.py @@ -44,8 +44,8 @@ class AutotaskClient: To keep connection testing reliable, we try the expected base first and fall back to the alternative if needed. """ - prod = "https://webservices.autotask.net/ATServicesRest" - sb = "https://webservices2.autotask.net/ATServicesRest" + prod = "https://webservices.autotask.net/atservicesrest" + sb = "https://webservices2.autotask.net/atservicesrest" if self.environment == "sandbox": return [sb, prod] return [prod, sb] @@ -57,7 +57,7 @@ class AutotaskClient: last_error: Optional[str] = None data: Optional[Dict[str, Any]] = None for base in self._zoneinfo_bases(): - url = f"{base.rstrip('/')}/V1.0/zoneInformation" + url = f"{base.rstrip('/')}/v1.0/zoneInformation" params = {"user": self.username} try: resp = requests.get(url, params=params, timeout=self.timeout_seconds) @@ -115,7 +115,7 @@ class AutotaskClient: ) -> Any: zone = self.get_zone_info() base = zone.api_url.rstrip("/") - url = f"{base}/V1.0/{path.lstrip('/')}" + url = f"{base}/v1.0/{path.lstrip('/')}" headers = self._headers() def do_request(use_basic_auth: bool, extra_headers: Optional[Dict[str, str]] = None): @@ -558,6 +558,102 @@ class AutotaskClient: return {"id": tid} + + +def update_ticket_resolution_safe(self, ticket_id: int, resolution_text: str) -> Dict[str, Any]: + """Safely update the Ticket 'resolution' field without changing status. + + Autotask Tickets require a full PUT update; therefore we must: + - GET /Tickets/{id} to retrieve current stabilising fields (including classification/routing) + - PUT /Tickets with those stabilising fields unchanged, and only update 'resolution' + + IMPORTANT: + - GET /Tickets/{id} returns the ticket object under the 'item' envelope in most tenants. + - PUT payloads are not wrapped; fields are sent at the JSON root. + """ + + 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}") + + ticket: Dict[str, Any] = {} + if isinstance(data, dict): + if "item" in data and isinstance(data.get("item"), dict): + ticket = data["item"] + elif "items" in data and isinstance(data.get("items"), list) and data.get("items"): + first = data.get("items")[0] + if isinstance(first, dict): + ticket = first + else: + ticket = data + elif isinstance(data, list) and data: + if isinstance(data[0], dict): + ticket = data[0] + + if not isinstance(ticket, dict) or not ticket: + raise AutotaskError("Autotask did not return a ticket object.") + + def _pick(d: Dict[str, Any], keys: List[str]) -> Any: + for k in keys: + if k in d and d.get(k) not in (None, ""): + return d.get(k) + return None + + # Required stabilising fields for safe resolution updates (validated via Postman tests) + resolved_issue_type = _pick(ticket, ["issueType", "issueTypeID", "issueTypeId"]) + resolved_sub_issue_type = _pick(ticket, ["subIssueType", "subIssueTypeID", "subIssueTypeId"]) + resolved_source = _pick(ticket, ["source", "sourceID", "sourceId"]) + resolved_status = _pick(ticket, ["status", "statusID", "statusId"]) + + missing: List[str] = [] + if _pick(ticket, ["id"]) in (None, ""): + missing.append("id") + if resolved_issue_type in (None, ""): + missing.append("issueType") + if resolved_sub_issue_type in (None, ""): + missing.append("subIssueType") + if resolved_source in (None, ""): + missing.append("source") + if resolved_status in (None, ""): + missing.append("status") + + if missing: + raise AutotaskError( + "Cannot safely update ticket resolution because required fields are missing: " + ", ".join(missing) + ) + + payload: Dict[str, Any] = { + "id": int(ticket.get("id")), + "issueType": resolved_issue_type, + "subIssueType": resolved_sub_issue_type, + "source": resolved_source, + # Keep status unchanged + "status": resolved_status, + "resolution": str(resolution_text or ""), + } + + # Copy other stabilising fields when available (helps avoid tenant-specific validation errors) + optional_fields = [ + "companyID", + "queueID", + "title", + "priority", + "dueDateTime", + "ticketCategory", + "organizationalLevelAssociationID", + ] + for f in optional_fields: + if f in ticket: + payload[f] = ticket.get(f) + + return self.update_ticket(payload) + def create_ticket_note(self, note_payload: Dict[str, Any]) -> Dict[str, Any]: """Create a user-visible note on a Ticket. 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 3a76084..00f3e88 100644 --- a/containers/backupchecks/src/backend/app/main/routes_run_checks.py +++ b/containers/backupchecks/src/backend/app/main/routes_run_checks.py @@ -1914,60 +1914,11 @@ def api_run_checks_autotask_resolve_note(): } ), 400 - # Also write the same information into the Ticket resolution field. - # Contract: Always GET /Tickets/{id} first, then PUT /Tickets with stabilising fields. + + # Also mirror the same information into the Ticket 'resolution' field (validated Postman pattern): + # GET /Tickets/{id} -> copy stabilising fields -> PUT /Tickets with status unchanged and only resolution changed. try: - t = client.get_ticket(ticket_id) - except Exception as exc: - return jsonify( - { - "status": "error", - "message": "Resolve note was created, but updating the ticket resolution field failed: Autotask ticket retrieval failed: " - + str(exc), - } - ), 400 - - if not isinstance(t, dict): - return jsonify( - { - "status": "error", - "message": "Resolve note was created, but updating the ticket resolution field failed: Autotask did not return a ticket object.", - } - ), 400 - - # Required stabilising fields (must be copied from GET; never guessed) - stabilise_fields = [ - "id", - "companyID", - "queueID", - "title", - "priority", - "status", - "dueDateTime", - "ticketCategory", - "issueType", - "subIssueType", - "source", - "organizationalLevelAssociationID", - ] - missing = [f for f in stabilise_fields if t.get(f) in (None, "")] - if missing: - return jsonify( - { - "status": "error", - "message": "Resolve note was created, but updating the ticket resolution field failed: " - + "Cannot safely update ticket resolution because required fields are missing: " - + ", ".join(missing), - } - ), 400 - - payload = {k: t.get(k) for k in stabilise_fields} - payload["id"] = int(t.get("id")) - # Keep status unchanged; only update resolution. - payload["resolution"] = body - - try: - client.update_ticket(payload) + client.update_ticket_resolution_safe(ticket_id, body) except Exception as exc: return jsonify( { @@ -1975,28 +1926,8 @@ def api_run_checks_autotask_resolve_note(): "message": "Resolve note was created, but updating the ticket resolution field failed: " + str(exc), } ), 400 - - try: - t2 = client.get_ticket(ticket_id) - except Exception as exc: - return jsonify( - { - "status": "error", - "message": "Resolve note was created, but updating the ticket resolution field failed: verification GET failed: " - + str(exc), - } - ), 400 - - persisted_res = str((t2 or {}).get("resolution") or "") - if marker not in persisted_res: - return jsonify( - { - "status": "error", - "message": "Resolve note was created, but updating the ticket resolution field failed: resolution was not persisted.", - } - ), 400 - - return jsonify({"status": "ok", "message": "Resolve note posted to Autotask ticket and written to ticket resolution."}) + + return jsonify({"status": "ok", "message": "Resolve note posted to Autotask ticket."}) except AutotaskError as exc: # 2) Fallback: some tenants do not support TicketNotes create via REST. @@ -2064,6 +1995,20 @@ def api_run_checks_autotask_resolve_note(): } ), 400 + + + # Also mirror the same information into the Ticket 'resolution' field. + try: + client.update_ticket_resolution_safe(ticket_id, body) + except Exception as exc: + return jsonify( + { + "status": "error", + "message": "Resolve note marker was appended to the ticket description, but updating the ticket resolution field failed: " + + str(exc), + } + ), 400 + return jsonify( { "status": "ok", @@ -2323,4 +2268,4 @@ def api_run_checks_mark_success_override(): except Exception: pass - return jsonify({"status": "ok", "message": "Override created."}) \ No newline at end of file + return jsonify({"status": "ok", "message": "Override created."}) diff --git a/docs/changelog.md b/docs/changelog.md index ffb763d..f48dccc 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -523,6 +523,12 @@ Changes: - Ticket GET for resolution updates now uses the authoritative endpoint GET /Tickets/{TicketID} on .../ATServicesRest/V1.0, ensuring issueType/subIssueType/source are retrieved before PUT. - Resolution update continues to keep ticket status unchanged and only writes the resolution field. +## v20260203-13-autotask-resolution-item-wrapper + +- Fix: Resolution update now always reads stabilising fields from GET /Tickets/{id} response under item.* (issueType, subIssueType, source, status). +- Added a dedicated safe helper to update the Ticket resolution field via GET+PUT while keeping status unchanged. +- The resolve-note action now mirrors the same note text into the Ticket resolution field (both in the normal path and the 404 fallback path). + *** ## v0.1.21