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:
claude-dev
2026-03-08 14:30:21 +01:00
Ursprung 18954cf70e
Commit 5e19736a25
13 geänderte Dateien mit 149 neuen und 108 gelöschten Zeilen

Datei anzeigen

@@ -332,7 +332,7 @@ class AgentOrchestrator:
self._running = False
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."""
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")
@@ -341,7 +341,7 @@ class AgentOrchestrator:
visibility, created_by, tenant_id = await self._get_incident_visibility(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()
logger.info(f"Refresh fuer Lage {incident_id} eingereiht (Queue: {queue_size}, Trigger: {trigger_type})")
@@ -386,7 +386,11 @@ class AgentOrchestrator:
except asyncio.TimeoutError:
continue
if len(item) == 3:
incident_id, trigger_type, user_id = item
else:
incident_id, trigger_type = item
user_id = None
self._queued_ids.discard(incident_id)
self._current_task = incident_id
logger.info(f"Starte Refresh für Lage {incident_id} (Trigger: {trigger_type})")
@@ -398,7 +402,7 @@ class AgentOrchestrator:
try:
for attempt in range(3):
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
break # Erfolg
except asyncio.CancelledError:
@@ -509,7 +513,7 @@ class AgentOrchestrator:
await db.close()
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."""
import aiosqlite
from database import get_db
@@ -604,7 +608,7 @@ class AgentOrchestrator:
keywords = feed_sel_keywords
articles = await rss_parser.search_feeds_selective(title, selected_feeds, keywords=keywords)
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})")
return articles, feed_usage
@@ -612,7 +616,7 @@ class AgentOrchestrator:
async def _web_search_pipeline():
"""Claude WebSearch-Recherche."""
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")
return results, usage

Datei anzeigen

@@ -269,7 +269,7 @@ class ResearcherAgent:
logger.warning(f"Keyword-Extraktion fehlgeschlagen: {e}")
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."""
from config import OUTPUT_LANGUAGE
if incident_type == "research":
@@ -290,7 +290,7 @@ class ResearcherAgent:
articles = self._parse_response(result)
# 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
filtered = []
@@ -317,14 +317,23 @@ class ResearcherAgent:
logger.error(f"Recherche-Fehler: {e}")
return [], None
async def _get_excluded_sources(self) -> list[str]:
"""Lädt ausgeschlossene Quellen aus der Datenbank."""
async def _get_excluded_sources(self, user_id: int = None) -> list[str]:
"""Laedt ausgeschlossene Quellen (global + per-User)."""
try:
from source_rules import get_source_rules
from source_rules import get_source_rules, get_user_excluded_domains
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:
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
return list(EXCLUDED_SOURCES)

Datei anzeigen

@@ -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_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)
);
"""

Datei anzeigen

@@ -26,7 +26,7 @@ class RSSParser:
cleaned = [w for w in words if not w.isdigit()]
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.
Args:
@@ -50,6 +50,19 @@ class RSSParser:
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
if international:
categories = rss_feeds.keys()

Datei anzeigen

@@ -124,6 +124,7 @@ class SourceResponse(BaseModel):
article_count: int = 0
last_seen_at: Optional[str] = None
created_at: str
is_global: bool = False
# Source Discovery

Datei anzeigen

