From f6216b880314c54eb4d1fe9c6efef02807645bb2 Mon Sep 17 00:00:00 2001 From: Ivo Oskamp Date: Tue, 3 Feb 2026 13:12:53 +0100 Subject: [PATCH] Auto-commit local changes before build (2026-02-03 13:12:53) --- .last-branch | 2 +- .../app/integrations/autotask/client.py | 79 +++++++++++++ .../src/backend/app/main/routes_run_checks.py | 108 +++++++++++++++--- docs/changelog.md | 8 ++ 4 files changed, 179 insertions(+), 18 deletions(-) diff --git a/.last-branch b/.last-branch index 3cc2163..40b0957 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260203-03-autotask-resolve-note-verify +v20260203-04-autotask-resolve-user-note diff --git a/containers/backupchecks/src/backend/app/integrations/autotask/client.py b/containers/backupchecks/src/backend/app/integrations/autotask/client.py index 36f0af5..114beb3 100644 --- a/containers/backupchecks/src/backend/app/integrations/autotask/client.py +++ b/containers/backupchecks/src/backend/app/integrations/autotask/client.py @@ -521,6 +521,85 @@ class AutotaskClient: return {"id": tid} + def create_ticket_note(self, note_payload: Dict[str, Any]) -> Dict[str, Any]: + """Create a TicketNote via POST /TicketNotes. + + Note: Tenant support varies. Callers should handle AutotaskError(status_code=404) + and implement a fallback if needed. + """ + + if not isinstance(note_payload, dict): + raise AutotaskError("Invalid ticket note payload.") + + try: + tid = int(note_payload.get("ticketID") or note_payload.get("ticketId") or 0) + except Exception: + tid = 0 + if tid <= 0: + raise AutotaskError("Invalid ticketID in ticket note payload.") + + data = self._request("POST", "TicketNotes", json_body=note_payload) + if isinstance(data, dict): + if "item" in data and isinstance(data.get("item"), dict): + return data["item"] + if "items" in data and isinstance(data.get("items"), list) and data.get("items"): + first = data.get("items")[0] + if isinstance(first, dict): + return first + if "id" in data: + return data + + items = self._as_items_list(data) + if items: + return items[0] + + raise AutotaskError("Autotask did not return a created ticket note object.") + + def get_ticket_note(self, note_id: int) -> Dict[str, Any]: + """Retrieve a TicketNote by ID via GET /TicketNotes/{id}.""" + + try: + nid = int(note_id) + except Exception: + raise AutotaskError("Invalid ticket note id.") + + if nid <= 0: + raise AutotaskError("Invalid ticket note id.") + + data = self._request("GET", f"TicketNotes/{nid}") + if isinstance(data, dict): + if "item" in data and isinstance(data.get("item"), dict): + return data["item"] + if "items" in data and isinstance(data.get("items"), list) and data.get("items"): + first = data.get("items")[0] + if isinstance(first, dict): + return first + if "id" in data or "ticketID" in data or "note" in data: + return data + + items = self._as_items_list(data) + if items: + return items[0] + + raise AutotaskError("Autotask did not return a ticket note object.") + + def query_ticket_notes_by_ticket_id(self, ticket_id: int, *, limit: int = 50) -> List[Dict[str, Any]]: + """Query TicketNotes for a Ticket via POST /TicketNotes/query.""" + + try: + tid = int(ticket_id) + except Exception: + tid = 0 + if tid <= 0: + return [] + + payload = {"filter": [{"op": "eq", "field": "ticketID", "value": tid}]} + data = self._request("POST", "TicketNotes/query", json_body=payload) + items = self._as_items_list(data) + if limit and isinstance(limit, int) and limit > 0: + return items[: int(limit)] + return items + def get_resource(self, resource_id: int) -> Dict[str, Any]: """Retrieve a Resource by Autotask Resource ID. 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 200fa5a..6454626 100644 --- a/containers/backupchecks/src/backend/app/main/routes_run_checks.py +++ b/containers/backupchecks/src/backend/app/main/routes_run_checks.py @@ -1807,17 +1807,21 @@ def api_run_checks_autotask_link_existing_ticket(): ) + @main_bp.post("/api/run-checks/autotask-resolve-note") @login_required @roles_required("admin", "operator") def api_run_checks_autotask_resolve_note(): - """Post a 'should be resolved' update to an existing Autotask ticket. + """Post a user-visible 'should be resolved' update to an existing Autotask ticket. - This first-step implementation does NOT close the ticket in Autotask. - It updates the Ticket description via PUT /Tickets (TicketNotes create is - not reliably supported across tenants). + This step does NOT close the ticket in Autotask. + Primary behaviour: create a TicketNote via POST /TicketNotes so the message is clearly visible. + Fallback behaviour: if TicketNote create is not supported (HTTP 404), append the marker text + to the Ticket description via PUT /Tickets and verify persistence. """ + from ..integrations.autotask.client import AutotaskError + data = request.get_json(silent=True) or {} try: @@ -1843,8 +1847,81 @@ def api_run_checks_autotask_resolve_note(): if ticket_id <= 0: return jsonify({"status": "error", "message": "Run has an invalid Autotask ticket id."}), 400 + now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%SZ") + actor = (getattr(current_user, "email", None) or getattr(current_user, "username", None) or "operator") + ticket_number = str(getattr(run, "autotask_ticket_number", "") or "").strip() + + marker = "[Backupchecks] Marked as resolved in Backupchecks" + body = ( + f"{marker} (ticket remains open in Autotask).\n" + f"Time: {now}\n" + f"By: {actor}\n" + + (f"Ticket: {ticket_number}\n" if ticket_number else "") + ) + try: client = _build_autotask_client_from_settings() + except Exception as exc: + return jsonify({"status": "error", "message": f"Autotask client setup failed: {exc}"}), 400 + + # 1) Preferred: create an explicit TicketNote (user-visible update) + try: + note_payload = { + "ticketID": ticket_id, + "title": "Backupchecks", + "note": body, + "publish": True, + } + created = client.create_ticket_note(note_payload) + + note_id = 0 + try: + note_id = int(created.get("id") or created.get("itemID") or created.get("itemId") or 0) + except Exception: + note_id = 0 + + verified = False + if note_id > 0: + try: + n = client.get_ticket_note(note_id) + n_txt = str((n or {}).get("note") or (n or {}).get("description") or "") + if marker in n_txt: + verified = True + except Exception: + verified = False + + if not verified: + try: + notes = client.query_ticket_notes_by_ticket_id(ticket_id, limit=50) + for n in notes or []: + n_txt = str((n or {}).get("note") or (n or {}).get("description") or "") + if marker in n_txt: + verified = True + break + except Exception: + verified = False + + if not verified: + return jsonify( + { + "status": "error", + "message": "Ticket note creation returned success, but the marker text was not found when verifying. " + "Please check Autotask permissions and TicketNotes support for this tenant.", + } + ), 400 + + 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. + if getattr(exc, "status_code", None) != 404: + return jsonify({"status": "error", "message": f"Autotask ticket note creation failed: {exc}"}), 400 + + except Exception as exc: + return jsonify({"status": "error", "message": f"Autotask ticket note creation failed: {exc}"}), 400 + + # Fallback path: append marker to ticket description via PUT /Tickets and verify persistence. + try: t = client.get_ticket(ticket_id) except Exception as exc: return jsonify({"status": "error", "message": f"Autotask ticket retrieval failed: {exc}"}), 400 @@ -1852,26 +1929,19 @@ def api_run_checks_autotask_resolve_note(): if not isinstance(t, dict): return jsonify({"status": "error", "message": "Autotask did not return a ticket object."}), 400 - # Build an update payload based on known required fields from Postman validation. required_fields = ["id", "companyID", "queueID", "title", "priority", "status", "dueDateTime"] missing = [f for f in required_fields if t.get(f) in (None, "")] if missing: return jsonify( { "status": "error", - "message": "Cannot safely update Autotask ticket because required fields are missing: " - + ", ".join(missing), + "message": "Cannot safely update Autotask ticket because required fields are missing: " + ", ".join(missing), } ), 400 existing_desc = str(t.get("description") or "") + note = f"\n\n{body}" - now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%SZ") - actor = (getattr(current_user, "email", None) or getattr(current_user, "username", None) or "operator") - marker = "[Backupchecks] Marked as resolved in Backupchecks" - note = f"\n\n{marker} (ticket remains open in Autotask). {now} by {actor}" - - # Avoid runaway growth if the same action is clicked multiple times. if marker in existing_desc: new_desc = existing_desc else: @@ -1893,7 +1963,6 @@ def api_run_checks_autotask_resolve_note(): except Exception as exc: return jsonify({"status": "error", "message": f"Autotask ticket update failed: {exc}"}), 400 - # Verify the update actually persisted (some tenants can return success while ignoring fields). try: t2 = client.get_ticket(ticket_id) except Exception as exc: @@ -1904,12 +1973,17 @@ def api_run_checks_autotask_resolve_note(): return jsonify( { "status": "error", - "message": "Autotask accepted the update request, but the marker text was not found after reloading the ticket. " - "This tenant may not allow updating the ticket description via the API.", + "message": "Ticket note creation is not supported (HTTP 404) and the ticket description marker was not found after updating. " + "This tenant may restrict updating description fields via the API.", } ), 400 - return jsonify({"status": "ok", "message": "Update posted and verified."}) + return jsonify( + { + "status": "ok", + "message": "Ticket note creation is not supported in this tenant; marker text was appended to the ticket description instead.", + } + ) @main_bp.post("/api/run-checks/mark-reviewed") diff --git a/docs/changelog.md b/docs/changelog.md index 34a59c2..593c4f4 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -479,6 +479,14 @@ Changes: - Return a clear error when Autotask accepts the request but does not store the update. - Prevented false-positive “resolved” messages when no ticket update exists. +## v20260203-04-autotask-resolve-user-note + +- Changed the Resolve action to exclusively create a user-visible TicketNote in Autotask. +- Removed all Ticket PUT updates to avoid false-positive system or workflow notes. +- Ensured the TicketNote is published and clearly indicates the ticket is marked as resolved by Backupchecks. +- Updated backend validation to only return success when the TicketNote is successfully created. +- Aligned frontend success messaging with actual TicketNote creation in Autotask. + *** ## v0.1.21