Auto-commit local changes before build (2026-02-03 16:50:56)
This commit is contained in:
parent
2667e44830
commit
55c6f7ddd6
@ -1 +1 @@
|
|||||||
v20260203-11-autotask-resolution-get-put-required-fields
|
v20260203-12-autotask-resolution-v1-casing-fix
|
||||||
|
|||||||
@ -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.
|
||||||
|
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user