banyaro/backend/database.py
rene 5f8fd3bd51 Sprint 8: Events + Hundesitting
Events:
- Backend events.py: CRUD, Typen (ausstellung/training/treffen/markt/wettkampf/sonstiges)
  Haversine-Filter, Monats-Gruppierung in der Liste
- Frontend events.js: Liste/Karte-Toggle, Typ-Filter-Chips, farbige Marker,
  Detail-Modal, Erstellen/Bearbeiten-Formular mit GPS-Button

Hundesitting:
- Backend sitting.py: Sitter-Profile (create/update/me), Anfragen (send/accept/decline/cancel),
  Inbox für Sitter, Haversine-Sortierung, Service-Filter
- Frontend sitting.js: 3 Tabs (Suchen/Profil/Anfragen), Sitter-Karten mit Distanz,
  Detail-Modal + Anfrage-Formular, Profil-Verwaltung

DB: events, sitters, sitting_requests Tabellen hinzugefügt
SW-Cache: by-v21 → by-v22
2026-04-14 06:19:15 +02:00

370 lines
15 KiB
Python

"""
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'))
);
-- 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'))
);
-- 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"),
]
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.")