fix: Restore all files lost during destructive rebase
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>
This commit is contained in:
554
backend/alerts_agent/api/wizard.py
Normal file
554
backend/alerts_agent/api/wizard.py
Normal file
@@ -0,0 +1,554 @@
|
||||
"""
|
||||
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),
|
||||
}
|
||||
Reference in New Issue
Block a user