""" 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"}