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>
422 lines
14 KiB
Python
422 lines
14 KiB
Python
"""
|
|
API Routes für User Alert Subscriptions.
|
|
|
|
Verwaltet Nutzer-Abonnements für Templates und Digest-Einstellungen.
|
|
|
|
Endpoints:
|
|
- POST /subscriptions - Neue Subscription erstellen
|
|
- GET /subscriptions - User-Subscriptions auflisten
|
|
- GET /subscriptions/{id} - Subscription-Details
|
|
- PUT /subscriptions/{id} - Subscription aktualisieren
|
|
- DELETE /subscriptions/{id} - Subscription deaktivieren
|
|
- POST /subscriptions/{id}/activate-template - Template aktivieren
|
|
"""
|
|
|
|
import uuid
|
|
from typing import Optional, List
|
|
from datetime import datetime
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy.orm import Session as DBSession
|
|
|
|
from ..db.database import get_db
|
|
from ..db.models import (
|
|
UserAlertSubscriptionDB, AlertTemplateDB, AlertProfileDB,
|
|
AlertTopicDB, AlertRuleDB, AlertModeEnum, UserRoleEnum
|
|
)
|
|
|
|
|
|
router = APIRouter(prefix="/subscriptions", tags=["subscriptions"])
|
|
|
|
|
|
# ============================================================================
|
|
# Request/Response Models
|
|
# ============================================================================
|
|
|
|
class SubscriptionCreate(BaseModel):
|
|
"""Request für neue Subscription."""
|
|
mode: str = Field(default="guided", description="'guided' oder 'expert'")
|
|
user_role: Optional[str] = Field(default=None, description="lehrkraft, schulleitung, it_beauftragte")
|
|
template_ids: List[str] = Field(default=[], description="Ausgewählte Template-IDs (max. 3)")
|
|
notification_email: Optional[str] = Field(default=None)
|
|
digest_enabled: bool = Field(default=True)
|
|
digest_frequency: str = Field(default="weekly")
|
|
|
|
|
|
class SubscriptionUpdate(BaseModel):
|
|
"""Request für Subscription-Update."""
|
|
template_ids: Optional[List[str]] = None
|
|
notification_email: Optional[str] = None
|
|
digest_enabled: Optional[bool] = None
|
|
digest_frequency: Optional[str] = None
|
|
is_active: Optional[bool] = None
|
|
|
|
|
|
class SubscriptionResponse(BaseModel):
|
|
"""Response für eine Subscription."""
|
|
id: str
|
|
user_id: str
|
|
mode: str
|
|
user_role: Optional[str]
|
|
selected_template_ids: List[str]
|
|
template_names: List[str]
|
|
notification_email: Optional[str]
|
|
digest_enabled: bool
|
|
digest_frequency: str
|
|
wizard_completed: bool
|
|
is_active: bool
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
|
|
|
|
class SubscriptionListResponse(BaseModel):
|
|
"""Response für Subscription-Liste."""
|
|
subscriptions: List[SubscriptionResponse]
|
|
total: int
|
|
|
|
|
|
class ActivateTemplateRequest(BaseModel):
|
|
"""Request für Template-Aktivierung."""
|
|
create_topics: bool = Field(default=True, description="Topics aus Template-Config erstellen")
|
|
create_rules: bool = Field(default=True, description="Rules aus Template-Config erstellen")
|
|
|
|
|
|
class ActivateTemplateResponse(BaseModel):
|
|
"""Response für Template-Aktivierung."""
|
|
status: str
|
|
topics_created: int
|
|
rules_created: int
|
|
profile_updated: bool
|
|
message: str
|
|
|
|
|
|
# ============================================================================
|
|
# Helper Functions
|
|
# ============================================================================
|
|
|
|
def get_user_id_from_request() -> str:
|
|
"""
|
|
Extrahiert User-ID aus Request.
|
|
TODO: JWT-Token auswerten, aktuell Dummy.
|
|
"""
|
|
return "demo-user"
|
|
|
|
|
|
def _get_template_names(db: DBSession, template_ids: List[str]) -> List[str]:
|
|
"""Hole Template-Namen für IDs."""
|
|
if not template_ids:
|
|
return []
|
|
templates = db.query(AlertTemplateDB).filter(
|
|
AlertTemplateDB.id.in_(template_ids)
|
|
).all()
|
|
return [t.name for t in templates]
|
|
|
|
|
|
def _subscription_to_response(sub: UserAlertSubscriptionDB, db: DBSession) -> SubscriptionResponse:
|
|
"""Konvertiere DB-Model zu Response."""
|
|
template_ids = sub.selected_template_ids or []
|
|
return SubscriptionResponse(
|
|
id=sub.id,
|
|
user_id=sub.user_id,
|
|
mode=sub.mode.value if sub.mode else "guided",
|
|
user_role=sub.user_role.value if sub.user_role else None,
|
|
selected_template_ids=template_ids,
|
|
template_names=_get_template_names(db, template_ids),
|
|
notification_email=sub.notification_email,
|
|
digest_enabled=sub.digest_enabled if sub.digest_enabled is not None else True,
|
|
digest_frequency=sub.digest_frequency or "weekly",
|
|
wizard_completed=sub.wizard_completed if sub.wizard_completed is not None else False,
|
|
is_active=sub.is_active if sub.is_active is not None else True,
|
|
created_at=sub.created_at,
|
|
updated_at=sub.updated_at,
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Endpoints
|
|
# ============================================================================
|
|
|
|
@router.post("", response_model=SubscriptionResponse)
|
|
async def create_subscription(
|
|
request: SubscriptionCreate,
|
|
db: DBSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Erstelle eine neue Alert-Subscription.
|
|
|
|
Im Guided Mode werden 1-3 Templates ausgewählt.
|
|
Im Expert Mode wird ein eigenes Profil konfiguriert.
|
|
"""
|
|
user_id = get_user_id_from_request()
|
|
|
|
# Validiere Modus
|
|
try:
|
|
mode = AlertModeEnum(request.mode)
|
|
except ValueError:
|
|
raise HTTPException(status_code=400, detail="Ungültiger Modus. Erlaubt: 'guided', 'expert'")
|
|
|
|
# Validiere Rolle
|
|
user_role = None
|
|
if request.user_role:
|
|
try:
|
|
user_role = UserRoleEnum(request.user_role)
|
|
except ValueError:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Ungültige Rolle. Erlaubt: 'lehrkraft', 'schulleitung', 'it_beauftragte'"
|
|
)
|
|
|
|
# Validiere Template-IDs
|
|
if request.template_ids:
|
|
if len(request.template_ids) > 3:
|
|
raise HTTPException(status_code=400, detail="Maximal 3 Templates erlaubt")
|
|
|
|
# Prüfe ob Templates existieren
|
|
existing = db.query(AlertTemplateDB).filter(
|
|
AlertTemplateDB.id.in_(request.template_ids)
|
|
).count()
|
|
if existing != len(request.template_ids):
|
|
raise HTTPException(status_code=400, detail="Eine oder mehrere Template-IDs sind ungültig")
|
|
|
|
# Erstelle Subscription
|
|
subscription = UserAlertSubscriptionDB(
|
|
id=str(uuid.uuid4()),
|
|
user_id=user_id,
|
|
mode=mode,
|
|
user_role=user_role,
|
|
selected_template_ids=request.template_ids,
|
|
notification_email=request.notification_email,
|
|
digest_enabled=request.digest_enabled,
|
|
digest_frequency=request.digest_frequency,
|
|
wizard_completed=len(request.template_ids) > 0, # Abgeschlossen wenn Templates gewählt
|
|
is_active=True,
|
|
)
|
|
|
|
db.add(subscription)
|
|
db.commit()
|
|
db.refresh(subscription)
|
|
|
|
return _subscription_to_response(subscription, db)
|
|
|
|
|
|
@router.get("", response_model=SubscriptionListResponse)
|
|
async def list_subscriptions(
|
|
active_only: bool = Query(True, description="Nur aktive Subscriptions"),
|
|
db: DBSession = Depends(get_db)
|
|
):
|
|
"""Liste alle Subscriptions des aktuellen Users."""
|
|
user_id = get_user_id_from_request()
|
|
|
|
query = db.query(UserAlertSubscriptionDB).filter(
|
|
UserAlertSubscriptionDB.user_id == user_id
|
|
)
|
|
|
|
if active_only:
|
|
query = query.filter(UserAlertSubscriptionDB.is_active == True)
|
|
|
|
subscriptions = query.order_by(UserAlertSubscriptionDB.created_at.desc()).all()
|
|
|
|
return SubscriptionListResponse(
|
|
subscriptions=[_subscription_to_response(s, db) for s in subscriptions],
|
|
total=len(subscriptions)
|
|
)
|
|
|
|
|
|
@router.get("/{subscription_id}", response_model=SubscriptionResponse)
|
|
async def get_subscription(
|
|
subscription_id: str,
|
|
db: DBSession = Depends(get_db)
|
|
):
|
|
"""Hole Details einer Subscription."""
|
|
user_id = get_user_id_from_request()
|
|
|
|
subscription = db.query(UserAlertSubscriptionDB).filter(
|
|
UserAlertSubscriptionDB.id == subscription_id,
|
|
UserAlertSubscriptionDB.user_id == user_id
|
|
).first()
|
|
|
|
if not subscription:
|
|
raise HTTPException(status_code=404, detail="Subscription nicht gefunden")
|
|
|
|
return _subscription_to_response(subscription, db)
|
|
|
|
|
|
@router.put("/{subscription_id}", response_model=SubscriptionResponse)
|
|
async def update_subscription(
|
|
subscription_id: str,
|
|
request: SubscriptionUpdate,
|
|
db: DBSession = Depends(get_db)
|
|
):
|
|
"""Aktualisiere eine Subscription."""
|
|
user_id = get_user_id_from_request()
|
|
|
|
subscription = db.query(UserAlertSubscriptionDB).filter(
|
|
UserAlertSubscriptionDB.id == subscription_id,
|
|
UserAlertSubscriptionDB.user_id == user_id
|
|
).first()
|
|
|
|
if not subscription:
|
|
raise HTTPException(status_code=404, detail="Subscription nicht gefunden")
|
|
|
|
# Update Felder
|
|
if request.template_ids is not None:
|
|
if len(request.template_ids) > 3:
|
|
raise HTTPException(status_code=400, detail="Maximal 3 Templates erlaubt")
|
|
subscription.selected_template_ids = request.template_ids
|
|
|
|
if request.notification_email is not None:
|
|
subscription.notification_email = request.notification_email
|
|
|
|
if request.digest_enabled is not None:
|
|
subscription.digest_enabled = request.digest_enabled
|
|
|
|
if request.digest_frequency is not None:
|
|
subscription.digest_frequency = request.digest_frequency
|
|
|
|
if request.is_active is not None:
|
|
subscription.is_active = request.is_active
|
|
|
|
subscription.updated_at = datetime.utcnow()
|
|
db.commit()
|
|
db.refresh(subscription)
|
|
|
|
return _subscription_to_response(subscription, db)
|
|
|
|
|
|
@router.delete("/{subscription_id}")
|
|
async def deactivate_subscription(
|
|
subscription_id: str,
|
|
db: DBSession = Depends(get_db)
|
|
):
|
|
"""Deaktiviere eine Subscription (Soft-Delete)."""
|
|
user_id = get_user_id_from_request()
|
|
|
|
subscription = db.query(UserAlertSubscriptionDB).filter(
|
|
UserAlertSubscriptionDB.id == subscription_id,
|
|
UserAlertSubscriptionDB.user_id == user_id
|
|
).first()
|
|
|
|
if not subscription:
|
|
raise HTTPException(status_code=404, detail="Subscription nicht gefunden")
|
|
|
|
subscription.is_active = False
|
|
subscription.updated_at = datetime.utcnow()
|
|
db.commit()
|
|
|
|
return {"status": "success", "message": "Subscription deaktiviert"}
|
|
|
|
|
|
@router.post("/{subscription_id}/activate-template", response_model=ActivateTemplateResponse)
|
|
async def activate_template(
|
|
subscription_id: str,
|
|
request: ActivateTemplateRequest = None,
|
|
db: DBSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Aktiviere die gewählten Templates für eine Subscription.
|
|
|
|
Erstellt:
|
|
- Topics aus Template.topics_config (RSS-Feeds)
|
|
- Rules aus Template.rules_config (Filter-Regeln)
|
|
- Aktualisiert das User-Profil mit Template.profile_config
|
|
"""
|
|
user_id = get_user_id_from_request()
|
|
|
|
subscription = db.query(UserAlertSubscriptionDB).filter(
|
|
UserAlertSubscriptionDB.id == subscription_id,
|
|
UserAlertSubscriptionDB.user_id == user_id
|
|
).first()
|
|
|
|
if not subscription:
|
|
raise HTTPException(status_code=404, detail="Subscription nicht gefunden")
|
|
|
|
if not subscription.selected_template_ids:
|
|
raise HTTPException(status_code=400, detail="Keine Templates ausgewählt")
|
|
|
|
# Lade Templates
|
|
templates = db.query(AlertTemplateDB).filter(
|
|
AlertTemplateDB.id.in_(subscription.selected_template_ids)
|
|
).all()
|
|
|
|
topics_created = 0
|
|
rules_created = 0
|
|
profile_updated = False
|
|
|
|
for template in templates:
|
|
# Topics erstellen
|
|
if request is None or request.create_topics:
|
|
for topic_config in (template.topics_config or []):
|
|
topic = AlertTopicDB(
|
|
id=str(uuid.uuid4()),
|
|
user_id=user_id,
|
|
name=topic_config.get("name", f"Topic from {template.name}"),
|
|
description=f"Automatisch erstellt aus Template: {template.name}",
|
|
is_active=True,
|
|
fetch_interval_minutes=60,
|
|
)
|
|
db.add(topic)
|
|
topics_created += 1
|
|
|
|
# Rules erstellen
|
|
if request is None or request.create_rules:
|
|
for rule_config in (template.rules_config or []):
|
|
rule = AlertRuleDB(
|
|
id=str(uuid.uuid4()),
|
|
user_id=user_id,
|
|
name=rule_config.get("name", f"Rule from {template.name}"),
|
|
description=f"Automatisch erstellt aus Template: {template.name}",
|
|
conditions=rule_config.get("conditions", []),
|
|
action_type=rule_config.get("action_type", "keep"),
|
|
action_config=rule_config.get("action_config", {}),
|
|
priority=rule_config.get("priority", 50),
|
|
is_active=True,
|
|
)
|
|
db.add(rule)
|
|
rules_created += 1
|
|
|
|
# Profil aktualisieren
|
|
if template.profile_config:
|
|
profile = db.query(AlertProfileDB).filter(
|
|
AlertProfileDB.user_id == user_id
|
|
).first()
|
|
|
|
if not profile:
|
|
profile = AlertProfileDB(
|
|
id=str(uuid.uuid4()),
|
|
user_id=user_id,
|
|
name=f"Profil für {user_id}",
|
|
)
|
|
db.add(profile)
|
|
|
|
# Merge priorities
|
|
existing_priorities = profile.priorities or []
|
|
new_priorities = template.profile_config.get("priorities", [])
|
|
for p in new_priorities:
|
|
if p not in existing_priorities:
|
|
existing_priorities.append(p)
|
|
profile.priorities = existing_priorities
|
|
|
|
# Merge exclusions
|
|
existing_exclusions = profile.exclusions or []
|
|
new_exclusions = template.profile_config.get("exclusions", [])
|
|
for e in new_exclusions:
|
|
if e not in existing_exclusions:
|
|
existing_exclusions.append(e)
|
|
profile.exclusions = existing_exclusions
|
|
|
|
profile_updated = True
|
|
|
|
# Markiere Wizard als abgeschlossen
|
|
subscription.wizard_completed = True
|
|
subscription.updated_at = datetime.utcnow()
|
|
|
|
db.commit()
|
|
|
|
return ActivateTemplateResponse(
|
|
status="success",
|
|
topics_created=topics_created,
|
|
rules_created=rules_created,
|
|
profile_updated=profile_updated,
|
|
message=f"Templates aktiviert: {topics_created} Topics, {rules_created} Rules erstellt."
|
|
)
|