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>
555 lines
16 KiB
Python
555 lines
16 KiB
Python
"""
|
|
API Routes für den Guided Mode Wizard.
|
|
|
|
Verwaltet den 3-Schritt Setup-Wizard:
|
|
1. Rolle wählen (Lehrkraft, Schulleitung, IT-Beauftragte)
|
|
2. Templates auswählen (max. 3)
|
|
3. Bestätigung und Aktivierung
|
|
|
|
Zusätzlich: Migration-Wizard für bestehende Google Alerts.
|
|
|
|
Endpoints:
|
|
- GET /wizard/state - Aktuellen Wizard-Status abrufen
|
|
- PUT /wizard/step/{step} - Schritt speichern
|
|
- POST /wizard/complete - Wizard abschließen
|
|
- POST /wizard/reset - Wizard zurücksetzen
|
|
- POST /wizard/migrate/email - E-Mail-Migration starten
|
|
- POST /wizard/migrate/rss - RSS-Import
|
|
"""
|
|
|
|
import uuid
|
|
from typing import Optional, List, Dict, Any
|
|
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, AlertSourceDB,
|
|
AlertModeEnum, UserRoleEnum, MigrationModeEnum, FeedTypeEnum
|
|
)
|
|
|
|
|
|
router = APIRouter(prefix="/wizard", tags=["wizard"])
|
|
|
|
|
|
# ============================================================================
|
|
# Request/Response Models
|
|
# ============================================================================
|
|
|
|
class WizardState(BaseModel):
|
|
"""Aktueller Wizard-Status."""
|
|
subscription_id: Optional[str] = None
|
|
current_step: int = 0 # 0=nicht gestartet, 1-3=Schritte, 4=abgeschlossen
|
|
is_completed: bool = False
|
|
step_data: Dict[str, Any] = {}
|
|
recommended_templates: List[Dict[str, Any]] = []
|
|
|
|
|
|
class Step1Data(BaseModel):
|
|
"""Daten für Schritt 1: Rollenwahl."""
|
|
role: str = Field(..., description="lehrkraft, schulleitung, it_beauftragte")
|
|
|
|
|
|
class Step2Data(BaseModel):
|
|
"""Daten für Schritt 2: Template-Auswahl."""
|
|
template_ids: List[str] = Field(..., min_length=1, max_length=3)
|
|
|
|
|
|
class Step3Data(BaseModel):
|
|
"""Daten für Schritt 3: Bestätigung."""
|
|
notification_email: Optional[str] = None
|
|
digest_enabled: bool = True
|
|
digest_frequency: str = "weekly"
|
|
|
|
|
|
class StepResponse(BaseModel):
|
|
"""Response für Schritt-Update."""
|
|
status: str
|
|
current_step: int
|
|
next_step: int
|
|
message: str
|
|
recommended_templates: List[Dict[str, Any]] = []
|
|
|
|
|
|
class MigrateEmailRequest(BaseModel):
|
|
"""Request für E-Mail-Migration."""
|
|
original_label: Optional[str] = Field(default=None, description="Beschreibung des Alerts")
|
|
|
|
|
|
class MigrateEmailResponse(BaseModel):
|
|
"""Response für E-Mail-Migration."""
|
|
status: str
|
|
inbound_address: str
|
|
instructions: List[str]
|
|
source_id: str
|
|
|
|
|
|
class MigrateRssRequest(BaseModel):
|
|
"""Request für RSS-Import."""
|
|
rss_urls: List[str] = Field(..., min_length=1, max_length=20)
|
|
labels: Optional[List[str]] = None
|
|
|
|
|
|
class MigrateRssResponse(BaseModel):
|
|
"""Response für RSS-Import."""
|
|
status: str
|
|
sources_created: int
|
|
topics_created: int
|
|
message: str
|
|
|
|
|
|
# ============================================================================
|
|
# Helper Functions
|
|
# ============================================================================
|
|
|
|
def get_user_id_from_request() -> str:
|
|
"""Extrahiert User-ID aus Request."""
|
|
return "demo-user"
|
|
|
|
|
|
def _get_or_create_subscription(db: DBSession, user_id: str) -> UserAlertSubscriptionDB:
|
|
"""Hole oder erstelle Subscription für Wizard."""
|
|
subscription = db.query(UserAlertSubscriptionDB).filter(
|
|
UserAlertSubscriptionDB.user_id == user_id,
|
|
UserAlertSubscriptionDB.wizard_completed == False
|
|
).first()
|
|
|
|
if not subscription:
|
|
subscription = UserAlertSubscriptionDB(
|
|
id=str(uuid.uuid4()),
|
|
user_id=user_id,
|
|
mode=AlertModeEnum.GUIDED,
|
|
wizard_step=0,
|
|
wizard_completed=False,
|
|
wizard_state={},
|
|
is_active=True,
|
|
)
|
|
db.add(subscription)
|
|
db.commit()
|
|
db.refresh(subscription)
|
|
|
|
return subscription
|
|
|
|
|
|
def _get_recommended_templates(db: DBSession, role: str) -> List[Dict[str, Any]]:
|
|
"""Hole empfohlene Templates für eine Rolle."""
|
|
templates = db.query(AlertTemplateDB).filter(
|
|
AlertTemplateDB.is_active == True,
|
|
AlertTemplateDB.is_premium == False
|
|
).order_by(AlertTemplateDB.sort_order).all()
|
|
|
|
result = []
|
|
for t in templates:
|
|
if role in (t.target_roles or []):
|
|
result.append({
|
|
"id": t.id,
|
|
"slug": t.slug,
|
|
"name": t.name,
|
|
"description": t.description,
|
|
"icon": t.icon,
|
|
"category": t.category,
|
|
"recommended": True,
|
|
})
|
|
|
|
return result
|
|
|
|
|
|
def _generate_inbound_address(user_id: str, source_id: str) -> str:
|
|
"""Generiere eindeutige Inbound-E-Mail-Adresse."""
|
|
short_id = source_id[:8]
|
|
return f"alerts+{short_id}@breakpilot.app"
|
|
|
|
|
|
# ============================================================================
|
|
# Wizard Endpoints
|
|
# ============================================================================
|
|
|
|
@router.get("/state", response_model=WizardState)
|
|
async def get_wizard_state(
|
|
db: DBSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Hole aktuellen Wizard-Status.
|
|
|
|
Gibt Schritt, gespeicherte Daten und empfohlene Templates zurück.
|
|
"""
|
|
user_id = get_user_id_from_request()
|
|
|
|
subscription = db.query(UserAlertSubscriptionDB).filter(
|
|
UserAlertSubscriptionDB.user_id == user_id
|
|
).order_by(UserAlertSubscriptionDB.created_at.desc()).first()
|
|
|
|
if not subscription:
|
|
return WizardState(
|
|
subscription_id=None,
|
|
current_step=0,
|
|
is_completed=False,
|
|
step_data={},
|
|
recommended_templates=[],
|
|
)
|
|
|
|
# Empfohlene Templates basierend auf Rolle
|
|
role = subscription.user_role.value if subscription.user_role else None
|
|
recommended = _get_recommended_templates(db, role) if role else []
|
|
|
|
return WizardState(
|
|
subscription_id=subscription.id,
|
|
current_step=subscription.wizard_step or 0,
|
|
is_completed=subscription.wizard_completed or False,
|
|
step_data=subscription.wizard_state or {},
|
|
recommended_templates=recommended,
|
|
)
|
|
|
|
|
|
@router.put("/step/1", response_model=StepResponse)
|
|
async def save_step_1(
|
|
data: Step1Data,
|
|
db: DBSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Schritt 1: Rolle speichern.
|
|
|
|
Wählt die Rolle des Nutzers und gibt passende Template-Empfehlungen.
|
|
"""
|
|
user_id = get_user_id_from_request()
|
|
|
|
# Validiere Rolle
|
|
try:
|
|
role = UserRoleEnum(data.role)
|
|
except ValueError:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Ungültige Rolle. Erlaubt: 'lehrkraft', 'schulleitung', 'it_beauftragte'"
|
|
)
|
|
|
|
subscription = _get_or_create_subscription(db, user_id)
|
|
|
|
# Update
|
|
subscription.user_role = role
|
|
subscription.wizard_step = 1
|
|
wizard_state = subscription.wizard_state or {}
|
|
wizard_state["step1"] = {"role": data.role}
|
|
subscription.wizard_state = wizard_state
|
|
subscription.updated_at = datetime.utcnow()
|
|
|
|
db.commit()
|
|
db.refresh(subscription)
|
|
|
|
# Empfohlene Templates
|
|
recommended = _get_recommended_templates(db, data.role)
|
|
|
|
return StepResponse(
|
|
status="success",
|
|
current_step=1,
|
|
next_step=2,
|
|
message=f"Rolle '{data.role}' gespeichert. Bitte wählen Sie jetzt Ihre Themen.",
|
|
recommended_templates=recommended,
|
|
)
|
|
|
|
|
|
@router.put("/step/2", response_model=StepResponse)
|
|
async def save_step_2(
|
|
data: Step2Data,
|
|
db: DBSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Schritt 2: Templates auswählen.
|
|
|
|
Speichert die ausgewählten Templates (1-3).
|
|
"""
|
|
user_id = get_user_id_from_request()
|
|
|
|
subscription = db.query(UserAlertSubscriptionDB).filter(
|
|
UserAlertSubscriptionDB.user_id == user_id,
|
|
UserAlertSubscriptionDB.wizard_completed == False
|
|
).first()
|
|
|
|
if not subscription:
|
|
raise HTTPException(status_code=400, detail="Bitte zuerst Schritt 1 abschließen")
|
|
|
|
# Validiere Template-IDs
|
|
templates = db.query(AlertTemplateDB).filter(
|
|
AlertTemplateDB.id.in_(data.template_ids)
|
|
).all()
|
|
|
|
if len(templates) != len(data.template_ids):
|
|
raise HTTPException(status_code=400, detail="Eine oder mehrere Template-IDs sind ungültig")
|
|
|
|
# Update
|
|
subscription.selected_template_ids = data.template_ids
|
|
subscription.wizard_step = 2
|
|
wizard_state = subscription.wizard_state or {}
|
|
wizard_state["step2"] = {
|
|
"template_ids": data.template_ids,
|
|
"template_names": [t.name for t in templates],
|
|
}
|
|
subscription.wizard_state = wizard_state
|
|
subscription.updated_at = datetime.utcnow()
|
|
|
|
db.commit()
|
|
|
|
return StepResponse(
|
|
status="success",
|
|
current_step=2,
|
|
next_step=3,
|
|
message=f"{len(templates)} Themen ausgewählt. Bitte bestätigen Sie Ihre Auswahl.",
|
|
recommended_templates=[],
|
|
)
|
|
|
|
|
|
@router.put("/step/3", response_model=StepResponse)
|
|
async def save_step_3(
|
|
data: Step3Data,
|
|
db: DBSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Schritt 3: Digest-Einstellungen und Bestätigung.
|
|
|
|
Speichert E-Mail und Digest-Präferenzen.
|
|
"""
|
|
user_id = get_user_id_from_request()
|
|
|
|
subscription = db.query(UserAlertSubscriptionDB).filter(
|
|
UserAlertSubscriptionDB.user_id == user_id,
|
|
UserAlertSubscriptionDB.wizard_completed == False
|
|
).first()
|
|
|
|
if not subscription:
|
|
raise HTTPException(status_code=400, detail="Bitte zuerst Schritte 1 und 2 abschließen")
|
|
|
|
if not subscription.selected_template_ids:
|
|
raise HTTPException(status_code=400, detail="Bitte zuerst Templates auswählen (Schritt 2)")
|
|
|
|
# Update
|
|
subscription.notification_email = data.notification_email
|
|
subscription.digest_enabled = data.digest_enabled
|
|
subscription.digest_frequency = data.digest_frequency
|
|
subscription.wizard_step = 3
|
|
|
|
wizard_state = subscription.wizard_state or {}
|
|
wizard_state["step3"] = {
|
|
"notification_email": data.notification_email,
|
|
"digest_enabled": data.digest_enabled,
|
|
"digest_frequency": data.digest_frequency,
|
|
}
|
|
subscription.wizard_state = wizard_state
|
|
subscription.updated_at = datetime.utcnow()
|
|
|
|
db.commit()
|
|
|
|
return StepResponse(
|
|
status="success",
|
|
current_step=3,
|
|
next_step=4,
|
|
message="Einstellungen gespeichert. Klicken Sie auf 'Jetzt starten' um den Wizard abzuschließen.",
|
|
recommended_templates=[],
|
|
)
|
|
|
|
|
|
@router.post("/complete")
|
|
async def complete_wizard(
|
|
db: DBSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Wizard abschließen und Templates aktivieren.
|
|
|
|
Erstellt Topics, Rules und Profile basierend auf den gewählten Templates.
|
|
"""
|
|
user_id = get_user_id_from_request()
|
|
|
|
subscription = db.query(UserAlertSubscriptionDB).filter(
|
|
UserAlertSubscriptionDB.user_id == user_id,
|
|
UserAlertSubscriptionDB.wizard_completed == False
|
|
).first()
|
|
|
|
if not subscription:
|
|
raise HTTPException(status_code=400, detail="Kein aktiver Wizard gefunden")
|
|
|
|
if not subscription.selected_template_ids:
|
|
raise HTTPException(status_code=400, detail="Bitte zuerst Templates auswählen")
|
|
|
|
# Aktiviere Templates (über Subscription-Endpoint)
|
|
from .subscriptions import activate_template, ActivateTemplateRequest
|
|
|
|
# Markiere als abgeschlossen
|
|
subscription.wizard_completed = True
|
|
subscription.wizard_step = 4
|
|
subscription.updated_at = datetime.utcnow()
|
|
|
|
db.commit()
|
|
|
|
return {
|
|
"status": "success",
|
|
"message": "Wizard abgeschlossen! Ihre Alerts werden ab jetzt gesammelt.",
|
|
"subscription_id": subscription.id,
|
|
"selected_templates": subscription.selected_template_ids,
|
|
"next_action": "Besuchen Sie die Inbox, um Ihre ersten Alerts zu sehen.",
|
|
}
|
|
|
|
|
|
@router.post("/reset")
|
|
async def reset_wizard(
|
|
db: DBSession = Depends(get_db)
|
|
):
|
|
"""Wizard zurücksetzen (für Neustart)."""
|
|
user_id = get_user_id_from_request()
|
|
|
|
subscription = db.query(UserAlertSubscriptionDB).filter(
|
|
UserAlertSubscriptionDB.user_id == user_id,
|
|
UserAlertSubscriptionDB.wizard_completed == False
|
|
).first()
|
|
|
|
if subscription:
|
|
db.delete(subscription)
|
|
db.commit()
|
|
|
|
return {
|
|
"status": "success",
|
|
"message": "Wizard zurückgesetzt. Sie können neu beginnen.",
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# Migration Endpoints (für bestehende Google Alerts)
|
|
# ============================================================================
|
|
|
|
@router.post("/migrate/email", response_model=MigrateEmailResponse)
|
|
async def start_email_migration(
|
|
request: MigrateEmailRequest = None,
|
|
db: DBSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Starte E-Mail-Migration für bestehende Google Alerts.
|
|
|
|
Generiert eine eindeutige Inbound-E-Mail-Adresse, an die der Nutzer
|
|
seine Google Alerts weiterleiten kann.
|
|
"""
|
|
user_id = get_user_id_from_request()
|
|
|
|
# Erstelle AlertSource
|
|
source = AlertSourceDB(
|
|
id=str(uuid.uuid4()),
|
|
user_id=user_id,
|
|
source_type=FeedTypeEnum.EMAIL,
|
|
original_label=request.original_label if request else "Google Alert Migration",
|
|
migration_mode=MigrationModeEnum.FORWARD,
|
|
is_active=True,
|
|
)
|
|
|
|
# Generiere Inbound-Adresse
|
|
source.inbound_address = _generate_inbound_address(user_id, source.id)
|
|
|
|
db.add(source)
|
|
db.commit()
|
|
db.refresh(source)
|
|
|
|
return MigrateEmailResponse(
|
|
status="success",
|
|
inbound_address=source.inbound_address,
|
|
source_id=source.id,
|
|
instructions=[
|
|
"1. Öffnen Sie Google Alerts (google.com/alerts)",
|
|
"2. Klicken Sie auf das Bearbeiten-Symbol bei Ihrem Alert",
|
|
f"3. Ändern Sie die E-Mail-Adresse zu: {source.inbound_address}",
|
|
"4. Speichern Sie die Änderung",
|
|
"5. Ihre Alerts werden automatisch importiert und gefiltert",
|
|
],
|
|
)
|
|
|
|
|
|
@router.post("/migrate/rss", response_model=MigrateRssResponse)
|
|
async def import_rss_feeds(
|
|
request: MigrateRssRequest,
|
|
db: DBSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Importiere bestehende Google Alert RSS-Feeds.
|
|
|
|
Erstellt für jede RSS-URL einen AlertSource und Topic.
|
|
"""
|
|
user_id = get_user_id_from_request()
|
|
|
|
from ..db.models import AlertTopicDB
|
|
|
|
sources_created = 0
|
|
topics_created = 0
|
|
|
|
for i, url in enumerate(request.rss_urls):
|
|
# Label aus Request oder generieren
|
|
label = None
|
|
if request.labels and i < len(request.labels):
|
|
label = request.labels[i]
|
|
if not label:
|
|
label = f"RSS Feed {i + 1}"
|
|
|
|
# Erstelle AlertSource
|
|
source = AlertSourceDB(
|
|
id=str(uuid.uuid4()),
|
|
user_id=user_id,
|
|
source_type=FeedTypeEnum.RSS,
|
|
original_label=label,
|
|
rss_url=url,
|
|
migration_mode=MigrationModeEnum.IMPORT,
|
|
is_active=True,
|
|
)
|
|
db.add(source)
|
|
sources_created += 1
|
|
|
|
# Erstelle Topic
|
|
topic = AlertTopicDB(
|
|
id=str(uuid.uuid4()),
|
|
user_id=user_id,
|
|
name=label,
|
|
description=f"Importiert aus RSS: {url[:50]}...",
|
|
feed_url=url,
|
|
feed_type=FeedTypeEnum.RSS,
|
|
is_active=True,
|
|
fetch_interval_minutes=60,
|
|
)
|
|
db.add(topic)
|
|
|
|
# Verknüpfe Source mit Topic
|
|
source.topic_id = topic.id
|
|
topics_created += 1
|
|
|
|
db.commit()
|
|
|
|
return MigrateRssResponse(
|
|
status="success",
|
|
sources_created=sources_created,
|
|
topics_created=topics_created,
|
|
message=f"{sources_created} RSS-Feeds importiert. Die Alerts werden automatisch abgerufen.",
|
|
)
|
|
|
|
|
|
@router.get("/migrate/sources")
|
|
async def list_migration_sources(
|
|
db: DBSession = Depends(get_db)
|
|
):
|
|
"""Liste alle Migration-Quellen des Users."""
|
|
user_id = get_user_id_from_request()
|
|
|
|
sources = db.query(AlertSourceDB).filter(
|
|
AlertSourceDB.user_id == user_id
|
|
).order_by(AlertSourceDB.created_at.desc()).all()
|
|
|
|
return {
|
|
"sources": [
|
|
{
|
|
"id": s.id,
|
|
"type": s.source_type.value if s.source_type else "unknown",
|
|
"label": s.original_label,
|
|
"inbound_address": s.inbound_address,
|
|
"rss_url": s.rss_url,
|
|
"migration_mode": s.migration_mode.value if s.migration_mode else "unknown",
|
|
"items_received": s.items_received,
|
|
"is_active": s.is_active,
|
|
"created_at": s.created_at.isoformat() if s.created_at else None,
|
|
}
|
|
for s in sources
|
|
],
|
|
"total": len(sources),
|
|
}
|