feat: Support page with budget transparency (replaces About)
This commit is contained in:
@@ -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})
|
||||
@@ -55,7 +55,7 @@ app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origin_regex=r"https?://localhost(:\d+)?|https://[a-z0-9-]+\.bincio\.org",
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "DELETE"],
|
||||
allow_methods=["GET", "POST", "PATCH", "DELETE"],
|
||||
allow_headers=["Content-Type"],
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user