Initial commit: AegisSight-Monitor (OSINT-Monitoringsystem)
Dieser Commit ist enthalten in:
276
src/routers/auth.py
Normale Datei
276
src/routers/auth.py
Normale Datei
@@ -0,0 +1,276 @@
|
||||
"""Auth-Router: Magic-Link-Login und Nutzerverwaltung."""
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from models import (
|
||||
MagicLinkRequest,
|
||||
MagicLinkResponse,
|
||||
VerifyTokenRequest,
|
||||
VerifyCodeRequest,
|
||||
TokenResponse,
|
||||
UserMeResponse,
|
||||
)
|
||||
from auth import (
|
||||
create_token,
|
||||
get_current_user,
|
||||
generate_magic_token,
|
||||
generate_magic_code,
|
||||
verify_password,
|
||||
)
|
||||
from database import db_dependency
|
||||
from config import TIMEZONE, MAGIC_LINK_EXPIRE_MINUTES, MAGIC_LINK_BASE_URL
|
||||
from email_utils.sender import send_email
|
||||
from email_utils.templates import magic_link_login_email
|
||||
from email_utils.rate_limiter import magic_link_limiter, verify_code_limiter
|
||||
import aiosqlite
|
||||
|
||||
logger = logging.getLogger("osint.auth")
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/magic-link", response_model=MagicLinkResponse)
|
||||
async def request_magic_link(
|
||||
data: MagicLinkRequest,
|
||||
request: Request,
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Magic Link anfordern. Sendet E-Mail mit Link + Code."""
|
||||
email = data.email.lower().strip()
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
|
||||
# Rate-Limit pruefen
|
||||
allowed, reason = magic_link_limiter.check(email, ip)
|
||||
if not allowed:
|
||||
logger.warning(f"Rate-Limit fuer {email} von {ip}: {reason}")
|
||||
# Trotzdem 200 zurueckgeben (kein Information-Leak)
|
||||
return MagicLinkResponse(message="Wenn ein Konto existiert, wurde eine E-Mail gesendet.")
|
||||
|
||||
# Nutzer suchen
|
||||
cursor = await db.execute(
|
||||
"""SELECT u.id, u.email, u.username, u.role, u.organization_id, u.is_active,
|
||||
o.is_active as org_active, o.slug as org_slug
|
||||
FROM users u
|
||||
JOIN organizations o ON o.id = u.organization_id
|
||||
WHERE LOWER(u.email) = ?""",
|
||||
(email,),
|
||||
)
|
||||
user = await cursor.fetchone()
|
||||
|
||||
if not user:
|
||||
magic_link_limiter.record(email, ip)
|
||||
return MagicLinkResponse(message="Wenn ein Konto existiert, wurde eine E-Mail gesendet.")
|
||||
|
||||
if not user["is_active"]:
|
||||
magic_link_limiter.record(email, ip)
|
||||
return MagicLinkResponse(message="Wenn ein Konto existiert, wurde eine E-Mail gesendet.")
|
||||
|
||||
if not user["org_active"]:
|
||||
magic_link_limiter.record(email, ip)
|
||||
return MagicLinkResponse(message="Wenn ein Konto existiert, wurde eine E-Mail gesendet.")
|
||||
|
||||
# Lizenz pruefen
|
||||
from services.license_service import check_license
|
||||
lic = await check_license(db, user["organization_id"])
|
||||
if lic.get("status") == "org_disabled":
|
||||
magic_link_limiter.record(email, ip)
|
||||
return MagicLinkResponse(message="Wenn ein Konto existiert, wurde eine E-Mail gesendet.")
|
||||
|
||||
# Token + Code generieren
|
||||
token = generate_magic_token()
|
||||
code = generate_magic_code()
|
||||
expires_at = (datetime.now(timezone.utc) + timedelta(minutes=MAGIC_LINK_EXPIRE_MINUTES)).strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
# Alte ungenutzte Magic Links fuer diese E-Mail invalidieren
|
||||
await db.execute(
|
||||
"UPDATE magic_links SET is_used = 1 WHERE email = ? AND is_used = 0",
|
||||
(email,),
|
||||
)
|
||||
|
||||
# Neuen Magic Link speichern
|
||||
await db.execute(
|
||||
"""INSERT INTO magic_links (email, token, code, purpose, user_id, expires_at, ip_address)
|
||||
VALUES (?, ?, ?, 'login', ?, ?, ?)""",
|
||||
(email, token, code, user["id"], expires_at, ip),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
# E-Mail senden
|
||||
link = f"{MAGIC_LINK_BASE_URL}/auth/verify?token={token}"
|
||||
subject, html = magic_link_login_email(user["username"], code, link)
|
||||
await send_email(email, subject, html)
|
||||
|
||||
magic_link_limiter.record(email, ip)
|
||||
return MagicLinkResponse(message="Wenn ein Konto existiert, wurde eine E-Mail gesendet.")
|
||||
|
||||
|
||||
@router.post("/verify", response_model=TokenResponse)
|
||||
async def verify_magic_link(
|
||||
data: VerifyTokenRequest,
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Magic Link verifizieren (Token aus URL)."""
|
||||
cursor = await db.execute(
|
||||
"""SELECT ml.*, u.username, u.email, u.role, u.organization_id, u.is_active,
|
||||
o.slug as org_slug, o.is_active as org_active
|
||||
FROM magic_links ml
|
||||
JOIN users u ON u.id = ml.user_id
|
||||
JOIN organizations o ON o.id = u.organization_id
|
||||
WHERE ml.token = ? AND ml.is_used = 0""",
|
||||
(data.token,),
|
||||
)
|
||||
ml = await cursor.fetchone()
|
||||
|
||||
if not ml:
|
||||
raise HTTPException(status_code=400, detail="Ungueltiger oder bereits verwendeter Link")
|
||||
|
||||
# Ablauf pruefen
|
||||
now = datetime.now(timezone.utc)
|
||||
expires = datetime.fromisoformat(ml["expires_at"])
|
||||
if expires.tzinfo is None:
|
||||
expires = expires.replace(tzinfo=timezone.utc)
|
||||
if now > expires:
|
||||
raise HTTPException(status_code=400, detail="Link abgelaufen. Bitte neuen Link anfordern.")
|
||||
|
||||
if not ml["is_active"] or not ml["org_active"]:
|
||||
raise HTTPException(status_code=403, detail="Konto oder Organisation deaktiviert")
|
||||
|
||||
# Magic Link als verwendet markieren
|
||||
await db.execute("UPDATE magic_links SET is_used = 1 WHERE id = ?", (ml["id"],))
|
||||
|
||||
# Letzten Login aktualisieren
|
||||
await db.execute(
|
||||
"UPDATE users SET last_login_at = ? WHERE id = ?",
|
||||
(now.isoformat(), ml["user_id"]),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
# JWT erstellen
|
||||
token = create_token(
|
||||
user_id=ml["user_id"],
|
||||
username=ml["username"],
|
||||
email=ml["email"],
|
||||
role=ml["role"],
|
||||
tenant_id=ml["organization_id"],
|
||||
org_slug=ml["org_slug"],
|
||||
)
|
||||
|
||||
return TokenResponse(
|
||||
access_token=token,
|
||||
username=ml["username"],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/verify-code", response_model=TokenResponse)
|
||||
async def verify_magic_code(
|
||||
data: VerifyCodeRequest,
|
||||
request: Request,
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Magic Code verifizieren (6-stelliger Code + E-Mail)."""
|
||||
email = data.email.lower().strip()
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
|
||||
# Brute-Force-Schutz: Fehlversuche pruefen
|
||||
allowed, reason = verify_code_limiter.check(email, ip)
|
||||
if not allowed:
|
||||
logger.warning(f"Verify-Code Rate-Limit fuer {email} von {ip}: {reason}")
|
||||
# Bei Sperre alle offenen Magic Links fuer diese E-Mail invalidieren
|
||||
await db.execute(
|
||||
"UPDATE magic_links SET is_used = 1 WHERE email = ? AND is_used = 0",
|
||||
(email,),
|
||||
)
|
||||
await db.commit()
|
||||
raise HTTPException(status_code=429, detail=reason)
|
||||
|
||||
cursor = await db.execute(
|
||||
"""SELECT ml.*, u.username, u.email as user_email, u.role, u.organization_id, u.is_active,
|
||||
o.slug as org_slug, o.is_active as org_active
|
||||
FROM magic_links ml
|
||||
JOIN users u ON u.id = ml.user_id
|
||||
JOIN organizations o ON o.id = u.organization_id
|
||||
WHERE LOWER(ml.email) = ? AND ml.code = ? AND ml.is_used = 0
|
||||
ORDER BY ml.created_at DESC LIMIT 1""",
|
||||
(email, data.code),
|
||||
)
|
||||
ml = await cursor.fetchone()
|
||||
|
||||
if not ml:
|
||||
verify_code_limiter.record_failure(email, ip)
|
||||
logger.warning(f"Fehlgeschlagener Code-Versuch fuer {email} von {ip}")
|
||||
raise HTTPException(status_code=400, detail="Ungueltiger Code")
|
||||
|
||||
# Ablauf pruefen
|
||||
now = datetime.now(timezone.utc)
|
||||
expires = datetime.fromisoformat(ml["expires_at"])
|
||||
if expires.tzinfo is None:
|
||||
expires = expires.replace(tzinfo=timezone.utc)
|
||||
if now > expires:
|
||||
raise HTTPException(status_code=400, detail="Code abgelaufen. Bitte neuen Code anfordern.")
|
||||
|
||||
if not ml["is_active"] or not ml["org_active"]:
|
||||
raise HTTPException(status_code=403, detail="Konto oder Organisation deaktiviert")
|
||||
|
||||
# Magic Link als verwendet markieren
|
||||
await db.execute("UPDATE magic_links SET is_used = 1 WHERE id = ?", (ml["id"],))
|
||||
|
||||
# Letzten Login aktualisieren
|
||||
await db.execute(
|
||||
"UPDATE users SET last_login_at = ? WHERE id = ?",
|
||||
(now.isoformat(), ml["user_id"]),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
# Fehlversuche-Zaehler nach Erfolg zuruecksetzen
|
||||
verify_code_limiter.clear(email)
|
||||
|
||||
token = create_token(
|
||||
user_id=ml["user_id"],
|
||||
username=ml["username"],
|
||||
email=ml["user_email"],
|
||||
role=ml["role"],
|
||||
tenant_id=ml["organization_id"],
|
||||
org_slug=ml["org_slug"],
|
||||
)
|
||||
|
||||
return TokenResponse(
|
||||
access_token=token,
|
||||
username=ml["username"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserMeResponse)
|
||||
async def get_me(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Aktuellen Nutzer mit Org-Info abfragen."""
|
||||
# Org-Name laden
|
||||
org_name = ""
|
||||
if current_user.get("tenant_id"):
|
||||
cursor = await db.execute(
|
||||
"SELECT name FROM organizations WHERE id = ?",
|
||||
(current_user["tenant_id"],),
|
||||
)
|
||||
org = await cursor.fetchone()
|
||||
if org:
|
||||
org_name = org["name"]
|
||||
|
||||
# Lizenzstatus laden
|
||||
license_info = {}
|
||||
if current_user.get("tenant_id"):
|
||||
from services.license_service import check_license
|
||||
license_info = await check_license(db, current_user["tenant_id"])
|
||||
|
||||
return UserMeResponse(
|
||||
id=current_user["id"],
|
||||
username=current_user["username"],
|
||||
email=current_user.get("email", ""),
|
||||
role=current_user["role"],
|
||||
org_name=org_name,
|
||||
org_slug=current_user.get("org_slug", ""),
|
||||
tenant_id=current_user.get("tenant_id"),
|
||||
license_status=license_info.get("status", "unknown"),
|
||||
license_type=license_info.get("license_type", ""),
|
||||
read_only=license_info.get("read_only", False),
|
||||
)
|
||||
115
src/routers/feedback.py
Normale Datei
115
src/routers/feedback.py
Normale Datei
@@ -0,0 +1,115 @@
|
||||
"""Feedback-Router: Nutzer-Feedback per E-Mail an das Team."""
|
||||
import html
|
||||
import time
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
import aiosmtplib
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from auth import get_current_user
|
||||
from models import FeedbackRequest
|
||||
from config import (
|
||||
SMTP_HOST,
|
||||
SMTP_PORT,
|
||||
SMTP_USER,
|
||||
SMTP_PASSWORD,
|
||||
SMTP_FROM_EMAIL,
|
||||
SMTP_FROM_NAME,
|
||||
SMTP_USE_TLS,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("osint.feedback")
|
||||
router = APIRouter(prefix="/api", tags=["feedback"])
|
||||
|
||||
FEEDBACK_EMAIL = "feedback@aegis-sight.de"
|
||||
CATEGORY_LABELS = {
|
||||
"bug": "Fehlerbericht",
|
||||
"feature": "Feature-Wunsch",
|
||||
"question": "Frage",
|
||||
"other": "Sonstiges",
|
||||
}
|
||||
|
||||
# In-Memory Rate-Limiting: max 3 pro Nutzer/Stunde
|
||||
_user_timestamps: dict[int, list[float]] = defaultdict(list)
|
||||
_MAX_PER_HOUR = 3
|
||||
_WINDOW = 3600
|
||||
|
||||
|
||||
@router.post("/feedback", status_code=204)
|
||||
async def send_feedback(
|
||||
data: FeedbackRequest,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Feedback per E-Mail an das Team senden."""
|
||||
user_id = current_user["id"]
|
||||
|
||||
# Rate-Limiting
|
||||
now = time.time()
|
||||
cutoff = now - _WINDOW
|
||||
_user_timestamps[user_id] = [t for t in _user_timestamps[user_id] if t > cutoff]
|
||||
if len(_user_timestamps[user_id]) >= _MAX_PER_HOUR:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Maximal 3 Feedback-Nachrichten pro Stunde. Bitte spaeter erneut versuchen.",
|
||||
)
|
||||
|
||||
if not SMTP_HOST:
|
||||
logger.warning("SMTP nicht konfiguriert - Feedback nicht gesendet")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="E-Mail-Versand nicht verfuegbar.",
|
||||
)
|
||||
|
||||
username = current_user["username"]
|
||||
email = current_user.get("email", "")
|
||||
category_label = CATEGORY_LABELS.get(data.category, data.category)
|
||||
message_escaped = html.escape(data.message)
|
||||
|
||||
subject = f"[AegisSight Feedback] {category_label} von {username}"
|
||||
html_body = f"""\
|
||||
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;">
|
||||
<div style="background:#151D2E;color:#E8ECF4;padding:20px;border-radius:8px 8px 0 0;">
|
||||
<h2 style="margin:0;color:#C8A851;">Neues Feedback</h2>
|
||||
</div>
|
||||
<div style="background:#1A2440;color:#E8ECF4;padding:20px;border-radius:0 0 8px 8px;">
|
||||
<table style="border-collapse:collapse;">
|
||||
<tr><td style="color:#8896AB;padding:4px 16px 4px 0;">Kategorie:</td><td><strong>{category_label}</strong></td></tr>
|
||||
<tr><td style="color:#8896AB;padding:4px 16px 4px 0;">Nutzer:</td><td>{html.escape(username)}</td></tr>
|
||||
<tr><td style="color:#8896AB;padding:4px 16px 4px 0;">E-Mail:</td><td>{html.escape(email) if email else "nicht hinterlegt"}</td></tr>
|
||||
</table>
|
||||
<hr style="border:none;border-top:1px solid #1E2D45;margin:16px 0;">
|
||||
<div style="white-space:pre-wrap;line-height:1.5;">{message_escaped}</div>
|
||||
</div>
|
||||
</div>"""
|
||||
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["From"] = f"{SMTP_FROM_NAME} <{SMTP_FROM_EMAIL}>"
|
||||
msg["To"] = FEEDBACK_EMAIL
|
||||
msg["Subject"] = subject
|
||||
if email:
|
||||
msg["Reply-To"] = email
|
||||
|
||||
text_fallback = f"Feedback von {username} ({category_label}):\n\n{data.message}"
|
||||
msg.attach(MIMEText(text_fallback, "plain", "utf-8"))
|
||||
msg.attach(MIMEText(html_body, "html", "utf-8"))
|
||||
|
||||
try:
|
||||
await aiosmtplib.send(
|
||||
msg,
|
||||
hostname=SMTP_HOST,
|
||||
port=SMTP_PORT,
|
||||
username=SMTP_USER if SMTP_USER else None,
|
||||
password=SMTP_PASSWORD if SMTP_PASSWORD else None,
|
||||
start_tls=SMTP_USE_TLS,
|
||||
)
|
||||
_user_timestamps[user_id].append(now)
|
||||
logger.info(f"Feedback von {username} ({category_label}) gesendet")
|
||||
except Exception as e:
|
||||
logger.error(f"Feedback-E-Mail fehlgeschlagen: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="E-Mail konnte nicht gesendet werden.",
|
||||
)
|
||||
636
src/routers/incidents.py
Normale Datei
636
src/routers/incidents.py
Normale Datei
@@ -0,0 +1,636 @@
|
||||
"""Incidents-Router: Lagen verwalten (Multi-Tenant)."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from models import IncidentCreate, IncidentUpdate, IncidentResponse, SubscriptionUpdate, SubscriptionResponse
|
||||
from auth import get_current_user
|
||||
from middleware.license_check import require_writable_license
|
||||
from database import db_dependency
|
||||
from datetime import datetime, timezone
|
||||
import aiosqlite
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
router = APIRouter(prefix="/api/incidents", tags=["incidents"])
|
||||
|
||||
INCIDENT_UPDATE_COLUMNS = {
|
||||
"title", "description", "type", "status", "refresh_mode",
|
||||
"refresh_interval", "retention_days", "international_sources", "visibility",
|
||||
}
|
||||
|
||||
|
||||
async def _check_incident_access(
|
||||
db: aiosqlite.Connection, incident_id: int, user_id: int, tenant_id: int
|
||||
) -> aiosqlite.Row:
|
||||
"""Lage laden und Zugriff pruefen (Tenant + Sichtbarkeit)."""
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM incidents WHERE id = ? AND tenant_id = ?",
|
||||
(incident_id, tenant_id),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Lage nicht gefunden")
|
||||
if row["visibility"] == "private" and row["created_by"] != user_id:
|
||||
raise HTTPException(status_code=403, detail="Kein Zugriff auf private Lage")
|
||||
return row
|
||||
|
||||
|
||||
async def _enrich_incident(db: aiosqlite.Connection, row: aiosqlite.Row) -> dict:
|
||||
"""Incident-Row mit Statistiken und Ersteller-Name anreichern."""
|
||||
incident = dict(row)
|
||||
cursor = await db.execute(
|
||||
"SELECT COUNT(*) as cnt FROM articles WHERE incident_id = ?",
|
||||
(incident["id"],),
|
||||
)
|
||||
article_count = (await cursor.fetchone())["cnt"]
|
||||
|
||||
cursor = await db.execute(
|
||||
"SELECT COUNT(DISTINCT source) as cnt FROM articles WHERE incident_id = ?",
|
||||
(incident["id"],),
|
||||
)
|
||||
source_count = (await cursor.fetchone())["cnt"]
|
||||
|
||||
cursor = await db.execute(
|
||||
"SELECT username FROM users WHERE id = ?",
|
||||
(incident["created_by"],),
|
||||
)
|
||||
user_row = await cursor.fetchone()
|
||||
|
||||
incident["article_count"] = article_count
|
||||
incident["source_count"] = source_count
|
||||
incident["created_by_username"] = user_row["username"] if user_row else "Unbekannt"
|
||||
return incident
|
||||
|
||||
|
||||
@router.get("", response_model=list[IncidentResponse])
|
||||
async def list_incidents(
|
||||
status_filter: str = None,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Alle Lagen des Tenants auflisten (oeffentliche + eigene private)."""
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
user_id = current_user["id"]
|
||||
|
||||
query = "SELECT * FROM incidents WHERE tenant_id = ? AND (visibility = 'public' OR created_by = ?)"
|
||||
params = [tenant_id, user_id]
|
||||
|
||||
if status_filter:
|
||||
query += " AND status = ?"
|
||||
params.append(status_filter)
|
||||
|
||||
query += " ORDER BY updated_at DESC"
|
||||
cursor = await db.execute(query, params)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
results = []
|
||||
for row in rows:
|
||||
results.append(await _enrich_incident(db, row))
|
||||
return results
|
||||
|
||||
|
||||
@router.post("", response_model=IncidentResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_incident(
|
||||
data: IncidentCreate,
|
||||
current_user: dict = Depends(require_writable_license),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Neue Lage anlegen."""
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
now = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
|
||||
cursor = await db.execute(
|
||||
"""INSERT INTO incidents (title, description, type, refresh_mode, refresh_interval,
|
||||
retention_days, international_sources, visibility,
|
||||
tenant_id, created_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
data.title,
|
||||
data.description,
|
||||
data.type,
|
||||
data.refresh_mode,
|
||||
data.refresh_interval,
|
||||
data.retention_days,
|
||||
1 if data.international_sources else 0,
|
||||
data.visibility,
|
||||
tenant_id,
|
||||
current_user["id"],
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
cursor = await db.execute("SELECT * FROM incidents WHERE id = ?", (cursor.lastrowid,))
|
||||
row = await cursor.fetchone()
|
||||
return await _enrich_incident(db, row)
|
||||
|
||||
|
||||
@router.get("/refreshing")
|
||||
async def get_refreshing_incidents(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Gibt IDs aller Lagen mit laufendem Refresh zurueck (nur eigener Tenant)."""
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
cursor = await db.execute(
|
||||
"""SELECT rl.incident_id, rl.started_at FROM refresh_log rl
|
||||
JOIN incidents i ON i.id = rl.incident_id
|
||||
WHERE rl.status = 'running'
|
||||
AND i.tenant_id = ?
|
||||
AND (i.visibility = 'public' OR i.created_by = ?)""",
|
||||
(tenant_id, current_user["id"]),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return {
|
||||
"refreshing": [row["incident_id"] for row in rows],
|
||||
"details": {str(row["incident_id"]): {"started_at": row["started_at"]} for row in rows},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{incident_id}", response_model=IncidentResponse)
|
||||
async def get_incident(
|
||||
incident_id: int,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Einzelne Lage abrufen."""
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
row = await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
|
||||
return await _enrich_incident(db, row)
|
||||
|
||||
|
||||
@router.put("/{incident_id}", response_model=IncidentResponse)
|
||||
async def update_incident(
|
||||
incident_id: int,
|
||||
data: IncidentUpdate,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Lage aktualisieren."""
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
row = await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
|
||||
|
||||
updates = {}
|
||||
for field, value in data.model_dump(exclude_none=True).items():
|
||||
if field not in INCIDENT_UPDATE_COLUMNS:
|
||||
continue
|
||||
updates[field] = value
|
||||
|
||||
if not updates:
|
||||
return await _enrich_incident(db, row)
|
||||
|
||||
updates["updated_at"] = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')
|
||||
set_clause = ", ".join(f"{k} = ?" for k in updates)
|
||||
values = list(updates.values()) + [incident_id]
|
||||
|
||||
await db.execute(f"UPDATE incidents SET {set_clause} WHERE id = ?", values)
|
||||
await db.commit()
|
||||
|
||||
cursor = await db.execute("SELECT * FROM incidents WHERE id = ?", (incident_id,))
|
||||
row = await cursor.fetchone()
|
||||
return await _enrich_incident(db, row)
|
||||
|
||||
|
||||
@router.delete("/{incident_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_incident(
|
||||
incident_id: int,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Lage loeschen (nur Ersteller)."""
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
cursor = await db.execute(
|
||||
"SELECT id, created_by FROM incidents WHERE id = ? AND tenant_id = ?",
|
||||
(incident_id, tenant_id),
|
||||
)
|
||||
incident = await cursor.fetchone()
|
||||
if not incident:
|
||||
raise HTTPException(status_code=404, detail="Lage nicht gefunden")
|
||||
|
||||
if incident["created_by"] != current_user["id"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Nur der Ersteller kann diese Lage loeschen",
|
||||
)
|
||||
|
||||
await db.execute("DELETE FROM incidents WHERE id = ?", (incident_id,))
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.get("/{incident_id}/articles")
|
||||
async def get_articles(
|
||||
incident_id: int,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Alle Artikel einer Lage abrufen."""
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM articles WHERE incident_id = ? ORDER BY collected_at DESC",
|
||||
(incident_id,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
@router.get("/{incident_id}/snapshots")
|
||||
async def get_snapshots(
|
||||
incident_id: int,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Lageberichte (Snapshots) einer Lage abrufen."""
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
|
||||
cursor = await db.execute(
|
||||
"""SELECT id, incident_id, summary, sources_json,
|
||||
article_count, fact_check_count, created_at
|
||||
FROM incident_snapshots WHERE incident_id = ?
|
||||
ORDER BY created_at DESC""",
|
||||
(incident_id,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
@router.get("/{incident_id}/factchecks")
|
||||
async def get_factchecks(
|
||||
incident_id: int,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Faktenchecks einer Lage abrufen."""
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM fact_checks WHERE incident_id = ? ORDER BY checked_at DESC",
|
||||
(incident_id,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
@router.get("/{incident_id}/refresh-log")
|
||||
async def get_refresh_log(
|
||||
incident_id: int,
|
||||
limit: int = 20,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Refresh-Verlauf einer Lage abrufen."""
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
|
||||
cursor = await db.execute(
|
||||
"""SELECT id, started_at, completed_at, articles_found, status,
|
||||
trigger_type, retry_count, error_message
|
||||
FROM refresh_log WHERE incident_id = ?
|
||||
ORDER BY started_at DESC LIMIT ?""",
|
||||
(incident_id, min(limit, 100)),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
results = []
|
||||
for row in rows:
|
||||
entry = dict(row)
|
||||
if entry["started_at"] and entry["completed_at"]:
|
||||
try:
|
||||
start = datetime.fromisoformat(entry["started_at"])
|
||||
end = datetime.fromisoformat(entry["completed_at"])
|
||||
entry["duration_seconds"] = round((end - start).total_seconds(), 1)
|
||||
except Exception:
|
||||
entry["duration_seconds"] = None
|
||||
else:
|
||||
entry["duration_seconds"] = None
|
||||
results.append(entry)
|
||||
return results
|
||||
|
||||
|
||||
@router.get("/{incident_id}/subscription", response_model=SubscriptionResponse)
|
||||
async def get_subscription(
|
||||
incident_id: int,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""E-Mail-Abo-Einstellungen des aktuellen Nutzers fuer eine Lage abrufen."""
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
|
||||
cursor = await db.execute(
|
||||
"""SELECT notify_email_summary, notify_email_new_articles, notify_email_status_change
|
||||
FROM incident_subscriptions WHERE user_id = ? AND incident_id = ?""",
|
||||
(current_user["id"], incident_id),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row:
|
||||
return dict(row)
|
||||
return {"notify_email_summary": False, "notify_email_new_articles": False, "notify_email_status_change": False}
|
||||
|
||||
|
||||
@router.put("/{incident_id}/subscription", response_model=SubscriptionResponse)
|
||||
async def update_subscription(
|
||||
incident_id: int,
|
||||
data: SubscriptionUpdate,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""E-Mail-Abo-Einstellungen des aktuellen Nutzers fuer eine Lage setzen."""
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
|
||||
await db.execute(
|
||||
"""INSERT INTO incident_subscriptions (user_id, incident_id, notify_email_summary, notify_email_new_articles, notify_email_status_change)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, incident_id) DO UPDATE SET
|
||||
notify_email_summary = excluded.notify_email_summary,
|
||||
notify_email_new_articles = excluded.notify_email_new_articles,
|
||||
notify_email_status_change = excluded.notify_email_status_change""",
|
||||
(
|
||||
current_user["id"],
|
||||
incident_id,
|
||||
1 if data.notify_email_summary else 0,
|
||||
1 if data.notify_email_new_articles else 0,
|
||||
1 if data.notify_email_status_change else 0,
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
return {
|
||||
"notify_email_summary": data.notify_email_summary,
|
||||
"notify_email_new_articles": data.notify_email_new_articles,
|
||||
"notify_email_status_change": data.notify_email_status_change,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{incident_id}/refresh")
|
||||
async def trigger_refresh(
|
||||
incident_id: int,
|
||||
current_user: dict = Depends(require_writable_license),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Manuellen Refresh fuer eine Lage ausloesen."""
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
|
||||
|
||||
from agents.orchestrator import orchestrator
|
||||
enqueued = await orchestrator.enqueue_refresh(incident_id)
|
||||
|
||||
if not enqueued:
|
||||
return {"status": "skipped", "incident_id": incident_id}
|
||||
return {"status": "queued", "incident_id": incident_id}
|
||||
|
||||
|
||||
@router.post("/{incident_id}/cancel-refresh")
|
||||
async def cancel_refresh(
|
||||
incident_id: int,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Laufenden Refresh fuer eine Lage abbrechen."""
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
|
||||
|
||||
from agents.orchestrator import orchestrator
|
||||
cancelled = await orchestrator.cancel_refresh(incident_id)
|
||||
|
||||
return {"status": "cancelling" if cancelled else "not_running"}
|
||||
|
||||
|
||||
|
||||
def _slugify(text: str) -> str:
|
||||
"""Dateinamen-sicherer Slug aus Titel."""
|
||||
replacements = {
|
||||
"\u00e4": "ae", "\u00f6": "oe", "\u00fc": "ue", "\u00df": "ss",
|
||||
"\u00c4": "Ae", "\u00d6": "Oe", "\u00dc": "Ue",
|
||||
}
|
||||
for src, dst in replacements.items():
|
||||
text = text.replace(src, dst)
|
||||
text = unicodedata.normalize("NFKD", text)
|
||||
text = re.sub(r"[^\w\s-]", "", text)
|
||||
text = re.sub(r"[\s_]+", "-", text).strip("-")
|
||||
return text[:80].lower()
|
||||
|
||||
|
||||
def _build_markdown_export(
|
||||
incident: dict, articles: list, fact_checks: list,
|
||||
snapshots: list, scope: str, creator: str
|
||||
) -> str:
|
||||
"""Markdown-Dokument zusammenbauen."""
|
||||
typ = "Hintergrundrecherche" if incident.get("type") == "research" else "Breaking News"
|
||||
updated = (incident.get("updated_at") or "")[:16].replace("T", " ")
|
||||
|
||||
lines = []
|
||||
lines.append(f"# {incident['title']}")
|
||||
lines.append(f"> {typ} | Erstellt von {creator} | Stand: {updated}")
|
||||
lines.append("")
|
||||
|
||||
# Lagebild
|
||||
summary = incident.get("summary") or "*Noch kein Lagebild verf\u00fcgbar.*"
|
||||
lines.append("## Lagebild")
|
||||
lines.append("")
|
||||
lines.append(summary)
|
||||
lines.append("")
|
||||
|
||||
# Quellenverzeichnis aus sources_json
|
||||
sources_json = incident.get("sources_json")
|
||||
if sources_json:
|
||||
try:
|
||||
sources = json.loads(sources_json) if isinstance(sources_json, str) else sources_json
|
||||
if sources:
|
||||
lines.append("## Quellenverzeichnis")
|
||||
lines.append("")
|
||||
for i, src in enumerate(sources, 1):
|
||||
name = src.get("name") or src.get("title") or src.get("url", "")
|
||||
url = src.get("url", "")
|
||||
if url:
|
||||
lines.append(f"{i}. [{name}]({url})")
|
||||
else:
|
||||
lines.append(f"{i}. {name}")
|
||||
lines.append("")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
# Faktencheck
|
||||
if fact_checks:
|
||||
lines.append("## Faktencheck")
|
||||
lines.append("")
|
||||
for fc in fact_checks:
|
||||
claim = fc.get("claim", "")
|
||||
fc_status = fc.get("status", "")
|
||||
sources_count = fc.get("sources_count", 0)
|
||||
evidence = fc.get("evidence", "")
|
||||
status_label = {
|
||||
"confirmed": "Best\u00e4tigt", "unconfirmed": "Unbest\u00e4tigt",
|
||||
"disputed": "Umstritten", "false": "Falsch",
|
||||
}.get(fc_status, fc_status)
|
||||
line = f"- **{claim}** \u2014 {status_label} ({sources_count} Quellen)"
|
||||
if evidence:
|
||||
line += f"\n {evidence}"
|
||||
lines.append(line)
|
||||
lines.append("")
|
||||
|
||||
# Scope=full: Artikel\u00fcbersicht
|
||||
if scope == "full" and articles:
|
||||
lines.append("## Artikel\u00fcbersicht")
|
||||
lines.append("")
|
||||
lines.append("| Headline | Quelle | Sprache | Datum |")
|
||||
lines.append("|----------|--------|---------|-------|")
|
||||
for art in articles:
|
||||
headline = (art.get("headline_de") or art.get("headline") or "").replace("|", "/")
|
||||
source = (art.get("source") or "").replace("|", "/")
|
||||
lang = art.get("language", "")
|
||||
pub = (art.get("published_at") or art.get("collected_at") or "")[:16]
|
||||
lines.append(f"| {headline} | {source} | {lang} | {pub} |")
|
||||
lines.append("")
|
||||
|
||||
# Scope=full: Snapshot-Verlauf
|
||||
if scope == "full" and snapshots:
|
||||
lines.append("## Snapshot-Verlauf")
|
||||
lines.append("")
|
||||
for snap in snapshots:
|
||||
snap_date = (snap.get("created_at") or "")[:16].replace("T", " ")
|
||||
art_count = snap.get("article_count", 0)
|
||||
fc_count = snap.get("fact_check_count", 0)
|
||||
lines.append(f"### Snapshot vom {snap_date}")
|
||||
lines.append(f"Artikel: {art_count} | Faktenchecks: {fc_count}")
|
||||
lines.append("")
|
||||
snap_summary = snap.get("summary", "")
|
||||
if snap_summary:
|
||||
lines.append(snap_summary)
|
||||
lines.append("")
|
||||
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
lines.append("---")
|
||||
lines.append(f"*Exportiert am {now} aus AegisSight Monitor*")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_json_export(
|
||||
incident: dict, articles: list, fact_checks: list,
|
||||
snapshots: list, scope: str, creator: str
|
||||
) -> dict:
|
||||
"""Strukturiertes JSON fuer Export."""
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
sources = []
|
||||
sources_json = incident.get("sources_json")
|
||||
if sources_json:
|
||||
try:
|
||||
sources = json.loads(sources_json) if isinstance(sources_json, str) else sources_json
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
export = {
|
||||
"export_version": "1.0",
|
||||
"exported_at": now,
|
||||
"scope": scope,
|
||||
"incident": {
|
||||
"id": incident["id"],
|
||||
"title": incident["title"],
|
||||
"description": incident.get("description"),
|
||||
"type": incident.get("type"),
|
||||
"status": incident.get("status"),
|
||||
"visibility": incident.get("visibility"),
|
||||
"created_by": creator,
|
||||
"created_at": incident.get("created_at"),
|
||||
"updated_at": incident.get("updated_at"),
|
||||
"summary": incident.get("summary"),
|
||||
"international_sources": bool(incident.get("international_sources")),
|
||||
},
|
||||
"sources": sources,
|
||||
"fact_checks": [
|
||||
{
|
||||
"claim": fc.get("claim"),
|
||||
"status": fc.get("status"),
|
||||
"sources_count": fc.get("sources_count"),
|
||||
"evidence": fc.get("evidence"),
|
||||
"checked_at": fc.get("checked_at"),
|
||||
}
|
||||
for fc in fact_checks
|
||||
],
|
||||
}
|
||||
|
||||
if scope == "full":
|
||||
export["articles"] = [
|
||||
{
|
||||
"headline": art.get("headline"),
|
||||
"headline_de": art.get("headline_de"),
|
||||
"source": art.get("source"),
|
||||
"source_url": art.get("source_url"),
|
||||
"language": art.get("language"),
|
||||
"published_at": art.get("published_at"),
|
||||
"collected_at": art.get("collected_at"),
|
||||
"verification_status": art.get("verification_status"),
|
||||
}
|
||||
for art in articles
|
||||
]
|
||||
export["snapshots"] = [
|
||||
{
|
||||
"created_at": snap.get("created_at"),
|
||||
"article_count": snap.get("article_count"),
|
||||
"fact_check_count": snap.get("fact_check_count"),
|
||||
"summary": snap.get("summary"),
|
||||
}
|
||||
for snap in snapshots
|
||||
]
|
||||
|
||||
return export
|
||||
|
||||
|
||||
@router.get("/{incident_id}/export")
|
||||
async def export_incident(
|
||||
incident_id: int,
|
||||
format: str = Query(..., pattern="^(md|json)$"),
|
||||
scope: str = Query("report", pattern="^(report|full)$"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Lage als Markdown oder JSON exportieren."""
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
row = await _check_incident_access(db, incident_id, current_user["id"], tenant_id)
|
||||
incident = dict(row)
|
||||
|
||||
# Ersteller-Name
|
||||
cursor = await db.execute("SELECT username FROM users WHERE id = ?", (incident["created_by"],))
|
||||
user_row = await cursor.fetchone()
|
||||
creator = user_row["username"] if user_row else "Unbekannt"
|
||||
|
||||
# Artikel
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM articles WHERE incident_id = ? ORDER BY collected_at DESC",
|
||||
(incident_id,),
|
||||
)
|
||||
articles = [dict(r) for r in await cursor.fetchall()]
|
||||
|
||||
# Faktenchecks
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM fact_checks WHERE incident_id = ? ORDER BY checked_at DESC",
|
||||
(incident_id,),
|
||||
)
|
||||
fact_checks = [dict(r) for r in await cursor.fetchall()]
|
||||
|
||||
# Snapshots (nur bei full)
|
||||
snapshots = []
|
||||
if scope == "full":
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM incident_snapshots WHERE incident_id = ? ORDER BY created_at DESC",
|
||||
(incident_id,),
|
||||
)
|
||||
snapshots = [dict(r) for r in await cursor.fetchall()]
|
||||
|
||||
# Dateiname
|
||||
date_str = datetime.now(timezone.utc).strftime("%Y%m%d")
|
||||
slug = _slugify(incident["title"])
|
||||
scope_suffix = "_vollexport" if scope == "full" else ""
|
||||
|
||||
if format == "md":
|
||||
body = _build_markdown_export(incident, articles, fact_checks, snapshots, scope, creator)
|
||||
filename = f"{slug}{scope_suffix}_{date_str}.md"
|
||||
media_type = "text/markdown; charset=utf-8"
|
||||
else:
|
||||
export_data = _build_json_export(incident, articles, fact_checks, snapshots, scope, creator)
|
||||
body = json.dumps(export_data, ensure_ascii=False, indent=2)
|
||||
filename = f"{slug}{scope_suffix}_{date_str}.json"
|
||||
media_type = "application/json; charset=utf-8"
|
||||
|
||||
return StreamingResponse(
|
||||
iter([body]),
|
||||
media_type=media_type,
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
62
src/routers/notifications.py
Normale Datei
62
src/routers/notifications.py
Normale Datei
@@ -0,0 +1,62 @@
|
||||
"""Notifications-Router: Persistente Benachrichtigungen (Multi-Tenant)."""
|
||||
import logging
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from models import NotificationResponse, NotificationMarkReadRequest
|
||||
from auth import get_current_user
|
||||
from database import db_dependency
|
||||
import aiosqlite
|
||||
|
||||
logger = logging.getLogger("osint.notifications")
|
||||
|
||||
router = APIRouter(prefix="/api/notifications", tags=["notifications"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[NotificationResponse])
|
||||
async def list_notifications(
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Eigene Notifications abrufen (neueste zuerst)."""
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT ?",
|
||||
(current_user["id"], limit),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
@router.get("/unread-count")
|
||||
async def get_unread_count(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Anzahl ungelesener Notifications."""
|
||||
cursor = await db.execute(
|
||||
"SELECT COUNT(*) as cnt FROM notifications WHERE user_id = ? AND is_read = 0",
|
||||
(current_user["id"],),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return {"unread_count": row["cnt"]}
|
||||
|
||||
|
||||
@router.put("/mark-read")
|
||||
async def mark_notifications_read(
|
||||
body: NotificationMarkReadRequest,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Notifications als gelesen markieren (bestimmte IDs oder alle)."""
|
||||
if body.notification_ids:
|
||||
placeholders = ",".join("?" for _ in body.notification_ids)
|
||||
await db.execute(
|
||||
f"UPDATE notifications SET is_read = 1 WHERE user_id = ? AND id IN ({placeholders})",
|
||||
[current_user["id"]] + body.notification_ids,
|
||||
)
|
||||
else:
|
||||
await db.execute(
|
||||
"UPDATE notifications SET is_read = 1 WHERE user_id = ? AND is_read = 0",
|
||||
(current_user["id"],),
|
||||
)
|
||||
await db.commit()
|
||||
return {"ok": True}
|
||||
527
src/routers/sources.py
Normale Datei
527
src/routers/sources.py
Normale Datei
@@ -0,0 +1,527 @@
|
||||
"""Sources-Router: Quellenverwaltung (Multi-Tenant)."""
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from models import SourceCreate, SourceUpdate, SourceResponse, DiscoverRequest, DiscoverResponse, DiscoverMultiResponse, DomainActionRequest
|
||||
from auth import get_current_user
|
||||
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
|
||||
import aiosqlite
|
||||
|
||||
logger = logging.getLogger("osint.sources")
|
||||
|
||||
router = APIRouter(prefix="/api/sources", tags=["sources"])
|
||||
|
||||
SOURCE_UPDATE_COLUMNS = {"name", "url", "domain", "source_type", "category", "status", "notes"}
|
||||
|
||||
|
||||
def _check_source_ownership(source: dict, username: str):
|
||||
"""Prueft ob der Nutzer die Quelle bearbeiten/loeschen darf."""
|
||||
added_by = source.get("added_by", "")
|
||||
if added_by == "system":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="System-Quellen koennen nicht veraendert werden",
|
||||
)
|
||||
if added_by and added_by != username:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Nur der Ersteller kann diese Quelle bearbeiten",
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[SourceResponse])
|
||||
async def list_sources(
|
||||
source_type: str = None,
|
||||
category: str = None,
|
||||
source_status: str = None,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Alle Quellen auflisten (global + org-spezifisch)."""
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
|
||||
# Global (tenant_id=NULL) + eigene Org
|
||||
query = "SELECT * FROM sources WHERE (tenant_id IS NULL OR tenant_id = ?)"
|
||||
params = [tenant_id]
|
||||
|
||||
if source_type:
|
||||
query += " AND source_type = ?"
|
||||
params.append(source_type)
|
||||
if category:
|
||||
query += " AND category = ?"
|
||||
params.append(category)
|
||||
if source_status:
|
||||
query += " AND status = ?"
|
||||
params.append(source_status)
|
||||
|
||||
query += " ORDER BY source_type, category, name"
|
||||
cursor = await db.execute(query, params)
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_source_stats(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Aggregierte Quellen-Statistiken (global + eigene Org)."""
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
|
||||
cursor = await db.execute("""
|
||||
SELECT
|
||||
source_type,
|
||||
COUNT(*) as count,
|
||||
SUM(article_count) as total_articles
|
||||
FROM sources
|
||||
WHERE status = 'active' AND (tenant_id IS NULL OR tenant_id = ?)
|
||||
GROUP BY source_type
|
||||
""", (tenant_id,))
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
stats = {
|
||||
"rss_feed": {"count": 0, "articles": 0},
|
||||
"web_source": {"count": 0, "articles": 0},
|
||||
"excluded": {"count": 0, "articles": 0},
|
||||
}
|
||||
for row in rows:
|
||||
st = row["source_type"]
|
||||
if st in stats:
|
||||
stats[st]["count"] = row["count"]
|
||||
stats[st]["articles"] = row["total_articles"] or 0
|
||||
|
||||
cursor = await db.execute(
|
||||
"SELECT COUNT(*) as cnt FROM articles WHERE tenant_id = ?",
|
||||
(tenant_id,),
|
||||
)
|
||||
total_row = await cursor.fetchone()
|
||||
|
||||
return {
|
||||
"by_type": stats,
|
||||
"total_sources": sum(s["count"] for s in stats.values()),
|
||||
"total_articles": total_row["cnt"],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/discover", response_model=DiscoverResponse)
|
||||
async def discover_source_endpoint(
|
||||
data: DiscoverRequest,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""RSS-Feed, Name, Kategorie und Domain einer URL automatisch erkennen."""
|
||||
try:
|
||||
result = await discover_source(data.url)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Discovery fehlgeschlagen: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Discovery fehlgeschlagen")
|
||||
|
||||
|
||||
@router.post("/discover-multi", response_model=DiscoverMultiResponse)
|
||||
async def discover_multi_endpoint(
|
||||
data: DiscoverRequest,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Findet ALLE RSS-Feeds einer Domain, bewertet sie mit Claude und legt relevante als Quellen an."""
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
try:
|
||||
multi = await discover_all_feeds(data.url)
|
||||
domain = multi["domain"]
|
||||
category = multi["category"]
|
||||
|
||||
if not multi["feeds"]:
|
||||
single = await discover_source(data.url)
|
||||
sources = []
|
||||
if single.get("rss_url"):
|
||||
cursor = await db.execute(
|
||||
"SELECT id FROM sources WHERE url = ?", (single["rss_url"],)
|
||||
)
|
||||
existing = await cursor.fetchone()
|
||||
if not existing:
|
||||
cursor = await db.execute(
|
||||
"""INSERT INTO sources (name, url, domain, source_type, category, status, added_by, tenant_id)
|
||||
VALUES (?, ?, ?, ?, ?, 'active', ?, ?)""",
|
||||
(single["name"], single["rss_url"], single["domain"],
|
||||
single["source_type"], single["category"], current_user["username"], tenant_id),
|
||||
)
|
||||
await db.commit()
|
||||
cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (cursor.lastrowid,))
|
||||
row = await cursor.fetchone()
|
||||
sources.append(dict(row))
|
||||
|
||||
return DiscoverMultiResponse(
|
||||
domain=single.get("domain", domain),
|
||||
category=single.get("category", category),
|
||||
added_count=len(sources),
|
||||
skipped_count=1 if not sources and single.get("rss_url") else 0,
|
||||
total_found=1 if single.get("rss_url") else 0,
|
||||
sources=sources,
|
||||
fallback_single=True,
|
||||
)
|
||||
|
||||
relevant_feeds = await evaluate_feeds_with_claude(domain, multi["feeds"])
|
||||
|
||||
cursor = await db.execute("SELECT url FROM sources WHERE url IS NOT NULL")
|
||||
existing_urls = {row["url"] for row in await cursor.fetchall()}
|
||||
|
||||
new_ids = []
|
||||
skipped = 0
|
||||
for feed in relevant_feeds:
|
||||
if feed["url"] in existing_urls:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
cursor = await db.execute(
|
||||
"""INSERT INTO sources (name, url, domain, source_type, category, status, added_by, tenant_id)
|
||||
VALUES (?, ?, ?, 'rss_feed', ?, 'active', ?, ?)""",
|
||||
(feed["name"], feed["url"], domain, category, current_user["username"], tenant_id),
|
||||
)
|
||||
new_ids.append(cursor.lastrowid)
|
||||
existing_urls.add(feed["url"])
|
||||
|
||||
cursor = await db.execute(
|
||||
"SELECT id FROM sources WHERE LOWER(domain) = ? AND source_type = 'web_source'",
|
||||
(domain.lower(),),
|
||||
)
|
||||
if not await cursor.fetchone():
|
||||
cursor = await db.execute(
|
||||
"""INSERT INTO sources (name, url, domain, source_type, category, status, added_by, tenant_id)
|
||||
VALUES (?, ?, ?, 'web_source', ?, 'active', ?, ?)""",
|
||||
(domain_to_display_name(domain), f"https://{domain}", domain, category, current_user["username"], tenant_id),
|
||||
)
|
||||
new_ids.append(cursor.lastrowid)
|
||||
|
||||
await db.commit()
|
||||
|
||||
added_sources = []
|
||||
if new_ids:
|
||||
placeholders = ",".join("?" for _ in new_ids)
|
||||
cursor = await db.execute(
|
||||
f"SELECT * FROM sources WHERE id IN ({placeholders}) ORDER BY id",
|
||||
new_ids,
|
||||
)
|
||||
added_sources = [dict(row) for row in await cursor.fetchall()]
|
||||
|
||||
return DiscoverMultiResponse(
|
||||
domain=domain,
|
||||
category=category,
|
||||
added_count=len(added_sources),
|
||||
skipped_count=skipped,
|
||||
total_found=len(multi["feeds"]),
|
||||
sources=added_sources,
|
||||
fallback_single=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Multi-Discovery fehlgeschlagen: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Multi-Discovery fehlgeschlagen")
|
||||
|
||||
|
||||
@router.post("/rediscover-existing")
|
||||
async def rediscover_existing_endpoint(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Einmalige Migration: Bestehende RSS-Quellen nach zusaetzlichen Feeds durchsuchen."""
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
try:
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM sources WHERE source_type = 'rss_feed' AND status = 'active' AND (tenant_id IS NULL OR tenant_id = ?)",
|
||||
(tenant_id,),
|
||||
)
|
||||
existing_sources = [dict(row) for row in await cursor.fetchall()]
|
||||
|
||||
domains = defaultdict(list)
|
||||
for src in existing_sources:
|
||||
if src["domain"]:
|
||||
domains[src["domain"]].append(src)
|
||||
|
||||
cursor = await db.execute("SELECT url FROM sources WHERE url IS NOT NULL")
|
||||
existing_urls = {row["url"] for row in await cursor.fetchall()}
|
||||
|
||||
domains_processed = 0
|
||||
feeds_added = 0
|
||||
feeds_skipped = 0
|
||||
|
||||
for domain, sources in domains.items():
|
||||
domains_processed += 1
|
||||
base_url = f"https://{domain}"
|
||||
|
||||
try:
|
||||
multi = await discover_all_feeds(base_url)
|
||||
if not multi["feeds"]:
|
||||
continue
|
||||
|
||||
relevant_feeds = await evaluate_feeds_with_claude(domain, multi["feeds"])
|
||||
category = _detect_category(domain)
|
||||
|
||||
for feed in relevant_feeds:
|
||||
if feed["url"] in existing_urls:
|
||||
feeds_skipped += 1
|
||||
continue
|
||||
|
||||
await db.execute(
|
||||
"""INSERT INTO sources (name, url, domain, source_type, category, status, added_by, tenant_id)
|
||||
VALUES (?, ?, ?, 'rss_feed', ?, 'active', ?, ?)""",
|
||||
(feed["name"], feed["url"], domain, category, current_user["username"], tenant_id),
|
||||
)
|
||||
existing_urls.add(feed["url"])
|
||||
feeds_added += 1
|
||||
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"Rediscovery fuer {domain} fehlgeschlagen: {e}")
|
||||
continue
|
||||
|
||||
return {
|
||||
"domains_processed": domains_processed,
|
||||
"feeds_added": feeds_added,
|
||||
"feeds_skipped": feeds_skipped,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Rediscovery fehlgeschlagen: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Rediscovery fehlgeschlagen")
|
||||
|
||||
|
||||
@router.post("/block-domain")
|
||||
async def block_domain(
|
||||
data: DomainActionRequest,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Domain sperren: Alle Feeds deaktivieren + excluded-Eintrag anlegen."""
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
domain = data.domain.lower().strip()
|
||||
username = current_user["username"]
|
||||
|
||||
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 IS NULL OR 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()
|
||||
|
||||
if existing:
|
||||
excluded_id = existing["id"]
|
||||
if data.notes:
|
||||
await db.execute(
|
||||
"UPDATE sources SET notes = ? WHERE id = ?",
|
||||
(data.notes, excluded_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,
|
||||
"feeds_deactivated": feeds_deactivated,
|
||||
"excluded_id": excluded_id,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/unblock-domain")
|
||||
async def unblock_domain(
|
||||
data: DomainActionRequest,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Domain entsperren: excluded-Eintrag loeschen + Feeds reaktivieren."""
|
||||
tenant_id = current_user.get("tenant_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),
|
||||
)
|
||||
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 IS NULL OR tenant_id = ?)",
|
||||
(domain, tenant_id),
|
||||
)
|
||||
cursor = await db.execute(
|
||||
"UPDATE sources SET status = 'active' WHERE LOWER(domain) = ? AND source_type != 'excluded' AND (tenant_id IS NULL OR tenant_id = ?)",
|
||||
(domain, tenant_id),
|
||||
)
|
||||
feeds_reactivated = cursor.rowcount
|
||||
else:
|
||||
await db.execute(
|
||||
"""UPDATE sources SET source_type = 'web_source', status = 'active', notes = 'Entsperrt'
|
||||
WHERE LOWER(domain) = ? AND source_type = 'excluded' AND (tenant_id IS NULL OR tenant_id = ?)""",
|
||||
(domain, tenant_id),
|
||||
)
|
||||
feeds_reactivated = 0
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"domain": domain,
|
||||
"feeds_reactivated": feeds_reactivated,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/domain/{domain}")
|
||||
async def delete_domain(
|
||||
domain: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Alle Quellen einer Domain loeschen (nur org-eigene, nicht globale)."""
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
domain_lower = domain.lower().strip()
|
||||
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM sources WHERE LOWER(domain) = ? AND tenant_id = ?",
|
||||
(domain_lower, tenant_id),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
if not rows:
|
||||
raise HTTPException(status_code=404, detail="Keine Quellen fuer diese Domain gefunden")
|
||||
|
||||
username = current_user["username"]
|
||||
for row in rows:
|
||||
source = dict(row)
|
||||
if source["added_by"] == "system":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Domain enthaelt System-Quellen, die nicht geloescht werden koennen",
|
||||
)
|
||||
if source["added_by"] and source["added_by"] != username:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Domain enthaelt Quellen anderer Nutzer",
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"DELETE FROM sources WHERE LOWER(domain) = ? AND tenant_id = ?",
|
||||
(domain_lower, tenant_id),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"domain": domain_lower,
|
||||
"deleted_count": len(rows),
|
||||
}
|
||||
|
||||
|
||||
@router.post("", response_model=SourceResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_source(
|
||||
data: SourceCreate,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Neue Quelle hinzufuegen (org-spezifisch)."""
|
||||
tenant_id = current_user.get("tenant_id")
|
||||
cursor = await db.execute(
|
||||
"""INSERT INTO sources (name, url, domain, source_type, category, status, notes, added_by, tenant_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
data.name,
|
||||
data.url,
|
||||
data.domain,
|
||||
data.source_type,
|
||||
data.category,
|
||||
data.status,
|
||||
data.notes,
|
||||
current_user["username"],
|
||||
tenant_id,
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
cursor = await db.execute("SELECT * FROM sources WHERE id = ?", (cursor.lastrowid,))
|
||||
row = await cursor.fetchone()
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.put("/{source_id}", response_model=SourceResponse)
|
||||
async def update_source(
|
||||
source_id: int,
|
||||
data: SourceUpdate,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Quelle bearbeiten."""
|
||||
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")
|
||||
|
||||
_check_source_ownership(dict(row), current_user["username"])
|
||||
|
||||
updates = {}
|
||||
for field, value in data.model_dump(exclude_none=True).items():
|
||||
if field not in SOURCE_UPDATE_COLUMNS:
|
||||
continue
|
||||
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,))
|
||||
row = await cursor.fetchone()
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.delete("/{source_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_source(
|
||||
source_id: int,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Quelle loeschen."""
|
||||
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")
|
||||
|
||||
_check_source_ownership(dict(row), current_user["username"])
|
||||
|
||||
await db.execute("DELETE FROM sources WHERE id = ?", (source_id,))
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/refresh-counts")
|
||||
async def trigger_refresh_counts(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(db_dependency),
|
||||
):
|
||||
"""Artikelzaehler fuer alle Quellen neu berechnen."""
|
||||
await refresh_source_counts(db)
|
||||
return {"status": "ok"}
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren