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:
405
backend/alerts_agent/api/topics.py
Normal file
405
backend/alerts_agent/api/topics.py
Normal file
@@ -0,0 +1,405 @@
|
||||
"""
|
||||
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,
|
||||
)
|
||||
Reference in New Issue
Block a user