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
- 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>
427 lines
15 KiB
Python
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"}
|