Files
breakpilot-compliance/backend-compliance/compliance/api/change_request_routes.py
Benjamin Admin 1e84df9769
All checks were successful
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) Successful in 32s
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 18s
feat(sdk): Multi-Tenancy, Versionierung, Change-Requests, Dokumentengenerierung (Phase 1-6)
6-Phasen-Implementation fuer cloud-faehiges, mandantenfaehiges Compliance SDK:

Phase 1: Multi-Tenancy Fix
- Shared tenant_utils.py Dependency (UUID-Validierung, kein "default" mehr)
- VVT tenant_id Column + tenant-scoped Queries
- DSFA/Vendor DEFAULT_TENANT_ID von "default" auf UUID migriert
- Migration 035

Phase 2: Stammdaten-Erweiterung
- Company Profile um JSONB-Felder erweitert (processing_systems, ai_systems, technical_contacts)
- Regulierungs-Flags (NIS2, AI Act, ISO 27001)
- GET /template-context Endpoint
- Migration 036

Phase 3: Dokument-Versionierung
- 5 Versions-Tabellen (DSFA, VVT, TOM, Loeschfristen, Obligations)
- Shared versioning_utils.py Helper
- /{id}/versions Endpoints auf allen 5 Dokumenttypen
- Migration 037

Phase 4: Change-Request System
- Zentrale CR-Inbox mit CRUD + Accept/Reject/Edit Workflow
- Regelbasierte CR-Engine (VVT DPIA → DSFA CR, Datenkategorien → Loeschfristen CR)
- Audit-Trail
- Migration 038

Phase 5: Dokumentengenerierung
- 5 Template-Generatoren (DSFA, VVT, TOM, Loeschfristen, Obligations)
- Preview + Apply Endpoints (erzeugt CRs, keine direkten Dokumente)

Phase 6: Frontend-Integration
- Change-Request Inbox Page mit Stats, Filtern, Modals
- VersionHistory Timeline-Komponente
- SDKSidebar CR-Badge (60s Polling)
- Company Profile: 2 neue Wizard-Steps + "Dokumente generieren" CTA

Docs: 5 neue MkDocs-Seiten, CLAUDE.md aktualisiert
Tests: 97 neue Tests (alle bestanden)

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

428 lines
15 KiB
Python

