""" BAN YARO — Datenbank SQLite mit WAL-Modus (bewährt von akku-werkstatt). """ import sqlite3 import os import logging import unicodedata from contextlib import contextmanager logger = logging.getLogger(__name__) DB_PATH = os.getenv("DB_PATH", "/data/banyaro.db") def _norm(s): """Diakritika entfernen + lowercase — für akzentunabhängige Suche.""" if not s: return '' return unicodedata.normalize('NFKD', str(s)).encode('ascii', 'ignore').decode('ascii').lower() 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") conn.create_function('norm', 1, _norm) 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')) ); CREATE TABLE IF NOT EXISTS route_walks ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, route_id INTEGER REFERENCES routes(id) ON DELETE SET NULL, walked_km REAL NOT NULL, walked_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_route_walks_user ON route_walks(user_id); CREATE TABLE IF NOT EXISTS exercise_progress ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, exercise_id TEXT NOT NULL, status TEXT, updated_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(user_id, exercise_id) ); CREATE INDEX IF NOT EXISTS idx_exercise_progress_user ON exercise_progress(user_id); CREATE TABLE IF NOT EXISTS training_plan_progress ( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, item_key TEXT NOT NULL, checked INTEGER NOT NULL DEFAULT 1, checked_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (user_id, item_key) ); -- 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')) ); -- EVENTS (Hundeveranstaltungen) CREATE TABLE IF NOT EXISTS events ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id), titel TEXT NOT NULL, datum TEXT NOT NULL, -- YYYY-MM-DD uhrzeit TEXT, lat REAL, lon REAL, ort_name TEXT, typ TEXT NOT NULL DEFAULT 'sonstiges', beschreibung TEXT, link TEXT, status TEXT NOT NULL DEFAULT 'aktiv', created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_events_datum ON events(datum ASC); -- SITTING — Sitter-Profile CREATE TABLE IF NOT EXISTS sitters ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, beschreibung TEXT, preis_pro_tag REAL DEFAULT 0, max_hunde INTEGER DEFAULT 1, lat REAL, lon REAL, radius_km INTEGER DEFAULT 20, services TEXT DEFAULT '[]', -- JSON: ['tagesbetreuung','uebernachtung','gassi','hausbesuch'] aktiv INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); -- SITTING — Anfragen CREATE TABLE IF NOT EXISTS sitting_requests ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id), -- Anfragender sitter_id INTEGER NOT NULL REFERENCES sitters(id), dog_ids TEXT DEFAULT '[]', -- JSON Array von TEXT NOT NULL, -- YYYY-MM-DD bis TEXT NOT NULL, nachricht TEXT, status TEXT NOT NULL DEFAULT 'offen', -- offen|angenommen|abgelehnt|abgebrochen created_at TEXT NOT NULL DEFAULT (datetime('now')) ); -- OSM POI-Cache (Mülleimer, Hundewiesen, Wasserstellen aus OpenStreetMap) CREATE TABLE IF NOT EXISTS osm_pois ( osm_id INTEGER NOT NULL, type TEXT NOT NULL, lat REAL NOT NULL, lon REAL NOT NULL, name TEXT, cached_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (osm_id, type) ); CREATE INDEX IF NOT EXISTS idx_osm_pois_loc ON osm_pois(type, lat, lon); -- VERLORENE HUNDE CREATE TABLE IF NOT EXISTS lost_dogs ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL, name TEXT NOT NULL, rasse TEXT, beschreibung TEXT NOT NULL, foto_url TEXT, lat REAL NOT NULL, lon REAL NOT NULL, is_active INTEGER NOT NULL DEFAULT 1, gefunden_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_lost_active ON lost_dogs(is_active, created_at DESC); -- OSM Tile-Cache: welche Kacheln wurden schon geladen? CREATE TABLE IF NOT EXISTS osm_tiles ( type TEXT NOT NULL, tile_key TEXT NOT NULL, -- "zoom_x_y" cached_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (type, tile_key) ); -- Community-Pins: von Nutzern gesetzte Karten-Marker CREATE TABLE IF NOT EXISTS user_map_pois ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, type TEXT NOT NULL, -- waste_basket | drinking_water | dog_park | sonstiges lat REAL NOT NULL, lon REAL NOT NULL, name TEXT, notiz TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_user_pois_loc ON user_map_pois(type, lat, lon); -- Meldungen: ungültige OSM- oder Community-Marker CREATE TABLE IF NOT EXISTS osm_reports ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, osm_id INTEGER, -- gesetzt wenn OSM-Marker gemeldet user_poi_id INTEGER, -- gesetzt wenn Community-Marker gemeldet type TEXT, grund TEXT NOT NULL, -- existiert_nicht | falsche_position | spam | sonstiges 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"), # Gesundheit: Erinnerungsintervall für wiederkehrende Einträge ("health", "intervall_tage", "INTEGER"), # Routen: neue Felder ("routes", "is_public", "INTEGER NOT NULL DEFAULT 1"), ("routes", "hunde_tauglichkeit", "TEXT"), ("routes", "foto_urls", "TEXT NOT NULL DEFAULT '[]'"), # OSM POIs: Kontaktdaten ("osm_pois", "opening_hours", "TEXT"), ("osm_pois", "phone", "TEXT"), ("osm_pois", "website", "TEXT"), # Forum: Threads brauchen text + antworten-Zähler ("forum_threads", "text", "TEXT NOT NULL DEFAULT ''"), ("forum_threads", "antworten", "INTEGER NOT NULL DEFAULT 0"), # Forum Sprint 11: erweiterte Thread-Felder ("forum_threads", "foto_urls", "TEXT"), ("forum_threads", "is_pinned", "INTEGER NOT NULL DEFAULT 0"), ("forum_threads", "is_locked", "INTEGER NOT NULL DEFAULT 0"), ("forum_threads", "is_deleted", "INTEGER NOT NULL DEFAULT 0"), ("forum_threads", "likes", "INTEGER NOT NULL DEFAULT 0"), # Forum Sprint 11: erweiterte Post-Felder ("forum_posts", "foto_urls", "TEXT"), ("forum_posts", "is_deleted", "INTEGER NOT NULL DEFAULT 0"), ("forum_posts", "likes", "INTEGER NOT NULL DEFAULT 0"), # Users: Moderator-Flag + Forum-Standort ("users", "is_moderator", "INTEGER NOT NULL DEFAULT 0"), ("users", "forum_lat", "REAL"), ("users", "forum_lon", "REAL"), ("users", "forum_show_location", "INTEGER NOT NULL DEFAULT 0"), # Events: Quelle + externe ID für gescrapte Events ("events", "quelle", "TEXT NOT NULL DEFAULT 'nutzer'"), ("events", "external_id", "TEXT"), # Admin: User-Sperre ("users", "is_banned", "INTEGER NOT NULL DEFAULT 0"), ("users", "ban_reason", "TEXT"), # WebCal: Kalender-Abo-Token ("users", "calendar_token", "TEXT"), # User-Profil-Felder ("users", "email_verified", "INTEGER NOT NULL DEFAULT 0"), ("users", "bio", "TEXT"), ("users", "wohnort", "TEXT"), ("users", "erfahrung", "TEXT"), ("users", "social_link", "TEXT"), ("users", "profil_sichtbarkeit", "TEXT NOT NULL DEFAULT 'public'"), ("users", "avatar_url", "TEXT"), ("places", "telefon", "TEXT"), # Chat: Foto-Versand ("direct_messages", "media_url", "TEXT"), ("direct_messages", "media_type", "TEXT"), # Chat: Read Receipts ("direct_messages", "read_at", "TEXT"), # Chat: Online-Indikator ("users", "last_seen", "TEXT"), # Foto-Editor: Zoom + Position ("dogs", "foto_zoom", "REAL NOT NULL DEFAULT 1.0"), ("dogs", "foto_offset_x", "REAL NOT NULL DEFAULT 0.0"), ("dogs", "foto_offset_y", "REAL NOT NULL DEFAULT 0.0"), # Tagebuch: Ortsname (POI/Adresse) ("diary", "location_name", "TEXT"), # Bewertungen: walks + sitters brauchen bewertung + anz_bewertungen ("walks", "bewertung", "REAL DEFAULT 0"), ("walks", "anz_bewertungen", "INTEGER DEFAULT 0"), ("sitters", "bewertung", "REAL DEFAULT 0"), ("sitters", "anz_bewertungen", "INTEGER DEFAULT 0"), # Tagebuch-Medien: Cover-Bild markieren ("diary_media", "is_cover", "INTEGER NOT NULL DEFAULT 0"), # Forum-Threads: optionaler Standort ("forum_threads", "thread_lat", "REAL"), ("forum_threads", "thread_lon", "REAL"), ("forum_threads", "thread_ort", "TEXT"), # Referral (idempotent, falls try/except-Block oben fehlgeschlagen ist) ("users", "referral_code", "TEXT"), ("users", "referred_by", "INTEGER"), # Routen: Original-Werte für Gamification (bleiben nach Kürzen erhalten) ("routes", "original_km", "REAL"), ("routes", "original_dauer_min","INTEGER"), # Gamification: Streaks ("users", "current_streak", "INTEGER NOT NULL DEFAULT 0"), ("users", "max_streak", "INTEGER NOT NULL DEFAULT 0"), ("users", "last_activity_date","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.") # Knigge: Community-Votes conn.executescript(""" CREATE TABLE IF NOT EXISTS knigge_votes ( id INTEGER PRIMARY KEY AUTOINCREMENT, szenario_id TEXT NOT NULL, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, answer TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(szenario_id, user_id) ); """) # Forum Sprint 11: neue Tabellen conn.executescript(""" CREATE TABLE IF NOT EXISTS forum_likes ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, target_type TEXT NOT NULL, target_id INTEGER NOT NULL, UNIQUE(user_id, target_type, target_id) ); CREATE TABLE IF NOT EXISTS forum_reports ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, target_type TEXT NOT NULL, target_id INTEGER NOT NULL, grund TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), resolved INTEGER NOT NULL DEFAULT 0 ); """) # Wiki: Community-Berichte conn.executescript(""" CREATE TABLE IF NOT EXISTS wiki_berichte ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, rasse TEXT NOT NULL, titel TEXT NOT NULL, text TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_wiki_berichte_rasse ON wiki_berichte(rasse, created_at DESC); """) # Hunde-Filme: Bewertungen + Hund des Monats conn.executescript(""" CREATE TABLE IF NOT EXISTS movie_votes ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, film_id TEXT NOT NULL, bewertung INTEGER NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(user_id, film_id) ); CREATE TABLE IF NOT EXISTS hund_des_monats_votes ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, monat TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(user_id, monat) ); """) # Events: Unique-Index für externe IDs (idempotent) conn.executescript(""" CREATE UNIQUE INDEX IF NOT EXISTS idx_events_external ON events(external_id) WHERE external_id IS NOT NULL; """) # Users: Eindeutiger Benutzername (case-insensitive via COLLATE NOCASE) conn.executescript(""" CREATE UNIQUE INDEX IF NOT EXISTS idx_users_name_unique ON users(name COLLATE NOCASE); """) # Wiki: User-Foto-Einreichungen conn.executescript(""" CREATE TABLE IF NOT EXISTS wiki_foto_submissions ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, rasse_id INTEGER NOT NULL REFERENCES wiki_rassen(id) ON DELETE CASCADE, foto_url TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', created_at TEXT NOT NULL DEFAULT (datetime('now')), reviewed_by INTEGER REFERENCES users(id), reviewed_at TEXT, reject_reason TEXT ); CREATE INDEX IF NOT EXISTS idx_wfs_status ON wiki_foto_submissions(status, created_at DESC); """) # Freundschaften + Direktnachrichten conn.executescript(""" CREATE TABLE IF NOT EXISTS friendships ( id INTEGER PRIMARY KEY AUTOINCREMENT, requester_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, addressee_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, status TEXT NOT NULL DEFAULT 'pending', created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT, UNIQUE(requester_id, addressee_id) ); CREATE INDEX IF NOT EXISTS idx_friendships_addressee ON friendships(addressee_id, status); CREATE INDEX IF NOT EXISTS idx_friendships_requester ON friendships(requester_id, status); CREATE TABLE IF NOT EXISTS conversations ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_a_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_b_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, last_msg_at TEXT, a_read_at TEXT, b_read_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(user_a_id, user_b_id) ); CREATE TABLE IF NOT EXISTS direct_messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, conversation_id INTEGER NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, sender_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, text TEXT NOT NULL, is_deleted INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_dm_conv ON direct_messages(conversation_id, created_at ASC); """) # Wiki: Rassen-Datenbank (TheDogAPI) conn.executescript(""" CREATE TABLE IF NOT EXISTS wiki_rassen ( id INTEGER PRIMARY KEY AUTOINCREMENT, external_id INTEGER UNIQUE, name TEXT NOT NULL, name_de TEXT, gruppe TEXT, herkunft TEXT, temperament TEXT, gewicht_min_kg REAL, gewicht_max_kg REAL, groesse TEXT, lebensdauer TEXT, foto_url TEXT, bred_for TEXT, aktivitaet TEXT, wohnung_geeignet INTEGER DEFAULT 0, kinder_geeignet INTEGER DEFAULT 1, erfahrung TEXT DEFAULT 'anfaenger', slug TEXT UNIQUE, created_at TEXT DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_wiki_rassen_slug ON wiki_rassen(slug); CREATE INDEX IF NOT EXISTS idx_wiki_rassen_gruppe ON wiki_rassen(gruppe); """) # 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.") # Benachrichtigungs-Center conn.executescript(""" CREATE TABLE IF NOT EXISTS notifications ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, type TEXT NOT NULL, title TEXT NOT NULL, body TEXT, data TEXT, read_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id, created_at DESC); """) # Hund-Teilen: Einladungssystem conn.executescript(""" CREATE TABLE IF NOT EXISTS dog_shares ( id INTEGER PRIMARY KEY AUTOINCREMENT, dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, shared_with_id INTEGER REFERENCES users(id) ON DELETE SET NULL, invite_token TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'editor', accepted_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_dog_shares_dog ON dog_shares(dog_id); CREATE INDEX IF NOT EXISTS idx_dog_shares_token ON dog_shares(invite_token); CREATE INDEX IF NOT EXISTS idx_dog_shares_user ON dog_shares(shared_with_id); """) logger.info("Migration: dog_shares Tabelle bereit.") # Event-RSVP conn.executescript(""" CREATE TABLE IF NOT EXISTS event_rsvp ( id INTEGER PRIMARY KEY AUTOINCREMENT, event_id INTEGER NOT NULL, user_id INTEGER NOT NULL, status TEXT DEFAULT 'going', created_at TEXT DEFAULT (datetime('now')), UNIQUE(event_id, user_id) ); CREATE INDEX IF NOT EXISTS idx_event_rsvp_event ON event_rsvp(event_id); """) logger.info("Migration: event_rsvp Tabelle bereit.") # Events: user_id NOT NULL Constraint entfernen (für Scheduler-Imports ohne User) _ev_cols = {r[1]: r[3] for r in conn.execute("PRAGMA table_info(events)").fetchall()} if _ev_cols.get("user_id") == 1: conn.executescript(""" CREATE TABLE IF NOT EXISTS events_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, titel TEXT NOT NULL, datum TEXT NOT NULL, uhrzeit TEXT, lat REAL, lon REAL, ort_name TEXT, typ TEXT NOT NULL DEFAULT 'sonstiges', beschreibung TEXT, link TEXT, status TEXT NOT NULL DEFAULT 'aktiv', created_at TEXT NOT NULL DEFAULT (datetime('now')), quelle TEXT NOT NULL DEFAULT 'nutzer', external_id TEXT ); INSERT OR IGNORE INTO events_new SELECT * FROM events; DROP TABLE events; ALTER TABLE events_new RENAME TO events; CREATE INDEX IF NOT EXISTS idx_events_datum ON events(datum ASC); CREATE UNIQUE INDEX IF NOT EXISTS idx_events_external ON events(external_id) WHERE external_id IS NOT NULL; """) logger.info("Migration: events.user_id NOT NULL Constraint entfernt.") # Service-Angebote: Sitting + Walks Matching conn.executescript(""" CREATE TABLE IF NOT EXISTS service_offers ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, type TEXT NOT NULL, -- 'sitting' oder 'walks' beschreibung TEXT, preis_pro_tag REAL, lat REAL, lon REAL, radius_km INTEGER DEFAULT 10, aktiv INTEGER DEFAULT 1, created_at TEXT DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_service_offers_type ON service_offers(type, aktiv); CREATE INDEX IF NOT EXISTS idx_service_offers_user ON service_offers(user_id, type); """) # Ratings — einheitliches Bewertungssystem conn.executescript(""" CREATE TABLE IF NOT EXISTS ratings ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, target_type TEXT NOT NULL, target_id INTEGER NOT NULL, stars INTEGER NOT NULL, kommentar TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(user_id, target_type, target_id) ); CREATE INDEX IF NOT EXISTS idx_ratings_target ON ratings(target_type, target_id); """) logger.info("Migration: ratings Tabelle bereit.") # Tagebuch: mehrere Mediendateien pro Eintrag conn.executescript(""" CREATE TABLE IF NOT EXISTS diary_media ( id INTEGER PRIMARY KEY AUTOINCREMENT, diary_id INTEGER NOT NULL REFERENCES diary(id) ON DELETE CASCADE, url TEXT NOT NULL, media_type TEXT NOT NULL DEFAULT 'image', sort_order INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_diary_media_entry ON diary_media(diary_id, sort_order); """) logger.info("Migration: diary_media Tabelle bereit.") # Gesundheit: mehrere Mediendateien pro Eintrag conn.executescript(""" CREATE TABLE IF NOT EXISTS health_media ( id INTEGER PRIMARY KEY AUTOINCREMENT, health_id INTEGER NOT NULL REFERENCES health(id) ON DELETE CASCADE, url TEXT NOT NULL, media_type TEXT NOT NULL DEFAULT 'image', sort_order INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_health_media_entry ON health_media(health_id, sort_order); """) logger.info("Migration: health_media Tabelle bereit.") # Gasthund-Zugang: Sitter darf temporär für Hund schreiben conn.executescript(""" CREATE TABLE IF NOT EXISTS sitting_subscriptions ( id INTEGER PRIMARY KEY AUTOINCREMENT, dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, owner_id INTEGER NOT NULL REFERENCES users(id), sitter_id INTEGER NOT NULL REFERENCES users(id), valid_until TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now')), UNIQUE(dog_id, sitter_id) ); CREATE INDEX IF NOT EXISTS idx_sitting_sub_sitter ON sitting_subscriptions(sitter_id, valid_until); CREATE INDEX IF NOT EXISTS idx_sitting_sub_owner ON sitting_subscriptions(owner_id); """) logger.info("Migration: sitting_subscriptions Tabelle bereit.") # Walk-Einladungen (RSVP) conn.executescript(""" CREATE TABLE IF NOT EXISTS walk_invitations ( id INTEGER PRIMARY KEY AUTOINCREMENT, walk_id INTEGER NOT NULL REFERENCES walks(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, status TEXT NOT NULL DEFAULT 'invited', invited_at TEXT NOT NULL DEFAULT (datetime('now')), responded_at TEXT, UNIQUE(walk_id, user_id) ); CREATE INDEX IF NOT EXISTS idx_walk_inv_walk ON walk_invitations(walk_id); CREATE INDEX IF NOT EXISTS idx_walk_inv_user ON walk_invitations(user_id, status); """) logger.info("Migration: walk_invitations Tabelle bereit.") # Referral-Code für jeden User (einmalig generiert) try: conn.execute("ALTER TABLE users ADD COLUMN referral_code TEXT UNIQUE") conn.execute("ALTER TABLE users ADD COLUMN referred_by INTEGER REFERENCES users(id)") # Bestehende User bekommen einen Code import secrets, string rows = conn.execute("SELECT id FROM users WHERE referral_code IS NULL").fetchall() for r in rows: code = ''.join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(8)) conn.execute("UPDATE users SET referral_code=? WHERE id=?", (code, r['id'])) logger.info("Migration: referral_code + referred_by zu users hinzugefügt.") except Exception: pass # Gamification: Badge-Tabelle conn.executescript(""" CREATE TABLE IF NOT EXISTS user_badges ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, badge_id TEXT NOT NULL, earned_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(user_id, badge_id) ); CREATE INDEX IF NOT EXISTS idx_user_badges_user ON user_badges(user_id); """) logger.info("Migration: user_badges Tabelle bereit.") # Username / Real-Name Trennung try: conn.execute("ALTER TABLE users ADD COLUMN real_name TEXT") # Bestehende User: Leerzeichen im name → name enthält echten Namen → in real_name verschieben rows = conn.execute("SELECT id, name FROM users WHERE name LIKE '% %'").fetchall() for r in rows: first_word = r['name'].split()[0] conn.execute("UPDATE users SET real_name=?, name=? WHERE id=?", (r['name'], first_word, r['id'])) logger.info("Migration: real_name Spalte hinzugefügt, %d User migriert.", len(rows)) except Exception: pass # Wiki: Rassen-Anreicherung for col, typedef in [ ("beschreibung", "TEXT"), ("vorkommen_de", "TEXT"), ("wikipedia_url_de","TEXT"), ("ki_enriched", "INTEGER DEFAULT 0"), ]: try: conn.execute(f"ALTER TABLE wiki_rassen ADD COLUMN {col} {typedef}") except Exception: pass logger.info("Migration: wiki_rassen Anreicherungs-Felder bereit.") # Wiki: Züchter-Verzeichnis conn.executescript(""" CREATE TABLE IF NOT EXISTS wiki_zuchter ( id INTEGER PRIMARY KEY AUTOINCREMENT, rasse_slug TEXT NOT NULL, name TEXT NOT NULL, zwingername TEXT, ort TEXT, plz TEXT, bundesland TEXT, vdh_mitglied INTEGER DEFAULT 0, website TEXT, telefon TEXT, beschreibung TEXT, verified INTEGER DEFAULT 0, user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, created_at TEXT DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_wiki_zuchter_rasse ON wiki_zuchter(rasse_slug, verified); """) logger.info("Migration: wiki_zuchter Tabelle bereit.") # Wiki: Rasse-Interesse ("So einen hab ich" / "Interessiert mich") conn.executescript(""" CREATE TABLE IF NOT EXISTS wiki_breed_interest ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, rasse_slug TEXT NOT NULL, typ TEXT NOT NULL DEFAULT 'hat', created_at TEXT DEFAULT (datetime('now')), UNIQUE(user_id, rasse_slug) ); CREATE INDEX IF NOT EXISTS idx_wbi_rasse ON wiki_breed_interest(rasse_slug, typ); CREATE INDEX IF NOT EXISTS idx_wbi_user ON wiki_breed_interest(user_id); """) logger.info("Migration: wiki_breed_interest Tabelle bereit.") # Training: Session-Protokoll conn.executescript(""" CREATE TABLE IF NOT EXISTS training_sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, exercise_id TEXT NOT NULL, exercise_name TEXT NOT NULL, datum TEXT NOT NULL DEFAULT (date('now')), wiederholungen INTEGER DEFAULT 1, erfolgsquote INTEGER DEFAULT 50, hund_stimmung TEXT DEFAULT 'aufmerksam', zufriedenheit INTEGER DEFAULT 3, notiz TEXT, ist_top INTEGER DEFAULT 0, diary_entry_id INTEGER REFERENCES diary(id) ON DELETE SET NULL, created_at TEXT DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_ts_user_dog ON training_sessions(user_id, dog_id, datum DESC); CREATE INDEX IF NOT EXISTS idx_ts_exercise ON training_sessions(exercise_id, user_id); """) logger.info("Migration: training_sessions Tabelle bereit.") # Training: KI-Feedback-Cache conn.executescript(""" CREATE TABLE IF NOT EXISTS training_ki_cache ( dog_id INTEGER PRIMARY KEY, feedback TEXT NOT NULL, generated_at TEXT NOT NULL DEFAULT (datetime('now')) ); """) logger.info("Migration: training_ki_cache Tabelle bereit.") # Fortschritts-Lober: wöchentliche Lob-Karten conn.executescript(""" CREATE TABLE IF NOT EXISTS weekly_praise ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, week_key TEXT NOT NULL, -- ISO-Woche, z.B. "2026-W17" praise_text TEXT NOT NULL, stats_json TEXT, -- JSON der gesammelten Stats (für Debugging) generated_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(dog_id, week_key) ); CREATE INDEX IF NOT EXISTS idx_wp_dog_week ON weekly_praise(dog_id, week_key DESC); """) logger.info("Migration: weekly_praise Tabelle bereit.")