Feat: Grundquellen-Verwaltung und Kundenquellen-Übersicht

- Neuer Tab "Quellen" mit Sub-Tabs "Grundquellen" und "Kundenquellen"
- Grundquellen: CRUD (Erstellen, Bearbeiten, Löschen) - gilt für alle Monitore
- Kundenquellen: Übersicht aller tenant-spezifischen Quellen mit Org-Zuordnung
- Kundenquellen können zu Grundquellen befördert werden
- Suche/Filter in beiden Ansichten
- Sources-Router mit vollständiger API

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Dieser Commit ist enthalten in:
claude-dev
2026-03-05 19:46:45 +01:00
Ursprung af6040cbf6
Commit 19fbf152eb
4 geänderte Dateien mit 547 neuen und 1 gelöschten Zeilen

167
src/routers/sources.py Normale Datei
Datei anzeigen

@@ -0,0 +1,167 @@
"""Grundquellen-Verwaltung und Kundenquellen-Übersicht."""
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from typing import Optional
from auth import get_current_admin
from database import db_dependency
import aiosqlite
router = APIRouter(prefix="/api/sources", tags=["sources"])
class GlobalSourceCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
url: Optional[str] = None
domain: Optional[str] = None
source_type: str = Field(default="rss_feed", pattern="^(rss_feed|web_source|excluded)$")
category: str = Field(default="sonstige")
status: str = Field(default="active", pattern="^(active|inactive)$")
notes: Optional[str] = None
class GlobalSourceUpdate(BaseModel):
name: Optional[str] = Field(default=None, max_length=200)
url: Optional[str] = None
domain: Optional[str] = None
source_type: Optional[str] = Field(default=None, pattern="^(rss_feed|web_source|excluded)$")
category: Optional[str] = None
status: Optional[str] = Field(default=None, pattern="^(active|inactive)$")
notes: Optional[str] = None
@router.get("/global")
async def list_global_sources(
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Alle Grundquellen auflisten (tenant_id IS NULL)."""
cursor = await db.execute(
"SELECT * FROM sources WHERE tenant_id IS NULL ORDER BY category, source_type, name"
)
return [dict(row) for row in await cursor.fetchall()]
@router.post("/global", status_code=201)
async def create_global_source(
data: GlobalSourceCreate,
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Neue Grundquelle anlegen."""
if data.url:
cursor = await db.execute(
"SELECT id, name FROM sources WHERE url = ? AND tenant_id IS NULL",
(data.url,),
)
existing = await cursor.fetchone()
if existing:
raise HTTPException(
status_code=409,
detail=f"URL bereits vorhanden: {existing['name']}",
)
cursor = await db.execute(
"""INSERT INTO sources (name, url, domain, source_type, category, status, notes, added_by, tenant_id)
VALUES (?, ?, ?, ?, ?, ?, ?, 'system', NULL)""",
(data.name, data.url, data.domain, data.source_type, data.category, data.status, data.notes),
)
await db.commit()
cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (cursor.lastrowid,))
return dict(await cursor.fetchone())
@router.put("/global/{source_id}")
async def update_global_source(
source_id: int,
data: GlobalSourceUpdate,
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Grundquelle bearbeiten."""
cursor = await db.execute(
"SELECT * FROM sources WHERE id = ? AND tenant_id IS NULL", (source_id,)
)
row = await cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Grundquelle nicht gefunden")
updates = {}
for field, value in data.model_dump(exclude_none=True).items():
updates[field] = value
if not updates:
return dict(row)
set_clause = ", ".join(f"{k} = ?" for k in updates)
values = list(updates.values()) + [source_id]
await db.execute(f"UPDATE sources SET {set_clause} WHERE id = ?", values)
await db.commit()
cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (source_id,))
return dict(await cursor.fetchone())
@router.delete("/global/{source_id}", status_code=204)
async def delete_global_source(
source_id: int,
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Grundquelle loeschen."""
cursor = await db.execute(
"SELECT id FROM sources WHERE id = ? AND tenant_id IS NULL", (source_id,)
)
if not await cursor.fetchone():
raise HTTPException(status_code=404, detail="Grundquelle nicht gefunden")
await db.execute("DELETE FROM sources WHERE id = ?", (source_id,))
await db.commit()
@router.get("/tenant")
async def list_tenant_sources(
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Alle tenant-spezifischen Quellen mit Org-Name auflisten."""
cursor = await db.execute("""
SELECT s.*, o.name as org_name
FROM sources s
LEFT JOIN organizations o ON o.id = s.tenant_id
WHERE s.tenant_id IS NOT NULL
ORDER BY o.name, s.category, s.name
""")
return [dict(row) for row in await cursor.fetchall()]
@router.post("/tenant/{source_id}/promote")
async def promote_to_global(
source_id: int,
admin: dict = Depends(get_current_admin),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Tenant-Quelle zur Grundquelle befoerdern."""
cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (source_id,))
row = await cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Quelle nicht gefunden")
if row["tenant_id"] is None:
raise HTTPException(status_code=400, detail="Bereits eine Grundquelle")
if row["url"]:
cursor = await db.execute(
"SELECT id FROM sources WHERE url = ? AND tenant_id IS NULL",
(row["url"],),
)
if await cursor.fetchone():
raise HTTPException(status_code=409, detail="URL bereits als Grundquelle vorhanden")
await db.execute(
"UPDATE sources SET tenant_id = NULL, added_by = 'system' WHERE id = ?",
(source_id,),
)
await db.commit()
cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (source_id,))
return dict(await cursor.fetchone())