Files
duty-teller/duty_teller/utils/dates.py
Nikolay Tatarinov d02d0a1835
All checks were successful
CI / lint-and-test (push) Successful in 21s
refactor: improve language normalization and date handling utilities
- 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.
2026-02-20 22:42:54 +03:00

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")