Auto-commit local changes before build (2026-02-03 16:50:56)

This commit is contained in:
Ivo Oskamp 2026-02-03 16:50:56 +01:00
parent 2667e44830
commit 55c6f7ddd6
4 changed files with 85 additions and 133 deletions

View File

@ -1 +1 @@
v20260203-11-autotask-resolution-get-put-required-fields v20260203-12-autotask-resolution-v1-casing-fix

View File

@ -44,8 +44,8 @@ class AutotaskClient:
To keep connection testing reliable, we try the expected base first To keep connection testing reliable, we try the expected base first
and fall back to the alternative if needed. and fall back to the alternative if needed.
""" """
prod = "https://webservices.autotask.net/atservicesrest" prod = "https://webservices.autotask.net/ATServicesRest"
sb = "https://webservices2.autotask.net/atservicesrest" sb = "https://webservices2.autotask.net/ATServicesRest"
if self.environment == "sandbox": if self.environment == "sandbox":
return [sb, prod] return [sb, prod]
return [prod, sb] return [prod, sb]
@ -57,7 +57,7 @@ class AutotaskClient:
last_error: Optional[str] = None last_error: Optional[str] = None
data: Optional[Dict[str, Any]] = None data: Optional[Dict[str, Any]] = None
for base in self._zoneinfo_bases(): for base in self._zoneinfo_bases():
url = f"{base.rstrip('/')}/v1.0/zoneInformation" url = f"{base.rstrip('/')}/V1.0/zoneInformation"
params = {"user": self.username} params = {"user": self.username}
try: try:
resp = requests.get(url, params=params, timeout=self.timeout_seconds) resp = requests.get(url, params=params, timeout=self.timeout_seconds)
@ -115,7 +115,7 @@ class AutotaskClient:
) -> Any: ) -> Any:
zone = self.get_zone_info() zone = self.get_zone_info()
base = zone.api_url.rstrip("/") base = zone.api_url.rstrip("/")
url = f"{base}/v1.0/{path.lstrip('/')}" url = f"{base}/V1.0/{path.lstrip('/')}"
headers = self._headers() 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):
@ -558,118 +558,6 @@ class AutotaskClient:
return {"id": tid} 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]: def create_ticket_note(self, note_payload: Dict[str, Any]) -> Dict[str, Any]:
"""Create a user-visible note on a Ticket. """Create a user-visible note on a Ticket.

View File

@ -1914,18 +1914,89 @@ def api_run_checks_autotask_resolve_note():
} }
), 400 ), 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: try:
client.update_ticket_resolution(ticket_id, body) t = client.get_ticket(ticket_id)
except Exception as exc: except Exception as exc:
return jsonify( return jsonify(
{ {
"status": "error", "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 ), 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: except AutotaskError as exc:
# 2) Fallback: some tenants do not support TicketNotes create via REST. # 2) Fallback: some tenants do not support TicketNotes create via REST.
@ -1993,18 +2064,6 @@ def api_run_checks_autotask_resolve_note():
} }
), 400 ), 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( return jsonify(
{ {
"status": "ok", "status": "ok",

View File

@ -518,6 +518,11 @@ Changes:
- Prevents failure when issueType/subIssueType/source are not present under the expected keys after ticket creation or later 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. - 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 ## v0.1.21