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>
406 lines
12 KiB
Python
406 lines
12 KiB
Python
"""
|
|
Topic API Routes für Alerts Agent.
|
|
|
|
CRUD-Operationen für Alert-Topics (Feed-Quellen).
|
|
"""
|
|
from typing import List, Optional
|
|
from datetime import datetime
|
|
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
|
from pydantic import BaseModel, Field, HttpUrl
|
|
|
|
from sqlalchemy.orm import Session as DBSession
|
|
from alerts_agent.db import get_db
|
|
from alerts_agent.db.repository import TopicRepository, AlertItemRepository
|
|
from alerts_agent.db.models import FeedTypeEnum
|
|
|
|
|
|
router = APIRouter(prefix="/topics", tags=["alerts"])
|
|
|
|
|
|
# =============================================================================
|
|
# PYDANTIC MODELS
|
|
# =============================================================================
|
|
|
|
class TopicCreate(BaseModel):
|
|
"""Request-Model für Topic-Erstellung."""
|
|
name: str = Field(..., min_length=1, max_length=255)
|
|
description: str = Field(default="", max_length=2000)
|
|
feed_url: Optional[str] = Field(default=None, max_length=2000)
|
|
feed_type: str = Field(default="rss") # rss, email, webhook
|
|
fetch_interval_minutes: int = Field(default=60, ge=5, le=1440)
|
|
is_active: bool = Field(default=True)
|
|
|
|
|
|
class TopicUpdate(BaseModel):
|
|
"""Request-Model für Topic-Update."""
|
|
name: Optional[str] = Field(default=None, min_length=1, max_length=255)
|
|
description: Optional[str] = Field(default=None, max_length=2000)
|
|
feed_url: Optional[str] = Field(default=None, max_length=2000)
|
|
feed_type: Optional[str] = None
|
|
fetch_interval_minutes: Optional[int] = Field(default=None, ge=5, le=1440)
|
|
is_active: Optional[bool] = None
|
|
|
|
|
|
class TopicResponse(BaseModel):
|
|
"""Response-Model für Topic."""
|
|
id: str
|
|
name: str
|
|
description: str
|
|
feed_url: Optional[str]
|
|
feed_type: str
|
|
is_active: bool
|
|
fetch_interval_minutes: int
|
|
last_fetched_at: Optional[datetime]
|
|
last_fetch_error: Optional[str]
|
|
total_items_fetched: int
|
|
items_kept: int
|
|
items_dropped: int
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class TopicListResponse(BaseModel):
|
|
"""Response-Model für Topic-Liste."""
|
|
topics: List[TopicResponse]
|
|
total: int
|
|
|
|
|
|
class TopicStatsResponse(BaseModel):
|
|
"""Response-Model für Topic-Statistiken."""
|
|
topic_id: str
|
|
name: str
|
|
total_alerts: int
|
|
by_status: dict
|
|
by_decision: dict
|
|
keep_rate: Optional[float]
|
|
|
|
|
|
class FetchResultResponse(BaseModel):
|
|
"""Response-Model für manuellen Fetch."""
|
|
success: bool
|
|
topic_id: str
|
|
new_items: int
|
|
duplicates_skipped: int
|
|
error: Optional[str] = None
|
|
|
|
|
|
# =============================================================================
|
|
# API ENDPOINTS
|
|
# =============================================================================
|
|
|
|
@router.post("", response_model=TopicResponse, status_code=201)
|
|
async def create_topic(
|
|
topic: TopicCreate,
|
|
db: DBSession = Depends(get_db),
|
|
) -> TopicResponse:
|
|
"""
|
|
Erstellt ein neues Topic (Feed-Quelle).
|
|
|
|
Topics repräsentieren Google Alerts RSS-Feeds oder andere Feed-Quellen.
|
|
"""
|
|
repo = TopicRepository(db)
|
|
|
|
created = repo.create(
|
|
name=topic.name,
|
|
description=topic.description,
|
|
feed_url=topic.feed_url,
|
|
feed_type=topic.feed_type,
|
|
fetch_interval_minutes=topic.fetch_interval_minutes,
|
|
is_active=topic.is_active,
|
|
)
|
|
|
|
return TopicResponse(
|
|
id=created.id,
|
|
name=created.name,
|
|
description=created.description or "",
|
|
feed_url=created.feed_url,
|
|
feed_type=created.feed_type.value if created.feed_type else "rss",
|
|
is_active=created.is_active,
|
|
fetch_interval_minutes=created.fetch_interval_minutes,
|
|
last_fetched_at=created.last_fetched_at,
|
|
last_fetch_error=created.last_fetch_error,
|
|
total_items_fetched=created.total_items_fetched,
|
|
items_kept=created.items_kept,
|
|
items_dropped=created.items_dropped,
|
|
created_at=created.created_at,
|
|
updated_at=created.updated_at,
|
|
)
|
|
|
|
|
|
@router.get("", response_model=TopicListResponse)
|
|
async def list_topics(
|
|
is_active: Optional[bool] = None,
|
|
db: DBSession = Depends(get_db),
|
|
) -> TopicListResponse:
|
|
"""
|
|
Listet alle Topics auf.
|
|
|
|
Optional nach aktivem Status filterbar.
|
|
"""
|
|
repo = TopicRepository(db)
|
|
topics = repo.get_all(is_active=is_active)
|
|
|
|
return TopicListResponse(
|
|
topics=[
|
|
TopicResponse(
|
|
id=t.id,
|
|
name=t.name,
|
|
description=t.description or "",
|
|
feed_url=t.feed_url,
|
|
feed_type=t.feed_type.value if t.feed_type else "rss",
|
|
is_active=t.is_active,
|
|
fetch_interval_minutes=t.fetch_interval_minutes,
|
|
last_fetched_at=t.last_fetched_at,
|
|
last_fetch_error=t.last_fetch_error,
|
|
total_items_fetched=t.total_items_fetched,
|
|
items_kept=t.items_kept,
|
|
items_dropped=t.items_dropped,
|
|
created_at=t.created_at,
|
|
updated_at=t.updated_at,
|
|
)
|
|
for t in topics
|
|
],
|
|
total=len(topics),
|
|
)
|
|
|
|
|
|
@router.get("/{topic_id}", response_model=TopicResponse)
|
|
async def get_topic(
|
|
topic_id: str,
|
|
db: DBSession = Depends(get_db),
|
|
) -> TopicResponse:
|
|
"""
|
|
Ruft ein Topic nach ID ab.
|
|
"""
|
|
repo = TopicRepository(db)
|
|
topic = repo.get_by_id(topic_id)
|
|
|
|
if not topic:
|
|
raise HTTPException(status_code=404, detail="Topic nicht gefunden")
|
|
|
|
return TopicResponse(
|
|
id=topic.id,
|
|
name=topic.name,
|
|
description=topic.description or "",
|
|
feed_url=topic.feed_url,
|
|
feed_type=topic.feed_type.value if topic.feed_type else "rss",
|
|
is_active=topic.is_active,
|
|
fetch_interval_minutes=topic.fetch_interval_minutes,
|
|
last_fetched_at=topic.last_fetched_at,
|
|
last_fetch_error=topic.last_fetch_error,
|
|
total_items_fetched=topic.total_items_fetched,
|
|
items_kept=topic.items_kept,
|
|
items_dropped=topic.items_dropped,
|
|
created_at=topic.created_at,
|
|
updated_at=topic.updated_at,
|
|
)
|
|
|
|
|
|
@router.put("/{topic_id}", response_model=TopicResponse)
|
|
async def update_topic(
|
|
topic_id: str,
|
|
updates: TopicUpdate,
|
|
db: DBSession = Depends(get_db),
|
|
) -> TopicResponse:
|
|
"""
|
|
Aktualisiert ein Topic.
|
|
"""
|
|
repo = TopicRepository(db)
|
|
|
|
# Nur übergebene Werte updaten
|
|
update_dict = {k: v for k, v in updates.model_dump().items() if v is not None}
|
|
|
|
if not update_dict:
|
|
raise HTTPException(status_code=400, detail="Keine Updates angegeben")
|
|
|
|
updated = repo.update(topic_id, **update_dict)
|
|
|
|
if not updated:
|
|
raise HTTPException(status_code=404, detail="Topic nicht gefunden")
|
|
|
|
return TopicResponse(
|
|
id=updated.id,
|
|
name=updated.name,
|
|
description=updated.description or "",
|
|
feed_url=updated.feed_url,
|
|
feed_type=updated.feed_type.value if updated.feed_type else "rss",
|
|
is_active=updated.is_active,
|
|
fetch_interval_minutes=updated.fetch_interval_minutes,
|
|
last_fetched_at=updated.last_fetched_at,
|
|
last_fetch_error=updated.last_fetch_error,
|
|
total_items_fetched=updated.total_items_fetched,
|
|
items_kept=updated.items_kept,
|
|
items_dropped=updated.items_dropped,
|
|
created_at=updated.created_at,
|
|
updated_at=updated.updated_at,
|
|
)
|
|
|
|
|
|
@router.delete("/{topic_id}", status_code=204)
|
|
async def delete_topic(
|
|
topic_id: str,
|
|
db: DBSession = Depends(get_db),
|
|
):
|
|
"""
|
|
Löscht ein Topic und alle zugehörigen Alerts (CASCADE).
|
|
"""
|
|
repo = TopicRepository(db)
|
|
|
|
success = repo.delete(topic_id)
|
|
|
|
if not success:
|
|
raise HTTPException(status_code=404, detail="Topic nicht gefunden")
|
|
|
|
return None
|
|
|
|
|
|
@router.get("/{topic_id}/stats", response_model=TopicStatsResponse)
|
|
async def get_topic_stats(
|
|
topic_id: str,
|
|
db: DBSession = Depends(get_db),
|
|
) -> TopicStatsResponse:
|
|
"""
|
|
Ruft Statistiken für ein Topic ab.
|
|
"""
|
|
topic_repo = TopicRepository(db)
|
|
alert_repo = AlertItemRepository(db)
|
|
|
|
topic = topic_repo.get_by_id(topic_id)
|
|
if not topic:
|
|
raise HTTPException(status_code=404, detail="Topic nicht gefunden")
|
|
|
|
by_status = alert_repo.count_by_status(topic_id)
|
|
by_decision = alert_repo.count_by_decision(topic_id)
|
|
|
|
total = sum(by_status.values())
|
|
keep_count = by_decision.get("KEEP", 0)
|
|
|
|
return TopicStatsResponse(
|
|
topic_id=topic_id,
|
|
name=topic.name,
|
|
total_alerts=total,
|
|
by_status=by_status,
|
|
by_decision=by_decision,
|
|
keep_rate=keep_count / total if total > 0 else None,
|
|
)
|
|
|
|
|
|
@router.post("/{topic_id}/fetch", response_model=FetchResultResponse)
|
|
async def fetch_topic(
|
|
topic_id: str,
|
|
background_tasks: BackgroundTasks,
|
|
db: DBSession = Depends(get_db),
|
|
) -> FetchResultResponse:
|
|
"""
|
|
Löst einen manuellen Fetch für ein Topic aus.
|
|
|
|
Der Fetch wird im Hintergrund ausgeführt. Das Ergebnis zeigt
|
|
die Anzahl neuer Items und übersprungener Duplikate.
|
|
"""
|
|
topic_repo = TopicRepository(db)
|
|
|
|
topic = topic_repo.get_by_id(topic_id)
|
|
if not topic:
|
|
raise HTTPException(status_code=404, detail="Topic nicht gefunden")
|
|
|
|
if not topic.feed_url:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Topic hat keine Feed-URL konfiguriert"
|
|
)
|
|
|
|
# Import hier um zirkuläre Imports zu vermeiden
|
|
from alerts_agent.ingestion.rss_fetcher import fetch_and_store_feed
|
|
|
|
try:
|
|
result = await fetch_and_store_feed(
|
|
topic_id=topic_id,
|
|
feed_url=topic.feed_url,
|
|
db=db,
|
|
)
|
|
|
|
return FetchResultResponse(
|
|
success=True,
|
|
topic_id=topic_id,
|
|
new_items=result.get("new_items", 0),
|
|
duplicates_skipped=result.get("duplicates_skipped", 0),
|
|
)
|
|
except Exception as e:
|
|
# Fehler im Topic speichern
|
|
topic_repo.update(topic_id, last_fetch_error=str(e))
|
|
|
|
return FetchResultResponse(
|
|
success=False,
|
|
topic_id=topic_id,
|
|
new_items=0,
|
|
duplicates_skipped=0,
|
|
error=str(e),
|
|
)
|
|
|
|
|
|
@router.post("/{topic_id}/activate", response_model=TopicResponse)
|
|
async def activate_topic(
|
|
topic_id: str,
|
|
db: DBSession = Depends(get_db),
|
|
) -> TopicResponse:
|
|
"""
|
|
Aktiviert ein Topic für automatisches Fetching.
|
|
"""
|
|
repo = TopicRepository(db)
|
|
updated = repo.update(topic_id, is_active=True)
|
|
|
|
if not updated:
|
|
raise HTTPException(status_code=404, detail="Topic nicht gefunden")
|
|
|
|
return TopicResponse(
|
|
id=updated.id,
|
|
name=updated.name,
|
|
description=updated.description or "",
|
|
feed_url=updated.feed_url,
|
|
feed_type=updated.feed_type.value if updated.feed_type else "rss",
|
|
is_active=updated.is_active,
|
|
fetch_interval_minutes=updated.fetch_interval_minutes,
|
|
last_fetched_at=updated.last_fetched_at,
|
|
last_fetch_error=updated.last_fetch_error,
|
|
total_items_fetched=updated.total_items_fetched,
|
|
items_kept=updated.items_kept,
|
|
items_dropped=updated.items_dropped,
|
|
created_at=updated.created_at,
|
|
updated_at=updated.updated_at,
|
|
)
|
|
|
|
|
|
@router.post("/{topic_id}/deactivate", response_model=TopicResponse)
|
|
async def deactivate_topic(
|
|
topic_id: str,
|
|
db: DBSession = Depends(get_db),
|
|
) -> TopicResponse:
|
|
"""
|
|
Deaktiviert ein Topic (stoppt automatisches Fetching).
|
|
"""
|
|
repo = TopicRepository(db)
|
|
updated = repo.update(topic_id, is_active=False)
|
|
|
|
if not updated:
|
|
raise HTTPException(status_code=404, detail="Topic nicht gefunden")
|
|
|
|
return TopicResponse(
|
|
id=updated.id,
|
|
name=updated.name,
|
|
description=updated.description or "",
|
|
feed_url=updated.feed_url,
|
|
feed_type=updated.feed_type.value if updated.feed_type else "rss",
|
|
is_active=updated.is_active,
|
|
fetch_interval_minutes=updated.fetch_interval_minutes,
|
|
last_fetched_at=updated.last_fetched_at,
|
|
last_fetch_error=updated.last_fetch_error,
|
|
total_items_fetched=updated.total_items_fetched,
|
|
items_kept=updated.items_kept,
|
|
items_dropped=updated.items_dropped,
|
|
created_at=updated.created_at,
|
|
updated_at=updated.updated_at,
|
|
)
|