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 @@