Add expense listing API endpoint

This commit is contained in:
2025-12-31 23:18:46 +01:00
parent 13dcad6c0f
commit 61a13d65a4
2 changed files with 126 additions and 1 deletions

View File

@@ -1,9 +1,11 @@
import re
import sqlite3 import sqlite3
from datetime import datetime, timezone 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 pydantic import BaseModel, Field
from mft.auth import verify_token from mft.auth import verify_token
from mft.database import get_db from mft.database import get_db
from dateutil.relativedelta import relativedelta
router = APIRouter() router = APIRouter()
@@ -23,6 +25,128 @@ class Expense(BaseModel):
note: str | None 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) @router.post("/expenses", response_model=Expense, status_code=status.HTTP_201_CREATED)
def create_expense(expense: ExpenseCreate, uid: int = Depends(verify_token)): def create_expense(expense: ExpenseCreate, uid: int = Depends(verify_token)):
""" """

View File

@@ -6,6 +6,7 @@ requires-python = ">=3.12"
dependencies = [ dependencies = [
"fastapi", "fastapi",
"uvicorn[standard]", "uvicorn[standard]",
"python-dateutil",
] ]
[project.optional-dependencies] [project.optional-dependencies]