Split into a FastAPI backend and a React frontend, add case state and SSO
The single-file stdlib server became the limit: no way to track what had been done about an alert, no accounts, and a UI that had to be hand-rolled in template strings. This restructures it into something deployable. Backend (FastAPI) - app/ holds config, database, auth, delivery and the routers; triagelib keeps the triage engine unchanged, so the validated screening and runbook logic is untouched. - Cases persist per alert fingerprint with a status workflow (investigating, customer contacted, escalated to Infra, waiting, remediated, resolved, won't fix, false positive), an assignee, notes and an append-only history. An alert that stops and re-fires lands back on the same case and counts as a reopen. - Suppression rules move from a JSON file into the database. Auth - Signed session cookies over PBKDF2 local accounts, plus an OIDC flow ready for Authentik: users are created on first login and admin follows a group claim. Local login can be switched off entirely once SSO is live. Zendesk and Jira - Delivery is now implemented, behind three gates: the integration must be configured, its feature flag on, and CX_FEATURE_SEND_ENABLED on. A demo instance leaves the last off and cannot mail anyone. Both search before creating, so re-diagnosing an alert updates one ticket rather than opening several, and a rolling daily cap stops a loop mailing everybody. Deployment - Multi-stage Dockerfile builds the bundle and serves it from the API origin. - docker-compose for local and single-host use; Gitea Actions runs the tests, builds the image and renders deploy/k8s with envsubst. Two fixes found while testing: assigning a case returned a null assignee, and add_event could leave an already-loaded history collection stale. Known gap: the engine reaches OpenStack via `docker exec <region>-osc`, which does not work in a pod without the CX-Tools containers alongside it. docs/DEPLOYMENT.md sets out the three ways to close that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
174
backend/app/delivery.py
Normal file
174
backend/app/delivery.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""Outbound delivery to Zendesk and Jira.
|
||||
|
||||
Three independent gates have to be open before anything leaves this process:
|
||||
|
||||
1. the integration is configured (subdomain/email/token present)
|
||||
2. its feature flag is on - CX_FEATURE_ZENDESK / CX_FEATURE_JIRA
|
||||
3. sending is globally enabled - CX_FEATURE_SEND_ENABLED
|
||||
|
||||
A demo or staging instance simply leaves the third off, and then no combination
|
||||
of clicks can email a customer. Every send is recorded as a case event before it
|
||||
is attempted, so an audit trail exists even when the call fails.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .config import get_settings
|
||||
from .models import Case, CaseEvent, CaseStatus, User
|
||||
from .services import add_event, set_status
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class DeliveryError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _guard(kind: str) -> None:
|
||||
if not settings.feature_send_enabled:
|
||||
raise DeliveryError(
|
||||
"Sending is disabled on this instance (CX_FEATURE_SEND_ENABLED is off). "
|
||||
"The payload is ready but nothing will leave the server."
|
||||
)
|
||||
if kind == "zendesk" and not settings.zendesk_ready:
|
||||
raise DeliveryError("Zendesk is not configured. Set CX_FEATURE_ZENDESK plus "
|
||||
"CX_ZENDESK_SUBDOMAIN, CX_ZENDESK_EMAIL and CX_ZENDESK_TOKEN.")
|
||||
if kind == "jira" and not settings.jira_ready:
|
||||
raise DeliveryError("Jira is not configured. Set CX_FEATURE_JIRA plus "
|
||||
"CX_JIRA_BASE, CX_JIRA_EMAIL, CX_JIRA_TOKEN and CX_JIRA_PROJECT.")
|
||||
|
||||
|
||||
def sends_today(db: Session) -> int:
|
||||
since = dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=1)
|
||||
return (db.query(CaseEvent)
|
||||
.filter(CaseEvent.action.in_(["zendesk_sent", "jira_created"]))
|
||||
.filter(CaseEvent.created_at >= since).count())
|
||||
|
||||
|
||||
def _check_cap(db: Session) -> None:
|
||||
used = sends_today(db)
|
||||
if used >= settings.send_daily_cap:
|
||||
raise DeliveryError(
|
||||
f"Daily send cap reached ({used}/{settings.send_daily_cap}). Raise CX_SEND_DAILY_CAP "
|
||||
"if this is deliberate - the cap exists so a loop cannot mail every customer."
|
||||
)
|
||||
|
||||
|
||||
# --- Zendesk ----------------------------------------------------------------
|
||||
|
||||
async def send_zendesk(db: Session, case: Case, actor: User, *, to: str, subject: str,
|
||||
body: str, priority: str = "normal",
|
||||
tags: Optional[list[str]] = None, public: Optional[bool] = None) -> dict[str, Any]:
|
||||
_guard("zendesk")
|
||||
_check_cap(db)
|
||||
if not to.strip():
|
||||
raise DeliveryError("No recipient address.")
|
||||
|
||||
base = f"https://{settings.zendesk_subdomain}.zendesk.com/api/v2"
|
||||
auth = (f"{settings.zendesk_email}/token", settings.zendesk_token)
|
||||
external_id = f"cx-triage-{case.fingerprint}"
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
# Search first so a re-diagnosed alert comments on the existing ticket
|
||||
# instead of opening a second one for the same customer.
|
||||
found = await client.get(f"{base}/search.json",
|
||||
params={"query": f'type:ticket external_id:"{external_id}"'}, auth=auth)
|
||||
existing = None
|
||||
if found.status_code == 200:
|
||||
results = found.json().get("results") or []
|
||||
existing = results[0] if results else None
|
||||
|
||||
comment = {"body": body, "public": settings.zendesk_default_public if public is None else public}
|
||||
if existing:
|
||||
resp = await client.put(f"{base}/tickets/{existing['id']}.json",
|
||||
json={"ticket": {"comment": comment}}, auth=auth)
|
||||
action = "updated"
|
||||
else:
|
||||
payload = {"ticket": {
|
||||
"subject": subject,
|
||||
"comment": comment,
|
||||
"requester": {"name": to.split("@")[0], "email": to},
|
||||
"priority": priority,
|
||||
"type": "incident",
|
||||
"tags": tags or ["cx-triage", f"alert-{case.kind}"],
|
||||
"external_id": external_id,
|
||||
}}
|
||||
resp = await client.post(f"{base}/tickets.json", json=payload, auth=auth)
|
||||
action = "created"
|
||||
|
||||
if resp.status_code not in (200, 201):
|
||||
add_event(db, case, actor, "zendesk_failed", f"HTTP {resp.status_code}: {resp.text[:300]}")
|
||||
db.commit()
|
||||
raise DeliveryError(f"Zendesk returned {resp.status_code}: {resp.text[:300]}")
|
||||
|
||||
ticket = resp.json().get("ticket") or {}
|
||||
ticket_id = str(ticket.get("id") or (existing or {}).get("id") or "")
|
||||
url = f"https://{settings.zendesk_subdomain}.zendesk.com/agent/tickets/{ticket_id}"
|
||||
|
||||
case.zendesk_ticket_id = ticket_id
|
||||
case.zendesk_ticket_url = url
|
||||
add_event(db, case, actor, "zendesk_sent",
|
||||
f"Ticket {ticket_id} {action} for {to}",
|
||||
{"ticket_id": ticket_id, "to": to, "subject": subject, "action": action})
|
||||
if case.status in (CaseStatus.NEW, CaseStatus.INVESTIGATING):
|
||||
set_status(db, case, CaseStatus.CUSTOMER_CONTACTED, actor, f"Zendesk ticket {ticket_id}")
|
||||
db.commit()
|
||||
return {"ok": True, "ticket_id": ticket_id, "url": url, "action": action}
|
||||
|
||||
|
||||
# --- Jira -------------------------------------------------------------------
|
||||
|
||||
async def create_jira(db: Session, case: Case, actor: User, *, summary: str, description: str,
|
||||
project: str = "", issue_type: str = "",
|
||||
labels: Optional[list[str]] = None) -> dict[str, Any]:
|
||||
_guard("jira")
|
||||
_check_cap(db)
|
||||
|
||||
base = settings.jira_base.rstrip("/")
|
||||
auth = (settings.jira_email, settings.jira_token)
|
||||
label = f"cx-triage-{case.fingerprint}"
|
||||
all_labels = sorted(set((labels or []) + ["cx-triage", label]))
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
found = await client.get(f"{base}/rest/api/3/search",
|
||||
params={"jql": f'labels = "{label}"', "maxResults": 1}, auth=auth)
|
||||
if found.status_code == 200 and (found.json().get("issues") or []):
|
||||
issue = found.json()["issues"][0]
|
||||
key = issue["key"]
|
||||
url = f"{base}/browse/{key}"
|
||||
case.jira_issue_key, case.jira_issue_url = key, url
|
||||
add_event(db, case, actor, "jira_exists", f"Issue {key} already exists for this case")
|
||||
db.commit()
|
||||
return {"ok": True, "key": key, "url": url, "action": "existing"}
|
||||
|
||||
payload = {"fields": {
|
||||
"project": {"key": project or settings.jira_project},
|
||||
"summary": summary[:250],
|
||||
"issuetype": {"name": issue_type or settings.jira_issue_type},
|
||||
"labels": all_labels,
|
||||
"description": {
|
||||
"type": "doc", "version": 1,
|
||||
"content": [{"type": "paragraph",
|
||||
"content": [{"type": "text", "text": description[:30000]}]}],
|
||||
},
|
||||
}}
|
||||
resp = await client.post(f"{base}/rest/api/3/issue", json=payload, auth=auth)
|
||||
|
||||
if resp.status_code not in (200, 201):
|
||||
add_event(db, case, actor, "jira_failed", f"HTTP {resp.status_code}: {resp.text[:300]}")
|
||||
db.commit()
|
||||
raise DeliveryError(f"Jira returned {resp.status_code}: {resp.text[:300]}")
|
||||
|
||||
key = resp.json().get("key", "")
|
||||
url = f"{base}/browse/{key}"
|
||||
case.jira_issue_key, case.jira_issue_url = key, url
|
||||
add_event(db, case, actor, "jira_created", f"Issue {key} created", {"key": key, "summary": summary})
|
||||
if case.status in (CaseStatus.NEW, CaseStatus.INVESTIGATING):
|
||||
set_status(db, case, CaseStatus.ESCALATED_INFRA, actor, f"Jira {key}")
|
||||
db.commit()
|
||||
return {"ok": True, "key": key, "url": url, "action": "created"}
|
||||
Reference in New Issue
Block a user