platform-core/modules/shared/dateRange.py
ValueOn AG 4a60086c80
Some checks failed
Deploy Plattform-Core (Int) / test (push) Failing after 15s
Deploy Plattform-Core (Int) / deploy (push) Has been skipped
cp adapted to 2026 poweron
2026-06-09 09:53:31 +02:00

80 lines
2.8 KiB
Python

# Copyright (c) 2026 PowerOn AG
# 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