"""Persist CRA Art. 14 incident reports — schema-neutral (frozen DB). Reuses the existing compliance_cra_documents table (doc_type='doc_incident_report'): one row per incident, the full incident + per-stage submissions live in generation_context (jsonb). No supersede chaining (unlike the assessment snapshot store) — each incident is an independent, evolving record. """ import json from typing import Optional from sqlalchemy import text from database import SessionLocal DOC_TYPE = "doc_incident_report" def _summary_md(inc: dict) -> str: return "\n".join([ "# CRA-Meldung: {}".format(inc.get("summary", "(ohne Titel)")), "", "- Produkt: {} {}".format(inc.get("product_name", ""), inc.get("product_version", "")), "- Art: {} · Schwere: {}".format(inc.get("kind", ""), inc.get("severity", "")), "- Bekannt seit: {}".format(inc.get("aware_at", "")), ]) def create_incident(cra_project_id: str, tenant_id: str, incident: dict) -> dict: inc = dict(incident) inc.setdefault("submissions", {}) # stage_key -> {submitted_at, report} db = SessionLocal() try: row = db.execute(text(""" INSERT INTO compliance_cra_documents (cra_project_id, tenant_id, doc_type, title, content_md, version, requirements_coverage, generation_context, status) VALUES (CAST(:pid AS uuid), :tid, :dt, :title, :md, 1, CAST('{}' AS jsonb), CAST(:ctx AS jsonb), 'open') RETURNING id, generated_at """), {"pid": cra_project_id, "tid": tenant_id, "dt": DOC_TYPE, "title": inc.get("summary", "CRA-Meldung")[:200], "md": _summary_md(inc), "ctx": json.dumps(inc)}).fetchone() db.commit() return {"id": str(row.id), "created_at": row.generated_at.isoformat(), "status": "open", **inc} except Exception: db.rollback() raise finally: db.close() def list_incidents(cra_project_id: str, tenant_id: str) -> list: db = SessionLocal() try: rows = db.execute(text(""" SELECT id, status, generated_at, generation_context FROM compliance_cra_documents WHERE cra_project_id = CAST(:pid AS uuid) AND tenant_id = :tid AND doc_type = :dt ORDER BY generated_at DESC """), {"pid": cra_project_id, "tid": tenant_id, "dt": DOC_TYPE}).fetchall() out = [] for r in rows: ctx = r.generation_context if isinstance(r.generation_context, dict) else {} out.append({"id": str(r.id), "status": r.status, "created_at": r.generated_at.isoformat(), **ctx}) return out finally: db.close() def get_incident(incident_id: str, tenant_id: str) -> Optional[dict]: db = SessionLocal() try: r = db.execute(text(""" SELECT id, status, generated_at, generation_context FROM compliance_cra_documents WHERE id = CAST(:iid AS uuid) AND tenant_id = :tid AND doc_type = :dt """), {"iid": incident_id, "tid": tenant_id, "dt": DOC_TYPE}).fetchone() if not r: return None ctx = r.generation_context if isinstance(r.generation_context, dict) else {} return {"id": str(r.id), "status": r.status, "created_at": r.generated_at.isoformat(), **ctx} finally: db.close() def _save_ctx(db, incident_id: str, tenant_id: str, ctx: dict, status: str) -> None: db.execute(text(""" UPDATE compliance_cra_documents SET generation_context = CAST(:ctx AS jsonb), status = :st, updated_at = NOW() WHERE id = CAST(:iid AS uuid) AND tenant_id = :tid AND doc_type = :dt """), {"ctx": json.dumps(ctx), "st": status, "iid": incident_id, "tid": tenant_id, "dt": DOC_TYPE}) def update_incident(incident_id: str, tenant_id: str, patch: dict) -> Optional[dict]: db = SessionLocal() try: r = db.execute(text(""" SELECT status, generation_context FROM compliance_cra_documents WHERE id = CAST(:iid AS uuid) AND tenant_id = :tid AND doc_type = :dt """), {"iid": incident_id, "tid": tenant_id, "dt": DOC_TYPE}).fetchone() if not r: return None ctx = dict(r.generation_context if isinstance(r.generation_context, dict) else {}) ctx.update({k: v for k, v in patch.items() if k != "submissions"}) _save_ctx(db, incident_id, tenant_id, ctx, r.status) db.commit() return {"id": incident_id, "status": r.status, **ctx} except Exception: db.rollback() raise finally: db.close() def record_submission(incident_id: str, tenant_id: str, stage: str, submitted_at: str, report: dict) -> Optional[dict]: """Mark a cascade stage as submitted (stores the ENISA export draft + ts).""" db = SessionLocal() try: r = db.execute(text(""" SELECT generation_context FROM compliance_cra_documents WHERE id = CAST(:iid AS uuid) AND tenant_id = :tid AND doc_type = :dt """), {"iid": incident_id, "tid": tenant_id, "dt": DOC_TYPE}).fetchone() if not r: return None ctx = dict(r.generation_context if isinstance(r.generation_context, dict) else {}) subs = dict(ctx.get("submissions") or {}) subs[stage] = {"submitted_at": submitted_at, "report": report} ctx["submissions"] = subs status = "closed" if stage == "final" else "reporting" _save_ctx(db, incident_id, tenant_id, ctx, status) db.commit() return {"id": incident_id, "status": status, **ctx} except Exception: db.rollback() raise finally: db.close()