diff --git a/.gitignore b/.gitignore
index 696ee7a..e51a866 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,3 @@ __pycache__/
*.db
*.db-wal
*.db-shm
-
-# Design-Quell-Dateien (nicht für Server)
-/icons/
diff --git a/Makefile b/Makefile
index 6eb84e9..ea0c151 100644
--- a/Makefile
+++ b/Makefile
@@ -8,8 +8,8 @@ DS_HOST := ds
DS_IP := 10.47.11.10
# Hinweis: NPM braucht 10.47.11.99 als Forward-IP (Macvlan-Shim), nicht .10
DS_SSH_PORT := 22
-DS_PATH := /volume1/docker/banyaro
-CONTAINER := banyaro # container_name (für docker logs/exec)
+DS_PATH := /volume1/docker/ban-yaro
+CONTAINER := ban-yaro # container_name (für docker logs/exec)
SERVICE := banyaro # service-name in docker-compose.yml (für docker compose restart)
GIT_REMOTE := origin
DOCKER := sudo /usr/local/bin/docker
diff --git a/backend/database.py b/backend/database.py
index d268de4..209239f 100644
--- a/backend/database.py
+++ b/backend/database.py
@@ -88,14 +88,6 @@ def init_db():
);
CREATE INDEX IF NOT EXISTS idx_diary_dog ON diary(dog_id, datum DESC);
- -- TAGEBUCH ↔ HUNDE (n:m — ein Eintrag kann mehrere Hunde betreffen)
- CREATE TABLE IF NOT EXISTS diary_dogs (
- diary_id INTEGER NOT NULL REFERENCES diary(id) ON DELETE CASCADE,
- dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
- PRIMARY KEY (diary_id, dog_id)
- );
- CREATE INDEX IF NOT EXISTS idx_diary_dogs_dog ON diary_dogs(dog_id);
-
-- GESUNDHEIT
CREATE TABLE IF NOT EXISTS health (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -194,15 +186,6 @@ def init_db():
PRIMARY KEY (walk_id, user_id)
);
- -- GASSI-TREFFEN ↔ HUNDE (n:m — Teilnehmer kann mehrere Hunde mitbringen)
- CREATE TABLE IF NOT EXISTS walk_participant_dogs (
- walk_id INTEGER NOT NULL REFERENCES walks(id) ON DELETE CASCADE,
- user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
- dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
- PRIMARY KEY (walk_id, user_id, dog_id)
- );
- CREATE INDEX IF NOT EXISTS idx_wpd_dog ON walk_participant_dogs(dog_id);
-
-- FORUM
CREATE TABLE IF NOT EXISTS forum_threads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -251,48 +234,4 @@ def init_db():
""")
- # Migrations: neue Spalten zu bestehenden Tabellen hinzufügen (idempotent)
- _migrate(conn_factory=db)
-
logger.info("Datenbank initialisiert.")
-
-
-def _migrate(conn_factory):
- """Fügt fehlende Spalten hinzu und führt Datenmigration durch (idempotent)."""
- migrations = [
- # Giftköder: Auflösungs-Details für spätere KI-Analyse
- ("poison", "geloest_von", "INTEGER"),
- ("poison", "geloest_at", "TEXT"),
- ("poison", "geloest_grund", "TEXT"),
- # Gesundheit: erweiterte Felder je nach Eintragstyp
- ("health", "charge_nr", "TEXT"),
- ("health", "tierarzt_name", "TEXT"),
- ("health", "kosten", "REAL"),
- ("health", "diagnose", "TEXT"),
- ("health", "dosierung", "TEXT"),
- ("health", "haeufigkeit", "TEXT"),
- ("health", "aktiv", "INTEGER NOT NULL DEFAULT 1"),
- ("health", "bis_datum", "TEXT"),
- ("health", "schweregrad", "TEXT"),
- ("health", "reaktion", "TEXT"),
- ("health", "datei_url", "TEXT"),
- ("health", "datei_typ", "TEXT"),
- ]
- with conn_factory() as conn:
- for table, column, col_type in migrations:
- existing = [
- row[1] for row in
- conn.execute(f"PRAGMA table_info({table})").fetchall()
- ]
- if column not in existing:
- conn.execute(
- f"ALTER TABLE {table} ADD COLUMN {column} {col_type}"
- )
- logger.info(f"Migration: {table}.{column} hinzugefügt.")
-
- # Datenmigration: diary_dogs für bestehende Einträge befüllen
- conn.execute("""
- INSERT OR IGNORE INTO diary_dogs (diary_id, dog_id)
- SELECT id, dog_id FROM diary
- """)
- logger.info("Migration: diary_dogs Backfill abgeschlossen.")
diff --git a/backend/ki.py b/backend/ki.py
index c4f6ec2..6eb0f1f 100644
--- a/backend/ki.py
+++ b/backend/ki.py
@@ -289,71 +289,6 @@ Bewerte als JSON:
return {"plausibel": True, "typ": "unbekannt", "hinweis": description}
-async def health_summary(health_data: list, dog_info: dict,
- user_is_premium: bool = False) -> str:
- """
- Tierärztliche Zusammenfassung aller Gesundheitsdaten — lokal für alle.
- Fasst Impfungen, Besuche, Medikamente etc. in einem lesbaren Bericht zusammen.
- """
- system = (
- "Du bist ein erfahrener Veterinär-Assistent. "
- "Erstelle präzise, strukturierte Gesundheitsberichte für Hunde auf Deutsch. "
- "Weise auf fällige Impfungen und wichtige Termine hin."
- )
-
- # Daten nach Typ gruppieren für den Prompt
- def _fmt(entries, typ):
- subset = [e for e in entries if e.get("typ") == typ]
- if not subset:
- return " (keine Einträge)"
- lines = []
- for e in subset[:10]: # maximal 10 pro Typ
- line = f" - {e.get('datum', '?')}: {e.get('bezeichnung', '?')}"
- if e.get("naechstes"):
- line += f" (nächste Fälligkeit: {e['naechstes']})"
- if e.get("notiz"):
- line += f" — {e['notiz']}"
- lines.append(line)
- return "\n".join(lines)
-
- heute = __import__("datetime").date.today().isoformat()
- prompt = f"""
-Hund: {dog_info.get('name')}, {dog_info.get('rasse', 'unbekannt')},
-Geburtstag: {dog_info.get('geburtstag', 'unbekannt')}, Gewicht: {dog_info.get('gewicht_kg', '?')} kg
-
-Heutiges Datum: {heute}
-
-=== IMPFUNGEN ===
-{_fmt(health_data, 'impfung')}
-
-=== ENTWURMUNG ===
-{_fmt(health_data, 'entwurmung')}
-
-=== TIERARZTBESUCHE ===
-{_fmt(health_data, 'tierarzt')}
-
-=== MEDIKAMENTE ===
-{_fmt(health_data, 'medikament')}
-
-=== ALLERGIEN ===
-{_fmt(health_data, 'allergie')}
-
-Erstelle einen strukturierten Gesundheitsbericht mit:
-1. Aktueller Status (2-3 Sätze)
-2. Fällige / überfällige Impfungen und Termine
-3. Wichtige Hinweise für den nächsten Tierarztbesuch
-4. Allgemeine Empfehlungen
-
-Schreibe klar und verständlich für den Hundebesitzer.
-"""
- return await complete(
- prompt, system,
- max_tokens=700,
- requires_premium=False, # Kostenlos für alle
- user_is_premium=user_is_premium,
- )
-
-
# ------------------------------------------------------------------
# Status-Endpoint (für Admin/Debug)
# ------------------------------------------------------------------
diff --git a/backend/main.py b/backend/main.py
index 88c7e1e..96d0236 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -89,10 +89,6 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
os.makedirs(MEDIA_DIR, exist_ok=True)
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
-@app.get("/favicon.ico")
-async def favicon():
- return FileResponse(f"{STATIC_DIR}/icons/favicon.ico")
-
@app.get("/manifest.json")
async def manifest():
return FileResponse(f"{STATIC_DIR}/manifest.json")
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 29e57bc..904fb5d 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -1,6 +1,4 @@
fastapi==0.115.0
-Pillow==11.2.1
-pillow-heif==0.22.0
uvicorn[standard]==0.30.6
python-multipart==0.0.9
pydantic[email]==2.8.2
diff --git a/backend/routes/diary.py b/backend/routes/diary.py
index ba59f6d..56f348d 100644
--- a/backend/routes/diary.py
+++ b/backend/routes/diary.py
@@ -13,23 +13,20 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
class DiaryCreate(BaseModel):
- datum: Optional[str] = None # ISO date, default heute
- typ: str = "eintrag"
- titel: Optional[str] = None
- text: Optional[str] = None
- tags: Optional[list] = None
- gps_lat: Optional[float] = None
- gps_lon: Optional[float] = None
- is_milestone: bool = False
- dog_ids: Optional[list[int]] = None # alle Hunde inkl. primär; None = nur primary
-
+ datum: Optional[str] = None # ISO date, default heute
+ typ: str = "eintrag"
+ titel: Optional[str] = None
+ text: Optional[str] = None
+ tags: Optional[list] = None
+ gps_lat: Optional[float] = None
+ gps_lon: Optional[float] = None
+ is_milestone: bool = False
class DiaryUpdate(BaseModel):
- titel: Optional[str] = None
- text: Optional[str] = None
- tags: Optional[list] = None
- is_milestone: Optional[bool] = None
- dog_ids: Optional[list[int]] = None # wenn gesetzt: Hunde-Zuweisung ersetzen
+ titel: Optional[str] = None
+ text: Optional[str] = None
+ tags: Optional[list] = None
+ is_milestone: Optional[bool] = None
def _own_dog(dog_id: int, user_id: int, conn):
@@ -41,63 +38,23 @@ def _own_dog(dog_id: int, user_id: int, conn):
return dog
-def _validate_dog_ids(dog_ids: list[int], primary: int, user_id: int, conn) -> list[int]:
- """Stellt sicher dass alle IDs dem User gehören. Gibt die bereinigte Liste zurück."""
- all_ids = list({primary} | set(dog_ids))
- for did in all_ids:
- _own_dog(did, user_id, conn)
- return all_ids
-
-
-def _fetch_dog_ids(conn, entry_ids: list[int]) -> dict:
- """Gibt {entry_id: [dog_id, ...]} zurück."""
- if not entry_ids:
- return {}
- ph = ",".join("?" * len(entry_ids))
- rows = conn.execute(
- f"SELECT diary_id, dog_id FROM diary_dogs WHERE diary_id IN ({ph})",
- entry_ids
- ).fetchall()
- result = {}
- for r in rows:
- result.setdefault(r["diary_id"], []).append(r["dog_id"])
- return result
-
-
-def _set_dog_ids(conn, entry_id: int, dog_ids: list[int]):
- conn.execute("DELETE FROM diary_dogs WHERE diary_id=?", (entry_id,))
- for did in dog_ids:
- conn.execute(
- "INSERT OR IGNORE INTO diary_dogs (diary_id, dog_id) VALUES (?,?)",
- (entry_id, did)
- )
-
-
-def _entry_dict(row, dog_ids_map: dict) -> dict:
- e = dict(row)
- e["tags"] = json.loads(e["tags"]) if e["tags"] else []
- e["dog_ids"] = dog_ids_map.get(e["id"], [e["dog_id"]])
- return e
-
-
@router.get("/{dog_id}/diary")
async def list_diary(dog_id: int, limit: int = 20, offset: int = 0,
user=Depends(get_current_user)):
with db() as conn:
_own_dog(dog_id, user["id"], conn)
- # Einträge des primären Hundes SOWIE Einträge wo der Hund als weiterer zugeordnet ist
rows = conn.execute(
- """SELECT DISTINCT d.* FROM diary d
- LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
- WHERE d.dog_id = ? OR dd.dog_id = ?
- ORDER BY d.datum DESC, d.created_at DESC
+ """SELECT * FROM diary WHERE dog_id=?
+ ORDER BY datum DESC, created_at DESC
LIMIT ? OFFSET ?""",
- (dog_id, dog_id, limit, offset)
+ (dog_id, limit, offset)
).fetchall()
- ids = [r["id"] for r in rows]
- dogs_map = _fetch_dog_ids(conn, ids)
-
- return [_entry_dict(r, dogs_map) for r in rows]
+ entries = []
+ for r in rows:
+ e = dict(r)
+ e["tags"] = json.loads(e["tags"]) if e["tags"] else []
+ entries.append(e)
+ return entries
@router.post("/{dog_id}/diary", status_code=201)
@@ -115,8 +72,6 @@ async def create_diary(dog_id: int, data: DiaryCreate,
with db() as conn:
_own_dog(dog_id, user["id"], conn)
- all_dogs = _validate_dog_ids(data.dog_ids or [], dog_id, user["id"], conn)
-
conn.execute(
"""INSERT INTO diary
(dog_id, datum, typ, titel, text, tags, gps_lat, gps_lon, is_milestone)
@@ -130,10 +85,10 @@ async def create_diary(dog_id: int, data: DiaryCreate,
"SELECT * FROM diary WHERE dog_id=? ORDER BY id DESC LIMIT 1",
(dog_id,)
).fetchone()
- _set_dog_ids(conn, entry["id"], all_dogs)
- dogs_map = _fetch_dog_ids(conn, [entry["id"]])
- return _entry_dict(entry, dogs_map)
+ e = dict(entry)
+ e["tags"] = json.loads(e["tags"]) if e["tags"] else []
+ return e
@router.get("/{dog_id}/diary/{entry_id}")
@@ -141,16 +96,13 @@ async def get_diary(dog_id: int, entry_id: int, user=Depends(get_current_user)):
with db() as conn:
_own_dog(dog_id, user["id"], conn)
row = conn.execute(
- """SELECT DISTINCT d.* FROM diary d
- LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
- WHERE d.id=? AND (d.dog_id=? OR dd.dog_id=?)""",
- (entry_id, dog_id, dog_id)
+ "SELECT * FROM diary WHERE id=? AND dog_id=?", (entry_id, dog_id)
).fetchone()
- if not row:
- raise HTTPException(404, "Eintrag nicht gefunden.")
- dogs_map = _fetch_dog_ids(conn, [entry_id])
-
- return _entry_dict(row, dogs_map)
+ if not row:
+ raise HTTPException(404, "Eintrag nicht gefunden.")
+ e = dict(row)
+ e["tags"] = json.loads(e["tags"]) if e["tags"] else []
+ return e
@router.patch("/{dog_id}/diary/{entry_id}")
@@ -158,43 +110,20 @@ async def update_diary(dog_id: int, entry_id: int, data: DiaryUpdate,
user=Depends(get_current_user)):
with db() as conn:
_own_dog(dog_id, user["id"], conn)
-
- # Prüfen ob Eintrag diesem Hund gehört (direkt oder via diary_dogs)
- exists = conn.execute(
- """SELECT 1 FROM diary d
- LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
- WHERE d.id=? AND (d.dog_id=? OR dd.dog_id=?)""",
- (entry_id, dog_id, dog_id)
- ).fetchone()
- if not exists:
- raise HTTPException(404, "Eintrag nicht gefunden.")
-
- # Felder updaten
- fields = {k: v for k, v in data.model_dump(exclude={"dog_ids"}).items()
- if v is not None}
+ fields = {k: v for k, v in data.model_dump().items() if v is not None}
if "tags" in fields:
fields["tags"] = json.dumps(fields["tags"])
- if fields:
- # primary dog_id für SET-Clause ermitteln (der Eintrag bleibt dem Erstell-Hund)
- set_clause = ", ".join(f"{k}=?" for k in fields)
- conn.execute(
- f"UPDATE diary SET {set_clause} WHERE id=?",
- list(fields.values()) + [entry_id]
- )
-
- # Hunde-Zuweisung aktualisieren
- if data.dog_ids is not None:
- # primary dog des Eintrags ermitteln
- primary = conn.execute(
- "SELECT dog_id FROM diary WHERE id=?", (entry_id,)
- ).fetchone()["dog_id"]
- all_dogs = _validate_dog_ids(data.dog_ids, primary, user["id"], conn)
- _set_dog_ids(conn, entry_id, all_dogs)
-
- row = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone()
- dogs_map = _fetch_dog_ids(conn, [entry_id])
-
- return _entry_dict(row, dogs_map)
+ if not fields:
+ raise HTTPException(400, "Keine Änderungen.")
+ set_clause = ", ".join(f"{k}=?" for k in fields)
+ conn.execute(
+ f"UPDATE diary SET {set_clause} WHERE id=? AND dog_id=?",
+ list(fields.values()) + [entry_id, dog_id]
+ )
+ row = conn.execute("SELECT * FROM diary WHERE id=?", (entry_id,)).fetchone()
+ e = dict(row)
+ e["tags"] = json.loads(e["tags"]) if e["tags"] else []
+ return e
@router.delete("/{dog_id}/diary/{entry_id}", status_code=204)
@@ -213,10 +142,7 @@ async def upload_media(dog_id: int, entry_id: int,
with db() as conn:
_own_dog(dog_id, user["id"], conn)
entry = conn.execute(
- """SELECT d.id FROM diary d
- LEFT JOIN diary_dogs dd ON dd.diary_id = d.id
- WHERE d.id=? AND (d.dog_id=? OR dd.dog_id=?)""",
- (entry_id, dog_id, dog_id)
+ "SELECT id FROM diary WHERE id=? AND dog_id=?", (entry_id, dog_id)
).fetchone()
if not entry:
raise HTTPException(404, "Eintrag nicht gefunden.")
diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py
index e59b01d..ca63968 100644
--- a/backend/routes/dogs.py
+++ b/backend/routes/dogs.py
@@ -113,28 +113,13 @@ async def upload_photo(
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
- # Datei immer als JPEG speichern (HEIC/PNG/WebP → kompatibel für alle Browser)
- import io
- from PIL import Image
- try:
- import pillow_heif
- pillow_heif.register_heif_opener()
- except ImportError:
- pass
-
- content = await file.read()
- try:
- img = Image.open(io.BytesIO(content)).convert("RGB")
- buf = io.BytesIO()
- img.save(buf, format="JPEG", quality=90)
- content = buf.getvalue()
- except Exception:
- pass # Fallback: Originaldaten speichern
-
- filename = f"dog_{dog_id}_{uuid.uuid4().hex[:8]}.jpg"
+ # 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)
diff --git a/backend/routes/health.py b/backend/routes/health.py
index 8911d99..db2c27d 100644
--- a/backend/routes/health.py
+++ b/backend/routes/health.py
@@ -1,283 +1,3 @@
-"""BAN YARO — Gesundheit & Impfpass Routes"""
-
-import os, uuid
-from datetime import date, datetime
-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")
-
-# Erlaubte Typen
-TYPEN = {"impfung", "entwurmung", "tierarzt", "medikament", "gewicht", "allergie", "dokument"}
-
-
-# ------------------------------------------------------------------
-# Schemas
-# ------------------------------------------------------------------
-class HealthCreate(BaseModel):
- typ: str
- bezeichnung: str
- datum: str
- naechstes: Optional[str] = None
- notiz: Optional[str] = None
- # Gewicht
- wert: Optional[float] = None
- einheit: Optional[str] = "kg"
- # Impfung
- charge_nr: Optional[str] = None
- tierarzt_name: Optional[str] = None
- # Tierarztbesuch
- kosten: Optional[float] = None
- diagnose: Optional[str] = None
- # Medikament
- dosierung: Optional[str] = None
- haeufigkeit: Optional[str] = None
- aktiv: Optional[int] = 1
- bis_datum: Optional[str] = None
- # Allergie
- schweregrad: Optional[str] = None # leicht | mittel | schwer
- reaktion: Optional[str] = None
- erinnerung: Optional[int] = 1
-
-
-class HealthUpdate(BaseModel):
- bezeichnung: Optional[str] = None
- datum: Optional[str] = None
- naechstes: Optional[str] = None
- notiz: Optional[str] = None
- wert: Optional[float] = None
- einheit: Optional[str] = None
- charge_nr: Optional[str] = None
- tierarzt_name: Optional[str] = None
- kosten: Optional[float] = None
- diagnose: Optional[str] = None
- dosierung: Optional[str] = None
- haeufigkeit: Optional[str] = None
- aktiv: Optional[int] = None
- bis_datum: Optional[str] = None
- schweregrad: Optional[str] = None
- reaktion: Optional[str] = None
- erinnerung: Optional[int] = None
-
-
-# ------------------------------------------------------------------
-# Hilfsfunktion: Zugriffscheck Dog → User
-# ------------------------------------------------------------------
-def _check_dog_owner(conn, dog_id: int, user_id: int):
- 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.")
- return dog
-
-
-# ------------------------------------------------------------------
-# GET /api/dogs/{dog_id}/health
-# ------------------------------------------------------------------
-@router.get("/{dog_id}/health")
-async def list_health(dog_id: int, typ: Optional[str] = None,
- user=Depends(get_current_user)):
- with db() as conn:
- _check_dog_owner(conn, dog_id, user["id"])
- if typ:
- rows = conn.execute(
- "SELECT * FROM health WHERE dog_id=? AND typ=? ORDER BY datum DESC",
- (dog_id, typ)
- ).fetchall()
- else:
- rows = conn.execute(
- "SELECT * FROM health WHERE dog_id=? ORDER BY datum DESC",
- (dog_id,)
- ).fetchall()
- return [dict(r) for r in rows]
-
-
-# ------------------------------------------------------------------
-# POST /api/dogs/{dog_id}/health
-# ------------------------------------------------------------------
-@router.post("/{dog_id}/health", status_code=201)
-async def create_health(dog_id: int, data: HealthCreate,
- user=Depends(get_current_user)):
- if data.typ not in TYPEN:
- raise HTTPException(400, f"Unbekannter Typ. Erlaubt: {', '.join(TYPEN)}")
-
- with db() as conn:
- _check_dog_owner(conn, dog_id, user["id"])
- conn.execute(
- """INSERT INTO health
- (dog_id, typ, bezeichnung, datum, naechstes, notiz,
- wert, einheit, charge_nr, tierarzt_name, kosten, diagnose,
- dosierung, haeufigkeit, aktiv, bis_datum,
- schweregrad, reaktion, erinnerung)
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
- (dog_id, data.typ, data.bezeichnung, data.datum, data.naechstes,
- data.notiz, data.wert, data.einheit, data.charge_nr,
- data.tierarzt_name, data.kosten, data.diagnose, data.dosierung,
- data.haeufigkeit, data.aktiv, data.bis_datum,
- data.schweregrad, data.reaktion, data.erinnerung)
- )
- row = conn.execute(
- "SELECT * FROM health WHERE dog_id=? ORDER BY id DESC LIMIT 1",
- (dog_id,)
- ).fetchone()
- return dict(row)
-
-
-# ------------------------------------------------------------------
-# PATCH /api/dogs/{dog_id}/health/{id}
-# ------------------------------------------------------------------
-@router.patch("/{dog_id}/health/{entry_id}")
-async def update_health(dog_id: int, entry_id: int, data: HealthUpdate,
- user=Depends(get_current_user)):
- with db() as conn:
- _check_dog_owner(conn, dog_id, user["id"])
- entry = conn.execute(
- "SELECT * FROM health WHERE id=? AND dog_id=?", (entry_id, dog_id)
- ).fetchone()
- if not entry:
- raise HTTPException(404, "Eintrag nicht gefunden.")
-
- updates = {k: v for k, v in data.model_dump().items() if v is not None}
- if not updates:
- return dict(entry)
-
- set_clause = ", ".join(f"{k}=?" for k in updates)
- values = list(updates.values()) + [entry_id]
- conn.execute(f"UPDATE health SET {set_clause} WHERE id=?", values)
- row = conn.execute("SELECT * FROM health WHERE id=?", (entry_id,)).fetchone()
- return dict(row)
-
-
-# ------------------------------------------------------------------
-# DELETE /api/dogs/{dog_id}/health/{id}
-# ------------------------------------------------------------------
-@router.delete("/{dog_id}/health/{entry_id}", status_code=204)
-async def delete_health(dog_id: int, entry_id: int, user=Depends(get_current_user)):
- with db() as conn:
- _check_dog_owner(conn, dog_id, user["id"])
- entry = conn.execute(
- "SELECT id FROM health WHERE id=? AND dog_id=?", (entry_id, dog_id)
- ).fetchone()
- if not entry:
- raise HTTPException(404, "Eintrag nicht gefunden.")
- conn.execute("DELETE FROM health WHERE id=?", (entry_id,))
- return None
-
-
-# ------------------------------------------------------------------
-# POST /api/dogs/{dog_id}/health/{id}/dokument — Datei-Upload
-# ------------------------------------------------------------------
-@router.post("/{dog_id}/health/{entry_id}/dokument")
-async def upload_dokument(
- dog_id: int,
- entry_id: int,
- file: UploadFile = File(...),
- user=Depends(get_current_user),
-):
- with db() as conn:
- _check_dog_owner(conn, dog_id, user["id"])
- entry = conn.execute(
- "SELECT id FROM health WHERE id=? AND dog_id=?", (entry_id, dog_id)
- ).fetchone()
- if not entry:
- raise HTTPException(404, "Eintrag nicht gefunden.")
-
- ext = os.path.splitext(file.filename or "")[1].lower() or ".jpg"
- if ext not in {".jpg", ".jpeg", ".png", ".pdf", ".webp"}:
- raise HTTPException(400, "Nur JPG, PNG, WebP und PDF erlaubt.")
-
- filename = f"health_{entry_id}_{uuid.uuid4().hex[:8]}{ext}"
- path = os.path.join(MEDIA_DIR, "health", filename)
- os.makedirs(os.path.dirname(path), exist_ok=True)
-
- with open(path, "wb") as f:
- f.write(await file.read())
-
- datei_url = f"/media/health/{filename}"
- datei_typ = "pdf" if ext == ".pdf" else "image"
-
- with db() as conn:
- conn.execute(
- "UPDATE health SET datei_url=?, datei_typ=? WHERE id=?",
- (datei_url, datei_typ, entry_id)
- )
-
- return {"datei_url": datei_url, "datei_typ": datei_typ}
-
-
-# ------------------------------------------------------------------
-# POST /api/dogs/{dog_id}/health/symptom-check — KI-Symptomprüfung
-# ------------------------------------------------------------------
-class SymptomCheckRequest(BaseModel):
- symptoms: str
-
-
-@router.post("/{dog_id}/health/symptom-check")
-async def symptom_check(dog_id: int, data: SymptomCheckRequest,
- user=Depends(get_current_user)):
- from ki import symptom_check as ki_symptom_check, KIUnavailableError
-
- 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.")
-
- dog_info = dict(dog)
- if dog_info.get("geburtstag"):
- try:
- from datetime import date
- geb = date.fromisoformat(dog_info["geburtstag"])
- dog_info["alter_jahre"] = round((date.today() - geb).days / 365.25, 1)
- except Exception:
- dog_info["alter_jahre"] = "unbekannt"
-
- try:
- result = await ki_symptom_check(
- symptoms=data.symptoms,
- dog_info=dog_info,
- user_is_premium=bool(user.get("is_premium")),
- )
- return result
- except KIUnavailableError as e:
- raise HTTPException(503, str(e))
-
-
-# ------------------------------------------------------------------
-# POST /api/dogs/{dog_id}/health/ki-zusammenfassung
-# ------------------------------------------------------------------
-@router.post("/{dog_id}/health/ki-zusammenfassung")
-async def ki_zusammenfassung(dog_id: int, user=Depends(get_current_user)):
- from ki import health_summary, KIUnavailableError, KIPremiumRequired
-
- 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.")
-
- rows = conn.execute(
- "SELECT * FROM health WHERE dog_id=? ORDER BY datum DESC",
- (dog_id,)
- ).fetchall()
-
- health_data = [dict(r) for r in rows]
-
- try:
- result = await health_summary(
- health_data=health_data,
- dog_info=dict(dog),
- user_is_premium=bool(user.get("is_premium")),
- )
- return {"zusammenfassung": result}
- except KIPremiumRequired as e:
- raise HTTPException(402, str(e))
- except KIUnavailableError as e:
- raise HTTPException(503, str(e))
+"""BAN YARO — health Routes (Stub, wird ausgebaut)"""
+from fastapi import APIRouter
+router = APIRouter()
diff --git a/backend/routes/poison.py b/backend/routes/poison.py
index 9459c61..4916a6f 100644
--- a/backend/routes/poison.py
+++ b/backend/routes/poison.py
@@ -35,10 +35,6 @@ class PoisonCreate(BaseModel):
typ: str = "unbekannt"
-class PoisonResolve(BaseModel):
- grund: str = "beseitigt" # beseitigt | fehlerhaft | anderes
-
-
# ------------------------------------------------------------------
# GET /api/poison — aktive Meldungen im Umkreis (kein Login nötig)
# ------------------------------------------------------------------
@@ -118,11 +114,7 @@ async def confirm_poison(poison_id: int, user=Depends(get_current_user)):
# Nur der Melder selbst oder ein Admin
# ------------------------------------------------------------------
@router.post("/{poison_id}/resolve")
-async def resolve_poison(
- poison_id: int,
- data: PoisonResolve = PoisonResolve(),
- user=Depends(get_current_user)
-):
+async def resolve_poison(poison_id: int, user=Depends(get_current_user)):
with db() as conn:
entry = conn.execute(
"SELECT * FROM poison WHERE id=?", (poison_id,)
@@ -132,16 +124,7 @@ async def resolve_poison(
e = dict(entry)
if e["user_id"] != user["id"] and user.get("rolle") != "admin":
raise HTTPException(403, "Keine Berechtigung.")
- # Soft-Delete: Eintrag bleibt für spätere KI-Musteranalyse erhalten
- conn.execute(
- """UPDATE poison
- SET geloest=1,
- geloest_von=?,
- geloest_at=datetime('now'),
- geloest_grund=?
- WHERE id=?""",
- (user["id"], data.grund, poison_id)
- )
+ conn.execute("UPDATE poison SET geloest=1 WHERE id=?", (poison_id,))
return {"ok": True}
diff --git a/backend/static/css/components.css b/backend/static/css/components.css
index 750fc4a..afdcb80 100644
--- a/backend/static/css/components.css
+++ b/backend/static/css/components.css
@@ -441,7 +441,6 @@ textarea.form-control {
padding: var(--space-4);
backdrop-filter: blur(2px);
animation: overlay-in var(--transition-normal) ease;
- touch-action: manipulation;
}
@media (min-width: 768px) {
.modal-overlay { align-items: center; }
@@ -813,409 +812,3 @@ textarea.form-control {
/* Leaflet-Attribution ausblenden */
.leaflet-control-attribution { display: none !important; }
-
-/* ============================================================
- GESUNDHEIT
- ============================================================ */
-
-/* Header mit KI-Button */
-.health-header {
- display: flex;
- justify-content: flex-end;
- padding: var(--space-3) 0 var(--space-2);
-}
-
-/* Tab-Leiste — Mobile: horizontal scrollbar, Desktop: umbrechen */
-.health-tabs {
- display: flex;
- flex-wrap: wrap;
- gap: var(--space-1) var(--space-1);
- padding-bottom: var(--space-2);
- margin-bottom: var(--space-3);
-}
-/* Auf sehr kleinen Screens: scrollen statt umbrechen */
-@media (max-width: 480px) {
- .health-tabs {
- flex-wrap: nowrap;
- overflow-x: auto;
- padding-right: var(--space-4);
- scrollbar-width: none;
- }
- .health-tabs::-webkit-scrollbar { display: none; }
-}
-
-.health-tab {
- flex-shrink: 0;
- padding: var(--space-2) var(--space-3);
- border: 2px solid var(--c-border);
- border-radius: var(--radius-full);
- background: var(--c-surface);
- color: var(--c-text-secondary);
- font-size: var(--text-sm);
- font-weight: var(--weight-medium);
- cursor: pointer;
- white-space: nowrap;
- transition: all var(--transition-fast);
- touch-action: manipulation;
-}
-.health-tab.active {
- background: var(--c-primary);
- border-color: var(--c-primary);
- color: var(--c-text-inverse);
-}
-
-/* Karten-Liste */
-.health-list {
- display: flex;
- flex-direction: column;
- gap: var(--space-3);
-}
-
-/* Einzelne Karte */
-.health-card {
- background: var(--c-surface);
- border: 1px solid var(--c-border);
- border-radius: var(--radius-md);
- padding: var(--space-4);
- cursor: pointer;
- display: flex;
- gap: var(--space-3);
- align-items: flex-start;
- transition: box-shadow var(--transition-fast), transform var(--transition-fast);
-}
-.health-card:active { transform: scale(0.985); }
-.health-card--inactive { opacity: 0.55; }
-
-.health-card-body { flex: 1; min-width: 0; }
-.health-card-title { font-weight: var(--weight-semibold); margin-bottom: var(--space-1); }
-.health-card-meta { font-size: var(--text-sm); color: var(--c-text-secondary); }
-.health-card-next { font-size: var(--text-sm); font-weight: var(--weight-medium); margin-top: var(--space-1); }
-.health-card-note { font-size: var(--text-sm); color: var(--c-text-secondary); margin-top: var(--space-1); }
-
-/* Ampel-Punkt (links an der Karte) */
-.health-card-ampel {
- width: 10px;
- height: 10px;
- border-radius: 50%;
- flex-shrink: 0;
- margin-top: 5px;
-}
-.ampel-green { background: #22c55e; }
-.ampel-yellow { background: #f59e0b; }
-.ampel-red { background: #ef4444; }
-.ampel-grey { background: var(--c-border); }
-
-.ampel-text-green { color: #16a34a; }
-.ampel-text-yellow { color: #d97706; }
-.ampel-text-red { color: #dc2626; }
-
-/* Gruppen-Label (z.B. "Aktuelle Medikamente") */
-.health-group-label {
- font-size: var(--text-sm);
- font-weight: var(--weight-semibold);
- color: var(--c-text-secondary);
- text-transform: uppercase;
- letter-spacing: 0.05em;
- padding: var(--space-3) 0 var(--space-1);
-}
-
-/* Gewicht-Diagramm-Wrapper */
-.health-chart-wrap {
- background: var(--c-surface);
- border: 1px solid var(--c-border);
- border-radius: var(--radius-md);
- padding: var(--space-4);
- margin-bottom: var(--space-4);
- overflow: hidden;
-}
-
-/* Dokument-Thumbnail und Icon */
-.health-doc-thumb {
- width: 56px;
- height: 56px;
- object-fit: cover;
- border-radius: var(--radius-sm);
- flex-shrink: 0;
-}
-.health-doc-icon {
- width: 56px;
- height: 56px;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 2rem;
- background: var(--c-surface-2);
- border-radius: var(--radius-sm);
- flex-shrink: 0;
-}
-
-/* Detail-Dialog DL */
-.health-detail-dl {
- display: grid;
- grid-template-columns: auto 1fr;
- gap: var(--space-1) var(--space-4);
- font-size: var(--text-sm);
-}
-.health-detail-dl dt {
- color: var(--c-text-secondary);
- font-weight: var(--weight-medium);
- white-space: nowrap;
-}
-.health-detail-dl dd { margin: 0; }
-
-/* ------------------------------------------------------------
- DOG SWITCHER
- Avatar-Leiste in Header (Mobile) + Sidebar-Logo (Desktop)
- ------------------------------------------------------------ */
-
-/* Aktiver (linker) Hund — primärer Ring */
-.dog-sw-active {
- width: 34px;
- height: 34px;
- border-radius: var(--radius-full);
- overflow: hidden;
- background: var(--c-surface-2);
- flex-shrink: 0;
- cursor: pointer;
- border: 2.5px solid var(--c-primary);
- transition: transform var(--transition-fast),
- box-shadow var(--transition-fast);
-}
-.dog-sw-active:hover {
- transform: scale(1.06);
- box-shadow: 0 0 0 3px var(--c-primary-subtle);
-}
-.dog-sw-active img { width:100%; height:100%; object-fit:cover; display:block; }
-.dog-sw-active span {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 100%;
- height: 100%;
- font-size: 17px;
-}
-
-/* "Ban Yaro" Label — füllt den Zwischenraum */
-.dog-sw-title {
- flex: 1;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-/* Gruppe der inaktiven Hunde (rechts) */
-.dog-sw-others {
- display: flex;
- align-items: center;
- position: relative; /* Anker für Quickpicker-Dropdown */
- flex-shrink: 0;
-}
-
-/* Inaktiver Hund-Avatar */
-.dog-sw-other {
- width: 28px;
- height: 28px;
- border-radius: var(--radius-full);
- overflow: hidden;
- background: var(--c-surface-2);
- border: 2px solid var(--c-surface);
- cursor: pointer;
- flex-shrink: 0;
- transition: transform var(--transition-fast);
- position: relative;
-}
-.dog-sw-other:hover { transform: scale(1.12); }
-.dog-sw-other img { width:100%; height:100%; object-fit:cover; display:block; }
-.dog-sw-other span {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 100%;
- height: 100%;
- font-size: 14px;
-}
-
-/* Gestapelter Avatar-Stack (3+ Hunde) */
-.dog-sw-stack {
- display: flex;
- align-items: center;
- cursor: pointer;
-}
-.dog-sw-stack .dog-sw-other { margin-left: -9px; }
-.dog-sw-stack .dog-sw-other:first-child { margin-left: 0; }
-.dog-sw-stack .dog-sw-other--0 { z-index: 3; }
-.dog-sw-stack .dog-sw-other--1 { z-index: 2; }
-.dog-sw-stack .dog-sw-other--2 { z-index: 1; }
-.dog-sw-stack:hover .dog-sw-other { filter: brightness(1.05); }
-
-/* +N Überlauf-Badge */
-.dog-sw-more {
- width: 28px;
- height: 28px;
- border-radius: var(--radius-full);
- background: var(--c-surface-2);
- border: 2px solid var(--c-surface);
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 9px;
- font-weight: var(--weight-bold);
- color: var(--c-text-secondary);
- margin-left: -9px;
- position: relative;
- z-index: 0;
-}
-
-/* Quickpicker-Dropdown */
-.dog-quickpick {
- position: absolute;
- top: calc(100% + 10px);
- right: 0;
- background: var(--c-surface);
- border: 1px solid var(--c-border-light);
- border-radius: var(--radius-lg);
- box-shadow: var(--shadow-lg);
- padding: var(--space-2);
- min-width: 170px;
- z-index: 600;
-}
-.dog-quickpick.hidden { display: none; }
-
-.dog-qp-item {
- display: flex;
- align-items: center;
- gap: var(--space-3);
- padding: var(--space-2) var(--space-3);
- border-radius: var(--radius-md);
- cursor: pointer;
- font-size: var(--text-sm);
- font-weight: var(--weight-medium);
- color: var(--c-text);
- transition: background var(--transition-fast);
- -webkit-tap-highlight-color: transparent;
-}
-.dog-qp-item:hover { background: var(--c-bg); }
-
-.dog-qp-av {
- width: 32px;
- height: 32px;
- border-radius: var(--radius-full);
- overflow: hidden;
- background: var(--c-surface-2);
- flex-shrink: 0;
-}
-.dog-qp-av img { width:100%; height:100%; object-fit:cover; display:block; }
-.dog-qp-av span {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 100%;
- height: 100%;
- font-size: 16px;
-}
-
-/* Sidebar: größerer aktiver Avatar */
-#sidebar-dog-switcher .dog-sw-active {
- width: 36px;
- height: 36px;
-}
-
-/* ------------------------------------------------------------
- DIARY — Multi-Dog (Hunde-Auswahl im Formular + Karten-Anzeige)
- ------------------------------------------------------------ */
-
-/* Avatar-Reihe in der Tagebuch-Karte */
-.diary-dog-row {
- display: flex;
- align-items: center;
- gap: -4px; /* überlappend via margin */
- margin-top: var(--space-2);
- flex-wrap: wrap;
- gap: var(--space-1);
-}
-
-.diary-dog-av {
- width: 22px;
- height: 22px;
- border-radius: var(--radius-full);
- overflow: hidden;
- background: var(--c-surface-2);
- border: 1.5px solid var(--c-surface);
- flex-shrink: 0;
-}
-.diary-dog-av img { width:100%; height:100%; object-fit:cover; display:block; }
-.diary-dog-av span {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 100%;
- height: 100%;
- font-size: 12px;
-}
-
-/* Hunde-Chip in der Detail-Ansicht */
-.diary-detail-dogs {
- display: flex;
- flex-wrap: wrap;
- gap: var(--space-2);
- margin-bottom: var(--space-3);
-}
-
-.diary-dog-chip {
- display: flex;
- align-items: center;
- gap: var(--space-1);
- padding: 3px 8px 3px 4px;
- background: var(--c-surface-2);
- border-radius: var(--radius-full);
- font-size: var(--text-xs);
- font-weight: var(--weight-medium);
- color: var(--c-text-secondary);
-}
-.diary-dog-chip .diary-dog-av {
- width: 20px;
- height: 20px;
-}
-
-/* Hunde-Picker im Formular */
-.diary-dog-picker {
- display: flex;
- flex-wrap: wrap;
- gap: var(--space-2);
-}
-
-.diary-dog-pick-item {
- display: flex;
- align-items: center;
- gap: var(--space-2);
- padding: var(--space-2) var(--space-3);
- border: 1.5px solid var(--c-border-light);
- border-radius: var(--radius-full);
- cursor: pointer;
- font-size: var(--text-sm);
- font-weight: var(--weight-medium);
- color: var(--c-text-secondary);
- transition: border-color var(--transition-fast),
- background var(--transition-fast),
- color var(--transition-fast);
- -webkit-tap-highlight-color: transparent;
- user-select: none;
-}
-.diary-dog-pick-item input { display: none; }
-
-.diary-dog-pick-item .diary-dog-av {
- width: 26px;
- height: 26px;
-}
-
-.diary-dog-pick-item:hover {
- border-color: var(--c-primary);
- color: var(--c-text);
-}
-
-.diary-dog-pick-item.checked {
- border-color: var(--c-primary);
- background: var(--c-primary-subtle);
- color: var(--c-primary-dark);
- font-weight: var(--weight-semibold);
-}
diff --git a/backend/static/css/layout.css b/backend/static/css/layout.css
index 2682530..88fe171 100644
--- a/backend/static/css/layout.css
+++ b/backend/static/css/layout.css
@@ -58,16 +58,6 @@
flex: 1;
}
-/* Dog Switcher Container im Header */
-#header-dog-switcher {
- flex: 1;
- display: flex;
- align-items: center;
- gap: var(--space-2);
- min-width: 0;
- overflow: visible;
-}
-
.header-back {
display: flex;
align-items: center;
@@ -211,7 +201,7 @@
background: var(--c-surface);
border-right: 1px solid var(--c-border-light);
flex-direction: column;
- overflow: hidden; /* Sidebar selbst scrollt nicht */
+ overflow-y: auto;
box-shadow: var(--shadow-sm);
}
@@ -239,30 +229,13 @@
color: var(--c-text);
}
-.sidebar-add {
- padding: var(--space-4) var(--space-4) var(--space-2);
- flex-shrink: 0;
-}
-
.sidebar-nav {
- flex: 1;
- padding: var(--space-2) var(--space-2) var(--space-4);
- display: flex;
- flex-direction: column;
- gap: var(--space-1);
- overflow-y: auto;
- min-height: 0; /* wichtig: flex-child darf kleiner werden als Inhalt */
- /* Firefox */
- scrollbar-width: thin;
- scrollbar-color: var(--c-primary) var(--c-surface);
+ flex: 1;
+ padding: var(--space-4) var(--space-2);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
}
-.sidebar-nav::-webkit-scrollbar { width: 6px; }
-.sidebar-nav::-webkit-scrollbar-track { background: var(--c-surface); }
-.sidebar-nav::-webkit-scrollbar-thumb {
- background: var(--c-primary);
- border-radius: 3px;
-}
-.sidebar-nav::-webkit-scrollbar-thumb:hover { background: var(--c-primary-dark); }
.sidebar-section-label {
font-size: var(--text-xs);
@@ -297,12 +270,6 @@
color: var(--c-primary-dark);
font-weight: var(--weight-semibold);
}
-/* User-Eintrag bekommt nie den Active-Stil */
-.sidebar-item--user.active {
- background: transparent;
- color: var(--c-text-secondary);
- font-weight: var(--weight-medium);
-}
.sidebar-item-icon {
font-size: 18px;
width: 24px;
@@ -324,7 +291,11 @@
padding: 0 var(--space-1);
}
-/* sidebar-footer entfernt — Einstellungen/Konto sind jetzt Teil der scrollbaren Nav */
+.sidebar-footer {
+ padding: var(--space-4) var(--space-2);
+ border-top: 1px solid var(--c-border-light);
+ flex-shrink: 0;
+}
/* ------------------------------------------------------------
5. PAGE WRAPPER (inneres Layout der Seiten)
diff --git a/backend/static/icons/favicon-16.png b/backend/static/icons/favicon-16.png
deleted file mode 100644
index 40b9c1f..0000000
Binary files a/backend/static/icons/favicon-16.png and /dev/null differ
diff --git a/backend/static/icons/favicon-32.png b/backend/static/icons/favicon-32.png
deleted file mode 100644
index 7667efc..0000000
Binary files a/backend/static/icons/favicon-32.png and /dev/null differ
diff --git a/backend/static/icons/favicon.ico b/backend/static/icons/favicon.ico
deleted file mode 100644
index 9c22a1c..0000000
Binary files a/backend/static/icons/favicon.ico and /dev/null differ
diff --git a/backend/static/icons/icon-180.png b/backend/static/icons/icon-180.png
deleted file mode 100644
index 57a5fa6..0000000
Binary files a/backend/static/icons/icon-180.png and /dev/null differ
diff --git a/backend/static/icons/icon-192.png b/backend/static/icons/icon-192.png
deleted file mode 100644
index 2554fff..0000000
Binary files a/backend/static/icons/icon-192.png and /dev/null differ
diff --git a/backend/static/icons/icon-512.png b/backend/static/icons/icon-512.png
deleted file mode 100644
index 58dc5fe..0000000
Binary files a/backend/static/icons/icon-512.png and /dev/null differ
diff --git a/backend/static/index.html b/backend/static/index.html
index f0fd842..d5c5e22 100644
--- a/backend/static/index.html
+++ b/backend/static/index.html
@@ -6,14 +6,9 @@
-
-
-
-
-
-
+
@@ -35,23 +30,17 @@