This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/backend/meeting_consent_api.py
Benjamin Admin 21a844cb8a fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.

This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).

Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 09:51:32 +01:00

438 lines
13 KiB
Python

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