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>
This commit is contained in:
437
backend/meeting_consent_api.py
Normal file
437
backend/meeting_consent_api.py
Normal file
@@ -0,0 +1,437 @@
|
||||
"""
|
||||
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."
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user