Per-User Domain-Ausschlüsse + Grundquellen-Schutz
- Neue Tabelle user_excluded_domains für benutzerspezifische Ausschlüsse - Domain-Ausschlüsse wirken nur für den jeweiligen User, nicht org-weit - user_id wird durch die gesamte Pipeline geschleust (Orchestrator → Researcher → RSS-Parser) - Grundquellen (is_global) können nicht mehr bearbeitet/gelöscht werden im Frontend - Grundquelle-Badge bei globalen Quellen statt Edit/Delete-Buttons - Filter Von mir ausgeschlossen im Quellen-Modal Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Dieser Commit ist enthalten in:
@@ -332,7 +332,7 @@ class AgentOrchestrator:
|
|||||||
self._running = False
|
self._running = False
|
||||||
logger.info("Agenten-Orchestrator gestoppt")
|
logger.info("Agenten-Orchestrator gestoppt")
|
||||||
|
|
||||||
async def enqueue_refresh(self, incident_id: int, trigger_type: str = "manual") -> bool:
|
async def enqueue_refresh(self, incident_id: int, trigger_type: str = "manual", user_id: int = None) -> bool:
|
||||||
"""Refresh-Auftrag in die Queue stellen. Gibt False zurueck wenn bereits in Queue/aktiv."""
|
"""Refresh-Auftrag in die Queue stellen. Gibt False zurueck wenn bereits in Queue/aktiv."""
|
||||||
if incident_id in self._queued_ids or self._current_task == incident_id:
|
if incident_id in self._queued_ids or self._current_task == incident_id:
|
||||||
logger.info(f"Refresh fuer Lage {incident_id} uebersprungen: bereits aktiv/in Queue")
|
logger.info(f"Refresh fuer Lage {incident_id} uebersprungen: bereits aktiv/in Queue")
|
||||||
@@ -341,7 +341,7 @@ class AgentOrchestrator:
|
|||||||
visibility, created_by, tenant_id = await self._get_incident_visibility(incident_id)
|
visibility, created_by, tenant_id = await self._get_incident_visibility(incident_id)
|
||||||
|
|
||||||
self._queued_ids.add(incident_id)
|
self._queued_ids.add(incident_id)
|
||||||
await self._queue.put((incident_id, trigger_type))
|
await self._queue.put((incident_id, trigger_type, user_id))
|
||||||
queue_size = self._queue.qsize()
|
queue_size = self._queue.qsize()
|
||||||
logger.info(f"Refresh fuer Lage {incident_id} eingereiht (Queue: {queue_size}, Trigger: {trigger_type})")
|
logger.info(f"Refresh fuer Lage {incident_id} eingereiht (Queue: {queue_size}, Trigger: {trigger_type})")
|
||||||
|
|
||||||
@@ -386,7 +386,11 @@ class AgentOrchestrator:
|
|||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if len(item) == 3:
|
||||||
|
incident_id, trigger_type, user_id = item
|
||||||
|
else:
|
||||||
incident_id, trigger_type = item
|
incident_id, trigger_type = item
|
||||||
|
user_id = None
|
||||||
self._queued_ids.discard(incident_id)
|
self._queued_ids.discard(incident_id)
|
||||||
self._current_task = incident_id
|
self._current_task = incident_id
|
||||||
logger.info(f"Starte Refresh für Lage {incident_id} (Trigger: {trigger_type})")
|
logger.info(f"Starte Refresh für Lage {incident_id} (Trigger: {trigger_type})")
|
||||||
@@ -398,7 +402,7 @@ class AgentOrchestrator:
|
|||||||
try:
|
try:
|
||||||
for attempt in range(3):
|
for attempt in range(3):
|
||||||
try:
|
try:
|
||||||
await self._run_refresh(incident_id, trigger_type=trigger_type, retry_count=attempt)
|
await self._run_refresh(incident_id, trigger_type=trigger_type, retry_count=attempt, user_id=user_id)
|
||||||
last_error = None
|
last_error = None
|
||||||
break # Erfolg
|
break # Erfolg
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
@@ -509,7 +513,7 @@ class AgentOrchestrator:
|
|||||||
await db.close()
|
await db.close()
|
||||||
return visibility, created_by, tenant_id
|
return visibility, created_by, tenant_id
|
||||||
|
|
||||||
async def _run_refresh(self, incident_id: int, trigger_type: str = "manual", retry_count: int = 0):
|
async def _run_refresh(self, incident_id: int, trigger_type: str = "manual", retry_count: int = 0, user_id: int = None):
|
||||||
"""Führt einen kompletten Refresh-Zyklus durch."""
|
"""Führt einen kompletten Refresh-Zyklus durch."""
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
from database import get_db
|
from database import get_db
|
||||||
@@ -604,7 +608,7 @@ class AgentOrchestrator:
|
|||||||
keywords = feed_sel_keywords
|
keywords = feed_sel_keywords
|
||||||
articles = await rss_parser.search_feeds_selective(title, selected_feeds, keywords=keywords)
|
articles = await rss_parser.search_feeds_selective(title, selected_feeds, keywords=keywords)
|
||||||
else:
|
else:
|
||||||
articles = await rss_parser.search_feeds(title, international=international, tenant_id=tenant_id, keywords=keywords)
|
articles = await rss_parser.search_feeds(title, international=international, tenant_id=tenant_id, keywords=keywords, user_id=user_id)
|
||||||
|
|
||||||
logger.info(f"RSS: {len(articles)} relevante Artikel gefunden (international={international})")
|
logger.info(f"RSS: {len(articles)} relevante Artikel gefunden (international={international})")
|
||||||
return articles, feed_usage
|
return articles, feed_usage
|
||||||
@@ -612,7 +616,7 @@ class AgentOrchestrator:
|
|||||||
async def _web_search_pipeline():
|
async def _web_search_pipeline():
|
||||||
"""Claude WebSearch-Recherche."""
|
"""Claude WebSearch-Recherche."""
|
||||||
researcher = ResearcherAgent()
|
researcher = ResearcherAgent()
|
||||||
results, usage = await researcher.search(title, description, incident_type, international=international)
|
results, usage = await researcher.search(title, description, incident_type, international=international, user_id=user_id)
|
||||||
logger.info(f"Claude-Recherche: {len(results)} Ergebnisse")
|
logger.info(f"Claude-Recherche: {len(results)} Ergebnisse")
|
||||||
return results, usage
|
return results, usage
|
||||||
|
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ class ResearcherAgent:
|
|||||||
logger.warning(f"Keyword-Extraktion fehlgeschlagen: {e}")
|
logger.warning(f"Keyword-Extraktion fehlgeschlagen: {e}")
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
async def search(self, title: str, description: str = "", incident_type: str = "adhoc", international: bool = True) -> tuple[list[dict], ClaudeUsage | None]:
|
async def search(self, title: str, description: str = "", incident_type: str = "adhoc", international: bool = True, user_id: int = None) -> tuple[list[dict], ClaudeUsage | None]:
|
||||||
"""Sucht nach Informationen zu einem Vorfall."""
|
"""Sucht nach Informationen zu einem Vorfall."""
|
||||||
from config import OUTPUT_LANGUAGE
|
from config import OUTPUT_LANGUAGE
|
||||||
if incident_type == "research":
|
if incident_type == "research":
|
||||||
@@ -290,7 +290,7 @@ class ResearcherAgent:
|
|||||||
articles = self._parse_response(result)
|
articles = self._parse_response(result)
|
||||||
|
|
||||||
# Ausgeschlossene Quellen dynamisch aus DB laden
|
# Ausgeschlossene Quellen dynamisch aus DB laden
|
||||||
excluded_sources = await self._get_excluded_sources()
|
excluded_sources = await self._get_excluded_sources(user_id=user_id)
|
||||||
|
|
||||||
# Ausgeschlossene Quellen filtern
|
# Ausgeschlossene Quellen filtern
|
||||||
filtered = []
|
filtered = []
|
||||||
@@ -317,14 +317,23 @@ class ResearcherAgent:
|
|||||||
logger.error(f"Recherche-Fehler: {e}")
|
logger.error(f"Recherche-Fehler: {e}")
|
||||||
return [], None
|
return [], None
|
||||||
|
|
||||||
async def _get_excluded_sources(self) -> list[str]:
|
async def _get_excluded_sources(self, user_id: int = None) -> list[str]:
|
||||||
"""Lädt ausgeschlossene Quellen aus der Datenbank."""
|
"""Laedt ausgeschlossene Quellen (global + per-User)."""
|
||||||
try:
|
try:
|
||||||
from source_rules import get_source_rules
|
from source_rules import get_source_rules, get_user_excluded_domains
|
||||||
rules = await get_source_rules()
|
rules = await get_source_rules()
|
||||||
return rules.get("excluded_domains", [])
|
excluded = list(rules.get("excluded_domains", []))
|
||||||
|
|
||||||
|
# User-spezifische Ausschluesse hinzufuegen
|
||||||
|
if user_id:
|
||||||
|
user_excluded = await get_user_excluded_domains(user_id)
|
||||||
|
for domain in user_excluded:
|
||||||
|
if domain not in excluded:
|
||||||
|
excluded.append(domain)
|
||||||
|
|
||||||
|
return excluded
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Fallback auf config.py für Excluded Sources: {e}")
|
logger.warning(f"Fallback auf config.py fuer Excluded Sources: {e}")
|
||||||
from config import EXCLUDED_SOURCES
|
from config import EXCLUDED_SOURCES
|
||||||
return list(EXCLUDED_SOURCES)
|
return list(EXCLUDED_SOURCES)
|
||||||
|
|
||||||
|
|||||||
@@ -183,6 +183,15 @@ CREATE TABLE IF NOT EXISTS article_locations (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_article_locations_incident ON article_locations(incident_id);
|
CREATE INDEX IF NOT EXISTS idx_article_locations_incident ON article_locations(incident_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_article_locations_article ON article_locations(article_id);
|
CREATE INDEX IF NOT EXISTS idx_article_locations_article ON article_locations(article_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_excluded_domains (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(user_id, domain)
|
||||||
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class RSSParser:
|
|||||||
cleaned = [w for w in words if not w.isdigit()]
|
cleaned = [w for w in words if not w.isdigit()]
|
||||||
return cleaned if cleaned else words
|
return cleaned if cleaned else words
|
||||||
|
|
||||||
async def search_feeds(self, search_term: str, international: bool = True, tenant_id: int = None, keywords: list[str] | None = None) -> list[dict]:
|
async def search_feeds(self, search_term: str, international: bool = True, tenant_id: int = None, keywords: list[str] | None = None, user_id: int = None) -> list[dict]:
|
||||||
"""Durchsucht RSS-Feeds nach einem Suchbegriff.
|
"""Durchsucht RSS-Feeds nach einem Suchbegriff.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -50,6 +50,19 @@ class RSSParser:
|
|||||||
|
|
||||||
rss_feeds = await self._get_rss_feeds(tenant_id=tenant_id)
|
rss_feeds = await self._get_rss_feeds(tenant_id=tenant_id)
|
||||||
|
|
||||||
|
# User-spezifische Ausschluesse anwenden
|
||||||
|
if user_id:
|
||||||
|
try:
|
||||||
|
from source_rules import get_user_excluded_domains
|
||||||
|
user_excluded = await get_user_excluded_domains(user_id)
|
||||||
|
if user_excluded:
|
||||||
|
for cat in rss_feeds:
|
||||||
|
rss_feeds[cat] = [f for f in rss_feeds[cat]
|
||||||
|
if not any(excl in (f.get("url", "") + f.get("name", "")).lower()
|
||||||
|
for excl in user_excluded)]
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"User-Ausschluesse konnten nicht geladen werden: {e}")
|
||||||
|
|
||||||
# Feed-Kategorien filtern
|
# Feed-Kategorien filtern
|
||||||
if international:
|
if international:
|
||||||
categories = rss_feeds.keys()
|
categories = rss_feeds.keys()
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ class SourceResponse(BaseModel):
|
|||||||
article_count: int = 0
|
article_count: int = 0
|
||||||
last_seen_at: Optional[str] = None
|
last_seen_at: Optional[str] = None
|
||||||
created_at: str
|
created_at: str
|
||||||
|
is_global: bool = False
|
||||||
|
|
||||||
|
|
||||||
# Source Discovery
|
# Source Discovery
|
||||||
|
|||||||
@@ -550,7 +550,7 @@ async def trigger_refresh(
|
|||||||
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
|
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
|
||||||
|
|
||||||
from agents.orchestrator import orchestrator
|
from agents.orchestrator import orchestrator
|
||||||
enqueued = await orchestrator.enqueue_refresh(incident_id)
|
enqueued = await orchestrator.enqueue_refresh(incident_id, user_id=current_user["id"])
|
||||||
|
|
||||||
if not enqueued:
|
if not enqueued:
|
||||||
return {"status": "skipped", "incident_id": incident_id}
|
return {"status": "skipped", "incident_id": incident_id}
|
||||||
|
|||||||
@@ -57,7 +57,12 @@ async def list_sources(
|
|||||||
query += " ORDER BY source_type, category, name"
|
query += " ORDER BY source_type, category, name"
|
||||||
cursor = await db.execute(query, params)
|
cursor = await db.execute(query, params)
|
||||||
rows = await cursor.fetchall()
|
rows = await cursor.fetchall()
|
||||||
return [dict(row) for row in rows]
|
results = []
|
||||||
|
for row in rows:
|
||||||
|
d = dict(row)
|
||||||
|
d["is_global"] = d.get("tenant_id") is None
|
||||||
|
results.append(d)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats")
|
@router.get("/stats")
|
||||||
@@ -285,64 +290,54 @@ async def rediscover_existing_endpoint(
|
|||||||
raise HTTPException(status_code=500, detail="Rediscovery fehlgeschlagen")
|
raise HTTPException(status_code=500, detail="Rediscovery fehlgeschlagen")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/my-exclusions")
|
||||||
|
async def get_my_exclusions(
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
|
):
|
||||||
|
"""Gibt die vom aktuellen User ausgeschlossenen Domains zurück."""
|
||||||
|
user_id = current_user["id"]
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT domain, notes, created_at FROM user_excluded_domains WHERE user_id = ? ORDER BY domain",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/block-domain")
|
@router.post("/block-domain")
|
||||||
async def block_domain(
|
async def block_domain(
|
||||||
data: DomainActionRequest,
|
data: DomainActionRequest,
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
db: aiosqlite.Connection = Depends(db_dependency),
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
):
|
):
|
||||||
"""Domain ausschließen: Alle Feeds deaktivieren + excluded-Eintrag anlegen."""
|
"""Domain fuer den aktuellen User ausschließen (per-User, nicht org-weit)."""
|
||||||
tenant_id = current_user.get("tenant_id")
|
user_id = current_user["id"]
|
||||||
domain = data.domain.lower().strip()
|
domain = data.domain.lower().strip()
|
||||||
username = current_user["username"]
|
|
||||||
|
|
||||||
|
# Pruefen ob bereits ausgeschlossen
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"SELECT added_by FROM sources WHERE LOWER(domain) = ? AND source_type != 'excluded' AND status = 'active' AND (tenant_id IS NULL OR tenant_id = ?)",
|
"SELECT id FROM user_excluded_domains WHERE user_id = ? AND domain = ?",
|
||||||
(domain, tenant_id),
|
(user_id, domain),
|
||||||
)
|
|
||||||
affected = await cursor.fetchall()
|
|
||||||
for row in affected:
|
|
||||||
ab = row["added_by"] or ""
|
|
||||||
if ab != "system" and ab != username and ab != "":
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Domain enthaelt Quellen anderer Nutzer",
|
|
||||||
)
|
|
||||||
|
|
||||||
cursor = await db.execute(
|
|
||||||
"UPDATE sources SET status = 'inactive' WHERE LOWER(domain) = ? AND source_type != 'excluded' AND tenant_id = ?",
|
|
||||||
(domain, tenant_id),
|
|
||||||
)
|
|
||||||
feeds_deactivated = cursor.rowcount
|
|
||||||
|
|
||||||
cursor = await db.execute(
|
|
||||||
"SELECT id FROM sources WHERE LOWER(domain) = ? AND source_type = 'excluded' AND (tenant_id IS NULL OR tenant_id = ?)",
|
|
||||||
(domain, tenant_id),
|
|
||||||
)
|
)
|
||||||
existing = await cursor.fetchone()
|
existing = await cursor.fetchone()
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
excluded_id = existing["id"]
|
|
||||||
if data.notes:
|
if data.notes:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"UPDATE sources SET notes = ? WHERE id = ?",
|
"UPDATE user_excluded_domains SET notes = ? WHERE id = ?",
|
||||||
(data.notes, excluded_id),
|
(data.notes, existing["id"]),
|
||||||
)
|
)
|
||||||
else:
|
await db.commit()
|
||||||
cursor = await db.execute(
|
return {"domain": domain, "status": "already_excluded"}
|
||||||
"""INSERT INTO sources (name, url, domain, source_type, category, status, notes, added_by, tenant_id)
|
|
||||||
VALUES (?, NULL, ?, 'excluded', 'sonstige', 'active', ?, ?, ?)""",
|
|
||||||
(domain, domain, data.notes, current_user["username"], tenant_id),
|
|
||||||
)
|
|
||||||
excluded_id = cursor.lastrowid
|
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO user_excluded_domains (user_id, domain, notes) VALUES (?, ?, ?)",
|
||||||
|
(user_id, domain, data.notes),
|
||||||
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return {
|
return {"domain": domain, "status": "excluded"}
|
||||||
"domain": domain,
|
|
||||||
"feeds_deactivated": feeds_deactivated,
|
|
||||||
"excluded_id": excluded_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/unblock-domain")
|
@router.post("/unblock-domain")
|
||||||
@@ -351,41 +346,18 @@ async def unblock_domain(
|
|||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
db: aiosqlite.Connection = Depends(db_dependency),
|
db: aiosqlite.Connection = Depends(db_dependency),
|
||||||
):
|
):
|
||||||
"""Domain-Ausschluss aufheben: excluded-Eintrag loeschen + Feeds reaktivieren."""
|
"""Domain-Ausschluss fuer den aktuellen User aufheben."""
|
||||||
tenant_id = current_user.get("tenant_id")
|
user_id = current_user["id"]
|
||||||
domain = data.domain.lower().strip()
|
domain = data.domain.lower().strip()
|
||||||
|
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"SELECT COUNT(*) as cnt FROM sources WHERE LOWER(domain) = ? AND source_type != 'excluded' AND (tenant_id IS NULL OR tenant_id = ?)",
|
"DELETE FROM user_excluded_domains WHERE user_id = ? AND domain = ?",
|
||||||
(domain, tenant_id),
|
(user_id, domain),
|
||||||
)
|
)
|
||||||
row = await cursor.fetchone()
|
removed = cursor.rowcount
|
||||||
has_feeds = row["cnt"] > 0
|
|
||||||
|
|
||||||
if has_feeds:
|
|
||||||
await db.execute(
|
|
||||||
"DELETE FROM sources WHERE LOWER(domain) = ? AND source_type = 'excluded' AND tenant_id = ?",
|
|
||||||
(domain, tenant_id),
|
|
||||||
)
|
|
||||||
cursor = await db.execute(
|
|
||||||
"UPDATE sources SET status = 'active' WHERE LOWER(domain) = ? AND source_type != 'excluded' AND tenant_id = ?",
|
|
||||||
(domain, tenant_id),
|
|
||||||
)
|
|
||||||
feeds_reactivated = cursor.rowcount
|
|
||||||
else:
|
|
||||||
await db.execute(
|
|
||||||
"""UPDATE sources SET source_type = 'web_source', status = 'active', notes = 'Ausschluss aufgehoben'
|
|
||||||
WHERE LOWER(domain) = ? AND source_type = 'excluded' AND (tenant_id IS NULL OR tenant_id = ?)""",
|
|
||||||
(domain, tenant_id),
|
|
||||||
)
|
|
||||||
feeds_reactivated = 0
|
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return {
|
return {"domain": domain, "removed": removed > 0}
|
||||||
"domain": domain,
|
|
||||||
"feeds_reactivated": feeds_reactivated,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/domain/{domain}")
|
@router.delete("/domain/{domain}")
|
||||||
|
|||||||
@@ -661,6 +661,24 @@ async def get_feeds_with_metadata(tenant_id: int = None) -> list[dict]:
|
|||||||
await db.close()
|
await db.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_excluded_domains(user_id: int) -> list[str]:
|
||||||
|
"""Laedt die vom User ausgeschlossenen Domains."""
|
||||||
|
from database import get_db
|
||||||
|
|
||||||
|
db = await get_db()
|
||||||
|
try:
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT domain FROM user_excluded_domains WHERE user_id = ?",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
return [row[0] for row in await cursor.fetchall()]
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Fehler beim Laden der User-Ausschluesse: {e}")
|
||||||
|
return []
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
|
||||||
|
|
||||||
async def get_source_rules(tenant_id: int = None) -> dict:
|
async def get_source_rules(tenant_id: int = None) -> dict:
|
||||||
"""Liest Quellen-Konfiguration aus DB (global + org-spezifisch).
|
"""Liest Quellen-Konfiguration aus DB (global + org-spezifisch).
|
||||||
|
|
||||||
|
|||||||
@@ -3437,6 +3437,16 @@ a:hover {
|
|||||||
gap: var(--sp-xs);
|
gap: var(--sp-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Grundquelle-Badge */
|
||||||
|
.source-global-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--bg-tertiary, #2a2a2a);
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* Ausgeschlossene Domain */
|
/* Ausgeschlossene Domain */
|
||||||
.source-group-header.excluded {
|
.source-group-header.excluded {
|
||||||
grid-template-columns: 1fr auto auto;
|
grid-template-columns: 1fr auto auto;
|
||||||
|
|||||||
@@ -427,7 +427,7 @@
|
|||||||
<option value="">Alle Typen</option>
|
<option value="">Alle Typen</option>
|
||||||
<option value="rss_feed">RSS-Feed</option>
|
<option value="rss_feed">RSS-Feed</option>
|
||||||
<option value="web_source">Web-Quelle</option>
|
<option value="web_source">Web-Quelle</option>
|
||||||
<option value="excluded">Ausgeschlossen</option>
|
<option value="excluded">Von mir ausgeschlossen</option>
|
||||||
</select>
|
</select>
|
||||||
<label for="sources-filter-category" class="sr-only">Kategorie filtern</label>
|
<label for="sources-filter-category" class="sr-only">Kategorie filtern</label>
|
||||||
<select id="sources-filter-category" class="timeline-filter-select" onchange="App.filterSources()">
|
<select id="sources-filter-category" class="timeline-filter-select" onchange="App.filterSources()">
|
||||||
|
|||||||
@@ -152,6 +152,10 @@ const API = {
|
|||||||
return this._request('POST', '/sources/discover-multi', { url });
|
return this._request('POST', '/sources/discover-multi', { url });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getMyExclusions() {
|
||||||
|
return this._request('GET', '/sources/my-exclusions');
|
||||||
|
},
|
||||||
|
|
||||||
blockDomain(domain, notes) {
|
blockDomain(domain, notes) {
|
||||||
return this._request('POST', '/sources/block-domain', { domain, notes });
|
return this._request('POST', '/sources/block-domain', { domain, notes });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -425,7 +425,7 @@ const App = {
|
|||||||
_currentUsername: '',
|
_currentUsername: '',
|
||||||
_allSources: [],
|
_allSources: [],
|
||||||
_sourcesOnly: [],
|
_sourcesOnly: [],
|
||||||
_blacklistOnly: [],
|
_myExclusions: [], // [{domain, notes, created_at}]
|
||||||
_expandedGroups: new Set(),
|
_expandedGroups: new Set(),
|
||||||
_editingSourceId: null,
|
_editingSourceId: null,
|
||||||
_timelineFilter: 'all',
|
_timelineFilter: 'all',
|
||||||
@@ -2173,13 +2173,14 @@ const App = {
|
|||||||
|
|
||||||
async loadSources() {
|
async loadSources() {
|
||||||
try {
|
try {
|
||||||
const [sources, stats] = await Promise.all([
|
const [sources, stats, myExclusions] = await Promise.all([
|
||||||
API.listSources(),
|
API.listSources(),
|
||||||
API.getSourceStats(),
|
API.getSourceStats(),
|
||||||
|
API.getMyExclusions(),
|
||||||
]);
|
]);
|
||||||
this._allSources = sources;
|
this._allSources = sources;
|
||||||
this._sourcesOnly = sources.filter(s => s.source_type !== 'excluded');
|
this._sourcesOnly = sources.filter(s => s.source_type !== 'excluded');
|
||||||
this._blacklistOnly = sources.filter(s => s.source_type === 'excluded');
|
this._myExclusions = myExclusions || [];
|
||||||
|
|
||||||
this.renderSourceStats(stats);
|
this.renderSourceStats(stats);
|
||||||
this.renderSourceList();
|
this.renderSourceList();
|
||||||
@@ -2194,7 +2195,7 @@ const App = {
|
|||||||
|
|
||||||
const rss = stats.by_type.rss_feed || { count: 0, articles: 0 };
|
const rss = stats.by_type.rss_feed || { count: 0, articles: 0 };
|
||||||
const web = stats.by_type.web_source || { count: 0, articles: 0 };
|
const web = stats.by_type.web_source || { count: 0, articles: 0 };
|
||||||
const excluded = this._blacklistOnly.length;
|
const excluded = this._myExclusions.length;
|
||||||
|
|
||||||
bar.innerHTML = `
|
bar.innerHTML = `
|
||||||
<span class="sources-stat-item"><span class="sources-stat-value">${rss.count}</span> RSS-Feeds</span>
|
<span class="sources-stat-item"><span class="sources-stat-value">${rss.count}</span> RSS-Feeds</span>
|
||||||
@@ -2221,12 +2222,12 @@ const App = {
|
|||||||
const excludedDomains = new Set();
|
const excludedDomains = new Set();
|
||||||
const excludedNotes = {};
|
const excludedNotes = {};
|
||||||
|
|
||||||
// Blacklist-Domains sammeln
|
// User-Ausschlüsse sammeln
|
||||||
this._blacklistOnly.forEach(s => {
|
this._myExclusions.forEach(e => {
|
||||||
const domain = (s.domain || s.name || '').toLowerCase();
|
const domain = (e.domain || '').toLowerCase();
|
||||||
if (domain) {
|
if (domain) {
|
||||||
excludedDomains.add(domain);
|
excludedDomains.add(domain);
|
||||||
excludedNotes[domain] = s.notes || '';
|
excludedNotes[domain] = e.notes || '';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2238,10 +2239,10 @@ const App = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Ausgeschlossene Domains die keine Feeds haben auch als Gruppe
|
// Ausgeschlossene Domains die keine Feeds haben auch als Gruppe
|
||||||
this._blacklistOnly.forEach(s => {
|
this._myExclusions.forEach(e => {
|
||||||
const domain = (s.domain || s.name || '').toLowerCase();
|
const domain = (e.domain || '').toLowerCase();
|
||||||
if (domain && !groups.has(domain)) {
|
if (domain && !groups.has(domain)) {
|
||||||
groups.set(domain, [s]);
|
groups.set(domain, []);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2249,6 +2250,7 @@ const App = {
|
|||||||
let filteredGroups = [];
|
let filteredGroups = [];
|
||||||
for (const [domain, feeds] of groups) {
|
for (const [domain, feeds] of groups) {
|
||||||
const isExcluded = excludedDomains.has(domain);
|
const isExcluded = excludedDomains.has(domain);
|
||||||
|
const isGlobal = feeds.some(f => f.is_global);
|
||||||
|
|
||||||
// Typ-Filter
|
// Typ-Filter
|
||||||
if (typeFilter === 'excluded' && !isExcluded) continue;
|
if (typeFilter === 'excluded' && !isExcluded) continue;
|
||||||
@@ -2271,7 +2273,7 @@ const App = {
|
|||||||
if (!groupText.includes(search)) continue;
|
if (!groupText.includes(search)) continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
filteredGroups.push({ domain, feeds, isExcluded });
|
filteredGroups.push({ domain, feeds, isExcluded, isGlobal });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filteredGroups.length === 0) {
|
if (filteredGroups.length === 0) {
|
||||||
@@ -2286,7 +2288,7 @@ const App = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
list.innerHTML = filteredGroups.map(g =>
|
list.innerHTML = filteredGroups.map(g =>
|
||||||
UI.renderSourceGroup(g.domain, g.feeds, g.isExcluded, excludedNotes[g.domain] || '')
|
UI.renderSourceGroup(g.domain, g.feeds, g.isExcluded, excludedNotes[g.domain] || '', g.isGlobal)
|
||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
// Erweiterte Gruppen wiederherstellen
|
// Erweiterte Gruppen wiederherstellen
|
||||||
@@ -2440,11 +2442,11 @@ const App = {
|
|||||||
* Domain direkt ausschließen (aus der Gruppenliste).
|
* Domain direkt ausschließen (aus der Gruppenliste).
|
||||||
*/
|
*/
|
||||||
async blockDomainDirect(domain) {
|
async blockDomainDirect(domain) {
|
||||||
if (!await confirmDialog(`"${domain}" wirklich ausschließen? Alle Feeds dieser Domain werden deaktiviert.`)) return;
|
if (!await confirmDialog(`"${domain}" wirklich ausschließen? Artikel dieser Domain werden bei deinen Recherchen ignoriert.`)) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await API.blockDomain(domain);
|
await API.blockDomain(domain);
|
||||||
UI.showToast(`${domain} gesperrt.`, 'success');
|
UI.showToast(`${domain} ausgeschlossen.`, 'success');
|
||||||
await this.loadSources();
|
await this.loadSources();
|
||||||
this.updateSidebarStats();
|
this.updateSidebarStats();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -2560,7 +2562,7 @@ const App = {
|
|||||||
|
|
||||||
// Prüfen ob Domain ausgeschlossen ist
|
// Prüfen ob Domain ausgeschlossen ist
|
||||||
const inputDomain = url.replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0].toLowerCase();
|
const inputDomain = url.replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0].toLowerCase();
|
||||||
const isBlocked = inputDomain && this._blacklistOnly.some(s => (s.domain || '').toLowerCase() === inputDomain);
|
const isBlocked = inputDomain && this._myExclusions.some(e => (e.domain || '').toLowerCase() === inputDomain);
|
||||||
|
|
||||||
if (isBlocked) {
|
if (isBlocked) {
|
||||||
if (!await confirmDialog(`"${inputDomain}" ist ausgeschlossen. Trotzdem hinzufügen? Der Ausschluss wird dabei aufgehoben.`)) return;
|
if (!await confirmDialog(`"${inputDomain}" ist ausgeschlossen. Trotzdem hinzufügen? Der Ausschluss wird dabei aufgehoben.`)) return;
|
||||||
|
|||||||
@@ -529,7 +529,7 @@ const UI = {
|
|||||||
/**
|
/**
|
||||||
* Domain-Gruppe rendern (aufklappbar mit Feeds).
|
* Domain-Gruppe rendern (aufklappbar mit Feeds).
|
||||||
*/
|
*/
|
||||||
renderSourceGroup(domain, feeds, isExcluded, excludedNotes) {
|
renderSourceGroup(domain, feeds, isExcluded, excludedNotes, isGlobal) {
|
||||||
const catLabel = this._categoryLabels[feeds[0]?.category] || feeds[0]?.category || '';
|
const catLabel = this._categoryLabels[feeds[0]?.category] || feeds[0]?.category || '';
|
||||||
const feedCount = feeds.filter(f => f.source_type !== 'excluded').length;
|
const feedCount = feeds.filter(f => f.source_type !== 'excluded').length;
|
||||||
const hasMultiple = feedCount > 1;
|
const hasMultiple = feedCount > 1;
|
||||||
@@ -547,7 +547,6 @@ const UI = {
|
|||||||
<span class="source-excluded-badge">Ausgeschlossen</span>
|
<span class="source-excluded-badge">Ausgeschlossen</span>
|
||||||
<div class="source-group-actions">
|
<div class="source-group-actions">
|
||||||
<button class="btn btn-small btn-secondary" onclick="App.unblockDomain('${escapedDomain}')">Ausschluss aufheben</button>
|
<button class="btn btn-small btn-secondary" onclick="App.unblockDomain('${escapedDomain}')">Ausschluss aufheben</button>
|
||||||
<button class="source-delete-btn" onclick="App.deleteDomain('${escapedDomain}')" title="Löschen" aria-label="Löschen">×</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -571,8 +570,8 @@ const UI = {
|
|||||||
<span class="source-feed-name">${this.escape(feed.name)}</span>
|
<span class="source-feed-name">${this.escape(feed.name)}</span>
|
||||||
<span class="source-type-badge type-${feed.source_type}">${typeLabel}</span>
|
<span class="source-type-badge type-${feed.source_type}">${typeLabel}</span>
|
||||||
<span class="source-feed-url" title="${this.escape(feed.url || '')}">${this.escape(urlDisplay)}</span>
|
<span class="source-feed-url" title="${this.escape(feed.url || '')}">${this.escape(urlDisplay)}</span>
|
||||||
<button class="source-edit-btn" onclick="App.editSource(${feed.id})" title="Bearbeiten" aria-label="Bearbeiten">✎</button>
|
${!feed.is_global ? `<button class="source-edit-btn" onclick="App.editSource(${feed.id})" title="Bearbeiten" aria-label="Bearbeiten">✎</button>
|
||||||
<button class="source-delete-btn" onclick="App.deleteSingleFeed(${feed.id})" title="Löschen" aria-label="Löschen">×</button>
|
<button class="source-delete-btn" onclick="App.deleteSingleFeed(${feed.id})" title="Löschen" aria-label="Löschen">×</button>` : '<span class="source-global-badge">Grundquelle</span>'}
|
||||||
</div>`;
|
</div>`;
|
||||||
});
|
});
|
||||||
feedRows += '</div>';
|
feedRows += '</div>';
|
||||||
@@ -591,9 +590,9 @@ const UI = {
|
|||||||
<span class="source-category-badge cat-${feeds[0]?.category || 'sonstige'}">${catLabel}</span>
|
<span class="source-category-badge cat-${feeds[0]?.category || 'sonstige'}">${catLabel}</span>
|
||||||
${feedCountBadge}
|
${feedCountBadge}
|
||||||
<div class="source-group-actions" onclick="event.stopPropagation()">
|
<div class="source-group-actions" onclick="event.stopPropagation()">
|
||||||
${!hasMultiple && feeds[0]?.id ? `<button class="source-edit-btn" onclick="App.editSource(${feeds[0].id})" title="Bearbeiten" aria-label="Bearbeiten">✎</button>` : ''}
|
${!isGlobal && !hasMultiple && feeds[0]?.id ? `<button class="source-edit-btn" onclick="App.editSource(${feeds[0].id})" title="Bearbeiten" aria-label="Bearbeiten">✎</button>` : ''}
|
||||||
<button class="btn btn-small btn-secondary" onclick="App.blockDomainDirect('${escapedDomain}')">Ausschließen</button>
|
<button class="btn btn-small btn-secondary" onclick="App.blockDomainDirect('${escapedDomain}')">Ausschließen</button>
|
||||||
<button class="source-delete-btn" onclick="App.deleteDomain('${escapedDomain}')" title="Löschen" aria-label="Löschen">×</button>
|
${!isGlobal ? `<button class="source-delete-btn" onclick="App.deleteDomain('${escapedDomain}')" title="Löschen" aria-label="Löschen">×</button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${feedRows}
|
${feedRows}
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren