Merge branch 'v20260203-13-autotask-resolution-item-wrapper' into main
This commit is contained in:
commit
e12755321a
@ -1 +1 @@
|
||||
v20260203-12-autotask-resolution-v1-casing-fix
|
||||
v20260203-13-autotask-resolution-item-wrapper
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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."})
|
||||
return jsonify({"status": "ok", "message": "Override created."})
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user