"""
FastAPI routes for Change-Request System.
Endpoints:
GET /change-requests — List (filter: status, doc_type, priority)
GET /change-requests/stats — Summary counts
GET /change-requests/{id} — Detail + audit log
POST /change-requests — Create manually
POST /change-requests/{id}/accept — Accept → create new version
POST /change-requests/{id}/reject — Reject with reason
POST /change-requests/{id}/edit — Edit proposal, then accept
DELETE /change-requests/{id} — Soft-delete
"""
import json
import logging
from datetime import datetime
from typing import Optional, List
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
from .tenant_utils import get_tenant_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/change-requests", tags=["change-requests"])
VALID_STATUSES = {"pending", "accepted", "rejected", "edited_and_accepted"}
VALID_PRIORITIES = {"low", "normal", "high", "critical"}
VALID_DOC_TYPES = {"dsfa", "vvt", "tom", "loeschfristen", "obligation"}
# =============================================================================
# Pydantic Schemas
# =============================================================================
class ChangeRequestCreate(BaseModel):
trigger_type: str = "manual"
trigger_source_id: Optional[str] = None
target_document_type: str
target_document_id: Optional[str] = None
target_section: Optional[str] = None
proposal_title: str
proposal_body: Optional[str] = None
proposed_changes: dict = {}
priority: str = "normal"
class ChangeRequestEdit(BaseModel):
proposal_body: Optional[str] = None
proposed_changes: Optional[dict] = None
class ChangeRequestReject(BaseModel):
rejection_reason: str
# =============================================================================
# Helpers
# =============================================================================
def _cr_to_dict(row) -> dict:
return {
"id": str(row["id"]),
"tenant_id": row["tenant_id"],
"trigger_type": row["trigger_type"],
"trigger_source_id": str(row["trigger_source_id"]) if row["trigger_source_id"] else None,
"target_document_type": row["target_document_type"],
"target_document_id": str(row["target_document_id"]) if row["target_document_id"] else None,
"target_section": row["target_section"],
"proposal_title": row["proposal_title"],
"proposal_body": row["proposal_body"],
"proposed_changes": row["proposed_changes"] or {},
"status": row["status"],
"priority": row["priority"],
"decided_by": row["decided_by"],
"decided_at": row["decided_at"].isoformat() if row["decided_at"] else None,
"rejection_reason": row["rejection_reason"],
"resulting_version_id": str(row["resulting_version_id"]) if row["resulting_version_id"] else None,
"created_by": row["created_by"],
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
"updated_at": row["updated_at"].isoformat() if row["updated_at"] else None,
}
def _log_cr_audit(db, cr_id, tenant_id, action, performed_by="system", before_state=None, after_state=None):
db.execute(
text("""
INSERT INTO compliance_change_request_audit
(change_request_id, tenant_id, action, performed_by, before_state, after_state)
VALUES (:cr_id, :tid, :action, :by, CAST(:before AS jsonb), CAST(:after AS jsonb))
"""),
{
"cr_id": cr_id,
"tid": tenant_id,
"action": action,
"by": performed_by,
"before": json.dumps(before_state) if before_state else None,
"after": json.dumps(after_state) if after_state else None,
},
)
# =============================================================================
# Routes
# =============================================================================
@router.get("")
async def list_change_requests(
status: Optional[str] = Query(None),
target_document_type: Optional[str] = Query(None),
priority: Optional[str] = Query(None),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=500),
tid: str = Depends(get_tenant_id),
db: Session = Depends(get_db),
):
"""List change requests with optional filters."""
sql = "SELECT * FROM compliance_change_requests WHERE tenant_id = :tid AND NOT is_deleted"
params = {"tid": tid}
if status:
sql += " AND status = :status"
params["status"] = status
if target_document_type:
sql += " AND target_document_type = :doc_type"
params["doc_type"] = target_document_type
if priority:
sql += " AND priority = :priority"
params["priority"] = priority
sql += " ORDER BY CASE priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'normal' THEN 2 ELSE 3 END, created_at DESC"
sql += " LIMIT :limit OFFSET :skip"
params["limit"] = limit
params["skip"] = skip
rows = db.execute(text(sql), params).fetchall()
return [_cr_to_dict(r) for r in rows]
@router.get("/stats")
async def get_stats(
tid: str = Depends(get_tenant_id),
db: Session = Depends(get_db),
):
"""Summary counts for change requests."""
rows = db.execute(
text("""
SELECT
COUNT(*) FILTER (WHERE status = 'pending') AS total_pending,
COUNT(*) FILTER (WHERE status = 'pending' AND priority = 'critical') AS critical_count,
COUNT(*) FILTER (WHERE status = 'accepted' OR status = 'edited_and_accepted') AS total_accepted,
COUNT(*) FILTER (WHERE status = 'rejected') AS total_rejected
FROM compliance_change_requests
WHERE tenant_id = :tid AND NOT is_deleted
"""),
{"tid": tid},
).fetchone()
# By document type
doc_type_rows = db.execute(
text("""
SELECT target_document_type, COUNT(*)
FROM compliance_change_requests
WHERE tenant_id = :tid AND status = 'pending' AND NOT is_deleted
GROUP BY target_document_type
"""),
{"tid": tid},
).fetchall()
return {
"total_pending": rows[0] or 0,
"critical_count": rows[1] or 0,
"total_accepted": rows[2] or 0,
"total_rejected": rows[3] or 0,
"by_document_type": {r[0]: r[1] for r in doc_type_rows},
}
@router.get("/{cr_id}")
async def get_change_request(
cr_id: str,
tid: str = Depends(get_tenant_id),
db: Session = Depends(get_db),
):
"""Get change request detail with audit log."""
row = db.execute(
text("SELECT * FROM compliance_change_requests WHERE id = :id AND tenant_id = :tid AND NOT is_deleted"),
{"id": cr_id, "tid": tid},
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Change request not found")
cr = _cr_to_dict(row)
# Attach audit log
audit_rows = db.execute(
text("""
SELECT id, action, performed_by, before_state, after_state, created_at
FROM compliance_change_request_audit
WHERE change_request_id = :cr_id
ORDER BY created_at DESC
"""),
{"cr_id": cr_id},
).fetchall()
cr["audit_log"] = [
{
"id": str(a[0]),
"action": a[1],
"performed_by": a[2],
"before_state": a[3],
"after_state": a[4],
"created_at": a[5].isoformat() if a[5] else None,
}
for a in audit_rows
]
return cr
@router.post("", status_code=201)
async def create_change_request(
body: ChangeRequestCreate,
tid: str = Depends(get_tenant_id),
db: Session = Depends(get_db),
x_user_id: Optional[str] = Header(None, alias="X-User-ID"),
):
"""Create a change request manually."""
if body.target_document_type not in VALID_DOC_TYPES:
raise HTTPException(status_code=400, detail=f"Invalid target_document_type: {body.target_document_type}")
if body.priority not in VALID_PRIORITIES:
raise HTTPException(status_code=400, detail=f"Invalid priority: {body.priority}")
row = db.execute(
text("""
INSERT INTO compliance_change_requests
(tenant_id, trigger_type, trigger_source_id, target_document_type,
target_document_id, target_section, proposal_title, proposal_body,
proposed_changes, priority, created_by)
VALUES (:tid, :trigger_type, :trigger_source_id, :doc_type,
:doc_id, :section, :title, :body,
CAST(:changes AS jsonb), :priority, :created_by)
RETURNING *
"""),
{
"tid": tid,
"trigger_type": body.trigger_type,
"trigger_source_id": body.trigger_source_id,
"doc_type": body.target_document_type,
"doc_id": body.target_document_id,
"section": body.target_section,
"title": body.proposal_title,
"body": body.proposal_body,
"changes": json.dumps(body.proposed_changes),
"priority": body.priority,
"created_by": x_user_id or "system",
},
).fetchone()
_log_cr_audit(db, row["id"], tid, "CREATED", x_user_id or "system")
db.commit()
return _cr_to_dict(row)
@router.post("/{cr_id}/accept")
async def accept_change_request(
cr_id: str,
tid: str = Depends(get_tenant_id),
db: Session = Depends(get_db),
x_user_id: Optional[str] = Header(None, alias="X-User-ID"),
):
"""Accept a change request → creates a new document version."""
row = db.execute(
text("SELECT * FROM compliance_change_requests WHERE id = :id AND tenant_id = :tid AND NOT is_deleted"),
{"id": cr_id, "tid": tid},
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Change request not found")
if row["status"] != "pending":
raise HTTPException(status_code=422, detail=f"Cannot accept CR in status '{row['status']}'")
user = x_user_id or "system"
before_state = {"status": row["status"]}
# If there's a target document, create a version snapshot
resulting_version_id = None
if row["target_document_id"]:
try:
from .versioning_utils import create_version_snapshot
version = create_version_snapshot(
db,
doc_type=row["target_document_type"],
doc_id=str(row["target_document_id"]),
tenant_id=tid,
snapshot=row["proposed_changes"] or {},
change_summary=f"Accepted CR: {row['proposal_title']}",
created_by=user,
)
resulting_version_id = version["id"]
except Exception as e:
logger.warning(f"Could not create version for CR {cr_id}: {e}")
# Update CR status
updated = db.execute(
text("""
UPDATE compliance_change_requests
SET status = 'accepted', decided_by = :user, decided_at = NOW(),
resulting_version_id = :ver_id, updated_at = NOW()
WHERE id = :id AND tenant_id = :tid
RETURNING *
"""),
{"id": cr_id, "tid": tid, "user": user, "ver_id": resulting_version_id},
).fetchone()
_log_cr_audit(db, cr_id, tid, "ACCEPTED", user, before_state, {"status": "accepted"})
db.commit()
return _cr_to_dict(updated)
@router.post("/{cr_id}/reject")
async def reject_change_request(
cr_id: str,
body: ChangeRequestReject,
tid: str = Depends(get_tenant_id),
db: Session = Depends(get_db),
x_user_id: Optional[str] = Header(None, alias="X-User-ID"),
):
"""Reject a change request with reason."""
row = db.execute(
text("SELECT * FROM compliance_change_requests WHERE id = :id AND tenant_id = :tid AND NOT is_deleted"),
{"id": cr_id, "tid": tid},
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Change request not found")
if row["status"] != "pending":
raise HTTPException(status_code=422, detail=f"Cannot reject CR in status '{row['status']}'")
user = x_user_id or "system"
updated = db.execute(
text("""
UPDATE compliance_change_requests
SET status = 'rejected', decided_by = :user, decided_at = NOW(),
rejection_reason = :reason, updated_at = NOW()
WHERE id = :id AND tenant_id = :tid
RETURNING *
"""),
{"id": cr_id, "tid": tid, "user": user, "reason": body.rejection_reason},
).fetchone()
_log_cr_audit(db, cr_id, tid, "REJECTED", user, {"status": "pending"}, {"status": "rejected", "reason": body.rejection_reason})
db.commit()
return _cr_to_dict(updated)
@router.post("/{cr_id}/edit")
async def edit_change_request(
cr_id: str,
body: ChangeRequestEdit,
tid: str = Depends(get_tenant_id),
db: Session = Depends(get_db),
x_user_id: Optional[str] = Header(None, alias="X-User-ID"),
):
"""Edit the proposal, then auto-accept."""
row = db.execute(
text("SELECT * FROM compliance_change_requests WHERE id = :id AND tenant_id = :tid AND NOT is_deleted"),
{"id": cr_id, "tid": tid},
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Change request not found")
if row["status"] != "pending":
raise HTTPException(status_code=422, detail=f"Cannot edit CR in status '{row['status']}'")
user = x_user_id or "system"
updates = []
params = {"id": cr_id, "tid": tid, "user": user}
if body.proposal_body is not None:
updates.append("proposal_body = :body")
params["body"] = body.proposal_body
if body.proposed_changes is not None:
updates.append("proposed_changes = CAST(:changes AS jsonb)")
params["changes"] = json.dumps(body.proposed_changes)
updates.append("status = 'edited_and_accepted'")
updates.append("decided_by = :user")
updates.append("decided_at = NOW()")
updates.append("updated_at = NOW()")
sql = f"UPDATE compliance_change_requests SET {', '.join(updates)} WHERE id = :id AND tenant_id = :tid RETURNING *"
updated = db.execute(text(sql), params).fetchone()
_log_cr_audit(db, cr_id, tid, "EDITED_AND_ACCEPTED", user, {"status": "pending"}, {"status": "edited_and_accepted"})
db.commit()
return _cr_to_dict(updated)
@router.delete("/{cr_id}")
async def delete_change_request(
cr_id: str,
tid: str = Depends(get_tenant_id),
db: Session = Depends(get_db),
x_user_id: Optional[str] = Header(None, alias="X-User-ID"),
):
"""Soft-delete a change request."""
result = db.execute(
text("""
UPDATE compliance_change_requests
SET is_deleted = TRUE, updated_at = NOW()
WHERE id = :id AND tenant_id = :tid AND NOT is_deleted
"""),
{"id": cr_id, "tid": tid},
)
if result.rowcount == 0:
raise HTTPException(status_code=404, detail="Change request not found")
_log_cr_audit(db, cr_id, tid, "DELETED", x_user_id or "system")
db.commit()
return {"success": True, "message": "Change request deleted"}