@@ -550,7 +550,7 @@ async def trigger_refresh(
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
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:
return {"status": "skipped", "incident_id": incident_id}

Datei anzeigen

@@ -57,7 +57,12 @@ async def list_sources(
query += " ORDER BY source_type, category, name"
cursor = await db.execute(query, params)
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")
@@ -285,64 +290,54 @@ async def rediscover_existing_endpoint(
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")
async def block_domain(
data: DomainActionRequest,
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Domain ausschließen: Alle Feeds deaktivieren + excluded-Eintrag anlegen."""
tenant_id = current_user.get("tenant_id")
"""Domain fuer den aktuellen User ausschließen (per-User, nicht org-weit)."""
user_id = current_user["id"]
domain = data.domain.lower().strip()
username = current_user["username"]
# Pruefen ob bereits ausgeschlossen
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 = ?)",
(domain, tenant_id),
)
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),
"SELECT id FROM user_excluded_domains WHERE user_id = ? AND domain = ?",
(user_id, domain),
)
existing = await cursor.fetchone()
if existing:
excluded_id = existing["id"]
if data.notes:
await db.execute(
"UPDATE sources SET notes = ? WHERE id = ?",
(data.notes, excluded_id),
"UPDATE user_excluded_domains SET notes = ? WHERE id = ?",
(data.notes, existing["id"]),
)
else:
cursor = await db.execute(
"""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.commit()
return {"domain": domain, "status": "already_excluded"}
await db.execute(
"INSERT INTO user_excluded_domains (user_id, domain, notes) VALUES (?, ?, ?)",
(user_id, domain, data.notes),
)
await db.commit()
return {
"domain": domain,
"feeds_deactivated": feeds_deactivated,
"excluded_id": excluded_id,
}
return {"domain": domain, "status": "excluded"}
@router.post("/unblock-domain")
@@ -351,41 +346,18 @@ async def unblock_domain(
current_user: dict = Depends(get_current_user),
db: aiosqlite.Connection = Depends(db_dependency),
):
"""Domain-Ausschluss aufheben: excluded-Eintrag loeschen + Feeds reaktivieren."""
tenant_id = current_user.get("tenant_id")
"""Domain-Ausschluss fuer den aktuellen User aufheben."""
user_id = current_user["id"]
domain = data.domain.lower().strip()
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 = ?)",
(domain, tenant_id),
"DELETE FROM user_excluded_domains WHERE user_id = ? AND domain = ?",
(user_id, domain),
)
row = await cursor.fetchone()
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
removed = cursor.rowcount
await db.commit()
return {
"domain": domain,
"feeds_reactivated": feeds_reactivated,
}
return {"domain": domain, "removed": removed > 0}
@router.delete("/domain/{domain}")

Datei anzeigen

@@ -661,6 +661,24 @@ async def get_feeds_with_metadata(tenant_id: int = None) -> list[dict]:
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:
"""Liest Quellen-Konfiguration aus DB (global + org-spezifisch).

Datei anzeigen

@@ -3437,6 +3437,16 @@ a:hover {
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 */
.source-group-header.excluded {
grid-template-columns: 1fr auto auto;

Datei anzeigen

@@ -427,7 +427,7 @@
<option value="">Alle Typen</option>
<option value="rss_feed">RSS-Feed</option>
<option value="web_source">Web-Quelle</option>
<option value="excluded">Ausgeschlossen</option>
<option value="excluded">Von mir ausgeschlossen</option>
</select>
<label for="sources-filter-category" class="sr-only">Kategorie filtern</label>
<select id="sources-filter-category" class="timeline-filter-select" onchange="App.filterSources()">

Datei anzeigen

@@ -152,6 +152,10 @@ const API = {
return this._request('POST', '/sources/discover-multi', { url });
},
getMyExclusions() {
return this._request('GET', '/sources/my-exclusions');
},
blockDomain(domain, notes) {
return this._request('POST', '/sources/block-domain', { domain, notes });
},

Datei anzeigen

@@ -425,7 +425,7 @@ const App = {
_currentUsername: '',
_allSources: [],
_sourcesOnly: [],
_blacklistOnly: [],
_myExclusions: [], // [{domain, notes, created_at}]
_expandedGroups: new Set(),
_editingSourceId: null,
_timelineFilter: 'all',
@@ -2173,13 +2173,14 @@ const App = {
async loadSources() {
try {
const [sources, stats] = await Promise.all([
const [sources, stats, myExclusions] = await Promise.all([
API.listSources(),
API.getSourceStats(),
API.getMyExclusions(),
]);
this._allSources = sources;
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.renderSourceList();
@@ -2194,7 +2195,7 @@ const App = {
const rss = stats.by_type.rss_feed || { 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 = `
<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 excludedNotes = {};
// Blacklist-Domains sammeln
this._blacklistOnly.forEach(s => {
const domain = (s.domain || s.name || '').toLowerCase();
// User-Ausschlüsse sammeln
this._myExclusions.forEach(e => {
const domain = (e.domain || '').toLowerCase();
if (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
this._blacklistOnly.forEach(s => {
const domain = (s.domain || s.name || '').toLowerCase();
this._myExclusions.forEach(e => {
const domain = (e.domain || '').toLowerCase();
if (domain && !groups.has(domain)) {
groups.set(domain, [s]);
groups.set(domain, []);
}
});
@@ -2249,6 +2250,7 @@ const App = {
let filteredGroups = [];
for (const [domain, feeds] of groups) {
const isExcluded = excludedDomains.has(domain);
const isGlobal = feeds.some(f => f.is_global);
// Typ-Filter
if (typeFilter === 'excluded' && !isExcluded) continue;
@@ -2271,7 +2273,7 @@ const App = {
if (!groupText.includes(search)) continue;
}
filteredGroups.push({ domain, feeds, isExcluded });
filteredGroups.push({ domain, feeds, isExcluded, isGlobal });
}
if (filteredGroups.length === 0) {
@@ -2286,7 +2288,7 @@ const App = {
});
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('');
// Erweiterte Gruppen wiederherstellen
@@ -2440,11 +2442,11 @@ const App = {
* Domain direkt ausschließen (aus der Gruppenliste).
*/
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 {
await API.blockDomain(domain);
UI.showToast(`${domain} gesperrt.`, 'success');
UI.showToast(`${domain} ausgeschlossen.`, 'success');
await this.loadSources();
this.updateSidebarStats();
} catch (err) {
@@ -2560,7 +2562,7 @@ const App = {
// Prüfen ob Domain ausgeschlossen ist
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 (!await confirmDialog(`"${inputDomain}" ist ausgeschlossen. Trotzdem hinzufügen? Der Ausschluss wird dabei aufgehoben.`)) return;

Datei anzeigen

@@ -529,7 +529,7 @@ const UI = {
/**
* 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 feedCount = feeds.filter(f => f.source_type !== 'excluded').length;
const hasMultiple = feedCount > 1;
@@ -547,7 +547,6 @@ const UI = {
<span class="source-excluded-badge">Ausgeschlossen</span>
<div class="source-group-actions">
<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">&times;</button>
</div>
</div>
</div>`;
@@ -571,8 +570,8 @@ const UI = {
<span class="source-feed-name">${this.escape(feed.name)}</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>
<button class="source-edit-btn" onclick="App.editSource(${feed.id})" title="Bearbeiten" aria-label="Bearbeiten">&#9998;</button>
<button class="source-delete-btn" onclick="App.deleteSingleFeed(${feed.id})" title="Löschen" aria-label="Löschen">&times;</button>
${!feed.is_global ? `<button class="source-edit-btn" onclick="App.editSource(${feed.id})" title="Bearbeiten" aria-label="Bearbeiten">&#9998;</button>
<button class="source-delete-btn" onclick="App.deleteSingleFeed(${feed.id})" title="Löschen" aria-label="Löschen">&times;</button>` : '<span class="source-global-badge">Grundquelle</span>'}
</div>`;
});
feedRows += '</div>';
@@ -591,9 +590,9 @@ const UI = {
<span class="source-category-badge cat-${feeds[0]?.category || 'sonstige'}">${catLabel}</span>
${feedCountBadge}
<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">&#9998;</button>` : ''}
${!isGlobal && !hasMultiple && feeds[0]?.id ? `<button class="source-edit-btn" onclick="App.editSource(${feeds[0].id})" title="Bearbeiten" aria-label="Bearbeiten">&#9998;</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">&times;</button>
${!isGlobal ? `<button class="source-delete-btn" onclick="App.deleteDomain('${escapedDomain}')" title="Löschen" aria-label="Löschen">&times;</button>` : ''}
</div>
</div>
${feedRows}