Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website, Klausur-Service, School-Service, Voice-Service, Geo-Service, BreakPilot Drive, Agent-Core Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
444 lines
12 KiB
Python
444 lines
12 KiB
Python
"""
|
|
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)
|
|
}
|