Files
breakpilot-compliance/backend-compliance/compliance/api/incident_routes.py
Benjamin Admin 95fcba34cd
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Failing after 30s
CI / test-python-backend-compliance (push) Successful in 30s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 17s
fix(quality): Ruff/CVE/TS-Fixes, 104 neue Tests, Complexity-Refactoring
- Ruff: 144 auto-fixes (unused imports, == None → is None), F821/F811/F841 manuell
- CVEs: python-multipart>=0.0.22, weasyprint>=68.0, pillow>=12.1.1, npm audit fix (0 vulns)
- TS: 5 tote Drafting-Engine-Dateien entfernt, allowed-facts/sanitizer/StepHeader/context fixes
- Tests: +104 (ISMS 58, Evidence 18, VVT 14, Generation 14) → 1449 passed
- Refactoring: collect_ci_evidence (F→A), row_to_response (E→A), extract_requirements (E→A)
- Dead Code: pca-platform, 7 Go-Handler, dsr_api.py, duplicate Schemas entfernt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 19:00:33 +01:00

917 lines
29 KiB
Python

"""
FastAPI routes for Incidents / Datenpannen-Management (DSGVO Art. 33/34).
Migrated from Go ai-compliance-sdk — Python backend is now Source of Truth.
Endpoints:
POST /incidents — create incident
GET /incidents — list (filter: status, severity, category)
GET /incidents/stats — statistics
GET /incidents/{id} — detail + measures + deadline_info
PUT /incidents/{id} — update
DELETE /incidents/{id} — delete
PUT /incidents/{id}/status — quick status change (NEW)
POST /incidents/{id}/assess-risk — risk assessment
POST /incidents/{id}/notify-authority — Art. 33 authority notification
POST /incidents/{id}/notify-subjects — Art. 34 data subject notification
POST /incidents/{id}/measures — add measure
PUT /incidents/{id}/measures/{mid} — update measure
POST /incidents/{id}/measures/{mid}/complete — complete measure
POST /incidents/{id}/timeline — add timeline entry
POST /incidents/{id}/close — close incident
"""
import json
import logging
from datetime import datetime, timedelta, timezone
from typing import Optional, List
from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException, Query, Header
from pydantic import BaseModel
from sqlalchemy import text
from sqlalchemy.orm import Session
from classroom_engine.database import get_db
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/incidents", tags=["incidents"])
DEFAULT_TENANT_ID = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"
# =============================================================================
# Helpers
# =============================================================================
def _calculate_risk_level(likelihood: int, impact: int) -> str:
"""Calculate risk level from likelihood * impact score."""
score = likelihood * impact
if score >= 20:
return "critical"
elif score >= 12:
return "high"
elif score >= 6:
return "medium"
return "low"
def _is_notification_required(risk_level: str) -> bool:
"""DSGVO Art. 33 — notification required for critical/high risk."""
return risk_level in ("critical", "high")
def _calculate_72h_deadline(detected_at: datetime) -> str:
"""Calculate 72-hour DSGVO Art. 33 deadline."""
deadline = detected_at + timedelta(hours=72)
return deadline.isoformat()
def _parse_jsonb(val):
"""Parse a JSONB field — already dict/list from psycopg or a JSON string."""
if val is None:
return None
if isinstance(val, (dict, list)):
return val
if isinstance(val, str):
try:
return json.loads(val)
except (json.JSONDecodeError, TypeError):
return val
return val
def _incident_to_response(row) -> dict:
"""Convert a DB row (RowMapping) to incident response dict."""
r = dict(row)
# Parse JSONB fields
for field in (
"risk_assessment", "authority_notification",
"data_subject_notification", "timeline",
"affected_data_categories", "affected_systems",
):
if field in r:
r[field] = _parse_jsonb(r[field])
# Ensure ISO strings for datetime fields
for field in ("detected_at", "created_at", "updated_at", "closed_at"):
if field in r and r[field] is not None and hasattr(r[field], "isoformat"):
r[field] = r[field].isoformat()
return r
def _measure_to_response(row) -> dict:
"""Convert a DB measure row to response dict."""
r = dict(row)
for field in ("due_date", "completed_at", "created_at", "updated_at"):
if field in r and r[field] is not None and hasattr(r[field], "isoformat"):
r[field] = r[field].isoformat()
return r
def _append_timeline(db: Session, incident_id: str, entry: dict):
"""Append a timeline entry to the incident's timeline JSONB array."""
db.execute(text("""
UPDATE incident_incidents
SET timeline = COALESCE(timeline, '[]'::jsonb) || :entry::jsonb,
updated_at = NOW()
WHERE id = :id
"""), {"id": incident_id, "entry": json.dumps(entry)})
# =============================================================================
# Pydantic Schemas
# =============================================================================
class IncidentCreate(BaseModel):
title: str
description: Optional[str] = None
category: Optional[str] = "data_breach"
severity: Optional[str] = "medium"
detected_at: Optional[str] = None
affected_data_categories: Optional[List[str]] = None
affected_data_subject_count: Optional[int] = 0
affected_systems: Optional[List[str]] = None
class IncidentUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
category: Optional[str] = None
status: Optional[str] = None
severity: Optional[str] = None
affected_data_categories: Optional[List[str]] = None
affected_data_subject_count: Optional[int] = None
affected_systems: Optional[List[str]] = None
class StatusUpdate(BaseModel):
status: str
class RiskAssessmentRequest(BaseModel):
likelihood: int
impact: int
notes: Optional[str] = None
class AuthorityNotificationRequest(BaseModel):
authority_name: str
reference_number: Optional[str] = None
contact_person: Optional[str] = None
notes: Optional[str] = None
class DataSubjectNotificationRequest(BaseModel):
notification_text: str
channel: str = "email"
affected_count: Optional[int] = 0
class MeasureCreate(BaseModel):
title: str
description: Optional[str] = None
measure_type: str = "corrective"
responsible: Optional[str] = None
due_date: Optional[str] = None
class MeasureUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
measure_type: Optional[str] = None
status: Optional[str] = None
responsible: Optional[str] = None
due_date: Optional[str] = None
class TimelineEntryRequest(BaseModel):
action: str
details: Optional[str] = None
class CloseIncidentRequest(BaseModel):
root_cause: str
lessons_learned: Optional[str] = None
# =============================================================================
# CRUD Endpoints
# =============================================================================
@router.post("")
def create_incident(
body: IncidentCreate,
db: Session = Depends(get_db),
x_tenant_id: Optional[str] = Header(None),
x_user_id: Optional[str] = Header(None),
):
tenant_id = x_tenant_id or DEFAULT_TENANT_ID
user_id = x_user_id or "system"
incident_id = str(uuid4())
now = datetime.now(timezone.utc)
detected_at = now
if body.detected_at:
try:
parsed = datetime.fromisoformat(body.detected_at.replace("Z", "+00:00"))
# Ensure timezone-aware
detected_at = parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
except (ValueError, AttributeError):
detected_at = now
deadline = detected_at + timedelta(hours=72)
authority_notification = {
"status": "pending",
"deadline": deadline.isoformat(),
}
data_subject_notification = {
"required": False,
"status": "not_required",
}
timeline = [{
"timestamp": now.isoformat(),
"action": "incident_created",
"user_id": user_id,
"details": "Incident detected and reported",
}]
db.execute(text("""
INSERT INTO incident_incidents (
id, tenant_id, title, description, category, status, severity,
detected_at, reported_by,
affected_data_categories, affected_data_subject_count, affected_systems,
authority_notification, data_subject_notification, timeline,
created_at, updated_at
) VALUES (
:id, :tenant_id, :title, :description, :category, 'detected', :severity,
:detected_at, :reported_by,
CAST(:affected_data_categories AS jsonb),
:affected_data_subject_count,
CAST(:affected_systems AS jsonb),
CAST(:authority_notification AS jsonb),
CAST(:data_subject_notification AS jsonb),
CAST(:timeline AS jsonb),
:now, :now
)
"""), {
"id": incident_id,
"tenant_id": tenant_id,
"title": body.title,
"description": body.description or "",
"category": body.category,
"severity": body.severity,
"detected_at": detected_at.isoformat(),
"reported_by": user_id,
"affected_data_categories": json.dumps(body.affected_data_categories or []),
"affected_data_subject_count": body.affected_data_subject_count or 0,
"affected_systems": json.dumps(body.affected_systems or []),
"authority_notification": json.dumps(authority_notification),
"data_subject_notification": json.dumps(data_subject_notification),
"timeline": json.dumps(timeline),
"now": now.isoformat(),
})
db.commit()
# Fetch back for response
result = db.execute(text(
"SELECT * FROM incident_incidents WHERE id = :id"
), {"id": incident_id})
row = result.mappings().first()
incident_resp = _incident_to_response(row) if row else {}
return {
"incident": incident_resp,
"authority_deadline": deadline.isoformat(),
"hours_until_deadline": (deadline - now).total_seconds() / 3600,
}
@router.get("")
def list_incidents(
db: Session = Depends(get_db),
x_tenant_id: Optional[str] = Header(None),
status: Optional[str] = Query(None),
severity: Optional[str] = Query(None),
category: Optional[str] = Query(None),
limit: int = Query(50),
offset: int = Query(0),
):
tenant_id = x_tenant_id or DEFAULT_TENANT_ID
where_clauses = ["tenant_id = :tenant_id"]
params: dict = {"tenant_id": tenant_id, "limit": limit, "offset": offset}
if status:
where_clauses.append("status = :status")
params["status"] = status
if severity:
where_clauses.append("severity = :severity")
params["severity"] = severity
if category:
where_clauses.append("category = :category")
params["category"] = category
where_sql = " AND ".join(where_clauses)
count_result = db.execute(
text(f"SELECT COUNT(*) FROM incident_incidents WHERE {where_sql}"),
params,
)
total = count_result.scalar() or 0
result = db.execute(text(f"""
SELECT * FROM incident_incidents
WHERE {where_sql}
ORDER BY created_at DESC
LIMIT :limit OFFSET :offset
"""), params)
incidents = [_incident_to_response(r) for r in result.mappings().all()]
return {"incidents": incidents, "total": total}
@router.get("/stats")
def get_stats(
db: Session = Depends(get_db),
x_tenant_id: Optional[str] = Header(None),
):
tenant_id = x_tenant_id or DEFAULT_TENANT_ID
result = db.execute(text("""
SELECT
COUNT(*) AS total,
SUM(CASE WHEN status != 'closed' THEN 1 ELSE 0 END) AS open,
SUM(CASE WHEN status = 'closed' THEN 1 ELSE 0 END) AS closed,
SUM(CASE WHEN severity = 'critical' THEN 1 ELSE 0 END) AS critical,
SUM(CASE WHEN severity = 'high' THEN 1 ELSE 0 END) AS high,
SUM(CASE WHEN severity = 'medium' THEN 1 ELSE 0 END) AS medium,
SUM(CASE WHEN severity = 'low' THEN 1 ELSE 0 END) AS low
FROM incident_incidents
WHERE tenant_id = :tenant_id
"""), {"tenant_id": tenant_id})
row = result.mappings().first()
return {
"total": int(row["total"] or 0),
"open": int(row["open"] or 0),
"closed": int(row["closed"] or 0),
"by_severity": {
"critical": int(row["critical"] or 0),
"high": int(row["high"] or 0),
"medium": int(row["medium"] or 0),
"low": int(row["low"] or 0),
},
}
@router.get("/{incident_id}")
def get_incident(
incident_id: UUID,
db: Session = Depends(get_db),
):
result = db.execute(text(
"SELECT * FROM incident_incidents WHERE id = :id"
), {"id": str(incident_id)})
row = result.mappings().first()
if not row:
raise HTTPException(status_code=404, detail="incident not found")
incident = _incident_to_response(row)
# Get measures
measures_result = db.execute(text(
"SELECT * FROM incident_measures WHERE incident_id = :id ORDER BY created_at"
), {"id": str(incident_id)})
measures = [_measure_to_response(m) for m in measures_result.mappings().all()]
# Calculate deadline info
deadline_info = None
auth_notif = _parse_jsonb(row["authority_notification"]) if "authority_notification" in row.keys() else None
if auth_notif and isinstance(auth_notif, dict) and "deadline" in auth_notif:
try:
deadline_dt = datetime.fromisoformat(auth_notif["deadline"].replace("Z", "+00:00"))
now = datetime.now(timezone.utc)
hours_remaining = (deadline_dt - now).total_seconds() / 3600
deadline_info = {
"deadline": auth_notif["deadline"],
"hours_remaining": hours_remaining,
"overdue": hours_remaining < 0,
}
except (ValueError, TypeError):
pass
return {
"incident": incident,
"measures": measures,
"deadline_info": deadline_info,
}
@router.put("/{incident_id}")
def update_incident(
incident_id: UUID,
body: IncidentUpdate,
db: Session = Depends(get_db),
):
iid = str(incident_id)
# Check exists
check = db.execute(text(
"SELECT id FROM incident_incidents WHERE id = :id"
), {"id": iid})
if not check.first():
raise HTTPException(status_code=404, detail="incident not found")
updates = []
params: dict = {"id": iid}
for field in ("title", "description", "category", "status", "severity"):
val = getattr(body, field, None)
if val is not None:
updates.append(f"{field} = :{field}")
params[field] = val
if body.affected_data_categories is not None:
updates.append("affected_data_categories = CAST(:adc AS jsonb)")
params["adc"] = json.dumps(body.affected_data_categories)
if body.affected_data_subject_count is not None:
updates.append("affected_data_subject_count = :adsc")
params["adsc"] = body.affected_data_subject_count
if body.affected_systems is not None:
updates.append("affected_systems = CAST(:asys AS jsonb)")
params["asys"] = json.dumps(body.affected_systems)
if not updates:
raise HTTPException(status_code=400, detail="no fields to update")
updates.append("updated_at = NOW()")
sql = f"UPDATE incident_incidents SET {', '.join(updates)} WHERE id = :id"
db.execute(text(sql), params)
db.commit()
result = db.execute(text(
"SELECT * FROM incident_incidents WHERE id = :id"
), {"id": iid})
row = result.mappings().first()
return {"incident": _incident_to_response(row)}
@router.delete("/{incident_id}")
def delete_incident(
incident_id: UUID,
db: Session = Depends(get_db),
):
iid = str(incident_id)
check = db.execute(text(
"SELECT id FROM incident_incidents WHERE id = :id"
), {"id": iid})
if not check.first():
raise HTTPException(status_code=404, detail="incident not found")
db.execute(text("DELETE FROM incident_measures WHERE incident_id = :id"), {"id": iid})
db.execute(text("DELETE FROM incident_incidents WHERE id = :id"), {"id": iid})
db.commit()
return {"message": "incident deleted"}
# =============================================================================
# Status Update (NEW — not in Go)
# =============================================================================
@router.put("/{incident_id}/status")
def update_status(
incident_id: UUID,
body: StatusUpdate,
db: Session = Depends(get_db),
x_user_id: Optional[str] = Header(None),
):
iid = str(incident_id)
user_id = x_user_id or "system"
check = db.execute(text(
"SELECT id FROM incident_incidents WHERE id = :id"
), {"id": iid})
if not check.first():
raise HTTPException(status_code=404, detail="incident not found")
db.execute(text("""
UPDATE incident_incidents
SET status = :status, updated_at = NOW()
WHERE id = :id
"""), {"id": iid, "status": body.status})
_append_timeline(db, iid, {
"timestamp": datetime.now(timezone.utc).isoformat(),
"action": "status_changed",
"user_id": user_id,
"details": f"Status changed to {body.status}",
})
db.commit()
result = db.execute(text(
"SELECT * FROM incident_incidents WHERE id = :id"
), {"id": iid})
row = result.mappings().first()
return {"incident": _incident_to_response(row)}
# =============================================================================
# Risk Assessment
# =============================================================================
@router.post("/{incident_id}/assess-risk")
def assess_risk(
incident_id: UUID,
body: RiskAssessmentRequest,
db: Session = Depends(get_db),
x_user_id: Optional[str] = Header(None),
):
iid = str(incident_id)
user_id = x_user_id or "system"
check = db.execute(text(
"SELECT id, status, authority_notification FROM incident_incidents WHERE id = :id"
), {"id": iid})
row = check.mappings().first()
if not row:
raise HTTPException(status_code=404, detail="incident not found")
risk_level = _calculate_risk_level(body.likelihood, body.impact)
notification_required = _is_notification_required(risk_level)
now = datetime.now(timezone.utc)
assessment = {
"likelihood": body.likelihood,
"impact": body.impact,
"risk_level": risk_level,
"assessed_at": now.isoformat(),
"assessed_by": user_id,
"notes": body.notes or "",
}
new_status = "assessment"
if notification_required:
new_status = "notification_required"
# Update authority notification status to pending
auth = _parse_jsonb(row["authority_notification"]) or {}
auth["status"] = "pending"
db.execute(text("""
UPDATE incident_incidents
SET authority_notification = CAST(:an AS jsonb)
WHERE id = :id
"""), {"id": iid, "an": json.dumps(auth)})
db.execute(text("""
UPDATE incident_incidents
SET risk_assessment = CAST(:ra AS jsonb),
status = :status,
updated_at = NOW()
WHERE id = :id
"""), {"id": iid, "ra": json.dumps(assessment), "status": new_status})
_append_timeline(db, iid, {
"timestamp": now.isoformat(),
"action": "risk_assessed",
"user_id": user_id,
"details": f"Risk level: {risk_level} (likelihood={body.likelihood}, impact={body.impact})",
})
db.commit()
return {
"risk_assessment": assessment,
"notification_required": notification_required,
"incident_status": new_status,
}
# =============================================================================
# Authority Notification (Art. 33)
# =============================================================================
@router.post("/{incident_id}/notify-authority")
def notify_authority(
incident_id: UUID,
body: AuthorityNotificationRequest,
db: Session = Depends(get_db),
x_user_id: Optional[str] = Header(None),
):
iid = str(incident_id)
user_id = x_user_id or "system"
check = db.execute(text(
"SELECT id, detected_at, authority_notification FROM incident_incidents WHERE id = :id"
), {"id": iid})
row = check.mappings().first()
if not row:
raise HTTPException(status_code=404, detail="incident not found")
now = datetime.now(timezone.utc)
# Preserve existing deadline
auth_existing = _parse_jsonb(row["authority_notification"]) or {}
deadline_str = auth_existing.get("deadline")
if not deadline_str and row["detected_at"]:
detected = row["detected_at"]
if hasattr(detected, "isoformat"):
deadline_str = (detected + timedelta(hours=72)).isoformat()
else:
deadline_str = _calculate_72h_deadline(
datetime.fromisoformat(str(detected).replace("Z", "+00:00"))
)
notification = {
"status": "sent",
"deadline": deadline_str,
"submitted_at": now.isoformat(),
"authority_name": body.authority_name,
"reference_number": body.reference_number or "",
"contact_person": body.contact_person or "",
"notes": body.notes or "",
}
db.execute(text("""
UPDATE incident_incidents
SET authority_notification = CAST(:an AS jsonb),
status = 'notification_sent',
updated_at = NOW()
WHERE id = :id
"""), {"id": iid, "an": json.dumps(notification)})
_append_timeline(db, iid, {
"timestamp": now.isoformat(),
"action": "authority_notified",
"user_id": user_id,
"details": f"Authority notification submitted to {body.authority_name}",
})
db.commit()
# Check if submitted within 72h
submitted_within_72h = True
if deadline_str:
try:
deadline_dt = datetime.fromisoformat(deadline_str.replace("Z", "+00:00"))
submitted_within_72h = now < deadline_dt
except (ValueError, TypeError):
pass
return {
"authority_notification": notification,
"submitted_within_72h": submitted_within_72h,
}
# =============================================================================
# Data Subject Notification (Art. 34)
# =============================================================================
@router.post("/{incident_id}/notify-subjects")
def notify_subjects(
incident_id: UUID,
body: DataSubjectNotificationRequest,
db: Session = Depends(get_db),
x_user_id: Optional[str] = Header(None),
):
iid = str(incident_id)
user_id = x_user_id or "system"
check = db.execute(text(
"SELECT id, affected_data_subject_count FROM incident_incidents WHERE id = :id"
), {"id": iid})
row = check.mappings().first()
if not row:
raise HTTPException(status_code=404, detail="incident not found")
now = datetime.now(timezone.utc)
affected_count = body.affected_count or row["affected_data_subject_count"] or 0
notification = {
"required": True,
"status": "sent",
"sent_at": now.isoformat(),
"affected_count": affected_count,
"notification_text": body.notification_text,
"channel": body.channel,
}
db.execute(text("""
UPDATE incident_incidents
SET data_subject_notification = CAST(:dsn AS jsonb),
updated_at = NOW()
WHERE id = :id
"""), {"id": iid, "dsn": json.dumps(notification)})
_append_timeline(db, iid, {
"timestamp": now.isoformat(),
"action": "data_subjects_notified",
"user_id": user_id,
"details": f"Data subjects notified via {body.channel} ({affected_count} affected)",
})
db.commit()
return {"data_subject_notification": notification}
# =============================================================================
# Measures
# =============================================================================
@router.post("/{incident_id}/measures")
def add_measure(
incident_id: UUID,
body: MeasureCreate,
db: Session = Depends(get_db),
x_user_id: Optional[str] = Header(None),
):
iid = str(incident_id)
user_id = x_user_id or "system"
check = db.execute(text(
"SELECT id FROM incident_incidents WHERE id = :id"
), {"id": iid})
if not check.first():
raise HTTPException(status_code=404, detail="incident not found")
measure_id = str(uuid4())
now = datetime.now(timezone.utc)
db.execute(text("""
INSERT INTO incident_measures (
id, incident_id, title, description, measure_type, status,
responsible, due_date, created_at, updated_at
) VALUES (
:id, :incident_id, :title, :description, :measure_type, 'planned',
:responsible, :due_date, :now, :now
)
"""), {
"id": measure_id,
"incident_id": iid,
"title": body.title,
"description": body.description or "",
"measure_type": body.measure_type,
"responsible": body.responsible or "",
"due_date": body.due_date,
"now": now.isoformat(),
})
_append_timeline(db, iid, {
"timestamp": now.isoformat(),
"action": "measure_added",
"user_id": user_id,
"details": f"Measure added: {body.title} ({body.measure_type})",
})
db.commit()
result = db.execute(text(
"SELECT * FROM incident_measures WHERE id = :id"
), {"id": measure_id})
measure = _measure_to_response(result.mappings().first())
return {"measure": measure}
@router.put("/{incident_id}/measures/{measure_id}")
def update_measure(
incident_id: UUID,
measure_id: UUID,
body: MeasureUpdate,
db: Session = Depends(get_db),
):
mid = str(measure_id)
check = db.execute(text(
"SELECT id FROM incident_measures WHERE id = :id"
), {"id": mid})
if not check.first():
raise HTTPException(status_code=404, detail="measure not found")
updates = []
params: dict = {"id": mid}
for field in ("title", "description", "measure_type", "status", "responsible", "due_date"):
val = getattr(body, field, None)
if val is not None:
updates.append(f"{field} = :{field}")
params[field] = val
if not updates:
raise HTTPException(status_code=400, detail="no fields to update")
updates.append("updated_at = NOW()")
sql = f"UPDATE incident_measures SET {', '.join(updates)} WHERE id = :id"
db.execute(text(sql), params)
db.commit()
result = db.execute(text(
"SELECT * FROM incident_measures WHERE id = :id"
), {"id": mid})
measure = _measure_to_response(result.mappings().first())
return {"measure": measure}
@router.post("/{incident_id}/measures/{measure_id}/complete")
def complete_measure(
incident_id: UUID,
measure_id: UUID,
db: Session = Depends(get_db),
):
mid = str(measure_id)
check = db.execute(text(
"SELECT id FROM incident_measures WHERE id = :id"
), {"id": mid})
if not check.first():
raise HTTPException(status_code=404, detail="measure not found")
db.execute(text("""
UPDATE incident_measures
SET status = 'completed', completed_at = NOW(), updated_at = NOW()
WHERE id = :id
"""), {"id": mid})
db.commit()
return {"message": "measure completed"}
# =============================================================================
# Timeline
# =============================================================================
@router.post("/{incident_id}/timeline")
def add_timeline_entry(
incident_id: UUID,
body: TimelineEntryRequest,
db: Session = Depends(get_db),
x_user_id: Optional[str] = Header(None),
):
iid = str(incident_id)
user_id = x_user_id or "system"
check = db.execute(text(
"SELECT id FROM incident_incidents WHERE id = :id"
), {"id": iid})
if not check.first():
raise HTTPException(status_code=404, detail="incident not found")
now = datetime.now(timezone.utc)
entry = {
"timestamp": now.isoformat(),
"action": body.action,
"user_id": user_id,
"details": body.details or "",
}
_append_timeline(db, iid, entry)
db.commit()
return {"timeline_entry": entry}
# =============================================================================
# Close Incident
# =============================================================================
@router.post("/{incident_id}/close")
def close_incident(
incident_id: UUID,
body: CloseIncidentRequest,
db: Session = Depends(get_db),
x_user_id: Optional[str] = Header(None),
):
iid = str(incident_id)
user_id = x_user_id or "system"
check = db.execute(text(
"SELECT id FROM incident_incidents WHERE id = :id"
), {"id": iid})
if not check.first():
raise HTTPException(status_code=404, detail="incident not found")
now = datetime.now(timezone.utc)
db.execute(text("""
UPDATE incident_incidents
SET status = 'closed',
root_cause = :root_cause,
lessons_learned = :lessons_learned,
closed_at = :now,
updated_at = :now
WHERE id = :id
"""), {
"id": iid,
"root_cause": body.root_cause,
"lessons_learned": body.lessons_learned or "",
"now": now.isoformat(),
})
_append_timeline(db, iid, {
"timestamp": now.isoformat(),
"action": "incident_closed",
"user_id": user_id,
"details": f"Incident closed. Root cause: {body.root_cause}",
})
db.commit()
return {
"message": "incident closed",
"root_cause": body.root_cause,
"lessons_learned": body.lessons_learned or "",
}