Add expense listing API endpoint
This commit is contained in:
@@ -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)):
|
||||
"""
|
||||
|
||||
@@ -6,6 +6,7 @@ requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi",
|
||||
"uvicorn[standard]",
|
||||
"python-dateutil",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
Reference in New Issue
Block a user