gateway/modules/shared/dateRange.py
2026-04-21 00:50:36 +02:00

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