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>
205 lines
7.0 KiB
Python
205 lines
7.0 KiB
Python
"""
|
|
Classroom API - WebSocket Connection Manager
|
|
|
|
Verwaltet WebSocket-Verbindungen fuer Echtzeit-Timer-Updates.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
from typing import Dict, List, Optional
|
|
from datetime import datetime
|
|
|
|
from fastapi import WebSocket
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ConnectionManager:
|
|
"""
|
|
Verwaltet WebSocket-Verbindungen fuer Echtzeit-Timer-Updates.
|
|
|
|
Features:
|
|
- Session-basierte Verbindungen (jede Session hat eigene Clients)
|
|
- Automatisches Cleanup bei Disconnect
|
|
- Broadcast an alle Clients einer Session
|
|
- Multi-Device Support
|
|
"""
|
|
|
|
def __init__(self):
|
|
# session_id -> Set[WebSocket]
|
|
self._connections: Dict[str, set] = {}
|
|
# WebSocket -> session_id (reverse lookup)
|
|
self._websocket_sessions: Dict[WebSocket, str] = {}
|
|
self._lock = asyncio.Lock()
|
|
|
|
async def connect(self, websocket: WebSocket, session_id: str):
|
|
"""Verbindet einen Client mit einer Session."""
|
|
await websocket.accept()
|
|
async with self._lock:
|
|
if session_id not in self._connections:
|
|
self._connections[session_id] = set()
|
|
self._connections[session_id].add(websocket)
|
|
self._websocket_sessions[websocket] = session_id
|
|
logger.info(f"WebSocket connected to session {session_id}, total clients: {len(self._connections[session_id])}")
|
|
|
|
async def disconnect(self, websocket: WebSocket):
|
|
"""Trennt einen Client."""
|
|
async with self._lock:
|
|
session_id = self._websocket_sessions.pop(websocket, None)
|
|
if session_id and session_id in self._connections:
|
|
self._connections[session_id].discard(websocket)
|
|
if not self._connections[session_id]:
|
|
del self._connections[session_id]
|
|
logger.info(f"WebSocket disconnected from session {session_id}")
|
|
|
|
async def broadcast_to_session(self, session_id: str, message: dict):
|
|
"""Sendet eine Nachricht an alle Clients einer Session."""
|
|
async with self._lock:
|
|
connections = self._connections.get(session_id, set()).copy()
|
|
|
|
if not connections:
|
|
return
|
|
|
|
message_json = json.dumps(message)
|
|
dead_connections = []
|
|
|
|
for websocket in connections:
|
|
try:
|
|
await websocket.send_text(message_json)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to send to websocket: {e}")
|
|
dead_connections.append(websocket)
|
|
|
|
# Cleanup dead connections
|
|
for ws in dead_connections:
|
|
await self.disconnect(ws)
|
|
|
|
async def broadcast_timer_update(self, session_id: str, timer_data: dict):
|
|
"""Sendet Timer-Update an alle Clients einer Session."""
|
|
await self.broadcast_to_session(session_id, {
|
|
"type": "timer_update",
|
|
"data": timer_data
|
|
})
|
|
|
|
async def broadcast_phase_change(self, session_id: str, phase_data: dict):
|
|
"""Sendet Phasenwechsel-Event an alle Clients."""
|
|
await self.broadcast_to_session(session_id, {
|
|
"type": "phase_change",
|
|
"data": phase_data
|
|
})
|
|
|
|
async def broadcast_session_ended(self, session_id: str):
|
|
"""Sendet Session-Ende-Event an alle Clients."""
|
|
await self.broadcast_to_session(session_id, {
|
|
"type": "session_ended",
|
|
"data": {"session_id": session_id}
|
|
})
|
|
|
|
def get_client_count(self, session_id: str) -> int:
|
|
"""Gibt die Anzahl der verbundenen Clients fuer eine Session zurueck."""
|
|
return len(self._connections.get(session_id, set()))
|
|
|
|
def get_active_sessions(self) -> List[str]:
|
|
"""Gibt alle Sessions mit aktiven WebSocket-Verbindungen zurueck."""
|
|
return list(self._connections.keys())
|
|
|
|
|
|
# Global connection manager instance
|
|
ws_manager = ConnectionManager()
|
|
|
|
# Background task handle
|
|
_timer_broadcast_task: Optional[asyncio.Task] = None
|
|
|
|
|
|
def build_timer_status(session) -> dict:
|
|
"""
|
|
Baut Timer-Status als dict fuer WebSocket-Broadcast.
|
|
|
|
Returns dict mit allen Timer-Feldern die der Client benoetigt.
|
|
"""
|
|
from classroom_engine import PhaseTimer
|
|
|
|
timer = PhaseTimer()
|
|
status = timer.get_phase_status(session)
|
|
|
|
# Zusaetzliche Felder fuer WebSocket
|
|
status["session_id"] = session.session_id
|
|
status["current_phase"] = session.current_phase.value
|
|
status["is_paused"] = session.is_paused
|
|
status["timestamp"] = datetime.utcnow().isoformat()
|
|
|
|
return status
|
|
|
|
|
|
async def timer_broadcast_loop(sessions_dict: Dict):
|
|
"""
|
|
Hintergrund-Task der Timer-Updates alle 1 Sekunde an verbundene Clients sendet.
|
|
|
|
Features:
|
|
- Sub-Sekunden Genauigkeit (jede Sekunde)
|
|
- Nur aktive Sessions werden aktualisiert
|
|
- Automatisches Cleanup bei Fehlern
|
|
"""
|
|
logger.info("Timer broadcast loop started")
|
|
while True:
|
|
try:
|
|
await asyncio.sleep(1)
|
|
|
|
active_ws_sessions = ws_manager.get_active_sessions()
|
|
if not active_ws_sessions:
|
|
continue
|
|
|
|
for session_id in active_ws_sessions:
|
|
session = sessions_dict.get(session_id)
|
|
if not session or session.is_ended:
|
|
continue
|
|
|
|
# Timer-Status berechnen
|
|
timer_status = build_timer_status(session)
|
|
|
|
# An alle Clients senden
|
|
await ws_manager.broadcast_timer_update(session_id, timer_status)
|
|
except asyncio.CancelledError:
|
|
logger.info("Timer broadcast loop cancelled")
|
|
break
|
|
except Exception as e:
|
|
logger.error(f"Error in timer broadcast loop: {e}")
|
|
await asyncio.sleep(5) # Kurze Pause bei Fehler
|
|
|
|
|
|
def start_timer_broadcast(sessions_dict: Dict):
|
|
"""Startet den Timer-Broadcast-Task wenn noch nicht laufend."""
|
|
global _timer_broadcast_task
|
|
if _timer_broadcast_task is None or _timer_broadcast_task.done():
|
|
_timer_broadcast_task = asyncio.create_task(timer_broadcast_loop(sessions_dict))
|
|
logger.info("Timer broadcast task created")
|
|
|
|
|
|
def stop_timer_broadcast():
|
|
"""Stoppt den Timer-Broadcast-Task."""
|
|
global _timer_broadcast_task
|
|
if _timer_broadcast_task and not _timer_broadcast_task.done():
|
|
_timer_broadcast_task.cancel()
|
|
logger.info("Timer broadcast task cancelled")
|
|
|
|
|
|
def is_timer_broadcast_running() -> bool:
|
|
"""Prueft ob der Timer-Broadcast-Task laeuft."""
|
|
return _timer_broadcast_task is not None and not _timer_broadcast_task.done()
|
|
|
|
|
|
# Broadcast bei Phasenwechsel und Session-Ende
|
|
async def notify_phase_change(session_id: str, new_phase: str, phase_info: dict):
|
|
"""Benachrichtigt alle verbundenen Clients ueber Phasenwechsel."""
|
|
await ws_manager.broadcast_phase_change(session_id, {
|
|
"new_phase": new_phase,
|
|
"phase_info": phase_info,
|
|
"timestamp": datetime.utcnow().isoformat()
|
|
})
|
|
|
|
|
|
async def notify_session_ended(session_id: str):
|
|
"""Benachrichtigt alle verbundenen Clients ueber Session-Ende."""
|
|
await ws_manager.broadcast_session_ended(session_id)
|