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