From 61a13d65a4c4eb8fbd8f7f1061273b7798881eed Mon Sep 17 00:00:00 2001 From: omicron Date: Wed, 31 Dec 2025 23:18:46 +0100 Subject: [PATCH] Add expense listing API endpoint --- mft/routes/expense.py | 126 +++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 1 + 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/mft/routes/expense.py b/mft/routes/expense.py index c3ebf05..b448736 100644 --- a/mft/routes/expense.py +++ b/mft/routes/expense.py @@ -1,9 +1,11 @@ +import re import sqlite3 from datetime import datetime, timezone -from fastapi import APIRouter, HTTPException, status, Depends +from fastapi import APIRouter, HTTPException, status, Depends, Query from pydantic import BaseModel, Field from mft.auth import verify_token from mft.database import get_db +from dateutil.relativedelta import relativedelta router = APIRouter() @@ -23,6 +25,128 @@ class Expense(BaseModel): note: str | None +def parse_month(month_str: str) -> datetime: + """ + Parse YYYY-MM format string to datetime at start of month. + + Args: + month_str: String in YYYY-MM format + + Returns: + datetime object at start of month (00:00:00 UTC) + + Raises: + ValueError: If format is invalid + """ + if not re.match(r"^\d{4}-\d{2}$", month_str): + raise ValueError(f"Invalid month format: {month_str}") + + year, month = map(int, month_str.split("-")) + if month < 1 or month > 12: + raise ValueError(f"Invalid month value: {month}") + if year < 1 or year > 5000: + raise ValueError(f"Invalid year value: {year}") + return datetime(year, month, 1, tzinfo=timezone.utc) + + +def get_default_range() -> tuple[datetime, datetime]: + """ + Get default time range: current month + previous month. + + Returns: + Tuple of (from_date, to_date) where range is [from, to) + """ + now = datetime.now(timezone.utc) + this_month = datetime(now.year, now.month, 1, tzinfo=timezone.utc) + to_date = this_month + relativedelta(months=1) + from_date = this_month - relativedelta(months=1) + + return from_date, to_date + + +@router.get("/expenses", response_model=list[Expense]) +def list_expenses( + from_month: str | None = Query( + None, description="Start month (YYYY-MM, inclusive)" + ), + to_month: str | None = Query(None, description="End month (YYYY-MM, exclusive)"), + uid: int = Depends(verify_token), +): + """ + List expenses for a time range. + + Query parameters must be both provided or both omitted. + If omitted, defaults to current month + previous month. + + Args: + from_month: Start of range in YYYY-MM format (inclusive) + to_month: End of range in YYYY-MM format (exclusive) + + Returns: + List of expenses ordered by timestamp descending (newest first) + + Raises: + 400: Invalid parameters (format, missing one param, from > to) + """ + # FIXME: Timezones are more or less ignored unless you happen to live in + # UTC. Possible fixes are adding a TZ parameter to the api endpoint or + # storing the TZ preference in the user account table. + + # Validate both-or-neither + if (from_month is None) != (to_month is None): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Both 'from' and 'to' parameters must be provided, or both omitted", + ) + + try: + if from_month is None: + from_date, to_date = get_default_range() + else: + from_date = parse_month(from_month) + to_date = parse_month(to_month) + + if from_date >= to_date: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="'from' must be before 'to'", + ) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) from e + + # Convert to ISO format strings for SQLite comparison + from_ts = from_date.isoformat() + to_ts = to_date.isoformat() + + with get_db() as conn: + cursor = conn.cursor() + cursor.execute( + """ + SELECT id, uid, cid, ts, value, note + FROM expense + WHERE ts >= ? AND ts < ? + ORDER BY ts DESC + """, + (from_ts, to_ts), + ) + + rows = cursor.fetchall() + return [ + Expense( + id=row[0], + uid=row[1], + cid=row[2], + ts=row[3], + value=row[4], + note=row[5], + ) + for row in rows + ] + + @router.post("/expenses", response_model=Expense, status_code=status.HTTP_201_CREATED) def create_expense(expense: ExpenseCreate, uid: int = Depends(verify_token)): """ diff --git a/pyproject.toml b/pyproject.toml index 365475c..8ca31a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ requires-python = ">=3.12" dependencies = [ "fastapi", "uvicorn[standard]", + "python-dateutil", ] [project.optional-dependencies]