""" BreakPilot Meeting Consent API DSGVO-konforme Zustimmungsverwaltung fuer Meeting-Aufzeichnungen. Alle Teilnehmer muessen zustimmen bevor eine Aufzeichnung gestartet wird. """ import os import uuid from datetime import datetime from typing import Optional, List from pydantic import BaseModel, Field from fastapi import APIRouter, HTTPException, Query router = APIRouter(prefix="/api/meeting-consent", tags=["Meeting Consent"]) # ========================================== # PYDANTIC MODELS # ========================================== class ConsentRequest(BaseModel): """Request to initiate consent for recording.""" meeting_id: str = Field(..., description="Jitsi meeting room ID") consent_type: str = Field( default="opt_in", description="Consent type: opt_in (explicit), announced (verbal)" ) participant_count: int = Field( default=0, description="Expected number of participants" ) requested_by: Optional[str] = Field(None, description="User ID of requester") class ParticipantConsent(BaseModel): """Individual participant consent.""" participant_id: str = Field(..., description="Participant identifier") participant_name: Optional[str] = Field(None, description="Display name") consented: bool = Field(..., description="Whether consent was given") class ConsentStatus(BaseModel): """Current consent status for a meeting.""" id: str meeting_id: str consent_type: str participant_count: int consented_count: int all_consented: bool can_record: bool status: str # pending, approved, rejected, withdrawn requested_at: datetime approved_at: Optional[datetime] expires_at: Optional[datetime] class ConsentWithdrawal(BaseModel): """Request to withdraw consent.""" reason: Optional[str] = Field(None, description="Reason for withdrawal") # ========================================== # IN-MEMORY STORAGE (Dev Mode) # ========================================== _consent_store: dict = {} _participant_consents: dict = {} # consent_id -> [participant consents] # ========================================== # API ENDPOINTS # ========================================== @router.get("/health") async def consent_health(): """Health check for consent service.""" return { "status": "healthy", "active_consents": len([c for c in _consent_store.values() if c["status"] in ["pending", "approved"]]), "total_consents": len(_consent_store) } @router.post("/request", response_model=ConsentStatus) async def request_recording_consent(request: ConsentRequest): """ Initiate a consent request for meeting recording. This creates a new consent record and returns a status object. Recording can only start when all_consented is True. """ # Check if consent already exists for this meeting existing = next( (c for c in _consent_store.values() if c["meeting_id"] == request.meeting_id and c["status"] not in ["withdrawn", "expired"]), None ) if existing: raise HTTPException( status_code=409, detail=f"Consent request already exists for meeting {request.meeting_id}" ) consent_id = str(uuid.uuid4()) now = datetime.utcnow() consent = { "id": consent_id, "meeting_id": request.meeting_id, "consent_type": request.consent_type, "participant_count": request.participant_count, "consented_count": 0, "all_consented": False, "status": "pending", "requested_by": request.requested_by, "requested_at": now.isoformat(), "approved_at": None, "withdrawn_at": None, "created_at": now.isoformat(), "updated_at": now.isoformat() } _consent_store[consent_id] = consent _participant_consents[consent_id] = [] return ConsentStatus( id=consent_id, meeting_id=request.meeting_id, consent_type=request.consent_type, participant_count=request.participant_count, consented_count=0, all_consented=False, can_record=False, status="pending", requested_at=now, approved_at=None, expires_at=None ) @router.get("/{meeting_id}", response_model=ConsentStatus) async def get_consent_status(meeting_id: str): """ Get current consent status for a meeting. Returns the consent status including whether recording is allowed. """ consent = next( (c for c in _consent_store.values() if c["meeting_id"] == meeting_id and c["status"] not in ["withdrawn", "expired"]), None ) if not consent: # Return default status (no consent requested) return ConsentStatus( id="", meeting_id=meeting_id, consent_type="none", participant_count=0, consented_count=0, all_consented=False, can_record=False, status="not_requested", requested_at=datetime.utcnow(), approved_at=None, expires_at=None ) requested_at = datetime.fromisoformat(consent["requested_at"]) approved_at = ( datetime.fromisoformat(consent["approved_at"]) if consent.get("approved_at") else None ) return ConsentStatus( id=consent["id"], meeting_id=consent["meeting_id"], consent_type=consent["consent_type"], participant_count=consent["participant_count"], consented_count=consent["consented_count"], all_consented=consent["all_consented"], can_record=consent["all_consented"] and consent["status"] == "approved", status=consent["status"], requested_at=requested_at, approved_at=approved_at, expires_at=None ) @router.post("/{meeting_id}/participant") async def record_participant_consent( meeting_id: str, consent: ParticipantConsent ): """ Record individual participant consent. Each participant must explicitly consent to recording. When all participants have consented, recording is automatically approved. """ consent_record = next( (c for c in _consent_store.values() if c["meeting_id"] == meeting_id and c["status"] == "pending"), None ) if not consent_record: raise HTTPException( status_code=404, detail="No pending consent request found for this meeting" ) consent_id = consent_record["id"] # Check if participant already consented existing = next( (p for p in _participant_consents.get(consent_id, []) if p["participant_id"] == consent.participant_id), None ) if existing: # Update existing consent existing["consented"] = consent.consented existing["updated_at"] = datetime.utcnow().isoformat() else: # Add new participant consent _participant_consents[consent_id].append({ "participant_id": consent.participant_id, "participant_name": consent.participant_name, "consented": consent.consented, "created_at": datetime.utcnow().isoformat(), "updated_at": datetime.utcnow().isoformat() }) # Recalculate consent count participants = _participant_consents.get(consent_id, []) consented_count = sum(1 for p in participants if p["consented"]) consent_record["consented_count"] = consented_count consent_record["updated_at"] = datetime.utcnow().isoformat() # Check if all participants have consented if consent_record["participant_count"] > 0: all_consented = consented_count >= consent_record["participant_count"] else: # If participant_count is 0, check if we have at least one consent all_consented = consented_count > 0 and all(p["consented"] for p in participants) consent_record["all_consented"] = all_consented # Auto-approve if all consented if all_consented and consent_record["status"] == "pending": consent_record["status"] = "approved" consent_record["approved_at"] = datetime.utcnow().isoformat() return { "success": True, "meeting_id": meeting_id, "participant_id": consent.participant_id, "consented": consent.consented, "consented_count": consented_count, "all_consented": consent_record["all_consented"], "can_record": consent_record["status"] == "approved" } @router.get("/{meeting_id}/participants") async def get_participant_consents(meeting_id: str): """ Get list of participant consents for a meeting. Returns all recorded consents with anonymized participant IDs. """ consent_record = next( (c for c in _consent_store.values() if c["meeting_id"] == meeting_id), None ) if not consent_record: raise HTTPException( status_code=404, detail="No consent request found for this meeting" ) participants = _participant_consents.get(consent_record["id"], []) return { "meeting_id": meeting_id, "consent_id": consent_record["id"], "participant_count": consent_record["participant_count"], "participants": [ { "participant_id": p["participant_id"][-8:], # Anonymize "consented": p["consented"], "timestamp": p["created_at"] } for p in participants ] } @router.post("/{meeting_id}/withdraw") async def withdraw_consent( meeting_id: str, withdrawal: ConsentWithdrawal ): """ Withdraw consent for a meeting recording (DSGVO right). This immediately stops any ongoing recording and marks the consent as withdrawn. Existing recordings will be marked for deletion. """ consent_record = next( (c for c in _consent_store.values() if c["meeting_id"] == meeting_id and c["status"] in ["pending", "approved"]), None ) if not consent_record: raise HTTPException( status_code=404, detail="No active consent found for this meeting" ) consent_record["status"] = "withdrawn" consent_record["withdrawn_at"] = datetime.utcnow().isoformat() consent_record["withdrawal_reason"] = withdrawal.reason consent_record["updated_at"] = datetime.utcnow().isoformat() return { "success": True, "meeting_id": meeting_id, "status": "withdrawn", "message": "Consent withdrawn. Recording stopped if active.", "reason": withdrawal.reason } @router.delete("/{meeting_id}") async def delete_consent_request( meeting_id: str, reason: str = Query(..., description="Reason for deletion") ): """ Delete a consent request (admin only). This removes the consent request entirely. Use withdraw for user-initiated cancellation. """ consent_record = next( (c for c in _consent_store.values() if c["meeting_id"] == meeting_id), None ) if not consent_record: raise HTTPException( status_code=404, detail="No consent request found for this meeting" ) consent_id = consent_record["id"] # Remove from stores del _consent_store[consent_id] if consent_id in _participant_consents: del _participant_consents[consent_id] return { "success": True, "meeting_id": meeting_id, "deleted": True, "reason": reason } # ========================================== # BULK OPERATIONS # ========================================== @router.post("/announce") async def announce_recording( meeting_id: str = Query(...), announced_by: str = Query(..., description="Name of person announcing") ): """ Mark recording as verbally announced. For scenarios where verbal announcement is used instead of explicit opt-in. This creates an 'announced' consent type that allows recording. """ # Check if consent already exists existing = next( (c for c in _consent_store.values() if c["meeting_id"] == meeting_id and c["status"] not in ["withdrawn", "expired"]), None ) if existing: raise HTTPException( status_code=409, detail="Consent already exists for this meeting" ) consent_id = str(uuid.uuid4()) now = datetime.utcnow() consent = { "id": consent_id, "meeting_id": meeting_id, "consent_type": "announced", "participant_count": 0, "consented_count": 0, "all_consented": True, # Announced = implicit consent "status": "approved", "requested_by": announced_by, "announced_by": announced_by, "requested_at": now.isoformat(), "approved_at": now.isoformat(), "withdrawn_at": None, "created_at": now.isoformat(), "updated_at": now.isoformat() } _consent_store[consent_id] = consent return { "success": True, "meeting_id": meeting_id, "consent_id": consent_id, "consent_type": "announced", "can_record": True, "announced_by": announced_by, "message": "Recording announced. Participants should be informed verbally." }