# 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