All checks were successful
CI / lint-and-test (push) Successful in 21s
- Introduced a new `normalize_lang` function to standardize language codes across the application, ensuring consistent handling of user language preferences. - Refactored date handling utilities by adding `parse_utc_iso` and `parse_utc_iso_naive` functions for better parsing of ISO 8601 date strings, enhancing timezone awareness. - Updated various modules to utilize the new language normalization and date parsing functions, improving code clarity and maintainability. - Enhanced error handling in date validation to raise specific `DateRangeValidationError` exceptions, providing clearer feedback on validation issues. - Improved test coverage for date range validation and language normalization functionalities, ensuring robustness and reliability.
98 lines
2.9 KiB
Python
98 lines
2.9 KiB
Python
"""Date and ISO helpers for duty ranges and API validation."""
|
|
|
|
import re
|
|
from datetime import date, datetime, timedelta, timezone
|
|
from typing import Literal
|
|
|
|
|
|
def day_start_iso(d: date) -> str:
|
|
"""ISO 8601 start of calendar day UTC: YYYY-MM-DDT00:00:00Z."""
|
|
return d.isoformat() + "T00:00:00Z"
|
|
|
|
|
|
def day_end_iso(d: date) -> str:
|
|
"""ISO 8601 end of calendar day UTC: YYYY-MM-DDT23:59:59Z."""
|
|
return d.isoformat() + "T23:59:59Z"
|
|
|
|
|
|
def duty_to_iso(d: date, hour_utc: int, minute_utc: int) -> str:
|
|
"""ISO 8601 with Z for start of duty on date d at given UTC time."""
|
|
dt = datetime(d.year, d.month, d.day, hour_utc, minute_utc, 0, tzinfo=timezone.utc)
|
|
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
|
|
_ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
|
|
|
|
class DateRangeValidationError(ValueError):
|
|
"""Raised when from_date/to_date validation fails. API uses kind for i18n key."""
|
|
|
|
def __init__(self, kind: Literal["bad_format", "from_after_to"]) -> None:
|
|
self.kind = kind
|
|
super().__init__(kind)
|
|
|
|
|
|
def to_date_exclusive_iso(to_date: str) -> str:
|
|
"""Return the day after to_date in YYYY-MM-DD for exclusive range end.
|
|
|
|
Use for queries like start_at < to_date_next (e.g. filter end before next day).
|
|
|
|
Args:
|
|
to_date: End date in YYYY-MM-DD.
|
|
|
|
Returns:
|
|
(to_date + 1 day) in YYYY-MM-DD.
|
|
"""
|
|
dt = datetime.fromisoformat(to_date + "T00:00:00") + timedelta(days=1)
|
|
return dt.strftime("%Y-%m-%d")
|
|
|
|
|
|
def parse_utc_iso(iso_str: str) -> datetime:
|
|
"""Parse UTC ISO 8601 string with Z suffix to timezone-aware datetime (UTC).
|
|
|
|
Args:
|
|
iso_str: e.g. '2025-01-15T09:00:00Z'.
|
|
|
|
Returns:
|
|
Timezone-aware datetime in UTC.
|
|
"""
|
|
normalized = (iso_str or "").strip().replace("Z", "+00:00")
|
|
return datetime.fromisoformat(normalized)
|
|
|
|
|
|
def parse_utc_iso_naive(iso_str: str) -> datetime:
|
|
"""Parse UTC ISO 8601 string with Z to naive datetime in UTC.
|
|
|
|
Use for job queue or code that expects naive UTC.
|
|
|
|
Args:
|
|
iso_str: e.g. '2025-01-15T09:00:00Z'.
|
|
|
|
Returns:
|
|
Naive datetime with same UTC wall time.
|
|
"""
|
|
dt = parse_utc_iso(iso_str)
|
|
return dt.replace(tzinfo=None)
|
|
|
|
|
|
def parse_iso_date(s: str) -> date | None:
|
|
"""Parse YYYY-MM-DD string to date. Returns None if invalid."""
|
|
if not s or not _ISO_DATE_RE.match(s.strip()):
|
|
return None
|
|
try:
|
|
return date.fromisoformat(s.strip())
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def validate_date_range(from_date: str, to_date: str) -> None:
|
|
"""Validate from_date and to_date are YYYY-MM-DD and from_date <= to_date.
|
|
|
|
Raises:
|
|
DateRangeValidationError: bad_format if format invalid, from_after_to if from > to.
|
|
"""
|
|
if not _ISO_DATE_RE.match(from_date or "") or not _ISO_DATE_RE.match(to_date or ""):
|
|
raise DateRangeValidationError("bad_format")
|
|
if from_date > to_date:
|
|
raise DateRangeValidationError("from_after_to")
|