Files
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

427 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 typing import Optional
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"}