Auto-commit local changes before build (2026-02-03 17:16:24)

This commit is contained in:
Ivo Oskamp 2026-02-03 17:16:24 +01:00
parent 55c6f7ddd6
commit 5ec64e6a13
4 changed files with 128 additions and 81 deletions

View File

@ -1 +1 @@
v20260203-12-autotask-resolution-v1-casing-fix v20260203-13-autotask-resolution-item-wrapper

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,6 +558,102 @@ class AutotaskClient:
return {"id": tid} 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]: 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,60 +1914,11 @@ def api_run_checks_autotask_resolve_note():
} }
), 400 ), 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: try:
t = client.get_ticket(ticket_id) client.update_ticket_resolution_safe(ticket_id, body)
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)
except Exception as exc: except Exception as exc:
return jsonify( 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), "message": "Resolve note was created, but updating the ticket resolution field failed: " + str(exc),
} }
), 400 ), 400
try: return jsonify({"status": "ok", "message": "Resolve note posted to Autotask ticket."})
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.
@ -2064,6 +1995,20 @@ def api_run_checks_autotask_resolve_note():
} }
), 400 ), 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( return jsonify(
{ {
"status": "ok", "status": "ok",
@ -2323,4 +2268,4 @@ def api_run_checks_mark_success_override():
except Exception: except Exception:
pass pass
return jsonify({"status": "ok", "message": "Override created."}) return jsonify({"status": "ok", "message": "Override created."})

View File

@ -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. - 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. - 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 ## v0.1.21