feat: Support page with budget transparency (replaces About)

This commit is contained in:
Davide Scaini
2026-06-03 10:34:18 +02:00
parent b781193d44
commit fa14d91359
5 changed files with 827 additions and 254 deletions
+131
View File
@@ -0,0 +1,131 @@
"""Budget transparency endpoints (/api/budget)."""
from __future__ import annotations
import json
import uuid
from pathlib import Path
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
from bincio.serve import deps
from bincio.serve.db import User
router = APIRouter()
_BUDGET_FILE = "budget.json"
def _budget_path() -> Path:
return deps._get_data_dir() / _BUDGET_FILE
def _load() -> dict:
p = _budget_path()
if not p.exists():
return {"monthly_target_eur": None, "entries": []}
try:
return json.loads(p.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return {"monthly_target_eur": None, "entries": []}
def _save(data: dict) -> None:
_budget_path().write_text(
json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8"
)
@router.get("/api/budget")
async def get_budget() -> JSONResponse:
return JSONResponse(_load())
@router.post("/api/budget/settings")
async def update_settings(
request: Request,
_: User = Depends(deps._require_admin),
) -> JSONResponse:
body = await request.json()
data = _load()
if "monthly_target_eur" in body:
v = body["monthly_target_eur"]
data["monthly_target_eur"] = round(float(v), 2) if v is not None else None
_save(data)
return JSONResponse({"ok": True})
@router.post("/api/budget/entries")
async def add_entry(
request: Request,
_: User = Depends(deps._require_admin),
) -> JSONResponse:
body = await request.json()
entry_type = body.get("type")
if entry_type not in ("donation", "expense"):
raise HTTPException(400, "type must be 'donation' or 'expense'")
label = str(body.get("label", "")).strip()
if not label:
raise HTTPException(400, "label is required")
try:
amount = round(float(body["amount_eur"]), 2)
except (KeyError, TypeError, ValueError):
raise HTTPException(400, "amount_eur must be a number")
month = str(body.get("month", "")).strip()
if len(month) != 7 or month[4] != "-":
raise HTTPException(400, "month must be YYYY-MM")
note = str(body.get("note", "")).strip()
entry = {
"id": str(uuid.uuid4())[:8],
"type": entry_type,
"label": label,
"amount_eur": amount,
"month": month,
"note": note,
}
data = _load()
data.setdefault("entries", []).append(entry)
_save(data)
return JSONResponse(entry, status_code=201)
@router.patch("/api/budget/entries/{entry_id}")
async def update_entry(
entry_id: str,
request: Request,
_: User = Depends(deps._require_admin),
) -> JSONResponse:
body = await request.json()
data = _load()
entry = next((e for e in data.get("entries", []) if e["id"] == entry_id), None)
if not entry:
raise HTTPException(404, "Entry not found")
if "label" in body:
entry["label"] = str(body["label"]).strip()
if "type" in body:
if body["type"] not in ("donation", "expense"):
raise HTTPException(400, "type must be 'donation' or 'expense'")
entry["type"] = body["type"]
if "amount_eur" in body:
entry["amount_eur"] = round(float(body["amount_eur"]), 2)
if "month" in body:
entry["month"] = str(body["month"]).strip()
if "note" in body:
entry["note"] = str(body["note"]).strip()
_save(data)
return JSONResponse(entry)
@router.delete("/api/budget/entries/{entry_id}")
async def delete_entry(
entry_id: str,
_: User = Depends(deps._require_admin),
) -> JSONResponse:
data = _load()
before = len(data.get("entries", []))
data["entries"] = [e for e in data.get("entries", []) if e["id"] != entry_id]
if len(data["entries"]) == before:
raise HTTPException(404, "Entry not found")
_save(data)
return JSONResponse({"ok": True})