banyaro/backend/routes/expenses.py
rene 742ad189e8 Feature: Sprint31 — 9 Features merged (Streak, Ausgaben, KI-Tierarzt, Rückrufe, Adoption, Vet+Befunde, Hundepass, Playdate, Rassenerkennung)
- Trainings-Streak: streak.py, DB training_streaks, Scheduler 19:00, Widget in welcome.js, Ping in uebungen.js
- Ausgaben-Tracker: expenses.py, expenses.js, DB expenses-Tabelle
- KI-Tierarztfragen: ki.py /tierarzt, health.js Button+Modal, DB ki_tierarzt_log
- Rückruf-Alarm: recalls.py, recalls.js, DB feed_recalls, Scheduler 08:00 RASFF
- Adoption: adoption.py, adoption.js, DB adoption_cache
- Tierarzt-Favorit + Befunde: tieraerzte.py /my-favorite+/favorite, health_docs.py, health.js, api.js, DB favorite_vets+health_documents
- Digitaler Hundepass: passport.py, dog-profile.js, main.py /pass/{token}, DB vaccinations+medications+dog_passport_meta+passport_shares, requirements.txt fpdf2
- Playdate-Matching: playdate.py, playdate.js, DB playdate_listings+playdate_requests
- Rassen-Erkennung: ki.py /rasse-erkennung (Claude Vision), dog-profile.js+wiki.js, CSS .rasse-result-card, DB ki_rasse_log
2026-05-02 09:29:48 +02:00

228 lines
7.7 KiB
Python

