diff --git a/.last-branch b/.last-branch index a370cad..c22fb62 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260203-11-autotask-resolution-get-put-required-fields +v20260203-12-autotask-resolution-v1-casing-fix diff --git a/containers/backupchecks/src/backend/app/integrations/autotask/client.py b/containers/backupchecks/src/backend/app/integrations/autotask/client.py index ae786bd..caf2261 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,118 +558,6 @@ class AutotaskClient: return {"id": tid} - def update_ticket_resolution(self, ticket_id: int, resolution_text: str) -> Dict[str, Any]: - """Update a Ticket's `resolution` field via PUT /Tickets. - - This follows the validated contract in `autotask_rest_api_postman_test_contract.md`: - - Always GET /Tickets/{id} first - - PUT /Tickets is a full update, so we copy stabilising fields and change only `resolution` - - Status must remain unchanged during the resolution write - - Raises AutotaskError on validation failures. - """ - - try: - tid = int(ticket_id) - except Exception: - tid = 0 - if tid <= 0: - raise AutotaskError("Invalid ticket id.") - - res_txt = str(resolution_text or "") - if not res_txt.strip(): - raise AutotaskError("Resolution text is empty.") - - t = self.get_ticket(tid) - if not isinstance(t, dict): - raise AutotaskError("Autotask did not return a ticket object.") - - # Some Autotask environments return slightly different field names (e.g. *ID vs *Id). - # We always source values from the fresh GET and send the canonical field names in the PUT payload. - field_sources: Dict[str, list[str]] = { - "id": ["id"], - "companyID": ["companyID", "companyId"], - "queueID": ["queueID", "queueId"], - "title": ["title"], - "priority": ["priority"], - "status": ["status"], - "dueDateTime": ["dueDateTime", "dueDate", "dueDateUtc"], - "ticketCategory": ["ticketCategory", "ticketCategoryID", "ticketCategoryId"], - "issueType": ["issueType", "issueTypeID", "issueTypeId"], - "subIssueType": ["subIssueType", "subIssueTypeID", "subIssueTypeId"], - "source": ["source", "sourceID", "sourceId"], - "organizationalLevelAssociationID": [ - "organizationalLevelAssociationID", - "organizationalLevelAssociationId", - ], - } - - def _get_first(ticket_obj: Dict[str, Any], keys: list[str]) -> Any: - """Return first matching value for any of the given keys. - - Autotask field casing / suffixes can vary by tenant and API surface. - We therefore try direct lookups first and then fall back to a - case-insensitive scan of the ticket payload keys. - """ - if not isinstance(ticket_obj, dict): - return None - - # Direct lookups (fast path) - for k in keys: - if k in ticket_obj: - return ticket_obj.get(k) - - # Case-insensitive fallback - lower_map = {str(k).lower(): k for k in ticket_obj.keys()} - for k in keys: - lk = str(k).lower() - if lk in lower_map: - return ticket_obj.get(lower_map[lk]) - - return None - - stabilising_fields = list(field_sources.keys()) - - resolved_values: Dict[str, Any] = {} - missing: list[str] = [] - for f in stabilising_fields: - v = _get_first(t, field_sources[f]) - # Treat None/"" as missing, but allow 0 for picklists. - if v in (None, ""): - missing.append(f) - resolved_values[f] = v - - if missing: - raise AutotaskError( - "Cannot safely update ticket resolution because required fields are missing: " + ", ".join(missing) - ) - - existing = str(t.get("resolution") or "") - if existing.strip(): - if res_txt.strip() in existing: - new_res = existing - else: - new_res = existing.rstrip() + "\n\n" + res_txt - else: - new_res = res_txt - - payload: Dict[str, Any] = dict(resolved_values) - payload["id"] = int(resolved_values.get("id")) - # Explicitly keep status unchanged. - payload["status"] = resolved_values.get("status") - payload["resolution"] = new_res - - self.update_ticket(payload) - - # Verify persistence. - t2 = self.get_ticket(tid) - persisted = str((t2 or {}).get("resolution") or "") - if res_txt.strip() not in persisted: - raise AutotaskError("Ticket resolution update returned success, but verification failed.") - - return {"id": tid, "resolution_updated": True} - - 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 3891ca7..3a76084 100644 --- a/containers/backupchecks/src/backend/app/main/routes_run_checks.py +++ b/containers/backupchecks/src/backend/app/main/routes_run_checks.py @@ -1914,18 +1914,89 @@ def api_run_checks_autotask_resolve_note(): } ), 400 - # Also write the same information into the Ticket resolution field (validated safe update pattern). + # Also write the same information into the Ticket resolution field. + # Contract: Always GET /Tickets/{id} first, then PUT /Tickets with stabilising fields. try: - client.update_ticket_resolution(ticket_id, body) + t = client.get_ticket(ticket_id) except Exception as exc: return jsonify( { "status": "error", - "message": f"Resolve note was created, but updating the ticket resolution field failed: {exc}", + "message": "Resolve note was created, but updating the ticket resolution field failed: Autotask ticket retrieval failed: " + + str(exc), } ), 400 - return jsonify({"status": "ok", "message": "Resolve note posted to Autotask ticket."}) + 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) + except Exception as exc: + return jsonify( + { + "status": "error", + "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."}) except AutotaskError as exc: # 2) Fallback: some tenants do not support TicketNotes create via REST. @@ -1993,18 +2064,6 @@ def api_run_checks_autotask_resolve_note(): } ), 400 - # Also write the same information into the Ticket resolution field (validated safe update pattern). - try: - client.update_ticket_resolution(ticket_id, body) - except Exception as exc: - return jsonify( - { - "status": "error", - "message": f"Ticket note creation is not supported (HTTP 404) and the marker was appended to the description, " - f"but updating the ticket resolution field failed: {exc}", - } - ), 400 - return jsonify( { "status": "ok", diff --git a/docs/changelog.md b/docs/changelog.md index 9edae79..ffb763d 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -518,6 +518,11 @@ Changes: - Prevents failure when issueType/subIssueType/source are not present under the expected keys after ticket creation or later changes. - Keeps ticket status unchanged while updating resolution, per validated Postman contract. +## v20260203-12-autotask-resolution-v1-casing-fix +- Fixed Autotask REST base URL casing: ATServicesRest and V1.0 are now used exactly as required by the validated Postman contract. +- 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. + *** ## v0.1.21