Sprint 0: Backend, Docker, KI-Layer mit Free/Premium-Trennung
This commit is contained in:
parent
84f49fafcf
commit
00be2bbcd5
17 changed files with 1107 additions and 0 deletions
27
.env.example
Normal file
27
.env.example
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# BAN YARO — Umgebungsvariablen
|
||||||
|
# Kopieren nach .env und anpassen
|
||||||
|
|
||||||
|
ENV=development # development | production
|
||||||
|
|
||||||
|
# Sicherheit
|
||||||
|
JWT_SECRET=bitte-aendern-langer-zufaelliger-string
|
||||||
|
JWT_EXPIRY_DAYS=30
|
||||||
|
|
||||||
|
# KI-Modus
|
||||||
|
# off = kein KI
|
||||||
|
# local = LM Studio auf DS (kostenlos, für Entwicklung)
|
||||||
|
# cloud = Claude API (nur für Premium-User, kostet Geld)
|
||||||
|
KI_MODE=local
|
||||||
|
KI_LOCAL_URL=http://10.47.11.10:1234/v1
|
||||||
|
KI_LOCAL_MODEL=qwen2.5-7b-instruct
|
||||||
|
|
||||||
|
# Claude API (nur setzen wenn KI_MODE=cloud oder als Fallback)
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
|
|
||||||
|
# Cloud-Modell (nur bei KI_MODE=cloud)
|
||||||
|
KI_CLOUD_MODEL=claude-opus-4-6
|
||||||
|
|
||||||
|
# Push Notifications (VAPID Keys generieren mit: npx web-push generate-vapid-keys)
|
||||||
|
VAPID_PUBLIC_KEY=
|
||||||
|
VAPID_PRIVATE_KEY=
|
||||||
|
VAPID_CONTACT=mailto:admin@banyaro.app
|
||||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
.env
|
||||||
|
data/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.DS_Store
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
22
Dockerfile
Normal file
22
Dockerfile
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# System-Dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
gcc \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Python-Dependencies zuerst (Docker Layer Cache)
|
||||||
|
COPY backend/requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# App-Code
|
||||||
|
COPY backend/ .
|
||||||
|
|
||||||
|
# Media-Verzeichnis
|
||||||
|
RUN mkdir -p /data/media/dogs /data/media/diary /data/media/poison
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"]
|
||||||
119
backend/auth.py
Normal file
119
backend/auth.py
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
"""
|
||||||
|
BAN YARO — Auth
|
||||||
|
JWT + Bcrypt. Einmal gebaut, von allen Routes genutzt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import jwt
|
||||||
|
import bcrypt
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from fastapi import Depends, HTTPException, status, Request
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
|
||||||
|
from database import db
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
JWT_SECRET = os.getenv("JWT_SECRET", "change-me-in-production")
|
||||||
|
JWT_ALGO = "HS256"
|
||||||
|
JWT_EXPIRY = int(os.getenv("JWT_EXPIRY_DAYS", "30"))
|
||||||
|
|
||||||
|
security = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Passwort
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, hashed: str) -> bool:
|
||||||
|
return bcrypt.checkpw(password.encode(), hashed.encode())
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# JWT
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def create_token(user_id: int, rolle: str) -> str:
|
||||||
|
payload = {
|
||||||
|
"sub": str(user_id),
|
||||||
|
"rolle": rolle,
|
||||||
|
"exp": datetime.now(timezone.utc) + timedelta(days=JWT_EXPIRY),
|
||||||
|
"iat": datetime.now(timezone.utc),
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGO)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str) -> dict:
|
||||||
|
return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGO])
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# FastAPI Dependencies
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _get_token_from_request(
|
||||||
|
request: Request,
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
) -> str | None:
|
||||||
|
"""Token aus Bearer-Header oder HttpOnly-Cookie."""
|
||||||
|
if credentials:
|
||||||
|
return credentials.credentials
|
||||||
|
return request.cookies.get("by_token")
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(
|
||||||
|
request: Request,
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
):
|
||||||
|
"""Dependency: gibt den eingeloggten User zurück oder wirft 401."""
|
||||||
|
token = _get_token_from_request(request, credentials)
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Nicht eingeloggt.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = decode_token(token)
|
||||||
|
except jwt.ExpiredSignatureError:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Session abgelaufen.")
|
||||||
|
except jwt.InvalidTokenError:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Ungültiges Token.")
|
||||||
|
|
||||||
|
user_id = int(payload["sub"])
|
||||||
|
with db() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id, email, name, rolle, is_premium FROM users WHERE id=?",
|
||||||
|
(user_id,)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "User nicht gefunden.")
|
||||||
|
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user_optional(
|
||||||
|
request: Request,
|
||||||
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
|
):
|
||||||
|
"""Dependency: gibt User zurück falls eingeloggt, sonst None."""
|
||||||
|
try:
|
||||||
|
return get_current_user(request, credentials)
|
||||||
|
except HTTPException:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def require_premium(user=Depends(get_current_user)):
|
||||||
|
"""Dependency: nur für Premium-User."""
|
||||||
|
if not user["is_premium"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_402_PAYMENT_REQUIRED,
|
||||||
|
"Dieses Feature erfordert Ban Yaro Premium."
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(user=Depends(get_current_user)):
|
||||||
|
"""Dependency: nur für Admins."""
|
||||||
|
if user["rolle"] != "admin":
|
||||||
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "Kein Zugriff.")
|
||||||
|
return user
|
||||||
237
backend/database.py
Normal file
237
backend/database.py
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
"""
|
||||||
|
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);
|
||||||
|
|
||||||
|
-- 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)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 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'))
|
||||||
|
);
|
||||||
|
|
||||||
|
""")
|
||||||
|
|
||||||
|
logger.info("Datenbank initialisiert.")
|
||||||
302
backend/ki.py
Normal file
302
backend/ki.py
Normal file
|
|
@ -0,0 +1,302 @@
|
||||||
|
"""
|
||||||
|
BAN YARO — KI-Abstraktions-Layer
|
||||||
|
|
||||||
|
Drei Modi:
|
||||||
|
- "off" → kein KI, Feature deaktiviert (Free-User ohne lokales Modell)
|
||||||
|
- "local" → LM Studio auf DS1621 (OpenAI-kompatibler Endpunkt, kostenlos)
|
||||||
|
- "cloud" → Claude API (nur für Premium-User, kostet Geld)
|
||||||
|
|
||||||
|
Wird über KI_MODE Umgebungsvariable gesteuert:
|
||||||
|
KI_MODE=local → Entwicklung + Free-User auf DS
|
||||||
|
KI_MODE=cloud → Production + Premium-User
|
||||||
|
KI_MODE=off → kein KI verfügbar
|
||||||
|
|
||||||
|
Wichtig: cloud-Aufrufe IMMER mit requires_premium=True schützen.
|
||||||
|
Kein API-Geld ohne zahlenden User.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
KI_MODE = os.getenv("KI_MODE", "local") # off | local | cloud
|
||||||
|
LOCAL_BASE_URL = os.getenv("KI_LOCAL_URL", "http://10.47.11.10:1234/v1")
|
||||||
|
LOCAL_MODEL = os.getenv("KI_LOCAL_MODEL", "qwen2.5-7b-instruct")
|
||||||
|
CLOUD_MODEL = os.getenv("KI_CLOUD_MODEL", "claude-opus-4-6")
|
||||||
|
ANTHROPIC_KEY = os.getenv("ANTHROPIC_API_KEY", "")
|
||||||
|
|
||||||
|
# Lazy Imports — nur laden wenn wirklich benötigt
|
||||||
|
_openai_client = None
|
||||||
|
_anthropic_client = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_local_client():
|
||||||
|
global _openai_client
|
||||||
|
if _openai_client is None:
|
||||||
|
from openai import OpenAI
|
||||||
|
_openai_client = OpenAI(base_url=LOCAL_BASE_URL, api_key="lm-studio")
|
||||||
|
return _openai_client
|
||||||
|
|
||||||
|
|
||||||
|
def _get_cloud_client():
|
||||||
|
global _anthropic_client
|
||||||
|
if _anthropic_client is None:
|
||||||
|
import anthropic
|
||||||
|
_anthropic_client = anthropic.Anthropic(api_key=ANTHROPIC_KEY)
|
||||||
|
return _anthropic_client
|
||||||
|
|
||||||
|
|
||||||
|
class KIUnavailableError(Exception):
|
||||||
|
"""KI-Feature nicht verfügbar (Off-Modus oder kein Premium)."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class KIPremiumRequired(Exception):
|
||||||
|
"""Dieses Feature erfordert Ban Yaro Premium."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Haupt-Aufruf-Funktion — zentraler Eingang für alle KI-Requests
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
async def complete(
|
||||||
|
prompt: str,
|
||||||
|
system: str = "Du bist ein hilfreicher Assistent für Hundebesitzer.",
|
||||||
|
max_tokens: int = 512,
|
||||||
|
requires_premium: bool = False,
|
||||||
|
user_is_premium: bool = False,
|
||||||
|
json_mode: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
KI-Completion. Wählt automatisch den richtigen Backend.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt: User-Nachricht
|
||||||
|
system: System-Prompt
|
||||||
|
max_tokens: Maximale Antwortlänge
|
||||||
|
requires_premium: True = nur für Premium-User (nutzt Cloud)
|
||||||
|
user_is_premium: Ob der anfragende User Premium hat
|
||||||
|
json_mode: Antwort als JSON anfordern
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
KI-Antwort als String
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KIPremiumRequired: Cloud-Feature ohne Premium
|
||||||
|
KIUnavailableError: KI komplett deaktiviert
|
||||||
|
"""
|
||||||
|
if KI_MODE == "off":
|
||||||
|
raise KIUnavailableError("KI ist deaktiviert.")
|
||||||
|
|
||||||
|
# Premium-Check vor Cloud-Aufrufen
|
||||||
|
if requires_premium and not user_is_premium:
|
||||||
|
raise KIPremiumRequired(
|
||||||
|
"Dieses Feature ist Teil von Ban Yaro Premium."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cloud-Aufruf: nur wenn Premium UND cloud-Modus
|
||||||
|
if requires_premium and user_is_premium and KI_MODE == "cloud":
|
||||||
|
return await _cloud_complete(prompt, system, max_tokens, json_mode)
|
||||||
|
|
||||||
|
# Lokaler Aufruf: Entwicklung + Free-User
|
||||||
|
if KI_MODE in ("local", "cloud"):
|
||||||
|
try:
|
||||||
|
return await _local_complete(prompt, system, max_tokens, json_mode)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Lokales KI-Modell nicht erreichbar: {e}")
|
||||||
|
if requires_premium and user_is_premium and ANTHROPIC_KEY:
|
||||||
|
logger.info("Fallback auf Cloud-KI.")
|
||||||
|
return await _cloud_complete(prompt, system, max_tokens, json_mode)
|
||||||
|
raise KIUnavailableError(
|
||||||
|
"KI-Modell momentan nicht erreichbar. Bitte später erneut versuchen."
|
||||||
|
) from e
|
||||||
|
|
||||||
|
raise KIUnavailableError("Unbekannter KI-Modus.")
|
||||||
|
|
||||||
|
|
||||||
|
async def _local_complete(
|
||||||
|
prompt: str, system: str, max_tokens: int, json_mode: bool
|
||||||
|
) -> str:
|
||||||
|
"""LM Studio lokale Completion (synchron in Thread-Pool)."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
def _sync():
|
||||||
|
client = _get_local_client()
|
||||||
|
kwargs = dict(
|
||||||
|
model=LOCAL_MODEL,
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": system},
|
||||||
|
{"role": "user", "content": prompt},
|
||||||
|
],
|
||||||
|
max_tokens=max_tokens,
|
||||||
|
temperature=0.3,
|
||||||
|
)
|
||||||
|
if json_mode:
|
||||||
|
kwargs["response_format"] = {"type": "json_object"}
|
||||||
|
|
||||||
|
response = client.chat.completions.create(**kwargs)
|
||||||
|
return response.choices[0].message.content.strip()
|
||||||
|
|
||||||
|
return await asyncio.get_event_loop().run_in_executor(None, _sync)
|
||||||
|
|
||||||
|
|
||||||
|
async def _cloud_complete(
|
||||||
|
prompt: str, system: str, max_tokens: int, json_mode: bool
|
||||||
|
) -> str:
|
||||||
|
"""Claude API Completion mit Prompt Caching."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
def _sync():
|
||||||
|
client = _get_cloud_client()
|
||||||
|
system_content = [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": system,
|
||||||
|
"cache_control": {"type": "ephemeral"}, # Prompt Caching
|
||||||
|
}
|
||||||
|
]
|
||||||
|
response = client.messages.create(
|
||||||
|
model=CLOUD_MODEL,
|
||||||
|
max_tokens=max_tokens,
|
||||||
|
system=system_content,
|
||||||
|
messages=[{"role": "user", "content": prompt}],
|
||||||
|
)
|
||||||
|
return response.content[0].text.strip()
|
||||||
|
|
||||||
|
return await asyncio.get_event_loop().run_in_executor(None, _sync)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Spezialisierte KI-Funktionen
|
||||||
|
# (hier liegen die konkreten Prompts — zentral, nicht in den Routes)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def symptom_check(symptoms: str, dog_info: dict,
|
||||||
|
user_is_premium: bool = False) -> dict:
|
||||||
|
"""
|
||||||
|
Symptom-Triage für Hunde.
|
||||||
|
Lokal für alle, Premium bekommt detailliertere Analyse.
|
||||||
|
"""
|
||||||
|
rasse = dog_info.get("rasse", "unbekannt")
|
||||||
|
alter = dog_info.get("alter_jahre", "unbekannt")
|
||||||
|
system = (
|
||||||
|
"Du bist ein erfahrener Veterinär-Assistent. "
|
||||||
|
"Deine Aufgabe ist eine erste Einschätzung von Symptomen beim Hund. "
|
||||||
|
"Antworte IMMER auf Deutsch und IMMER im angegebenen JSON-Format. "
|
||||||
|
"Überschreite nie deine Kompetenz — weise bei Unsicherheit zum Tierarzt."
|
||||||
|
)
|
||||||
|
prompt = f"""
|
||||||
|
Hund: {rasse}, {alter} Jahre alt.
|
||||||
|
Symptome: {symptoms}
|
||||||
|
|
||||||
|
Antworte NUR als JSON:
|
||||||
|
{{
|
||||||
|
"dringlichkeit": "beobachten" | "tierarzt_heute" | "notfall",
|
||||||
|
"einschaetzung": "Kurze Einschätzung in 1-2 Sätzen",
|
||||||
|
"hinweise": ["Hinweis 1", "Hinweis 2"],
|
||||||
|
"zum_tierarzt_wenn": "Wann unbedingt zum Tierarzt"
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
result = await complete(
|
||||||
|
prompt, system,
|
||||||
|
max_tokens=400,
|
||||||
|
requires_premium=False, # Basis-Triage ist kostenlos
|
||||||
|
user_is_premium=user_is_premium,
|
||||||
|
json_mode=True,
|
||||||
|
)
|
||||||
|
import json
|
||||||
|
try:
|
||||||
|
return json.loads(result)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {
|
||||||
|
"dringlichkeit": "tierarzt_heute",
|
||||||
|
"einschaetzung": result,
|
||||||
|
"hinweise": [],
|
||||||
|
"zum_tierarzt_wenn": "Bei Verschlechterung sofort.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def training_plan(problem: str, dog_info: dict,
|
||||||
|
user_is_premium: bool = False) -> str:
|
||||||
|
"""
|
||||||
|
Trainingsplan generieren — Premium-Feature.
|
||||||
|
"""
|
||||||
|
system = (
|
||||||
|
"Du bist ein zertifizierter Hundetrainer. "
|
||||||
|
"Erstelle konkrete, positive, gewaltfreie Trainingspläne auf Deutsch."
|
||||||
|
)
|
||||||
|
prompt = f"""
|
||||||
|
Hund: {dog_info.get('rasse')}, {dog_info.get('alter_jahre')} Jahre.
|
||||||
|
Problem: {problem}
|
||||||
|
|
||||||
|
Erstelle einen 2-Wochen-Trainingsplan mit täglichen Übungen (max. 15 Min/Tag).
|
||||||
|
Strukturiert, konkret, motivierend.
|
||||||
|
"""
|
||||||
|
return await complete(
|
||||||
|
prompt, system,
|
||||||
|
max_tokens=800,
|
||||||
|
requires_premium=True, # Cloud-Feature — kostet Geld
|
||||||
|
user_is_premium=user_is_premium,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def diary_tags(entry_text: str) -> list[str]:
|
||||||
|
"""
|
||||||
|
Automatische Tags für Tagebucheinträge — läuft lokal, kostenlos.
|
||||||
|
"""
|
||||||
|
if KI_MODE == "off":
|
||||||
|
return []
|
||||||
|
prompt = f"""
|
||||||
|
Tagebucheintrag: "{entry_text}"
|
||||||
|
|
||||||
|
Vergib 2-4 passende Tags aus dieser Liste:
|
||||||
|
training, spaziergang, gesundheit, freunde, spielen, futter, tierarzt,
|
||||||
|
reise, meilenstein, lustig, traurig, wetter, sonstige
|
||||||
|
|
||||||
|
Antworte NUR mit den Tags, kommagetrennt, keine Erklärung.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = await complete(prompt, max_tokens=50, requires_premium=False)
|
||||||
|
return [t.strip().lower() for t in result.split(",") if t.strip()]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def poison_description_check(description: str) -> dict:
|
||||||
|
"""
|
||||||
|
Giftköder-Beschreibung auf Plausibilität und Typ prüfen — lokal, kostenlos.
|
||||||
|
"""
|
||||||
|
if KI_MODE == "off":
|
||||||
|
return {"valid": True, "typ": "unbekannt"}
|
||||||
|
prompt = f"""
|
||||||
|
Giftköder-Meldung: "{description}"
|
||||||
|
|
||||||
|
Bewerte als JSON:
|
||||||
|
{{
|
||||||
|
"plausibel": true/false,
|
||||||
|
"typ": "fleisch" | "koeder" | "objekt" | "unbekannt",
|
||||||
|
"hinweis": "Kurzer Hinweis für andere Hundebesitzer (max 1 Satz)"
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
result = await complete(prompt, max_tokens=150, json_mode=True)
|
||||||
|
return json.loads(result)
|
||||||
|
except Exception:
|
||||||
|
return {"plausibel": True, "typ": "unbekannt", "hinweis": description}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Status-Endpoint (für Admin/Debug)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def status() -> dict:
|
||||||
|
return {
|
||||||
|
"mode": KI_MODE,
|
||||||
|
"local_url": LOCAL_BASE_URL if KI_MODE != "off" else None,
|
||||||
|
"local_model": LOCAL_MODEL if KI_MODE != "off" else None,
|
||||||
|
"cloud_model": CLOUD_MODEL,
|
||||||
|
"cloud_key_set": bool(ANTHROPIC_KEY),
|
||||||
|
}
|
||||||
114
backend/main.py
Normal file
114
backend/main.py
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
"""
|
||||||
|
BAN YARO — FastAPI Hauptanwendung
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from database import init_db
|
||||||
|
import ki
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level = logging.INFO,
|
||||||
|
format = "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Startup / Shutdown
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
logger.info("Ban Yaro startet...")
|
||||||
|
init_db()
|
||||||
|
logger.info(f"KI-Modus: {ki.KI_MODE}")
|
||||||
|
yield
|
||||||
|
logger.info("Ban Yaro beendet.")
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# App
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
app = FastAPI(
|
||||||
|
title = "Ban Yaro API",
|
||||||
|
version = "0.1.0",
|
||||||
|
lifespan = lifespan,
|
||||||
|
docs_url = "/api/docs" if os.getenv("ENV") != "production" else None,
|
||||||
|
redoc_url = None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# API-Router registrieren (werden nach und nach hinzugefügt)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
from routes.auth import router as auth_router
|
||||||
|
from routes.dogs import router as dogs_router
|
||||||
|
from routes.diary import router as diary_router
|
||||||
|
from routes.health import router as health_router
|
||||||
|
from routes.poison import router as poison_router
|
||||||
|
from routes.push import router as push_router
|
||||||
|
from routes.ki import router as ki_router
|
||||||
|
|
||||||
|
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
|
||||||
|
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
|
||||||
|
app.include_router(diary_router, prefix="/api/dogs", tags=["Tagebuch"])
|
||||||
|
app.include_router(health_router, prefix="/api/dogs", tags=["Gesundheit"])
|
||||||
|
app.include_router(poison_router, prefix="/api/poison", tags=["Giftköder"])
|
||||||
|
app.include_router(push_router, prefix="/api/push", tags=["Push"])
|
||||||
|
app.include_router(ki_router, prefix="/api/ki", tags=["KI"])
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Fehlerbehandlung — einheitliches JSON-Format
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def global_exception_handler(request: Request, exc: Exception):
|
||||||
|
logger.error(f"Unbehandelter Fehler: {exc}", exc_info=True)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"detail": "Interner Serverfehler."}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Statische Dateien + SPA-Fallback
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
|
||||||
|
|
||||||
|
app.mount("/css", StaticFiles(directory=f"{STATIC_DIR}/css"), name="css")
|
||||||
|
app.mount("/js", StaticFiles(directory=f"{STATIC_DIR}/js"), name="js")
|
||||||
|
app.mount("/icons", StaticFiles(directory=f"{STATIC_DIR}/icons"), name="icons")
|
||||||
|
|
||||||
|
@app.get("/manifest.json")
|
||||||
|
async def manifest():
|
||||||
|
return FileResponse(f"{STATIC_DIR}/manifest.json")
|
||||||
|
|
||||||
|
@app.get("/sw.js")
|
||||||
|
async def service_worker():
|
||||||
|
return FileResponse(
|
||||||
|
f"{STATIC_DIR}/sw.js",
|
||||||
|
headers={"Cache-Control": "no-cache, no-store, must-revalidate"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Web Share Target
|
||||||
|
@app.post("/share")
|
||||||
|
async def share_target(request: Request):
|
||||||
|
# Empfängt geteilte Inhalte vom Handy (Fotos, Links, Text)
|
||||||
|
# Weiterleitung zur App mit den Daten
|
||||||
|
return FileResponse(
|
||||||
|
f"{STATIC_DIR}/index.html",
|
||||||
|
headers={"Cache-Control": "no-cache"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# SPA Fallback — ALLE nicht-API-Routen gehen zur index.html
|
||||||
|
@app.get("/{full_path:path}")
|
||||||
|
async def spa_fallback(full_path: str):
|
||||||
|
return FileResponse(
|
||||||
|
f"{STATIC_DIR}/index.html",
|
||||||
|
headers={"Cache-Control": "no-cache"}
|
||||||
|
)
|
||||||
10
backend/requirements.txt
Normal file
10
backend/requirements.txt
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
fastapi==0.115.0
|
||||||
|
uvicorn[standard]==0.30.6
|
||||||
|
python-multipart==0.0.9
|
||||||
|
pydantic[email]==2.8.2
|
||||||
|
bcrypt==4.2.0
|
||||||
|
PyJWT==2.9.0
|
||||||
|
httpx==0.27.2
|
||||||
|
openai==1.50.0
|
||||||
|
anthropic==0.34.0
|
||||||
|
pywebpush==2.0.0
|
||||||
0
backend/routes/__init__.py
Normal file
0
backend/routes/__init__.py
Normal file
87
backend/routes/auth.py
Normal file
87
backend/routes/auth.py
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
"""BAN YARO — Auth Routes"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Response, Depends
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from database import db
|
||||||
|
from auth import (
|
||||||
|
hash_password, verify_password, create_token,
|
||||||
|
get_current_user
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
COOKIE_NAME = "by_token"
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
|
|
||||||
|
class RegisterRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
def _set_cookie(response: Response, token: str):
|
||||||
|
response.set_cookie(
|
||||||
|
key=COOKIE_NAME, value=token,
|
||||||
|
httponly=True, secure=True, samesite="lax",
|
||||||
|
max_age=30 * 24 * 3600
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register")
|
||||||
|
async def register(data: RegisterRequest, response: Response):
|
||||||
|
with db() as conn:
|
||||||
|
if conn.execute("SELECT 1 FROM users WHERE email=?", (data.email,)).fetchone():
|
||||||
|
raise HTTPException(400, "E-Mail bereits registriert.")
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO users (email, pw_hash, name) VALUES (?,?,?)",
|
||||||
|
(data.email, hash_password(data.password), data.name)
|
||||||
|
)
|
||||||
|
user = conn.execute(
|
||||||
|
"SELECT id, rolle FROM users WHERE email=?", (data.email,)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
token = create_token(user["id"], user["rolle"])
|
||||||
|
_set_cookie(response, token)
|
||||||
|
return {"token": token, "name": data.name}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
async def login(data: LoginRequest, response: Response):
|
||||||
|
with db() as conn:
|
||||||
|
user = conn.execute(
|
||||||
|
"SELECT id, pw_hash, name, rolle, is_premium FROM users WHERE email=?",
|
||||||
|
(data.email,)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if not user or not verify_password(data.password, user["pw_hash"]):
|
||||||
|
raise HTTPException(401, "E-Mail oder Passwort falsch.")
|
||||||
|
|
||||||
|
token = create_token(user["id"], user["rolle"])
|
||||||
|
_set_cookie(response, token)
|
||||||
|
|
||||||
|
with db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET last_login=datetime('now') WHERE id=?", (user["id"],)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"token": token, "name": user["name"], "is_premium": bool(user["is_premium"])}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
async def logout(response: Response):
|
||||||
|
response.delete_cookie(COOKIE_NAME)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
async def me(user=Depends(get_current_user)):
|
||||||
|
return {
|
||||||
|
"id": user["id"],
|
||||||
|
"name": user["name"],
|
||||||
|
"email": user["email"],
|
||||||
|
"rolle": user["rolle"],
|
||||||
|
"is_premium": bool(user["is_premium"]),
|
||||||
|
}
|
||||||
3
backend/routes/diary.py
Normal file
3
backend/routes/diary.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""BAN YARO — Tagebuch Routes (Stub, wird in Sprint 1 ausgebaut)"""
|
||||||
|
from fastapi import APIRouter
|
||||||
|
router = APIRouter()
|
||||||
146
backend/routes/dogs.py
Normal file
146
backend/routes/dogs.py
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
"""BAN YARO — Hunde-Profil Routes"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from database import db
|
||||||
|
from auth import get_current_user
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
||||||
|
|
||||||
|
|
||||||
|
class DogCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
rasse: Optional[str] = None
|
||||||
|
geburtstag: Optional[str] = None
|
||||||
|
geschlecht: Optional[str] = None
|
||||||
|
gewicht_kg: Optional[float] = None
|
||||||
|
chip_nr: Optional[str] = None
|
||||||
|
bio: Optional[str] = None
|
||||||
|
is_public: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class DogUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
rasse: Optional[str] = None
|
||||||
|
geburtstag: Optional[str] = None
|
||||||
|
geschlecht: Optional[str] = None
|
||||||
|
gewicht_kg: Optional[float] = None
|
||||||
|
chip_nr: Optional[str] = None
|
||||||
|
bio: Optional[str] = None
|
||||||
|
is_public: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_dogs(user=Depends(get_current_user)):
|
||||||
|
with db() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM dogs WHERE user_id=? ORDER BY id", (user["id"],)
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
async def create_dog(data: DogCreate, user=Depends(get_current_user)):
|
||||||
|
with db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO dogs (user_id, name, rasse, geburtstag, geschlecht,
|
||||||
|
gewicht_kg, chip_nr, bio, is_public)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(user["id"], data.name, data.rasse, data.geburtstag,
|
||||||
|
data.geschlecht, data.gewicht_kg, data.chip_nr,
|
||||||
|
data.bio, int(data.is_public))
|
||||||
|
)
|
||||||
|
dog = conn.execute(
|
||||||
|
"SELECT * FROM dogs WHERE user_id=? ORDER BY id DESC LIMIT 1",
|
||||||
|
(user["id"],)
|
||||||
|
).fetchone()
|
||||||
|
return dict(dog)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{dog_id}")
|
||||||
|
async def get_dog(dog_id: int, user=Depends(get_current_user)):
|
||||||
|
with db() as conn:
|
||||||
|
dog = conn.execute(
|
||||||
|
"SELECT * FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])
|
||||||
|
).fetchone()
|
||||||
|
if not dog:
|
||||||
|
raise HTTPException(404, "Hund nicht gefunden.")
|
||||||
|
return dict(dog)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{dog_id}")
|
||||||
|
async def update_dog(dog_id: int, data: DogUpdate, user=Depends(get_current_user)):
|
||||||
|
fields = {k: v for k, v in data.model_dump().items() if v is not None}
|
||||||
|
if not fields:
|
||||||
|
raise HTTPException(400, "Keine Änderungen angegeben.")
|
||||||
|
|
||||||
|
set_clause = ", ".join(f"{k}=?" for k in fields)
|
||||||
|
values = list(fields.values()) + [dog_id, user["id"]]
|
||||||
|
|
||||||
|
with db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
f"UPDATE dogs SET {set_clause} WHERE id=? AND user_id=?", values
|
||||||
|
)
|
||||||
|
dog = conn.execute(
|
||||||
|
"SELECT * FROM dogs WHERE id=?", (dog_id,)
|
||||||
|
).fetchone()
|
||||||
|
return dict(dog)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{dog_id}", status_code=204)
|
||||||
|
async def delete_dog(dog_id: int, user=Depends(get_current_user)):
|
||||||
|
with db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{dog_id}/photo")
|
||||||
|
async def upload_photo(
|
||||||
|
dog_id: int,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
# Hund gehört dem User?
|
||||||
|
with db() as conn:
|
||||||
|
dog = conn.execute(
|
||||||
|
"SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])
|
||||||
|
).fetchone()
|
||||||
|
if not dog:
|
||||||
|
raise HTTPException(404, "Hund nicht gefunden.")
|
||||||
|
|
||||||
|
# Datei speichern
|
||||||
|
ext = os.path.splitext(file.filename or "")[1] or ".jpg"
|
||||||
|
filename = f"dog_{dog_id}_{uuid.uuid4().hex[:8]}{ext}"
|
||||||
|
path = os.path.join(MEDIA_DIR, "dogs", filename)
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
|
||||||
|
content = await file.read()
|
||||||
|
with open(path, "wb") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
foto_url = f"/media/dogs/{filename}"
|
||||||
|
with db() as conn:
|
||||||
|
conn.execute("UPDATE dogs SET foto_url=? WHERE id=?", (foto_url, dog_id))
|
||||||
|
|
||||||
|
return {"foto_url": foto_url}
|
||||||
|
|
||||||
|
|
||||||
|
# Öffentliches Profil (für NFC-Tag, kein Login nötig)
|
||||||
|
@router.get("/public/{dog_id}")
|
||||||
|
async def public_dog_profile(dog_id: int):
|
||||||
|
with db() as conn:
|
||||||
|
dog = conn.execute(
|
||||||
|
"""SELECT d.id, d.name, d.rasse, d.geburtstag, d.foto_url, d.bio,
|
||||||
|
u.name as besitzer_name
|
||||||
|
FROM dogs d JOIN users u ON d.user_id=u.id
|
||||||
|
WHERE d.id=? AND d.is_public=1""",
|
||||||
|
(dog_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not dog:
|
||||||
|
raise HTTPException(404, "Profil nicht gefunden oder nicht öffentlich.")
|
||||||
|
return dict(dog)
|
||||||
3
backend/routes/health.py
Normal file
3
backend/routes/health.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""BAN YARO — health Routes (Stub, wird ausgebaut)"""
|
||||||
|
from fastapi import APIRouter
|
||||||
|
router = APIRouter()
|
||||||
3
backend/routes/ki.py
Normal file
3
backend/routes/ki.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""BAN YARO — ki Routes (Stub, wird ausgebaut)"""
|
||||||
|
from fastapi import APIRouter
|
||||||
|
router = APIRouter()
|
||||||
3
backend/routes/poison.py
Normal file
3
backend/routes/poison.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""BAN YARO — poison Routes (Stub, wird ausgebaut)"""
|
||||||
|
from fastapi import APIRouter
|
||||||
|
router = APIRouter()
|
||||||
3
backend/routes/push.py
Normal file
3
backend/routes/push.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""BAN YARO — push Routes (Stub, wird ausgebaut)"""
|
||||||
|
from fastapi import APIRouter
|
||||||
|
router = APIRouter()
|
||||||
19
docker-compose.yml
Normal file
19
docker-compose.yml
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
services:
|
||||||
|
banyaro:
|
||||||
|
build: .
|
||||||
|
container_name: ban-yaro
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3010:8000" # DS-intern, NPM leitet banyaro.app weiter
|
||||||
|
volumes:
|
||||||
|
- ./data:/data # SQLite + Media persistent
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- DB_PATH=/data/banyaro.db
|
||||||
|
- MEDIA_DIR=/data/media
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/api/docs"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
Loading…
Add table
Add a link
Reference in a new issue