""" Consent-Log Export (Borlabs-Parity + DSB-Audit-Anforderung). Auditors verlangen routinemaessig einen Auszug aller erteilten/ widerrufenen Einwilligungen pro Tenant — heute musste der DSB dafuer manuell SQL schreiben. Diese Endpunkte liefern CSV + JSON direkt aus dem Browser. Endpoints: GET /einwilligungen/export/consents.csv GET /einwilligungen/export/consents.json GET /einwilligungen/export/history.csv — Aenderungs-Historie """ from __future__ import annotations import csv import io import json import logging from datetime import datetime, timezone from fastapi import APIRouter, Depends, Header, Query from fastapi.responses import Response from sqlalchemy.orm import Session from classroom_engine.database import get_db from ..db.einwilligungen_models import ( EinwilligungenConsentDB, EinwilligungenConsentHistoryDB, ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/einwilligungen/export", tags=["einwilligungen-export"]) def _get_tenant(x_tenant_id: str | None = Header(None, alias="X-Tenant-ID")) -> str: if not x_tenant_id: from .tenant_utils import get_tenant_id return get_tenant_id() return x_tenant_id def _ts() -> str: return datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") def _consent_rows(consents: list[EinwilligungenConsentDB]) -> list[dict]: return [ { "consent_id": str(c.id), "user_id": c.user_id or "", "data_point_id": c.data_point_id or "", "granted": "yes" if c.granted else "no", "purpose": c.purpose or "", "consent_version": c.consent_version or "", "ip_address": c.ip_address or "", "user_agent": (c.user_agent or "")[:200], "source": c.source or "", "created_at": c.created_at.isoformat() if c.created_at else "", "updated_at": c.updated_at.isoformat() if c.updated_at else "", "revoked_at": c.revoked_at.isoformat() if getattr(c, "revoked_at", None) else "", } for c in consents ] def _history_rows(entries: list[EinwilligungenConsentHistoryDB]) -> list[dict]: return [ { "id": str(e.id), "consent_id": str(e.consent_id), "action": e.action or "", "consent_version": e.consent_version or "", "ip_address": e.ip_address or "", "user_agent": (e.user_agent or "")[:200], "source": e.source or "", "created_at": e.created_at.isoformat() if e.created_at else "", } for e in entries ] def _csv_response(rows: list[dict], filename: str) -> Response: if not rows: return Response(content="", media_type="text/csv", headers={"Content-Disposition": f"attachment; filename={filename}"}) buf = io.StringIO() w = csv.DictWriter(buf, fieldnames=list(rows[0].keys()), quoting=csv.QUOTE_ALL) w.writeheader() w.writerows(rows) return Response(content=buf.getvalue(), media_type="text/csv; charset=utf-8", headers={"Content-Disposition": f"attachment; filename={filename}"}) def _json_response(payload: dict, filename: str) -> Response: body = json.dumps(payload, ensure_ascii=False, indent=2, default=str) return Response(content=body, media_type="application/json; charset=utf-8", headers={"Content-Disposition": f"attachment; filename={filename}"}) @router.get("/consents.csv") async def export_consents_csv( user_id: str | None = Query(None, description="Filter by single user"), granted: bool | None = Query(None), since: str | None = Query(None, description="ISO timestamp"), tenant_id: str = Depends(_get_tenant), db: Session = Depends(get_db), ) -> Response: """Download all consent records of this tenant as CSV (auditor-ready).""" q = db.query(EinwilligungenConsentDB).filter( EinwilligungenConsentDB.tenant_id == tenant_id, ) if user_id: q = q.filter(EinwilligungenConsentDB.user_id == user_id) if granted is not None: q = q.filter(EinwilligungenConsentDB.granted == granted) if since: try: since_dt = datetime.fromisoformat(since.rstrip("Z")) q = q.filter(EinwilligungenConsentDB.created_at >= since_dt) except Exception: pass rows = _consent_rows(q.order_by(EinwilligungenConsentDB.created_at.desc()).all()) return _csv_response(rows, f"consents_{tenant_id[:8]}_{_ts()}.csv") @router.get("/consents.json") async def export_consents_json( user_id: str | None = Query(None), granted: bool | None = Query(None), since: str | None = Query(None), tenant_id: str = Depends(_get_tenant), db: Session = Depends(get_db), ) -> Response: """Same data as the CSV endpoint but JSON-shaped for further processing.""" q = db.query(EinwilligungenConsentDB).filter( EinwilligungenConsentDB.tenant_id == tenant_id, ) if user_id: q = q.filter(EinwilligungenConsentDB.user_id == user_id) if granted is not None: q = q.filter(EinwilligungenConsentDB.granted == granted) if since: try: since_dt = datetime.fromisoformat(since.rstrip("Z")) q = q.filter(EinwilligungenConsentDB.created_at >= since_dt) except Exception: pass rows = _consent_rows(q.order_by(EinwilligungenConsentDB.created_at.desc()).all()) payload = { "tenant_id": tenant_id, "exported_at": datetime.now(timezone.utc).isoformat(), "filter": {"user_id": user_id, "granted": granted, "since": since}, "count": len(rows), "consents": rows, } return _json_response(payload, f"consents_{tenant_id[:8]}_{_ts()}.json") @router.get("/history.csv") async def export_history_csv( consent_id: str | None = Query(None, description="Limit to one consent"), since: str | None = Query(None), tenant_id: str = Depends(_get_tenant), db: Session = Depends(get_db), ) -> Response: """Download the consent-change history (Art. 7(1) Nachweispflicht).""" q = db.query(EinwilligungenConsentHistoryDB).filter( EinwilligungenConsentHistoryDB.tenant_id == tenant_id, ) if consent_id: q = q.filter(EinwilligungenConsentHistoryDB.consent_id == consent_id) if since: try: since_dt = datetime.fromisoformat(since.rstrip("Z")) q = q.filter(EinwilligungenConsentHistoryDB.created_at >= since_dt) except Exception: pass rows = _history_rows(q.order_by(EinwilligungenConsentHistoryDB.created_at.asc()).all()) return _csv_response(rows, f"consent-history_{tenant_id[:8]}_{_ts()}.csv")