diff --git a/.last-branch b/.last-branch index 882b180..9671641 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260120-07-autotask-psa-resolution-handling +v20260120-08-runchecks-link-existing-autotask-ticket diff --git a/containers/backupchecks/src/backend/app/integrations/autotask/client.py b/containers/backupchecks/src/backend/app/integrations/autotask/client.py index bc8b963..e190716 100644 --- a/containers/backupchecks/src/backend/app/integrations/autotask/client.py +++ b/containers/backupchecks/src/backend/app/integrations/autotask/client.py @@ -601,3 +601,59 @@ class AutotaskClient: data = self._request("POST", "Tickets/query", json_body={"filter": flt}) return self._as_items_list(data) + + def query_tickets_for_company( + self, + company_id: int, + *, + search: str = "", + exclude_status_ids: Optional[List[int]] = None, + limit: int = 50, + ) -> List[Dict[str, Any]]: + """Query Tickets for a specific company, optionally searching by ticket number or title. + + Uses POST /Tickets/query. + + Note: + - Autotask query operators vary by tenant; we use common operators (eq, contains). + - If the query fails due to operator support, callers should fall back to get_ticket(id). + """ + + try: + cid = int(company_id) + except Exception: + cid = 0 + if cid <= 0: + return [] + + flt: List[Dict[str, Any]] = [ + {"op": "eq", "field": "companyID", "value": cid}, + ] + + ex: List[int] = [] + for x in exclude_status_ids or []: + try: + v = int(x) + except Exception: + continue + if v > 0: + ex.append(v) + if ex: + flt.append({"op": "notIn", "field": "status", "value": ex}) + + q = (search or "").strip() + if q: + # Ticket numbers in Autotask are typically like T20260119.0004 + if q.upper().startswith("T") and any(ch.isdigit() for ch in q): + flt.append({"op": "eq", "field": "ticketNumber", "value": q.strip()}) + else: + # Broad search on title + flt.append({"op": "contains", "field": "title", "value": q}) + + data = self._request("POST", "Tickets/query", json_body={"filter": flt}) + items = self._as_items_list(data) + + # Respect limit if tenant returns more. + if limit and isinstance(limit, int) and limit > 0: + return items[: int(limit)] + return items 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 3fd2370..1c4334e 100644 --- a/containers/backupchecks/src/backend/app/main/routes_run_checks.py +++ b/containers/backupchecks/src/backend/app/main/routes_run_checks.py @@ -427,8 +427,8 @@ def _poll_autotask_ticket_states_for_runs(*, run_ids: list[int]) -> None: else: resolved_at_raw = None if resolved_at_raw: - s = str(resolved_at_raw).replace("Z", "+00:00") - resolved_at = datetime.fromisoformat(s) + s_dt = str(resolved_at_raw).replace("Z", "+00:00") + resolved_at = datetime.fromisoformat(s_dt) if resolved_at.tzinfo is not None: resolved_at = resolved_at.astimezone(timezone.utc).replace(tzinfo=None) except Exception: @@ -446,7 +446,7 @@ def _poll_autotask_ticket_states_for_runs(*, run_ids: list[int]) -> None: job=job, run_ids=[int(x.id) for x in runs_for_ticket if getattr(x, "id", None)], now=resolved_at or now, - origin="Resolved by PSA", + origin="psa", ) try: @@ -1567,6 +1567,244 @@ def api_run_checks_create_autotask_ticket(): ) + + +@main_bp.get("/api/run-checks/autotask-existing-tickets") +@login_required +@roles_required("admin", "operator") +def api_run_checks_autotask_existing_tickets(): + """List open (non-terminal) Autotask tickets for the selected run's customer. + + Phase 2.2: used by the Run Checks modal to link an existing PSA ticket. + """ + + try: + run_id = int(request.args.get("run_id") or 0) + except Exception: + run_id = 0 + + q = (request.args.get("q") or "").strip() + + 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 + + job = Job.query.get(run.job_id) + if not job: + return jsonify({"status": "error", "message": "Job not found."}), 404 + + customer = Customer.query.get(job.customer_id) if getattr(job, "customer_id", None) else None + if not customer: + return jsonify({"status": "error", "message": "Customer not found."}), 404 + + if not getattr(customer, "autotask_company_id", None): + return jsonify({"status": "error", "message": "Customer has no Autotask company mapping."}), 400 + + if (getattr(customer, "autotask_mapping_status", None) or "").strip().lower() not in ("ok", "renamed"): + return jsonify({"status": "error", "message": "Autotask company mapping is not valid."}), 400 + + settings = _get_or_create_settings() + + # Map status ID -> label from cached settings (kept in sync by Settings page). + status_map = {} + try: + import json as _json + + raw = getattr(settings, "autotask_cached_ticket_statuses_json", None) + if raw: + for x in (_json.loads(raw) or []): + if isinstance(x, dict) and "value" in x: + status_map[str(x.get("value"))] = str(x.get("label") or "") + except Exception: + status_map = {} + + try: + client = _build_autotask_client_from_settings() + + # Ensure we have a status map; if empty, fetch and cache once. + if not status_map: + try: + import json as _json + + statuses = client.get_ticket_statuses() + settings.autotask_cached_ticket_statuses_json = _json.dumps(statuses) + settings.autotask_reference_last_sync_at = datetime.utcnow() + db.session.commit() + for x in (statuses or []): + if isinstance(x, dict) and "value" in x: + status_map[str(x.get("value"))] = str(x.get("label") or "") + except Exception: + # Best-effort; list will still work without labels. + pass + + tickets = client.query_tickets_for_company( + int(customer.autotask_company_id), + search=q, + exclude_status_ids=sorted(AUTOTASK_TERMINAL_STATUS_IDS), + limit=75, + ) + except Exception as exc: + return jsonify({"status": "error", "message": f"Autotask ticket lookup failed: {exc}"}), 400 + + items = [] + for t in tickets or []: + if not isinstance(t, dict): + continue + tid = t.get("id") + tnum = (t.get("ticketNumber") or t.get("number") or "") + title = (t.get("title") or "") + st = t.get("status") + try: + st_int = int(st) if st is not None else None + except Exception: + st_int = None + st_label = status_map.get(str(st_int)) if st_int is not None else "" + items.append( + { + "id": tid, + "ticketNumber": str(tnum or ""), + "title": str(title or ""), + "status": st_int, + "statusLabel": st_label or "", + } + ) + + # Sort: newest-ish first. Autotask query ordering isn't guaranteed, so we provide a stable sort. + items.sort(key=lambda x: (x.get("ticketNumber") or ""), reverse=True) + + return jsonify({"status": "ok", "items": items}) + + +@main_bp.post("/api/run-checks/autotask-link-existing-ticket") +@login_required +@roles_required("admin", "operator") +def api_run_checks_autotask_link_existing_ticket(): + """Link an existing Autotask ticket to the selected run (and propagate to all active runs of the job). + + Phase 2.2: used by the Run Checks modal. + """ + + data = request.get_json(silent=True) or {} + + try: + run_id = int(data.get("run_id") or 0) + except Exception: + run_id = 0 + + try: + ticket_id = int(data.get("ticket_id") or 0) + except Exception: + ticket_id = 0 + + if run_id <= 0 or ticket_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 + + # Do not overwrite an existing link unless the current one is resolved/deleted. + if getattr(run, "autotask_ticket_id", None): + return jsonify({"status": "error", "message": "Run already has an Autotask ticket linked."}), 400 + + job = Job.query.get(run.job_id) + if not job: + return jsonify({"status": "error", "message": "Job not found."}), 404 + + customer = Customer.query.get(job.customer_id) if getattr(job, "customer_id", None) else None + if not customer: + return jsonify({"status": "error", "message": "Customer not found."}), 404 + + if not getattr(customer, "autotask_company_id", None): + return jsonify({"status": "error", "message": "Customer has no Autotask company mapping."}), 400 + + if (getattr(customer, "autotask_mapping_status", None) or "").strip().lower() not in ("ok", "renamed"): + return jsonify({"status": "error", "message": "Autotask company mapping is not valid."}), 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 + + # Enforce company scope. + try: + t_company = int(t.get("companyID") or 0) + except Exception: + t_company = 0 + + if t_company != int(customer.autotask_company_id): + return jsonify({"status": "error", "message": "Selected ticket does not belong to the mapped Autotask company."}), 400 + + tnum = (t.get("ticketNumber") or t.get("number") or "") + tnum = str(tnum or "").strip() + if not tnum: + return jsonify({"status": "error", "message": "Autotask ticket does not have a ticket number."}), 400 + + # Block terminal tickets from being linked (Phase 2.2 only lists open tickets, but enforce server-side). + try: + st = int(t.get("status")) if t.get("status") is not None else 0 + except Exception: + st = 0 + if st in AUTOTASK_TERMINAL_STATUS_IDS: + return jsonify({"status": "error", "message": "Cannot link a terminal/completed Autotask ticket."}), 400 + + now = datetime.utcnow() + + run.autotask_ticket_id = int(ticket_id) + run.autotask_ticket_number = tnum + run.autotask_ticket_created_at = now + run.autotask_ticket_created_by_user_id = current_user.id + + # Propagate linkage to all active (unreviewed) runs of the same job. + active_runs = ( + JobRun.query.filter(JobRun.job_id == job.id, JobRun.reviewed_at.is_(None)).order_by(JobRun.id.asc()).all() + ) + run_ids = [] + for rr in active_runs or []: + if getattr(rr, "id", None) is None: + continue + rr.autotask_ticket_id = int(ticket_id) + rr.autotask_ticket_number = tnum + if getattr(rr, "autotask_ticket_created_at", None) is None: + rr.autotask_ticket_created_at = now + if getattr(rr, "autotask_ticket_created_by_user_id", None) is None: + rr.autotask_ticket_created_by_user_id = current_user.id + run_ids.append(int(rr.id)) + + # Ensure internal Ticket + TicketJobRun linkage for legacy ticket behavior. + internal_ticket = None + try: + internal_ticket = _ensure_internal_ticket_for_autotask( + ticket_number=tnum, + job=job, + run_ids=run_ids, + now=now, + active_from_dt=now, + ) + except Exception: + internal_ticket = None + + try: + db.session.commit() + except Exception: + db.session.rollback() + return jsonify({"status": "error", "message": "Failed to persist Autotask ticket link."}), 500 + + return jsonify( + { + "status": "ok", + "ticket_id": int(ticket_id), + "ticket_number": tnum, + "internal_ticket_id": int(getattr(internal_ticket, "id", 0) or 0) if internal_ticket else 0, + } + ) @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 e05d738..7eada09 100644 --- a/containers/backupchecks/src/templates/main/run_checks.html +++ b/containers/backupchecks/src/templates/main/run_checks.html @@ -219,7 +219,10 @@ {% if autotask_enabled %}
Autotask ticket
- +
+ + +
@@ -292,6 +295,43 @@ + + + +