""" FastAPI routes for VVT — Verzeichnis von Verarbeitungstaetigkeiten (Art. 30 DSGVO). Endpoints: GET /vvt/organization — Load organization header PUT /vvt/organization — Save organization header GET /vvt/activities — List activities (filter: status, business_function) POST /vvt/activities — Create new activity GET /vvt/activities/{id} — Get single activity PUT /vvt/activities/{id} — Update activity DELETE /vvt/activities/{id} — Delete activity GET /vvt/audit-log — Audit trail (limit, offset) GET /vvt/export — JSON export of all activities GET /vvt/stats — Statistics """ import logging from datetime import datetime from typing import Optional, List from uuid import uuid4 from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from classroom_engine.database import get_db from ..db.vvt_models import VVTOrganizationDB, VVTActivityDB, VVTAuditLogDB from .schemas import ( VVTOrganizationUpdate, VVTOrganizationResponse, VVTActivityCreate, VVTActivityUpdate, VVTActivityResponse, VVTStatsResponse, VVTAuditLogEntry, ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/vvt", tags=["compliance-vvt"]) def _log_audit( db: Session, action: str, entity_type: str, entity_id=None, changed_by: str = "system", old_values=None, new_values=None, ): entry = VVTAuditLogDB( action=action, entity_type=entity_type, entity_id=entity_id, changed_by=changed_by, old_values=old_values, new_values=new_values, ) db.add(entry) # ============================================================================ # Organization Header # ============================================================================ @router.get("/organization", response_model=Optional[VVTOrganizationResponse]) async def get_organization(db: Session = Depends(get_db)): """Load the VVT organization header (single record).""" org = db.query(VVTOrganizationDB).order_by(VVTOrganizationDB.created_at).first() if not org: return None return VVTOrganizationResponse( id=str(org.id), organization_name=org.organization_name, industry=org.industry, locations=org.locations or [], employee_count=org.employee_count, dpo_name=org.dpo_name, dpo_contact=org.dpo_contact, vvt_version=org.vvt_version or '1.0', last_review_date=org.last_review_date, next_review_date=org.next_review_date, review_interval=org.review_interval or 'annual', created_at=org.created_at, updated_at=org.updated_at, ) @router.put("/organization", response_model=VVTOrganizationResponse) async def upsert_organization( request: VVTOrganizationUpdate, db: Session = Depends(get_db), ): """Create or update the VVT organization header.""" org = db.query(VVTOrganizationDB).order_by(VVTOrganizationDB.created_at).first() if not org: data = request.dict(exclude_none=True) if 'organization_name' not in data: data['organization_name'] = 'Meine Organisation' org = VVTOrganizationDB(**data) db.add(org) else: for field, value in request.dict(exclude_none=True).items(): setattr(org, field, value) org.updated_at = datetime.utcnow() db.commit() db.refresh(org) return VVTOrganizationResponse( id=str(org.id), organization_name=org.organization_name, industry=org.industry, locations=org.locations or [], employee_count=org.employee_count, dpo_name=org.dpo_name, dpo_contact=org.dpo_contact, vvt_version=org.vvt_version or '1.0', last_review_date=org.last_review_date, next_review_date=org.next_review_date, review_interval=org.review_interval or 'annual', created_at=org.created_at, updated_at=org.updated_at, ) # ============================================================================ # Activities # ============================================================================ def _activity_to_response(act: VVTActivityDB) -> VVTActivityResponse: return VVTActivityResponse( id=str(act.id), vvt_id=act.vvt_id, name=act.name, description=act.description, purposes=act.purposes or [], legal_bases=act.legal_bases or [], data_subject_categories=act.data_subject_categories or [], personal_data_categories=act.personal_data_categories or [], recipient_categories=act.recipient_categories or [], third_country_transfers=act.third_country_transfers or [], retention_period=act.retention_period or {}, tom_description=act.tom_description, business_function=act.business_function, systems=act.systems or [], deployment_model=act.deployment_model, data_sources=act.data_sources or [], data_flows=act.data_flows or [], protection_level=act.protection_level or 'MEDIUM', dpia_required=act.dpia_required or False, structured_toms=act.structured_toms or {}, status=act.status or 'DRAFT', responsible=act.responsible, owner=act.owner, created_at=act.created_at, updated_at=act.updated_at, ) @router.get("/activities", response_model=List[VVTActivityResponse]) async def list_activities( status: Optional[str] = Query(None), business_function: Optional[str] = Query(None), search: Optional[str] = Query(None), db: Session = Depends(get_db), ): """List all processing activities with optional filters.""" query = db.query(VVTActivityDB) if status: query = query.filter(VVTActivityDB.status == status) if business_function: query = query.filter(VVTActivityDB.business_function == business_function) if search: term = f"%{search}%" query = query.filter( (VVTActivityDB.name.ilike(term)) | (VVTActivityDB.description.ilike(term)) | (VVTActivityDB.vvt_id.ilike(term)) ) activities = query.order_by(VVTActivityDB.created_at.desc()).all() return [_activity_to_response(a) for a in activities] @router.post("/activities", response_model=VVTActivityResponse, status_code=201) async def create_activity( request: VVTActivityCreate, db: Session = Depends(get_db), ): """Create a new processing activity.""" # Check for duplicate vvt_id existing = db.query(VVTActivityDB).filter( VVTActivityDB.vvt_id == request.vvt_id ).first() if existing: raise HTTPException( status_code=409, detail=f"Activity with VVT-ID '{request.vvt_id}' already exists" ) act = VVTActivityDB(**request.dict()) db.add(act) db.flush() # get ID before audit log _log_audit( db, action="CREATE", entity_type="activity", entity_id=act.id, new_values={"vvt_id": act.vvt_id, "name": act.name, "status": act.status}, ) db.commit() db.refresh(act) return _activity_to_response(act) @router.get("/activities/{activity_id}", response_model=VVTActivityResponse) async def get_activity(activity_id: str, db: Session = Depends(get_db)): """Get a single processing activity by ID.""" act = db.query(VVTActivityDB).filter(VVTActivityDB.id == activity_id).first() if not act: raise HTTPException(status_code=404, detail=f"Activity {activity_id} not found") return _activity_to_response(act) @router.put("/activities/{activity_id}", response_model=VVTActivityResponse) async def update_activity( activity_id: str, request: VVTActivityUpdate, db: Session = Depends(get_db), ): """Update a processing activity.""" act = db.query(VVTActivityDB).filter(VVTActivityDB.id == activity_id).first() if not act: raise HTTPException(status_code=404, detail=f"Activity {activity_id} not found") old_values = {"name": act.name, "status": act.status} updates = request.dict(exclude_none=True) for field, value in updates.items(): setattr(act, field, value) act.updated_at = datetime.utcnow() _log_audit( db, action="UPDATE", entity_type="activity", entity_id=act.id, old_values=old_values, new_values=updates, ) db.commit() db.refresh(act) return _activity_to_response(act) @router.delete("/activities/{activity_id}") async def delete_activity(activity_id: str, db: Session = Depends(get_db)): """Delete a processing activity.""" act = db.query(VVTActivityDB).filter(VVTActivityDB.id == activity_id).first() if not act: raise HTTPException(status_code=404, detail=f"Activity {activity_id} not found") _log_audit( db, action="DELETE", entity_type="activity", entity_id=act.id, old_values={"vvt_id": act.vvt_id, "name": act.name}, ) db.delete(act) db.commit() return {"success": True, "message": f"Activity {activity_id} deleted"} # ============================================================================ # Audit Log # ============================================================================ @router.get("/audit-log", response_model=List[VVTAuditLogEntry]) async def get_audit_log( limit: int = Query(50, ge=1, le=500), offset: int = Query(0, ge=0), db: Session = Depends(get_db), ): """Get the VVT audit trail.""" entries = ( db.query(VVTAuditLogDB) .order_by(VVTAuditLogDB.created_at.desc()) .offset(offset) .limit(limit) .all() ) return [ VVTAuditLogEntry( id=str(e.id), action=e.action, entity_type=e.entity_type, entity_id=str(e.entity_id) if e.entity_id else None, changed_by=e.changed_by, old_values=e.old_values, new_values=e.new_values, created_at=e.created_at, ) for e in entries ] # ============================================================================ # Export & Stats # ============================================================================ @router.get("/export") async def export_activities(db: Session = Depends(get_db)): """JSON export of all activities for external review / PDF generation.""" org = db.query(VVTOrganizationDB).order_by(VVTOrganizationDB.created_at).first() activities = db.query(VVTActivityDB).order_by(VVTActivityDB.created_at).all() _log_audit( db, action="EXPORT", entity_type="all_activities", new_values={"count": len(activities)}, ) db.commit() return { "exported_at": datetime.utcnow().isoformat(), "organization": { "name": org.organization_name if org else "", "dpo_name": org.dpo_name if org else "", "dpo_contact": org.dpo_contact if org else "", "vvt_version": org.vvt_version if org else "1.0", } if org else None, "activities": [ { "id": str(a.id), "vvt_id": a.vvt_id, "name": a.name, "description": a.description, "status": a.status, "purposes": a.purposes, "legal_bases": a.legal_bases, "data_subject_categories": a.data_subject_categories, "personal_data_categories": a.personal_data_categories, "recipient_categories": a.recipient_categories, "third_country_transfers": a.third_country_transfers, "retention_period": a.retention_period, "dpia_required": a.dpia_required, "protection_level": a.protection_level, "business_function": a.business_function, "responsible": a.responsible, "created_at": a.created_at.isoformat(), "updated_at": a.updated_at.isoformat() if a.updated_at else None, } for a in activities ], } @router.get("/stats", response_model=VVTStatsResponse) async def get_stats(db: Session = Depends(get_db)): """Get VVT statistics summary.""" activities = db.query(VVTActivityDB).all() by_status: dict = {} by_bf: dict = {} for a in activities: status = a.status or 'DRAFT' bf = a.business_function or 'unknown' by_status[status] = by_status.get(status, 0) + 1 by_bf[bf] = by_bf.get(bf, 0) + 1 return VVTStatsResponse( total=len(activities), by_status=by_status, by_business_function=by_bf, dpia_required_count=sum(1 for a in activities if a.dpia_required), third_country_count=sum(1 for a in activities if a.third_country_transfers), draft_count=by_status.get('DRAFT', 0), approved_count=by_status.get('APPROVED', 0), )