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:
443
backend/meetings_api.py
Normal file
443
backend/meetings_api.py
Normal file
@@ -0,0 +1,443 @@
|
||||
"""
|
||||
Meetings API Module
|
||||
Backend API endpoints for Jitsi Meet integration
|
||||
"""
|
||||
import os
|
||||
import uuid
|
||||
import httpx
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
router = APIRouter(prefix="/api/meetings", tags=["meetings"])
|
||||
|
||||
|
||||
# ============================================
|
||||
# Configuration
|
||||
# ============================================
|
||||
|
||||
JITSI_BASE_URL = os.getenv("JITSI_PUBLIC_URL", "http://localhost:8443")
|
||||
CONSENT_SERVICE_URL = os.getenv("CONSENT_SERVICE_URL", "http://localhost:8081")
|
||||
|
||||
|
||||
# ============================================
|
||||
# Models
|
||||
# ============================================
|
||||
|
||||
class MeetingConfig(BaseModel):
|
||||
enable_lobby: bool = True
|
||||
enable_recording: bool = False
|
||||
start_with_audio_muted: bool = True
|
||||
start_with_video_muted: bool = False
|
||||
require_display_name: bool = True
|
||||
enable_breakout: bool = False
|
||||
|
||||
|
||||
class CreateMeetingRequest(BaseModel):
|
||||
type: str = "quick" # quick, scheduled, training, parent, class
|
||||
title: str = "Neues Meeting"
|
||||
duration: int = 60
|
||||
scheduled_at: Optional[str] = None
|
||||
config: Optional[MeetingConfig] = None
|
||||
description: Optional[str] = None
|
||||
invites: Optional[List[str]] = None
|
||||
|
||||
|
||||
class ScheduleMeetingRequest(BaseModel):
|
||||
title: str
|
||||
scheduled_at: str
|
||||
duration: int = 60
|
||||
description: Optional[str] = None
|
||||
invites: Optional[List[str]] = None
|
||||
|
||||
|
||||
class TrainingRequest(BaseModel):
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
scheduled_at: str
|
||||
duration: int = 120
|
||||
max_participants: int = 20
|
||||
trainer: str
|
||||
config: Optional[MeetingConfig] = None
|
||||
|
||||
|
||||
class ParentTeacherRequest(BaseModel):
|
||||
student_name: str
|
||||
parent_name: str
|
||||
parent_email: Optional[str] = None
|
||||
scheduled_at: str
|
||||
reason: Optional[str] = None
|
||||
send_invite: bool = True
|
||||
duration: int = 30
|
||||
|
||||
|
||||
class MeetingResponse(BaseModel):
|
||||
room_name: str
|
||||
join_url: str
|
||||
moderator_url: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
expires_at: Optional[str] = None
|
||||
|
||||
|
||||
class MeetingStats(BaseModel):
|
||||
active: int = 0
|
||||
scheduled: int = 0
|
||||
recordings: int = 0
|
||||
participants: int = 0
|
||||
|
||||
|
||||
class ActiveMeeting(BaseModel):
|
||||
room_name: str
|
||||
title: str
|
||||
participants: int
|
||||
started_at: str
|
||||
|
||||
|
||||
# ============================================
|
||||
# In-Memory Storage (for demo purposes)
|
||||
# In production, use database
|
||||
# ============================================
|
||||
|
||||
scheduled_meetings = []
|
||||
active_meetings = []
|
||||
trainings = []
|
||||
recordings = []
|
||||
|
||||
|
||||
# ============================================
|
||||
# Helper Functions
|
||||
# ============================================
|
||||
|
||||
def generate_room_name(prefix: str = "meeting") -> str:
|
||||
"""Generate a unique room name"""
|
||||
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def generate_password() -> str:
|
||||
"""Generate a simple password"""
|
||||
return uuid.uuid4().hex[:8]
|
||||
|
||||
|
||||
def build_jitsi_url(room_name: str, config: Optional[MeetingConfig] = None) -> str:
|
||||
"""Build Jitsi meeting URL with config parameters"""
|
||||
params = []
|
||||
|
||||
if config:
|
||||
if config.start_with_audio_muted:
|
||||
params.append("config.startWithAudioMuted=true")
|
||||
if config.start_with_video_muted:
|
||||
params.append("config.startWithVideoMuted=true")
|
||||
if config.require_display_name:
|
||||
params.append("config.requireDisplayName=true")
|
||||
|
||||
# Common config
|
||||
params.extend([
|
||||
"config.prejoinPageEnabled=false",
|
||||
"config.disableDeepLinking=true",
|
||||
"config.defaultLanguage=de",
|
||||
"interfaceConfig.SHOW_JITSI_WATERMARK=false",
|
||||
"interfaceConfig.SHOW_BRAND_WATERMARK=false"
|
||||
])
|
||||
|
||||
url = f"{JITSI_BASE_URL}/{room_name}"
|
||||
if params:
|
||||
url += "#" + "&".join(params)
|
||||
|
||||
return url
|
||||
|
||||
|
||||
async def call_consent_service(endpoint: str, method: str = "GET", data: dict = None) -> dict:
|
||||
"""Call the consent service API"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
url = f"{CONSENT_SERVICE_URL}{endpoint}"
|
||||
|
||||
if method == "GET":
|
||||
response = await client.get(url)
|
||||
elif method == "POST":
|
||||
response = await client.post(url, json=data)
|
||||
else:
|
||||
raise ValueError(f"Unsupported method: {method}")
|
||||
|
||||
if response.status_code >= 400:
|
||||
return None
|
||||
|
||||
return response.json()
|
||||
|
||||
|
||||
# ============================================
|
||||
# API Endpoints
|
||||
# ============================================
|
||||
|
||||
@router.get("/stats", response_model=MeetingStats)
|
||||
async def get_meeting_stats():
|
||||
"""Get meeting statistics"""
|
||||
return MeetingStats(
|
||||
active=len(active_meetings),
|
||||
scheduled=len(scheduled_meetings),
|
||||
recordings=len(recordings),
|
||||
participants=sum(m.get("participants", 0) for m in active_meetings)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/active", response_model=List[ActiveMeeting])
|
||||
async def get_active_meetings():
|
||||
"""Get list of active meetings"""
|
||||
return [
|
||||
ActiveMeeting(
|
||||
room_name=m["room_name"],
|
||||
title=m["title"],
|
||||
participants=m.get("participants", 0),
|
||||
started_at=m.get("started_at", datetime.now().isoformat())
|
||||
)
|
||||
for m in active_meetings
|
||||
]
|
||||
|
||||
|
||||
@router.post("/create", response_model=MeetingResponse)
|
||||
async def create_meeting(request: CreateMeetingRequest):
|
||||
"""Create a new meeting"""
|
||||
config = request.config or MeetingConfig()
|
||||
|
||||
# Generate room name based on type
|
||||
if request.type == "quick":
|
||||
room_name = generate_room_name("quick")
|
||||
elif request.type == "training":
|
||||
room_name = generate_room_name("schulung")
|
||||
elif request.type == "parent":
|
||||
room_name = generate_room_name("elterngespraech")
|
||||
elif request.type == "class":
|
||||
room_name = generate_room_name("klasse")
|
||||
else:
|
||||
room_name = generate_room_name("meeting")
|
||||
|
||||
join_url = build_jitsi_url(room_name, config)
|
||||
|
||||
# Store meeting if scheduled
|
||||
if request.scheduled_at:
|
||||
scheduled_meetings.append({
|
||||
"room_name": room_name,
|
||||
"title": request.title,
|
||||
"scheduled_at": request.scheduled_at,
|
||||
"duration": request.duration,
|
||||
"config": config.model_dump() if config else None
|
||||
})
|
||||
|
||||
return MeetingResponse(
|
||||
room_name=room_name,
|
||||
join_url=join_url
|
||||
)
|
||||
|
||||
|
||||
@router.post("/schedule", response_model=MeetingResponse)
|
||||
async def schedule_meeting(request: ScheduleMeetingRequest):
|
||||
"""Schedule a new meeting"""
|
||||
room_name = generate_room_name("meeting")
|
||||
|
||||
meeting = {
|
||||
"room_name": room_name,
|
||||
"title": request.title,
|
||||
"scheduled_at": request.scheduled_at,
|
||||
"duration": request.duration,
|
||||
"description": request.description,
|
||||
"invites": request.invites or []
|
||||
}
|
||||
|
||||
scheduled_meetings.append(meeting)
|
||||
|
||||
join_url = build_jitsi_url(room_name)
|
||||
|
||||
# TODO: Send email invites if configured
|
||||
|
||||
return MeetingResponse(
|
||||
room_name=room_name,
|
||||
join_url=join_url
|
||||
)
|
||||
|
||||
|
||||
@router.post("/training", response_model=MeetingResponse)
|
||||
async def create_training(request: TrainingRequest):
|
||||
"""Create a training session"""
|
||||
# Generate room name from title
|
||||
title_slug = request.title.lower().replace(" ", "-")[:20]
|
||||
room_name = f"schulung-{title_slug}-{uuid.uuid4().hex[:4]}"
|
||||
|
||||
config = request.config or MeetingConfig(
|
||||
enable_lobby=True,
|
||||
enable_recording=True,
|
||||
start_with_audio_muted=True
|
||||
)
|
||||
|
||||
training = {
|
||||
"room_name": room_name,
|
||||
"title": request.title,
|
||||
"description": request.description,
|
||||
"scheduled_at": request.scheduled_at,
|
||||
"duration": request.duration,
|
||||
"max_participants": request.max_participants,
|
||||
"trainer": request.trainer,
|
||||
"config": config.model_dump()
|
||||
}
|
||||
|
||||
trainings.append(training)
|
||||
scheduled_meetings.append(training)
|
||||
|
||||
join_url = build_jitsi_url(room_name, config)
|
||||
|
||||
return MeetingResponse(
|
||||
room_name=room_name,
|
||||
join_url=join_url
|
||||
)
|
||||
|
||||
|
||||
@router.post("/parent-teacher", response_model=MeetingResponse)
|
||||
async def create_parent_teacher_meeting(request: ParentTeacherRequest):
|
||||
"""Create a parent-teacher meeting"""
|
||||
# Generate room name with student name and date
|
||||
student_slug = request.student_name.lower().replace(" ", "-")[:15]
|
||||
date_str = datetime.fromisoformat(request.scheduled_at).strftime("%Y%m%d-%H%M")
|
||||
room_name = f"elterngespraech-{student_slug}-{date_str}"
|
||||
|
||||
# Generate password for security
|
||||
password = generate_password()
|
||||
|
||||
config = MeetingConfig(
|
||||
enable_lobby=True,
|
||||
enable_recording=False,
|
||||
start_with_audio_muted=False
|
||||
)
|
||||
|
||||
meeting = {
|
||||
"room_name": room_name,
|
||||
"title": f"Elterngespräch - {request.student_name}",
|
||||
"student_name": request.student_name,
|
||||
"parent_name": request.parent_name,
|
||||
"parent_email": request.parent_email,
|
||||
"scheduled_at": request.scheduled_at,
|
||||
"duration": request.duration,
|
||||
"reason": request.reason,
|
||||
"password": password,
|
||||
"config": config.model_dump()
|
||||
}
|
||||
|
||||
scheduled_meetings.append(meeting)
|
||||
|
||||
join_url = build_jitsi_url(room_name, config)
|
||||
|
||||
# TODO: Send email invite to parents if configured
|
||||
|
||||
return MeetingResponse(
|
||||
room_name=room_name,
|
||||
join_url=join_url,
|
||||
password=password
|
||||
)
|
||||
|
||||
|
||||
@router.get("/scheduled")
|
||||
async def get_scheduled_meetings():
|
||||
"""Get all scheduled meetings"""
|
||||
return scheduled_meetings
|
||||
|
||||
|
||||
@router.get("/trainings")
|
||||
async def get_trainings():
|
||||
"""Get all training sessions"""
|
||||
return trainings
|
||||
|
||||
|
||||
@router.delete("/{room_name}")
|
||||
async def delete_meeting(room_name: str):
|
||||
"""Delete a scheduled meeting"""
|
||||
# Find and remove the meeting (in-place modification)
|
||||
for i, m in enumerate(scheduled_meetings):
|
||||
if m["room_name"] == room_name:
|
||||
scheduled_meetings.pop(i)
|
||||
break
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
# ============================================
|
||||
# Recording Endpoints
|
||||
# ============================================
|
||||
|
||||
@router.get("/recordings")
|
||||
async def get_recordings():
|
||||
"""Get list of recordings"""
|
||||
# Demo data
|
||||
return [
|
||||
{
|
||||
"id": "docker-basics",
|
||||
"title": "Docker Grundlagen Schulung",
|
||||
"date": "2025-12-10T10:00:00",
|
||||
"duration": "1:30:00",
|
||||
"size_mb": 156,
|
||||
"participants": 15
|
||||
},
|
||||
{
|
||||
"id": "team-kw49",
|
||||
"title": "Team-Meeting KW 49",
|
||||
"date": "2025-12-06T14:00:00",
|
||||
"duration": "1:00:00",
|
||||
"size_mb": 98,
|
||||
"participants": 8
|
||||
},
|
||||
{
|
||||
"id": "parent-mueller",
|
||||
"title": "Elterngespräch - Max Müller",
|
||||
"date": "2025-12-02T16:00:00",
|
||||
"duration": "0:28:00",
|
||||
"size_mb": 42,
|
||||
"participants": 2
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@router.get("/recordings/{recording_id}")
|
||||
async def get_recording(recording_id: str):
|
||||
"""Get recording details"""
|
||||
return {
|
||||
"id": recording_id,
|
||||
"title": "Recording " + recording_id,
|
||||
"date": "2025-12-10T10:00:00",
|
||||
"duration": "1:30:00",
|
||||
"size_mb": 156,
|
||||
"download_url": f"/api/recordings/{recording_id}/download"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/recordings/{recording_id}/download")
|
||||
async def download_recording(recording_id: str):
|
||||
"""Download a recording"""
|
||||
# In production, this would stream the actual file
|
||||
raise HTTPException(status_code=404, detail="Recording file not found (demo mode)")
|
||||
|
||||
|
||||
@router.delete("/recordings/{recording_id}")
|
||||
async def delete_recording(recording_id: str):
|
||||
"""Delete a recording"""
|
||||
return {"status": "deleted", "id": recording_id}
|
||||
|
||||
|
||||
# ============================================
|
||||
# Health Check
|
||||
# ============================================
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check():
|
||||
"""Check meetings service health"""
|
||||
# Check Jitsi availability
|
||||
jitsi_healthy = False
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.get(JITSI_BASE_URL)
|
||||
jitsi_healthy = response.status_code == 200
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"status": "healthy" if jitsi_healthy else "degraded",
|
||||
"jitsi_url": JITSI_BASE_URL,
|
||||
"jitsi_available": jitsi_healthy,
|
||||
"scheduled_meetings": len(scheduled_meetings),
|
||||
"active_meetings": len(active_meetings)
|
||||
}
|
||||
Reference in New Issue
Block a user