80 lines
2.8 KiB
Python
80 lines
2.8 KiB
Python
# Copyright (c) 2025 Patrick Motsch
|
|
# All rights reserved.
|
|
"""
|
|
Date-range parsing for API endpoints that accept user-provided
|
|
`dateFrom`/`dateTo` query params (ISO `YYYY-MM-DD`).
|
|
|
|
Centralizes:
|
|
- Parsing (HTTPException 400 on invalid format)
|
|
- Validation (`dateFrom <= dateTo`)
|
|
- Conversion to inclusive epoch boundaries for downstream filters
|
|
(`dateFrom` -> `00:00:00.000` of that local day,
|
|
`dateTo` -> `23:59:59.999` of that local day).
|
|
|
|
Local-day semantics intentionally follow `datetime.combine(d, time.min).timestamp()`
|
|
which the legacy billing/audit code used; this matches the user's calendar
|
|
expectation ("01.04 - 03.04" = three full local days). Servers running in a
|
|
timezone other than the user's will see consistent boundaries because the
|
|
date string carries no timezone.
|
|
"""
|
|
|
|
from datetime import date, datetime, time as dtTime
|
|
from typing import Tuple
|
|
|
|
from fastapi import HTTPException
|
|
|
|
|
|
def parseIsoDate(value: str, fieldName: str) -> date:
|
|
"""Parse an ISO `YYYY-MM-DD` string.
|
|
|
|
Raises HTTPException(400) on invalid input.
|
|
"""
|
|
if not isinstance(value, str) or not value:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"{fieldName} is required (ISO YYYY-MM-DD)",
|
|
)
|
|
try:
|
|
return date.fromisoformat(value)
|
|
except ValueError as e:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"{fieldName} is not a valid ISO date (YYYY-MM-DD): {value}",
|
|
) from e
|
|
|
|
|
|
def parseIsoDateRange(dateFrom: str, dateTo: str) -> Tuple[date, date]:
|
|
"""Parse and validate an inclusive ISO date range.
|
|
|
|
Raises HTTPException(400) on invalid format or `from > to`.
|
|
"""
|
|
fromDate = parseIsoDate(dateFrom, "dateFrom")
|
|
toDate = parseIsoDate(dateTo, "dateTo")
|
|
if fromDate > toDate:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"dateFrom must be <= dateTo (got {dateFrom} > {dateTo})",
|
|
)
|
|
return fromDate, toDate
|
|
|
|
|
|
def isoDateRangeToLocalEpoch(dateFrom: str, dateTo: str) -> Tuple[float, float]:
|
|
"""Convert an inclusive ISO date range to local-time epoch seconds.
|
|
|
|
`dateTo` boundary is end-of-day inclusive (23:59:59.999999), so a single-day
|
|
range `dateFrom == dateTo` covers the full 24h of that local day.
|
|
|
|
Returns:
|
|
(fromTs, toTs) - both float epoch seconds, suitable for `ts >= fromTs`
|
|
and `ts <= toTs` filters.
|
|
"""
|
|
fromDate, toDate = parseIsoDateRange(dateFrom, dateTo)
|
|
fromTs = datetime.combine(fromDate, dtTime.min).timestamp()
|
|
toTs = datetime.combine(toDate, dtTime.max).timestamp()
|
|
return fromTs, toTs
|
|
|
|
|
|
def daysInRange(dateFrom: str, dateTo: str) -> int:
|
|
"""Inclusive day count for a date range. `from == to` returns 1."""
|
|
fromDate, toDate = parseIsoDateRange(dateFrom, dateTo)
|
|
return (toDate - fromDate).days + 1
|