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>
438 lines
13 KiB
Python
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."
|
|
}
|
|
|
|
|