feat: Discovery-Funktion in Grundquellen-Verwaltung integriert
- POST /api/sources/discover: URL analysieren, RSS-Feeds erkennen, Duplikate prüfen - POST /api/sources/discover/add: Erkannte Feeds als Grundquellen anlegen (inkl. Web-Source) - Erkennen-Button und Modal im Dashboard mit Feed-Auswahl per Checkbox - Duplikat-Erkennung zeigt bereits vorhandene Grundquellen an - source_rules aus Monitor importiert für Feed-Discovery und Claude-Bewertung - config.py um Discovery-Konfiguration erweitert Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Dieser Commit ist enthalten in:
@@ -1,167 +1,314 @@
|
||||
"""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())
|
||||
"""Grundquellen-Verwaltung und Kundenquellen-Übersicht."""
|
||||
import sys
|
||||
import logging
|
||||
|
||||
# Monitor-Source-Rules verfügbar machen
|
||||
sys.path.insert(0, "/home/claude-dev/AegisSight-Monitor/src")
|
||||
|
||||
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
|
||||
|
||||
from source_rules import (
|
||||
discover_source,
|
||||
discover_all_feeds,
|
||||
evaluate_feeds_with_claude,
|
||||
_extract_domain,
|
||||
_detect_category,
|
||||
domain_to_display_name,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("verwaltung.sources")
|
||||
|
||||
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())
|
||||
|
||||
|
||||
|
||||
@router.post("/discover")
|
||||
async def discover_source_endpoint(
|
||||
url: str,
|
||||
admin: dict = Depends(get_current_admin),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""URL analysieren: Domain, Kategorie und RSS-Feeds automatisch erkennen.
|
||||
|
||||
Findet alle Feeds einer Domain, bewertet sie mit Claude und gibt
|
||||
die relevanten zurueck. Prueft auf bereits vorhandene Grundquellen.
|
||||
"""
|
||||
try:
|
||||
multi = await discover_all_feeds(url)
|
||||
except Exception as e:
|
||||
logger.error(f"Discovery fehlgeschlagen: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Discovery fehlgeschlagen")
|
||||
|
||||
domain = multi["domain"]
|
||||
category = multi["category"]
|
||||
feeds = multi.get("feeds", [])
|
||||
|
||||
# Fallback auf Einzel-Discovery wenn keine Feeds gefunden
|
||||
if not feeds:
|
||||
try:
|
||||
single = await discover_source(url)
|
||||
if single.get("rss_url"):
|
||||
feeds = [{"name": single["name"], "url": single["rss_url"]}]
|
||||
domain = single.get("domain", domain)
|
||||
category = single.get("category", category)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not feeds:
|
||||
return {
|
||||
"domain": domain,
|
||||
"category": category,
|
||||
"feeds": [],
|
||||
"existing": [],
|
||||
"message": "Keine RSS-Feeds gefunden",
|
||||
}
|
||||
|
||||
# Mit Claude bewerten
|
||||
try:
|
||||
relevant_feeds = await evaluate_feeds_with_claude(domain, feeds)
|
||||
except Exception:
|
||||
relevant_feeds = feeds[:3]
|
||||
|
||||
# Bereits vorhandene Grundquellen pruefen
|
||||
cursor = await db.execute(
|
||||
"SELECT url FROM sources WHERE tenant_id IS NULL AND url IS NOT NULL"
|
||||
)
|
||||
existing_urls = {row["url"] for row in await cursor.fetchall()}
|
||||
|
||||
result_feeds = []
|
||||
existing = []
|
||||
for feed in relevant_feeds:
|
||||
info = {
|
||||
"name": feed.get("name", domain_to_display_name(domain)),
|
||||
"url": feed["url"],
|
||||
"domain": domain,
|
||||
"category": category,
|
||||
}
|
||||
if feed["url"] in existing_urls:
|
||||
existing.append(info)
|
||||
else:
|
||||
result_feeds.append(info)
|
||||
|
||||
return {
|
||||
"domain": domain,
|
||||
"category": category,
|
||||
"feeds": result_feeds,
|
||||
"existing": existing,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/discover/add")
|
||||
async def add_discovered_sources(
|
||||
feeds: list[dict],
|
||||
admin: dict = Depends(get_current_admin),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Erkannte Feeds als Grundquellen anlegen.
|
||||
|
||||
Erwartet eine Liste von {name, url, domain, category}.
|
||||
Ueberspringt bereits vorhandene URLs.
|
||||
"""
|
||||
cursor = await db.execute(
|
||||
"SELECT url FROM sources WHERE tenant_id IS NULL AND url IS NOT NULL"
|
||||
)
|
||||
existing_urls = {row["url"] for row in await cursor.fetchall()}
|
||||
|
||||
added = 0
|
||||
skipped = 0
|
||||
for feed in feeds:
|
||||
if not feed.get("url"):
|
||||
continue
|
||||
if feed["url"] in existing_urls:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
domain = feed.get("domain", "")
|
||||
await db.execute(
|
||||
"""INSERT INTO sources (name, url, domain, source_type, category, status, added_by, tenant_id)
|
||||
VALUES (?, ?, ?, 'rss_feed', ?, 'active', 'system', NULL)""",
|
||||
(feed["name"], feed["url"], domain, feed.get("category", "sonstige")),
|
||||
)
|
||||
existing_urls.add(feed["url"])
|
||||
added += 1
|
||||
|
||||
# Web-Source für die Domain anlegen wenn noch nicht vorhanden
|
||||
if feeds and feeds[0].get("domain"):
|
||||
domain = feeds[0]["domain"]
|
||||
cursor = await db.execute(
|
||||
"SELECT id FROM sources WHERE LOWER(domain) = ? AND source_type = 'web_source' AND tenant_id IS NULL",
|
||||
(domain.lower(),),
|
||||
)
|
||||
if not await cursor.fetchone():
|
||||
await db.execute(
|
||||
"""INSERT INTO sources (name, url, domain, source_type, category, status, added_by, tenant_id)
|
||||
VALUES (?, ?, ?, 'web_source', ?, 'active', 'system', NULL)""",
|
||||
(domain_to_display_name(domain), f"https://{domain}", domain,
|
||||
feeds[0].get("category", "sonstige")),
|
||||
)
|
||||
added += 1
|
||||
|
||||
await db.commit()
|
||||
return {"added": added, "skipped": skipped}
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren