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