Quellenverwaltung: Boulevard-Kategorie, Duplikat-Prüfung, Domain-Normalisierung

- Boulevard als Kategorie in HTML-Dropdowns, JS-Labels und Pydantic-Validierung
- create_source: URL-Duplikat-Prüfung (409 Conflict bei existierender URL)
- create_source + update_source: Domain via _DOMAIN_ALIASES normalisieren
- System-Quellen (auto-entdeckt) sind jetzt von allen Nutzern editierbar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Dieser Commit ist enthalten in:
claude-dev
2026-03-04 23:45:01 +01:00
Ursprung 731a66ac80
Commit a6c24366a0
4 geänderte Dateien mit 35 neuen und 11 gelöschten Zeilen

Datei anzeigen

@@ -96,7 +96,7 @@ class SourceCreate(BaseModel):
url: Optional[str] = None url: Optional[str] = None
domain: Optional[str] = None domain: Optional[str] = None
source_type: str = Field(default="rss_feed", pattern="^(rss_feed|web_source|excluded)$") source_type: str = Field(default="rss_feed", pattern="^(rss_feed|web_source|excluded)$")
category: str = Field(default="sonstige", pattern="^(nachrichtenagentur|oeffentlich-rechtlich|qualitaetszeitung|behoerde|fachmedien|think-tank|international|regional|sonstige)$") category: str = Field(default="sonstige", pattern="^(nachrichtenagentur|oeffentlich-rechtlich|qualitaetszeitung|behoerde|fachmedien|think-tank|international|regional|boulevard|sonstige)$")
status: str = Field(default="active", pattern="^(active|inactive)$") status: str = Field(default="active", pattern="^(active|inactive)$")
notes: Optional[str] = None notes: Optional[str] = None
@@ -106,7 +106,7 @@ class SourceUpdate(BaseModel):
url: Optional[str] = None url: Optional[str] = None
domain: Optional[str] = None domain: Optional[str] = None
source_type: Optional[str] = Field(default=None, pattern="^(rss_feed|web_source|excluded)$") source_type: Optional[str] = Field(default=None, pattern="^(rss_feed|web_source|excluded)$")
category: Optional[str] = Field(default=None, pattern="^(nachrichtenagentur|oeffentlich-rechtlich|qualitaetszeitung|behoerde|fachmedien|think-tank|international|regional|sonstige)$") category: Optional[str] = Field(default=None, pattern="^(nachrichtenagentur|oeffentlich-rechtlich|qualitaetszeitung|behoerde|fachmedien|think-tank|international|regional|boulevard|sonstige)$")
status: Optional[str] = Field(default=None, pattern="^(active|inactive)$") status: Optional[str] = Field(default=None, pattern="^(active|inactive)$")
notes: Optional[str] = None notes: Optional[str] = None

Datei anzeigen

