From 3c7f4c792689ff71baca1c4eb9388c7e741e9c55 Mon Sep 17 00:00:00 2001 From: Ivo Oskamp Date: Tue, 3 Feb 2026 10:31:44 +0100 Subject: [PATCH] Auto-commit local changes before build (2026-02-03 10:31:44) --- .last-branch | 2 +- .../app/integrations/autotask/client.py | 38 ++++++++ .../src/backend/app/main/routes_run_checks.py | 91 +++++++++++++++++++ .../src/templates/main/run_checks.html | 49 ++++++++++ docs/changelog.md | 8 ++ 5 files changed, 187 insertions(+), 1 deletion(-) diff --git a/.last-branch b/.last-branch index 5b19c90..36e7b9e 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260120-11-runchecks-autotask-status-label +v20260203-01-autotask-resolve-note diff --git a/containers/backupchecks/src/backend/app/integrations/autotask/client.py b/containers/backupchecks/src/backend/app/integrations/autotask/client.py index e190716..36f0af5 100644 --- a/containers/backupchecks/src/backend/app/integrations/autotask/client.py +++ b/containers/backupchecks/src/backend/app/integrations/autotask/client.py @@ -483,6 +483,44 @@ class AutotaskClient: raise AutotaskError("Autotask did not return a ticket object.") + def update_ticket(self, ticket_payload: Dict[str, Any]) -> Dict[str, Any]: + """Update a Ticket via PUT /Tickets. + + Autotask does not support PATCH for Tickets. PUT behaves as a full update. + Callers must construct a valid payload (typically by copying required fields + from a fresh GET /Tickets/{id} response) and changing only intended fields. + """ + + if not isinstance(ticket_payload, dict): + raise AutotaskError("Invalid ticket payload.") + + try: + tid = int(ticket_payload.get("id") or 0) + except Exception: + tid = 0 + if tid <= 0: + raise AutotaskError("Invalid ticket id in payload.") + + data = self._request("PUT", "Tickets", json_body=ticket_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 + # Some environments return the raw object + if "id" in data: + return data + + items = self._as_items_list(data) + if items: + return items[0] + + # PUT may return an empty body in some tenants; treat as success. + return {"id": tid} + + 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 1c4334e..6e67d72 100644 --- a/containers/backupchecks/src/backend/app/main/routes_run_checks.py +++ b/containers/backupchecks/src/backend/app/main/routes_run_checks.py @@ -1805,6 +1805,97 @@ def api_run_checks_autotask_link_existing_ticket(): "internal_ticket_id": int(getattr(internal_ticket, "id", 0) or 0) if internal_ticket else 0, } ) + + +@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. + + 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). + """ + + data = request.get_json(silent=True) or {} + + try: + run_id = int(data.get("run_id") or 0) + except Exception: + run_id = 0 + + if run_id <= 0: + return jsonify({"status": "error", "message": "Invalid parameters."}), 400 + + run = JobRun.query.get(run_id) + if not run: + return jsonify({"status": "error", "message": "Run not found."}), 404 + + if not getattr(run, "autotask_ticket_id", None): + return jsonify({"status": "error", "message": "Run has no Autotask ticket linked."}), 400 + + try: + ticket_id = int(run.autotask_ticket_id) + except Exception: + ticket_id = 0 + + if ticket_id <= 0: + return jsonify({"status": "error", "message": "Run has an invalid Autotask ticket id."}), 400 + + try: + client = _build_autotask_client_from_settings() + t = client.get_ticket(ticket_id) + except Exception as exc: + return jsonify({"status": "error", "message": f"Autotask ticket retrieval failed: {exc}"}), 400 + + 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), + } + ), 400 + + existing_desc = str(t.get("description") or "") + + 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: + new_desc = (existing_desc or "") + note + + payload = { + "id": int(t.get("id")), + "companyID": t.get("companyID"), + "queueID": t.get("queueID"), + "title": t.get("title"), + "priority": t.get("priority"), + "status": t.get("status"), + "dueDateTime": t.get("dueDateTime"), + "description": new_desc, + } + + try: + client.update_ticket(payload) + except Exception as exc: + return jsonify({"status": "error", "message": f"Autotask ticket update failed: {exc}"}), 400 + + return jsonify({"status": "ok"}) + + @main_bp.post("/api/run-checks/mark-reviewed") @login_required @roles_required("admin", "operator") diff --git a/containers/backupchecks/src/templates/main/run_checks.html b/containers/backupchecks/src/templates/main/run_checks.html index d47509f..eda46b2 100644 --- a/containers/backupchecks/src/templates/main/run_checks.html +++ b/containers/backupchecks/src/templates/main/run_checks.html @@ -220,6 +220,7 @@
Autotask ticket
+
@@ -891,6 +892,7 @@ table.addEventListener('change', function (e) { function bindInlineCreateForms() { var btnAutotask = document.getElementById('rcm_autotask_create'); + var btnAutotaskResolveNote = document.getElementById('rcm_autotask_resolve_note'); var btnAutotaskLink = document.getElementById('rcm_autotask_link_existing'); var atInfo = document.getElementById('rcm_autotask_info'); var atStatus = document.getElementById('rcm_autotask_status'); @@ -911,6 +913,7 @@ table.addEventListener('change', function (e) { function setDisabled(disabled) { if (btnAutotask) btnAutotask.disabled = disabled; + if (btnAutotaskResolveNote) btnAutotaskResolveNote.disabled = disabled; if (btnAutotaskLink) btnAutotaskLink.disabled = disabled; if (btnTicket) btnTicket.disabled = disabled; if (tCode) tCode.disabled = disabled; @@ -963,6 +966,12 @@ table.addEventListener('change', function (e) { // Link existing is only meaningful when there is no PSA ticket linked to this run. btnAutotaskLink.style.display = hasAtTicket ? 'none' : ''; } + + if (btnAutotaskResolveNote) { + var hasTicket = !!(run && run.autotask_ticket_id); + // Resolve note is only meaningful when there is an active linked PSA ticket. + btnAutotaskResolveNote.style.display = (hasTicket && !isResolved && !isDeleted) ? '' : 'none'; + } } window.__rcmRenderAutotaskInfo = renderAutotaskInfo; @@ -1177,6 +1186,46 @@ table.addEventListener('change', function (e) { }); } + if (btnAutotaskResolveNote) { + btnAutotaskResolveNote.addEventListener('click', function () { + if (!currentRunId) { alert('Select a run first.'); return; } + clearStatus(); + if (!confirm('Add an update to the existing Autotask ticket that it should be resolved?\n\nThis will NOT close the ticket in Autotask.')) return; + if (atStatus) atStatus.textContent = 'Posting update...'; + btnAutotaskResolveNote.disabled = true; + apiJson('/api/run-checks/autotask-resolve-note', { + method: 'POST', + body: JSON.stringify({run_id: currentRunId}) + }) + .then(function (j) { + if (!j || j.status !== 'ok') throw new Error((j && j.message) || 'Failed.'); + if (atStatus) atStatus.textContent = 'Update posted.'; + // Keep UI consistent (button visibility depends on run state). + var keepRunId = currentRunId; + if (currentJobId) { + return fetch('/api/run-checks/details?job_id=' + encodeURIComponent(currentJobId)) + .then(function (r) { return r.json(); }) + .then(function (payload) { + currentPayload = payload; + var idx = 0; + var runs = (payload && payload.runs) || []; + for (var i = 0; i < runs.length; i++) { + if (String(runs[i].id) === String(keepRunId)) { idx = i; break; } + } + renderRun(payload, idx); + }); + } + }) + .catch(function (e) { + if (atStatus) atStatus.textContent = e.message || 'Failed.'; + else alert(e.message || 'Failed.'); + }) + .finally(function () { + btnAutotaskResolveNote.disabled = false; + }); + }); + } + if (btnRemark) { btnRemark.addEventListener('click', function () { if (!currentRunId) { alert('Select a run first.'); return; } diff --git a/docs/changelog.md b/docs/changelog.md index daa005a..22958ce 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -457,6 +457,14 @@ Changes: - Added a safe fallback chain so the UI shows: statusLabel (API) -> status_label (legacy) -> numeric status. +## v20260203-01-autotask-resolve-note + +- Added a Resolve button to the Autotask ticket section on the Run Checks page. +- Resolve action does NOT close the Autotask ticket. +- Implemented functionality to add a note/update to the existing Autotask ticket indicating it is marked as resolved from Backupchecks. +- Added backend API endpoint to handle the resolve-note action. +- Extended Autotask client with a helper to update existing tickets via PUT. + *** ## v0.1.21