Sprint 0: Backend, Docker, KI-Layer mit Free/Premium-Trennung

This commit is contained in:
rene 2026-04-12 16:39:34 +02:00
parent 84f49fafcf
commit 00be2bbcd5
17 changed files with 1107 additions and 0 deletions

27
.env.example Normal file
View 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
View file

@ -0,0 +1,9 @@
.env
data/
__pycache__/
*.pyc
*.pyo
.DS_Store
*.db
*.db-wal
*.db-shm

22
Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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
View 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

View file

87
backend/routes/auth.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,3 @@
"""BAN YARO — push Routes (Stub, wird ausgebaut)"""
from fastapi import APIRouter
router = APIRouter()

19
docker-compose.yml Normal file
View 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