This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/backend/alerts_agent/api/topics.py
Benjamin Admin bfdaf63ba9 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>
2026-02-09 09:51:32 +01:00

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,
)