4 feste Farbstufen (primary/secondary/tertiary/mentioned) mit variablen Labels pro Lage, die von Haiku generiert werden. - DB: category_labels Spalte in incidents, alte Kategorien migriert (target->primary, response/retaliation->secondary, actor->tertiary) - Geoparsing: generate_category_labels() + neuer Prompt mit neuen Keys - QC: Kategorieprüfung auf neue Keys umgestellt - Orchestrator: Tuple-Rückgabe + Labels in DB speichern - API: category_labels im Locations- und Lagebild-Response - Frontend: Dynamische Legende aus API-Labels mit Fallback-Defaults - Migrationsskript für bestehende Lagen Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
604 Zeilen
26 KiB
Python
604 Zeilen
26 KiB
Python
"""SQLite Datenbank-Setup und Zugriff."""
|
|
import aiosqlite
|
|
import logging
|
|
import os
|
|
from config import DB_PATH, DATA_DIR
|
|
|
|
logger = logging.getLogger("osint.database")
|
|
|
|
SCHEMA = """
|
|
CREATE TABLE IF NOT EXISTS organizations (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
slug TEXT UNIQUE NOT NULL,
|
|
is_active INTEGER DEFAULT 1,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS licenses (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
|
license_type TEXT NOT NULL DEFAULT 'trial',
|
|
max_users INTEGER NOT NULL DEFAULT 5,
|
|
valid_from TIMESTAMP NOT NULL,
|
|
valid_until TIMESTAMP,
|
|
status TEXT NOT NULL DEFAULT 'active',
|
|
notes TEXT,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
email TEXT UNIQUE NOT NULL,
|
|
username TEXT NOT NULL,
|
|
password_hash TEXT,
|
|
organization_id INTEGER NOT NULL REFERENCES organizations(id),
|
|
role TEXT NOT NULL DEFAULT 'member',
|
|
is_active INTEGER DEFAULT 1,
|
|
last_login_at TIMESTAMP,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
CREATE TABLE IF NOT EXISTS magic_links (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
email TEXT NOT NULL,
|
|
token TEXT UNIQUE NOT NULL,
|
|
code TEXT NOT NULL,
|
|
purpose TEXT NOT NULL DEFAULT 'login',
|
|
user_id INTEGER REFERENCES users(id),
|
|
is_used INTEGER DEFAULT 0,
|
|
expires_at TIMESTAMP NOT NULL,
|
|
ip_address TEXT,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
-- Hinweis: portal_admins wird von der Verwaltungs-App (Admin-Portal) genutzt, die dieselbe DB teilt.
|
|
CREATE TABLE IF NOT EXISTS portal_admins (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
username TEXT UNIQUE NOT NULL,
|
|
password_hash TEXT NOT NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS incidents (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
title TEXT NOT NULL,
|
|
description TEXT,
|
|
status TEXT DEFAULT 'active',
|
|
type TEXT DEFAULT 'adhoc',
|
|
refresh_mode TEXT DEFAULT 'manual',
|
|
refresh_interval INTEGER DEFAULT 15,
|
|
retention_days INTEGER DEFAULT 0,
|
|
visibility TEXT DEFAULT 'public',
|
|
summary TEXT,
|
|
sources_json TEXT,
|
|
international_sources INTEGER DEFAULT 1,
|
|
category_labels TEXT,
|
|
tenant_id INTEGER REFERENCES organizations(id),
|
|
created_by INTEGER REFERENCES users(id),
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
CREATE TABLE IF NOT EXISTS articles (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
incident_id INTEGER REFERENCES incidents(id) ON DELETE CASCADE,
|
|
headline TEXT NOT NULL,
|
|
headline_de TEXT,
|
|
source TEXT NOT NULL,
|
|
source_url TEXT,
|
|
content_original TEXT,
|
|
content_de TEXT,
|
|
language TEXT DEFAULT 'de',
|
|
published_at TIMESTAMP,
|
|
collected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
verification_status TEXT DEFAULT 'unverified',
|
|
tenant_id INTEGER REFERENCES organizations(id)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS fact_checks (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
incident_id INTEGER REFERENCES incidents(id) ON DELETE CASCADE,
|
|
claim TEXT NOT NULL,
|
|
status TEXT DEFAULT 'developing',
|
|
sources_count INTEGER DEFAULT 0,
|
|
evidence TEXT,
|
|
is_notification INTEGER DEFAULT 0,
|
|
checked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
tenant_id INTEGER REFERENCES organizations(id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS refresh_log (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
incident_id INTEGER REFERENCES incidents(id) ON DELETE CASCADE,
|
|
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
completed_at TIMESTAMP,
|
|
articles_found INTEGER DEFAULT 0,
|
|
status TEXT DEFAULT 'running',
|
|
tenant_id INTEGER REFERENCES organizations(id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS incident_snapshots (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
incident_id INTEGER REFERENCES incidents(id) ON DELETE CASCADE,
|
|
summary TEXT,
|
|
sources_json TEXT,
|
|
article_count INTEGER DEFAULT 0,
|
|
fact_check_count INTEGER DEFAULT 0,
|
|
refresh_log_id INTEGER REFERENCES refresh_log(id),
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
tenant_id INTEGER REFERENCES organizations(id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS sources (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
url TEXT,
|
|
domain TEXT,
|
|
source_type TEXT NOT NULL DEFAULT 'rss_feed',
|
|
category TEXT NOT NULL DEFAULT 'sonstige',
|
|
status TEXT NOT NULL DEFAULT 'active',
|
|
notes TEXT,
|
|
added_by TEXT,
|
|
article_count INTEGER DEFAULT 0,
|
|
last_seen_at TIMESTAMP,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
tenant_id INTEGER REFERENCES organizations(id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS notifications (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
incident_id INTEGER REFERENCES incidents(id) ON DELETE CASCADE,
|
|
type TEXT NOT NULL DEFAULT 'refresh_summary',
|
|
title TEXT NOT NULL,
|
|
text TEXT NOT NULL,
|
|
icon TEXT DEFAULT 'info',
|
|
is_read INTEGER DEFAULT 0,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
tenant_id INTEGER REFERENCES organizations(id)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_notifications_user_read ON notifications(user_id, is_read);
|
|
|
|
CREATE TABLE IF NOT EXISTS incident_subscriptions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
incident_id INTEGER NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
|
|
notify_email_summary INTEGER DEFAULT 0,
|
|
notify_email_new_articles INTEGER DEFAULT 0,
|
|
notify_email_status_change INTEGER DEFAULT 0,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(user_id, incident_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS article_locations (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
article_id INTEGER REFERENCES articles(id) ON DELETE CASCADE,
|
|
incident_id INTEGER REFERENCES incidents(id) ON DELETE CASCADE,
|
|
location_name TEXT NOT NULL,
|
|
location_name_normalized TEXT,
|
|
country_code TEXT,
|
|
latitude REAL NOT NULL,
|
|
longitude REAL NOT NULL,
|
|
confidence REAL DEFAULT 0.0,
|
|
source_text TEXT,
|
|
tenant_id INTEGER REFERENCES organizations(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 TABLE IF NOT EXISTS source_health_checks (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
|
|
check_type TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
message TEXT,
|
|
details TEXT,
|
|
checked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_source_health_source ON source_health_checks(source_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS source_suggestions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
suggestion_type TEXT NOT NULL,
|
|
title TEXT NOT NULL,
|
|
description TEXT,
|
|
source_id INTEGER REFERENCES sources(id) ON DELETE SET NULL,
|
|
suggested_data TEXT,
|
|
priority TEXT DEFAULT 'medium',
|
|
status TEXT DEFAULT 'pending',
|
|
reviewed_at TIMESTAMP,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
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)
|
|
);
|
|
"""
|
|
|
|
|
|
async def get_db() -> aiosqlite.Connection:
|
|
"""Erstellt eine neue Datenbankverbindung."""
|
|
os.makedirs(DATA_DIR, exist_ok=True)
|
|
db = await aiosqlite.connect(DB_PATH)
|
|
db.row_factory = aiosqlite.Row
|
|
await db.execute("PRAGMA journal_mode=WAL")
|
|
await db.execute("PRAGMA foreign_keys=ON")
|
|
await db.execute("PRAGMA busy_timeout=5000")
|
|
return db
|
|
|
|
|
|
async def init_db():
|
|
"""Initialisiert die Datenbank mit dem Schema."""
|
|
db = await get_db()
|
|
try:
|
|
await db.executescript(SCHEMA)
|
|
await db.commit()
|
|
|
|
# --- Migrationen fuer bestehende Datenbanken ---
|
|
|
|
# Incidents-Spalten pruefen
|
|
cursor = await db.execute("PRAGMA table_info(incidents)")
|
|
columns = [row[1] for row in await cursor.fetchall()]
|
|
|
|
if "type" not in columns:
|
|
await db.execute("ALTER TABLE incidents ADD COLUMN type TEXT DEFAULT 'adhoc'")
|
|
await db.commit()
|
|
|
|
if "sources_json" not in columns:
|
|
await db.execute("ALTER TABLE incidents ADD COLUMN sources_json TEXT")
|
|
await db.commit()
|
|
|
|
if "international_sources" not in columns:
|
|
await db.execute("ALTER TABLE incidents ADD COLUMN international_sources INTEGER DEFAULT 1")
|
|
await db.commit()
|
|
|
|
if "visibility" not in columns:
|
|
await db.execute("ALTER TABLE incidents ADD COLUMN visibility TEXT DEFAULT 'public'")
|
|
await db.commit()
|
|
|
|
if "include_telegram" not in columns:
|
|
await db.execute("ALTER TABLE incidents ADD COLUMN include_telegram INTEGER DEFAULT 0")
|
|
await db.commit()
|
|
logger.info("Migration: include_telegram zu incidents hinzugefuegt")
|
|
|
|
if "telegram_categories" not in columns:
|
|
await db.execute("ALTER TABLE incidents ADD COLUMN telegram_categories TEXT DEFAULT NULL")
|
|
await db.commit()
|
|
logger.info("Migration: telegram_categories zu incidents hinzugefuegt")
|
|
|
|
if "category_labels" not in columns:
|
|
await db.execute("ALTER TABLE incidents ADD COLUMN category_labels TEXT")
|
|
await db.commit()
|
|
logger.info("Migration: category_labels zu incidents hinzugefuegt")
|
|
|
|
if "tenant_id" not in columns:
|
|
await db.execute("ALTER TABLE incidents ADD COLUMN tenant_id INTEGER REFERENCES organizations(id)")
|
|
await db.commit()
|
|
logger.info("Migration: tenant_id zu incidents hinzugefuegt")
|
|
|
|
# Migration: Token-Spalten fuer refresh_log
|
|
cursor = await db.execute("PRAGMA table_info(refresh_log)")
|
|
rl_columns = [row[1] for row in await cursor.fetchall()]
|
|
if "input_tokens" not in rl_columns:
|
|
await db.execute("ALTER TABLE refresh_log ADD COLUMN input_tokens INTEGER DEFAULT 0")
|
|
await db.execute("ALTER TABLE refresh_log ADD COLUMN output_tokens INTEGER DEFAULT 0")
|
|
await db.execute("ALTER TABLE refresh_log ADD COLUMN cache_creation_tokens INTEGER DEFAULT 0")
|
|
await db.execute("ALTER TABLE refresh_log ADD COLUMN cache_read_tokens INTEGER DEFAULT 0")
|
|
await db.execute("ALTER TABLE refresh_log ADD COLUMN total_cost_usd REAL DEFAULT 0.0")
|
|
await db.execute("ALTER TABLE refresh_log ADD COLUMN api_calls INTEGER DEFAULT 0")
|
|
await db.commit()
|
|
|
|
if "trigger_type" not in rl_columns:
|
|
await db.execute("ALTER TABLE refresh_log ADD COLUMN trigger_type TEXT DEFAULT 'manual'")
|
|
await db.commit()
|
|
|
|
if "retry_count" not in rl_columns:
|
|
await db.execute("ALTER TABLE refresh_log ADD COLUMN retry_count INTEGER DEFAULT 0")
|
|
await db.execute("ALTER TABLE refresh_log ADD COLUMN error_message TEXT")
|
|
await db.commit()
|
|
|
|
if "tenant_id" not in rl_columns:
|
|
await db.execute("ALTER TABLE refresh_log ADD COLUMN tenant_id INTEGER REFERENCES organizations(id)")
|
|
await db.commit()
|
|
|
|
# Migration: notifications-Tabelle (fuer bestehende DBs)
|
|
cursor = await db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='notifications'")
|
|
if not await cursor.fetchone():
|
|
await db.executescript("""
|
|
CREATE TABLE IF NOT EXISTS notifications (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
incident_id INTEGER REFERENCES incidents(id) ON DELETE CASCADE,
|
|
type TEXT NOT NULL DEFAULT 'refresh_summary',
|
|
title TEXT NOT NULL,
|
|
text TEXT NOT NULL,
|
|
icon TEXT DEFAULT 'info',
|
|
is_read INTEGER DEFAULT 0,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
tenant_id INTEGER REFERENCES organizations(id)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_notifications_user_read ON notifications(user_id, is_read);
|
|
""")
|
|
await db.commit()
|
|
|
|
# Migration: incident_subscriptions-Tabelle (fuer bestehende DBs)
|
|
cursor = await db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='incident_subscriptions'")
|
|
if not await cursor.fetchone():
|
|
await db.executescript("""
|
|
CREATE TABLE IF NOT EXISTS incident_subscriptions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
incident_id INTEGER NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
|
|
notify_email_summary INTEGER DEFAULT 0,
|
|
notify_email_new_articles INTEGER DEFAULT 0,
|
|
notify_email_status_change INTEGER DEFAULT 0,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE(user_id, incident_id)
|
|
);
|
|
""")
|
|
await db.commit()
|
|
logger.info("Migration: incident_subscriptions-Tabelle erstellt")
|
|
else:
|
|
# Migration: Spalte umbenennen contradiction -> new_articles
|
|
cursor = await db.execute("PRAGMA table_info(incident_subscriptions)")
|
|
sub_columns = [row[1] for row in await cursor.fetchall()]
|
|
if "notify_email_contradiction" in sub_columns:
|
|
await db.execute("ALTER TABLE incident_subscriptions RENAME COLUMN notify_email_contradiction TO notify_email_new_articles")
|
|
await db.commit()
|
|
logger.info("Migration: notify_email_contradiction -> notify_email_new_articles umbenannt")
|
|
|
|
# Migration: role-Spalte fuer users
|
|
cursor = await db.execute("PRAGMA table_info(users)")
|
|
user_columns = [row[1] for row in await cursor.fetchall()]
|
|
if "role" not in user_columns:
|
|
await db.execute("ALTER TABLE users ADD COLUMN role TEXT DEFAULT 'member'")
|
|
await db.execute("UPDATE users SET role = 'org_admin'")
|
|
await db.commit()
|
|
logger.info("Migration: role-Spalte zu users hinzugefuegt")
|
|
|
|
# Migration: email, organization_id, is_active, last_login_at fuer users
|
|
if "email" not in user_columns:
|
|
await db.execute("ALTER TABLE users ADD COLUMN email TEXT")
|
|
await db.commit()
|
|
logger.info("Migration: email zu users hinzugefuegt")
|
|
|
|
if "organization_id" not in user_columns:
|
|
await db.execute("ALTER TABLE users ADD COLUMN organization_id INTEGER REFERENCES organizations(id)")
|
|
await db.commit()
|
|
logger.info("Migration: organization_id zu users hinzugefuegt")
|
|
|
|
# Index erst nach Spalten-Migration erstellen
|
|
try:
|
|
await db.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_org_username ON users(organization_id, username)")
|
|
await db.commit()
|
|
except Exception:
|
|
pass # Index existiert bereits oder Spalte fehlt noch
|
|
|
|
if "is_active" not in user_columns:
|
|
await db.execute("ALTER TABLE users ADD COLUMN is_active INTEGER DEFAULT 1")
|
|
await db.commit()
|
|
|
|
if "last_login_at" not in user_columns:
|
|
await db.execute("ALTER TABLE users ADD COLUMN last_login_at TIMESTAMP")
|
|
await db.commit()
|
|
|
|
# Migration: tenant_id fuer articles
|
|
cursor = await db.execute("PRAGMA table_info(articles)")
|
|
art_columns = [row[1] for row in await cursor.fetchall()]
|
|
if "tenant_id" not in art_columns:
|
|
await db.execute("ALTER TABLE articles ADD COLUMN tenant_id INTEGER REFERENCES organizations(id)")
|
|
await db.commit()
|
|
|
|
# Migration: tenant_id fuer fact_checks
|
|
cursor = await db.execute("PRAGMA table_info(fact_checks)")
|
|
fc_columns = [row[1] for row in await cursor.fetchall()]
|
|
if "tenant_id" not in fc_columns:
|
|
await db.execute("ALTER TABLE fact_checks ADD COLUMN tenant_id INTEGER REFERENCES organizations(id)")
|
|
await db.commit()
|
|
|
|
# Migration: status_history fuer fact_checks (Faktencheck-Verlauf)
|
|
if "status_history" not in fc_columns:
|
|
await db.execute("ALTER TABLE fact_checks ADD COLUMN status_history TEXT DEFAULT '[]'")
|
|
# Bestehende Eintraege initialisieren
|
|
cursor2 = await db.execute("SELECT id, status, checked_at FROM fact_checks")
|
|
for row2 in await cursor2.fetchall():
|
|
import json as _json
|
|
initial_history = _json.dumps([{"status": row2[1], "at": str(row2[2])}])
|
|
await db.execute("UPDATE fact_checks SET status_history = ? WHERE id = ?", (initial_history, row2[0]))
|
|
await db.commit()
|
|
logger.info("Migration: status_history zu fact_checks hinzugefuegt")
|
|
|
|
# Migration: category fuer article_locations (Marker-Klassifizierung)
|
|
cursor = await db.execute("PRAGMA table_info(article_locations)")
|
|
al_columns = [row[1] for row in await cursor.fetchall()]
|
|
if "category" not in al_columns:
|
|
await db.execute("ALTER TABLE article_locations ADD COLUMN category TEXT DEFAULT 'mentioned'")
|
|
await db.commit()
|
|
logger.info("Migration: category zu article_locations hinzugefuegt")
|
|
|
|
# Migration: Alte Kategorie-Werte auf neue Keys umbenennen
|
|
try:
|
|
await db.execute(
|
|
"UPDATE article_locations SET category = 'primary' WHERE category = 'target'"
|
|
)
|
|
await db.execute(
|
|
"UPDATE article_locations SET category = 'secondary' WHERE category IN ('response', 'retaliation')"
|
|
)
|
|
await db.execute(
|
|
"UPDATE article_locations SET category = 'tertiary' WHERE category IN ('actor', 'context')"
|
|
)
|
|
changed = db.total_changes
|
|
await db.commit()
|
|
if changed > 0:
|
|
logger.info("Migration: article_locations Kategorien umbenannt (target->primary, response/retaliation->secondary, actor->tertiary)")
|
|
except Exception:
|
|
pass # Bereits migriert oder keine Daten
|
|
|
|
# Migration: tenant_id fuer incident_snapshots
|
|
cursor = await db.execute("PRAGMA table_info(incident_snapshots)")
|
|
snap_columns2 = [row[1] for row in await cursor.fetchall()]
|
|
if "tenant_id" not in snap_columns2:
|
|
await db.execute("ALTER TABLE incident_snapshots ADD COLUMN tenant_id INTEGER REFERENCES organizations(id)")
|
|
await db.commit()
|
|
|
|
# Migration: tenant_id fuer sources
|
|
cursor = await db.execute("PRAGMA table_info(sources)")
|
|
src_columns = [row[1] for row in await cursor.fetchall()]
|
|
if "tenant_id" not in src_columns:
|
|
await db.execute("ALTER TABLE sources ADD COLUMN tenant_id INTEGER REFERENCES organizations(id)")
|
|
await db.commit()
|
|
|
|
# Migration: tenant_id fuer notifications
|
|
cursor = await db.execute("PRAGMA table_info(notifications)")
|
|
notif_columns = [row[1] for row in await cursor.fetchall()]
|
|
if "tenant_id" not in notif_columns:
|
|
await db.execute("ALTER TABLE notifications ADD COLUMN tenant_id INTEGER REFERENCES organizations(id)")
|
|
await db.commit()
|
|
|
|
# Indexes erstellen (nach Spalten-Migrationen)
|
|
for idx_sql in [
|
|
"CREATE INDEX IF NOT EXISTS idx_incidents_tenant_status ON incidents(tenant_id, status)",
|
|
"CREATE INDEX IF NOT EXISTS idx_articles_tenant_incident ON articles(tenant_id, incident_id)",
|
|
]:
|
|
try:
|
|
await db.execute(idx_sql)
|
|
await db.commit()
|
|
except Exception:
|
|
pass
|
|
|
|
# Migration: article_locations-Tabelle (fuer bestehende DBs)
|
|
cursor = await db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='article_locations'")
|
|
if not await cursor.fetchone():
|
|
await db.executescript("""
|
|
CREATE TABLE IF NOT EXISTS article_locations (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
article_id INTEGER REFERENCES articles(id) ON DELETE CASCADE,
|
|
incident_id INTEGER REFERENCES incidents(id) ON DELETE CASCADE,
|
|
location_name TEXT NOT NULL,
|
|
location_name_normalized TEXT,
|
|
country_code TEXT,
|
|
latitude REAL NOT NULL,
|
|
longitude REAL NOT NULL,
|
|
confidence REAL DEFAULT 0.0,
|
|
source_text TEXT,
|
|
tenant_id INTEGER REFERENCES organizations(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);
|
|
""")
|
|
await db.commit()
|
|
logger.info("Migration: article_locations-Tabelle erstellt")
|
|
|
|
# Verwaiste running-Eintraege beim Start als error markieren (aelter als 15 Min)
|
|
await db.execute(
|
|
"""UPDATE refresh_log SET status = 'error', error_message = 'Verwaist beim Neustart',
|
|
completed_at = CURRENT_TIMESTAMP
|
|
WHERE status = 'running'
|
|
AND started_at < datetime('now', '-15 minutes')"""
|
|
)
|
|
await db.commit()
|
|
|
|
# Sources-Tabelle seeden (nur wenn leer)
|
|
cursor = await db.execute("SELECT COUNT(*) as cnt FROM sources")
|
|
row = await cursor.fetchone()
|
|
if row["cnt"] == 0:
|
|
await _seed_sources(db)
|
|
|
|
finally:
|
|
await db.close()
|
|
|
|
|
|
async def _seed_sources(db: aiosqlite.Connection):
|
|
"""Befuellt die sources-Tabelle aus der config.py-Konfiguration."""
|
|
from config import RSS_FEEDS, EXCLUDED_SOURCES
|
|
|
|
category_map = {
|
|
"tagesschau": "oeffentlich-rechtlich",
|
|
"ZDF heute": "oeffentlich-rechtlich",
|
|
"Deutsche Welle": "oeffentlich-rechtlich",
|
|
"Spiegel": "qualitaetszeitung",
|
|
"Zeit": "qualitaetszeitung",
|
|
"FAZ": "qualitaetszeitung",
|
|
"Süddeutsche": "qualitaetszeitung",
|
|
"NZZ": "qualitaetszeitung",
|
|
"Reuters": "nachrichtenagentur",
|
|
"AP News": "nachrichtenagentur",
|
|
"BBC World": "international",
|
|
"Al Jazeera": "international",
|
|
"France24": "international",
|
|
"BMI": "behoerde",
|
|
"Europol": "behoerde",
|
|
}
|
|
|
|
for _rss_category, feeds in RSS_FEEDS.items():
|
|
for feed in feeds:
|
|
name = feed["name"]
|
|
url = feed["url"]
|
|
try:
|
|
from urllib.parse import urlparse
|
|
domain = urlparse(url).netloc.lower().replace("www.", "")
|
|
except Exception:
|
|
domain = ""
|
|
|
|
category = category_map.get(name, "sonstige")
|
|
await db.execute(
|
|
"""INSERT INTO sources (name, url, domain, source_type, category, status, added_by, tenant_id)
|
|
VALUES (?, ?, ?, 'rss_feed', ?, 'active', 'system', NULL)""",
|
|
(name, url, domain, category),
|
|
)
|
|
|
|
for excl in EXCLUDED_SOURCES:
|
|
await db.execute(
|
|
"""INSERT INTO sources (name, domain, source_type, category, status, added_by, tenant_id)
|
|
VALUES (?, ?, 'excluded', 'sonstige', 'active', 'system', NULL)""",
|
|
(excl, excl),
|
|
)
|
|
|
|
await db.commit()
|
|
await refresh_source_counts(db)
|
|
|
|
logger.info(f"Sources-Tabelle geseeded: {len(RSS_FEEDS.get('deutsch', []))+len(RSS_FEEDS.get('international', []))+len(RSS_FEEDS.get('behoerden', []))} RSS-Feeds, {len(EXCLUDED_SOURCES)} ausgeschlossene Quellen")
|
|
|
|
|
|
async def refresh_source_counts(db: aiosqlite.Connection):
|
|
"""Berechnet Artikelzaehler und last_seen_at fuer alle Quellen neu."""
|
|
cursor = await db.execute("SELECT id, name, domain FROM sources WHERE source_type != 'excluded'")
|
|
sources = await cursor.fetchall()
|
|
|
|
for source in sources:
|
|
sid = source["id"]
|
|
name = source["name"]
|
|
domain = source["domain"] or ""
|
|
|
|
if domain:
|
|
cursor = await db.execute(
|
|
"""SELECT COUNT(*) as cnt, MAX(collected_at) as last_seen
|
|
FROM articles WHERE source = ? OR source_url LIKE ?""",
|
|
(name, f"%{domain}%"),
|
|
)
|
|
else:
|
|
cursor = await db.execute(
|
|
"SELECT COUNT(*) as cnt, MAX(collected_at) as last_seen FROM articles WHERE source = ?",
|
|
(name,),
|
|
)
|
|
row = await cursor.fetchone()
|
|
await db.execute(
|
|
"UPDATE sources SET article_count = ?, last_seen_at = ? WHERE id = ?",
|
|
(row["cnt"], row["last_seen"], sid),
|
|
)
|
|
|
|
await db.commit()
|
|
|
|
|
|
async def db_dependency():
|
|
"""FastAPI Dependency fuer Datenbankverbindungen."""
|
|
db = await get_db()
|
|
try:
|
|
yield db
|
|
finally:
|
|
await db.close()
|