""" BAN YARO — Datenbank SQLite mit WAL-Modus (bewährt von akku-werkstatt). """ import sqlite3 import os import logging from contextlib import contextmanager logger = logging.getLogger(__name__) DB_PATH = os.getenv("DB_PATH", "/data/banyaro.db") def get_connection() -> sqlite3.Connection: conn = sqlite3.connect(DB_PATH, check_same_thread=False) conn.row_factory = sqlite3.Row conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA foreign_keys=ON") conn.execute("PRAGMA busy_timeout=5000") return conn @contextmanager def db(): conn = get_connection() try: yield conn conn.commit() except Exception: conn.rollback() raise finally: conn.close() def init_db(): """Erstellt alle Tabellen falls nicht vorhanden.""" logger.info(f"Initialisiere Datenbank: {DB_PATH}") os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) with db() as conn: conn.executescript(""" -- USERS CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, pw_hash TEXT NOT NULL, name TEXT NOT NULL, rolle TEXT NOT NULL DEFAULT 'user', is_premium INTEGER NOT NULL DEFAULT 0, push_sub TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), last_login TEXT ); -- HUNDE CREATE TABLE IF NOT EXISTS dogs ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, rasse TEXT, geburtstag TEXT, geschlecht TEXT, gewicht_kg REAL, chip_nr TEXT, foto_url TEXT, bio TEXT, is_public INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); -- TAGEBUCH CREATE TABLE IF NOT EXISTS diary ( id INTEGER PRIMARY KEY AUTOINCREMENT, dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, datum TEXT NOT NULL DEFAULT (date('now')), typ TEXT NOT NULL DEFAULT 'eintrag', titel TEXT, text TEXT, media_url TEXT, tags TEXT, -- JSON Array gps_lat REAL, gps_lon REAL, is_milestone INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_diary_dog ON diary(dog_id, datum DESC); -- TAGEBUCH ↔ HUNDE (n:m — ein Eintrag kann mehrere Hunde betreffen) CREATE TABLE IF NOT EXISTS diary_dogs ( diary_id INTEGER NOT NULL REFERENCES diary(id) ON DELETE CASCADE, dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, PRIMARY KEY (diary_id, dog_id) ); CREATE INDEX IF NOT EXISTS idx_diary_dogs_dog ON diary_dogs(dog_id); -- GESUNDHEIT CREATE TABLE IF NOT EXISTS health ( id INTEGER PRIMARY KEY AUTOINCREMENT, dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, typ TEXT NOT NULL, -- impfung | entwurmung | tierarzt | medikament | gewicht bezeichnung TEXT NOT NULL, datum TEXT NOT NULL, naechstes TEXT, notiz TEXT, wert REAL, -- für Gewicht o.ä. einheit TEXT, erinnerung INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_health_dog ON health(dog_id, datum DESC); -- GIFTKÖDER-ALARM CREATE TABLE IF NOT EXISTS poison ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id), lat REAL NOT NULL, lon REAL NOT NULL, beschreibung TEXT, typ TEXT DEFAULT 'unbekannt', foto_url TEXT, bestaetigt INTEGER NOT NULL DEFAULT 0, bestaetigt_von INTEGER, geloest INTEGER NOT NULL DEFAULT 0, expires_at TEXT NOT NULL, -- auto-expire nach 7 Tagen created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_poison_location ON poison(lat, lon); CREATE INDEX IF NOT EXISTS idx_poison_active ON poison(geloest, expires_at); -- ORTE (hundefreundliche Orte, Kotbeutelspender, etc.) CREATE TABLE IF NOT EXISTS places ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER REFERENCES users(id), name TEXT NOT NULL, typ TEXT NOT NULL, -- restaurant | shop | freilauf | kotbeutel | tierarzt | hundeschule lat REAL NOT NULL, lon REAL NOT NULL, adresse TEXT, website TEXT, hund_rein INTEGER, leine_pflicht INTEGER, wasser_fuer_hunde INTEGER, foto_url TEXT, bewertung REAL DEFAULT 0, anz_bewertungen INTEGER DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_places_location ON places(lat, lon); CREATE INDEX IF NOT EXISTS idx_places_typ ON places(typ); -- ROUTEN CREATE TABLE IF NOT EXISTS routes ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id), name TEXT NOT NULL, beschreibung TEXT, gps_track TEXT NOT NULL, -- JSON Array von {lat, lon} distanz_km REAL, dauer_min INTEGER, schwierigkeit TEXT DEFAULT 'leicht', untergrund TEXT, -- wald | asphalt | wiese | mix schatten INTEGER, leine_empfohlen INTEGER, bewertung REAL DEFAULT 0, anz_bewertungen INTEGER DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); -- GASSI-TREFFEN CREATE TABLE IF NOT EXISTS walks ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id), titel TEXT NOT NULL, datum TEXT NOT NULL, uhrzeit TEXT NOT NULL, lat REAL NOT NULL, lon REAL NOT NULL, ort_name TEXT, max_teilnehmer INTEGER DEFAULT 10, beschreibung TEXT, status TEXT NOT NULL DEFAULT 'offen', created_at TEXT NOT NULL DEFAULT (datetime('now')) ); -- GASSI-TREFFEN TEILNEHMER CREATE TABLE IF NOT EXISTS walk_participants ( walk_id INTEGER NOT NULL REFERENCES walks(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, dog_id INTEGER REFERENCES dogs(id), joined_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (walk_id, user_id) ); -- GASSI-TREFFEN ↔ HUNDE (n:m — Teilnehmer kann mehrere Hunde mitbringen) CREATE TABLE IF NOT EXISTS walk_participant_dogs ( walk_id INTEGER NOT NULL REFERENCES walks(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, PRIMARY KEY (walk_id, user_id, dog_id) ); CREATE INDEX IF NOT EXISTS idx_wpd_dog ON walk_participant_dogs(dog_id); -- FORUM CREATE TABLE IF NOT EXISTS forum_threads ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id), dog_id INTEGER REFERENCES dogs(id), titel TEXT NOT NULL, kategorie TEXT NOT NULL DEFAULT 'allgemein', rasse_tag TEXT, plz TEXT, views INTEGER NOT NULL DEFAULT 0, pinned INTEGER NOT NULL DEFAULT 0, locked INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_threads_kat ON forum_threads(kategorie, created_at DESC); CREATE TABLE IF NOT EXISTS forum_posts ( id INTEGER PRIMARY KEY AUTOINCREMENT, thread_id INTEGER NOT NULL REFERENCES forum_threads(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id), text TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), edited_at TEXT ); -- PUSH SUBSCRIPTIONS (alternativ zu users.push_sub für mehrere Geräte) CREATE TABLE IF NOT EXISTS push_subscriptions ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, endpoint TEXT NOT NULL UNIQUE, p256dh TEXT NOT NULL, auth TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); -- PREMIUM-TRANSAKTIONEN (später für Zahlungsabwicklung) CREATE TABLE IF NOT EXISTS premium_orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id), status TEXT NOT NULL DEFAULT 'pending', betrag_cent INTEGER NOT NULL, zahlungsart TEXT, valid_until TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); -- TIERÄRZTE (user-level, nie löschen — Historien-Erhalt bei Umzug) CREATE TABLE IF NOT EXISTS tieraerzte ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, strasse TEXT, plz TEXT, ort TEXT, telefon TEXT, notfall_telefon TEXT, email TEXT, website TEXT, notizen TEXT, ist_notfallpraxis INTEGER NOT NULL DEFAULT 0, aktiv INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_tieraerzte_user ON tieraerzte(user_id, aktiv); """) # Migrations: neue Spalten zu bestehenden Tabellen hinzufügen (idempotent) _migrate(conn_factory=db) logger.info("Datenbank initialisiert.") def _migrate(conn_factory): """Fügt fehlende Spalten hinzu und führt Datenmigration durch (idempotent).""" migrations = [ # Giftköder: Auflösungs-Details für spätere KI-Analyse ("poison", "geloest_von", "INTEGER"), ("poison", "geloest_at", "TEXT"), ("poison", "geloest_grund", "TEXT"), # Gesundheit: erweiterte Felder je nach Eintragstyp ("health", "charge_nr", "TEXT"), ("health", "tierarzt_name", "TEXT"), ("health", "kosten", "REAL"), ("health", "diagnose", "TEXT"), ("health", "dosierung", "TEXT"), ("health", "haeufigkeit", "TEXT"), ("health", "aktiv", "INTEGER NOT NULL DEFAULT 1"), ("health", "bis_datum", "TEXT"), ("health", "schweregrad", "TEXT"), ("health", "reaktion", "TEXT"), ("health", "datei_url", "TEXT"), ("health", "datei_typ", "TEXT"), ("health", "tierarzt_id", "INTEGER"), # Tierärzte: Adresse aufgeteilt in Strasse/PLZ/Ort ("tieraerzte", "strasse", "TEXT"), ("tieraerzte", "plz", "TEXT"), ("tieraerzte", "ort", "TEXT"), ] with conn_factory() as conn: for table, column, col_type in migrations: existing = [ row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall() ] if column not in existing: conn.execute( f"ALTER TABLE {table} ADD COLUMN {column} {col_type}" ) logger.info(f"Migration: {table}.{column} hinzugefügt.") # Datenmigration: diary_dogs für bestehende Einträge befüllen conn.execute(""" INSERT OR IGNORE INTO diary_dogs (diary_id, dog_id) SELECT id, dog_id FROM diary """) logger.info("Migration: diary_dogs Backfill abgeschlossen.")