@@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from models import SourceCreate, SourceUpdate, SourceResponse, DiscoverRequest, DiscoverResponse, DiscoverMultiResponse, DomainActionRequest from models import SourceCreate, SourceUpdate, SourceResponse, DiscoverRequest, DiscoverResponse, DiscoverMultiResponse, DomainActionRequest
from auth import get_current_user from auth import get_current_user
from database import db_dependency, refresh_source_counts from database import db_dependency, refresh_source_counts
from source_rules import discover_source, discover_all_feeds, evaluate_feeds_with_claude, _extract_domain, _detect_category, domain_to_display_name from source_rules import discover_source, discover_all_feeds, evaluate_feeds_with_claude, _extract_domain, _detect_category, domain_to_display_name, _DOMAIN_ALIASES
import aiosqlite import aiosqlite
logger = logging.getLogger("osint.sources") logger = logging.getLogger("osint.sources")
@@ -16,14 +16,13 @@ SOURCE_UPDATE_COLUMNS = {"name", "url", "domain", "source_type", "category", "st
def _check_source_ownership(source: dict, username: str): def _check_source_ownership(source: dict, username: str):
"""Prueft ob der Nutzer die Quelle bearbeiten/loeschen darf.""" """Prueft ob der Nutzer die Quelle bearbeiten/loeschen darf.
System-Quellen (auto-entdeckt) duerfen von jedem bearbeitet werden.
Nutzer-Quellen nur vom Ersteller.
"""
added_by = source.get("added_by", "") added_by = source.get("added_by", "")
if added_by == "system": if added_by and added_by != "system" and added_by != username:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="System-Quellen koennen nicht veraendert werden",
)
if added_by and added_by != username:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Nur der Ersteller kann diese Quelle bearbeiten", detail="Nur der Ersteller kann diese Quelle bearbeiten",
@@ -442,13 +441,32 @@ async def create_source(
): ):
"""Neue Quelle hinzufuegen (org-spezifisch).""" """Neue Quelle hinzufuegen (org-spezifisch)."""
tenant_id = current_user.get("tenant_id") tenant_id = current_user.get("tenant_id")
# Domain normalisieren (Subdomain-Aliase auflösen)
domain = data.domain
if domain:
domain = _DOMAIN_ALIASES.get(domain.lower(), domain.lower())
# Duplikat-Prüfung: gleiche URL bereits vorhanden?
if data.url:
cursor = await db.execute(
"SELECT id, name FROM sources WHERE url = ? AND status = 'active'",
(data.url,),
)
existing = await cursor.fetchone()
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Feed-URL bereits vorhanden: {existing['name']} (ID {existing['id']})",
)
cursor = await db.execute( cursor = await db.execute(
"""INSERT INTO sources (name, url, domain, source_type, category, status, notes, added_by, tenant_id) """INSERT INTO sources (name, url, domain, source_type, category, status, notes, added_by, tenant_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
( (
data.name, data.name,
data.url, data.url,
data.domain, domain,
data.source_type, data.source_type,
data.category, data.category,
data.status, data.status,
@@ -483,6 +501,9 @@ async def update_source(
for field, value in data.model_dump(exclude_none=True).items(): for field, value in data.model_dump(exclude_none=True).items():
if field not in SOURCE_UPDATE_COLUMNS: if field not in SOURCE_UPDATE_COLUMNS:
continue continue
# Domain normalisieren
if field == "domain" and value:
value = _DOMAIN_ALIASES.get(value.lower(), value.lower())
updates[field] = value updates[field] = value
if not updates: if not updates:

Datei anzeigen

@@ -420,6 +420,7 @@
<option value="think-tank">Think Tank</option> <option value="think-tank">Think Tank</option>
<option value="international">International</option> <option value="international">International</option>
<option value="regional">Regional</option> <option value="regional">Regional</option>
<option value="boulevard">Boulevard</option>
<option value="sonstige">Sonstige</option> <option value="sonstige">Sonstige</option>
</select> </select>
<label for="sources-search" class="sr-only">Quellen durchsuchen</label> <label for="sources-search" class="sr-only">Quellen durchsuchen</label>
@@ -475,6 +476,7 @@
<option value="think-tank">Think Tank</option> <option value="think-tank">Think Tank</option>
<option value="international">International</option> <option value="international">International</option>
<option value="regional">Regional</option> <option value="regional">Regional</option>
<option value="boulevard">Boulevard</option>
<option value="sonstige" selected>Sonstige</option> <option value="sonstige" selected>Sonstige</option>
</select> </select>
</div> </div>

Datei anzeigen

@@ -506,6 +506,7 @@ const UI = {
'think-tank': 'Think Tank', 'think-tank': 'Think Tank',
'international': 'Intl.', 'international': 'Intl.',
'regional': 'Regional', 'regional': 'Regional',
'boulevard': 'Boulevard',
'sonstige': 'Sonstige', 'sonstige': 'Sonstige',
}, },