Files
breakpilot-lehrer/backend-lehrer/alerts_agent/api/wizard.py
Benjamin Admin bd4b956e3c [split-required] Split final 43 files (500-668 LOC) to complete refactoring
klausur-service (11 files):
- cv_gutter_repair, ocr_pipeline_regression, upload_api
- ocr_pipeline_sessions, smart_spell, nru_worksheet_generator
- ocr_pipeline_overlays, mail/aggregator, zeugnis_api
- cv_syllable_detect, self_rag

backend-lehrer (17 files):
- classroom_engine/suggestions, generators/quiz_generator
- worksheets_api, llm_gateway/comparison, state_engine_api
- classroom/models (→ 4 submodules), services/file_processor
- alerts_agent/api/wizard+digests+routes, content_generators/pdf
- classroom/routes/sessions, llm_gateway/inference
- classroom_engine/analytics, auth/keycloak_auth
- alerts_agent/processing/rule_engine, ai_processor/print_versions

agent-core (5 files):
- brain/memory_store, brain/knowledge_graph, brain/context_manager
- orchestrator/supervisor, sessions/session_manager

admin-lehrer (5 components):
- GridOverlay, StepGridReview, DevOpsPipelineSidebar
- DataFlowDiagram, sbom/wizard/page

website (2 files):
- DependencyMap, lehrer/abitur-archiv

Other: nibis_ingestion, grid_detection_service, export-doclayout-onnx

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 09:41:42 +02:00

370 lines
13 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.
"""
import uuid
from typing import List, Dict, Any
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session as DBSession
from ..db.database import get_db
from ..db.models import (
UserAlertSubscriptionDB, AlertTemplateDB, AlertSourceDB,
AlertModeEnum, UserRoleEnum, MigrationModeEnum, FeedTypeEnum
)
from .wizard_models import (
WizardState,
Step1Data,
Step2Data,
Step3Data,
StepResponse,
MigrateEmailRequest,
MigrateEmailResponse,
MigrateRssRequest,
MigrateRssResponse,
)
router = APIRouter(prefix="/wizard", tags=["wizard"])
# ============================================================================
# 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."""
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()
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."""
user_id = get_user_id_from_request()
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)
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)
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."""
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")
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")
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.",
)
@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."""
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)")
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.",
)
@router.post("/complete")
async def complete_wizard(db: DBSession = Depends(get_db)):
"""Wizard abschließen und Templates aktivieren."""
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")
from .subscriptions import activate_template, ActivateTemplateRequest
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."""
user_id = get_user_id_from_request()
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,
)
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."""
user_id = get_user_id_from_request()
from ..db.models import AlertTopicDB
sources_created, topics_created = 0, 0
for i, url in enumerate(request.rss_urls):
label = None
if request.labels and i < len(request.labels):
label = request.labels[i]
if not label:
label = f"RSS Feed {i + 1}"
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
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)
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),
}