- database.py: diary_dogs + walk_participant_dogs Tabellen, idempotente Migration für Health-Felder (charge_nr, kosten, diagnose, …), Backfill - routes/health.py: vollständiges Health-Modul (war Stub), CRUD für Impfung/Entwurmung/Tierarzt/Medikament/Gewicht/Allergie/Dokument - routes/diary.py: Multi-Dog n:m via diary_dogs (dog_ids in allen Endpoints) - routes/dogs.py: Foto-Upload konvertiert HEIC/PNG/WebP → JPEG via Pillow - routes/poison.py: Resolve mit Grundauswahl + Soft-Delete (geloest_von/at/grund) - ki.py: health_summary() für KI-Gesundheitsbericht - main.py: /favicon.ico Route - requirements.txt: Pillow 11.2.1 + pillow-heif 0.22.0
298 lines
12 KiB
Python
298 lines
12 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'))
|
|
);
|
|
|
|
""")
|
|
|
|
# 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"),
|
|
]
|
|
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.")
|