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>
370 lines
13 KiB
Python
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),
|
|
}
|