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
This commit is contained in:
parent
031c6028ac
commit
742ad189e8
26 changed files with 5734 additions and 27 deletions
228
backend/routes/expenses.py
Normal file
228
backend/routes/expenses.py
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
"""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
|
||||
Loading…
Add table
Add a link
Reference in a new issue