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