"""BAN YARO — Ausgaben-Tracker Routes"""
import logging
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from typing import Optional
from database import db
from auth import get_current_user
router = APIRouter()
logger = logging.getLogger(__name__)
KATEGORIEN = {"tierarzt", "futter", "zubehoer", "versicherung", "sitter", "sonstiges"}
# ------------------------------------------------------------------
# Schemas
# ------------------------------------------------------------------
class ExpenseCreate(BaseModel):
dog_id: Optional[int] = None
kategorie: str
betrag: float
datum: str
notiz: Optional[str] = None
class ExpenseUpdate(BaseModel):
dog_id: Optional[int] = None
kategorie: Optional[str] = None
betrag: Optional[float] = None
datum: Optional[str] = None
notiz: Optional[str] = None
def _serialize(row) -> dict:
return dict(row)
# ------------------------------------------------------------------
# GET /api/expenses/summary — Monats- und Jahressummen
# WICHTIG: Diese Route muss VOR /{id} stehen!
# ------------------------------------------------------------------
@router.get("/summary")
async def get_summary(
dog_id: Optional[int] = Query(default=None),
user=Depends(get_current_user),
):
today = date.today()
monat_prefix = today.strftime("%Y-%m")
jahr_prefix = today.strftime("%Y")
extra_cond = ""
extra_params: list = []
if dog_id is not None:
extra_cond = " AND dog_id=?"
extra_params = [dog_id]
with db() as conn:
# Monats-Summen pro Kategorie
rows_monat = conn.execute(
f"""SELECT kategorie, COALESCE(SUM(betrag),0) AS summe
FROM expenses
WHERE user_id=? AND datum LIKE ?{extra_cond}
GROUP BY kategorie""",
[user["id"], f"{monat_prefix}%"] + extra_params,
).fetchall()
# Jahres-Summen pro Kategorie
rows_jahr = conn.execute(
f"""SELECT kategorie, COALESCE(SUM(betrag),0) AS summe
FROM expenses
WHERE user_id=? AND datum LIKE ?{extra_cond}
GROUP BY kategorie""",
[user["id"], f"{jahr_prefix}%"] + extra_params,
).fetchall()
monat = {r["kategorie"]: round(r["summe"], 2) for r in rows_monat}
jahr = {r["kategorie"]: round(r["summe"], 2) for r in rows_jahr}
gesamt_monat = round(sum(monat.values()), 2)
gesamt_jahr = round(sum(jahr.values()), 2)
return {
"monat": monat,
"jahr": jahr,
"gesamt_monat": gesamt_monat,
"gesamt_jahr": gesamt_jahr,
}
# ------------------------------------------------------------------
# GET /api/expenses — Liste mit optionalen Filtern
# ------------------------------------------------------------------
@router.get("")
async def list_expenses(
dog_id: Optional[int] = Query(default=None),
von: Optional[str] = Query(default=None),
bis: Optional[str] = Query(default=None),
limit: int = Query(default=100, le=500),
offset: int = Query(default=0),
user=Depends(get_current_user),
):
conditions = ["e.user_id=?"]
params: list = [user["id"]]
if dog_id is not None:
conditions.append("e.dog_id=?")
params.append(dog_id)
if von:
conditions.append("e.datum >= ?")
params.append(von)
if bis:
conditions.append("e.datum <= ?")
params.append(bis)
where = " AND ".join(conditions)
params += [limit, offset]
with db() as conn:
rows = conn.execute(
f"""SELECT e.*, d.name AS dog_name
FROM expenses e
LEFT JOIN dogs d ON d.id = e.dog_id
WHERE {where}
ORDER BY e.datum DESC, e.id DESC
LIMIT ? OFFSET ?""",
params,
).fetchall()
return [_serialize(r) for r in rows]
# ------------------------------------------------------------------
# POST /api/expenses — neuer Eintrag
# ------------------------------------------------------------------
@router.post("", status_code=201)
async def create_expense(data: ExpenseCreate, user=Depends(get_current_user)):
if data.kategorie not in KATEGORIEN:
raise HTTPException(400, f"Ungültige Kategorie: {data.kategorie}")
if data.betrag <= 0:
raise HTTPException(400, "Betrag muss größer als 0 sein.")
with db() as conn:
# dog_id prüfen — muss dem User gehören
if data.dog_id is not None:
dog = conn.execute(
"SELECT id FROM dogs WHERE id=? AND user_id=?",
(data.dog_id, user["id"]),
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
conn.execute(
"""INSERT INTO expenses (user_id, dog_id, kategorie, betrag, datum, notiz)
VALUES (?, ?, ?, ?, ?, ?)""",
(user["id"], data.dog_id, data.kategorie, data.betrag, data.datum, data.notiz),
)
row = conn.execute(
"SELECT * FROM expenses WHERE user_id=? ORDER BY id DESC LIMIT 1",
(user["id"],),
).fetchone()
return _serialize(row)
# ------------------------------------------------------------------
# PATCH /api/expenses/{id} — bearbeiten
# ------------------------------------------------------------------
@router.patch("/{expense_id}")
async def update_expense(
expense_id: int, data: ExpenseUpdate, user=Depends(get_current_user)
):
with db() as conn:
row = conn.execute(
"SELECT * FROM expenses WHERE id=? AND user_id=?",
(expense_id, user["id"]),
).fetchone()
if not row:
raise HTTPException(404, "Eintrag nicht gefunden.")
updates = {}
if data.kategorie is not None:
if data.kategorie not in KATEGORIEN:
raise HTTPException(400, f"Ungültige Kategorie: {data.kategorie}")
updates["kategorie"] = data.kategorie
if data.betrag is not None:
if data.betrag <= 0:
raise HTTPException(400, "Betrag muss größer als 0 sein.")
updates["betrag"] = data.betrag
if data.datum is not None:
updates["datum"] = data.datum
if data.notiz is not None:
updates["notiz"] = data.notiz
if data.dog_id is not None:
dog = conn.execute(
"SELECT id FROM dogs WHERE id=? AND user_id=?",
(data.dog_id, user["id"]),
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
updates["dog_id"] = data.dog_id
if not updates:
return _serialize(row)
set_clause = ", ".join(f"{k}=?" for k in updates)
values = list(updates.values()) + [expense_id]
conn.execute(f"UPDATE expenses SET {set_clause} WHERE id=?", values)
row = conn.execute("SELECT * FROM expenses WHERE id=?", (expense_id,)).fetchone()
return _serialize(row)
# ------------------------------------------------------------------
# DELETE /api/expenses/{id} — löschen
# ------------------------------------------------------------------
@router.delete("/{expense_id}", status_code=204)
async def delete_expense(expense_id: int, user=Depends(get_current_user)):
with db() as conn:
row = conn.execute(
"SELECT id FROM expenses WHERE id=? AND user_id=?",
(expense_id, user["id"]),
).fetchone()
if not row:
raise HTTPException(404, "Eintrag nicht gefunden.")
conn.execute("DELETE FROM expenses WHERE id=?", (expense_id,))
return None