Files
duty-teller/duty_teller/utils/dates.py
Nikolay Tatarinov 7ffa727832
Some checks failed
CI / lint-and-test (push) Failing after 27s
feat: enhance error handling and configuration validation
- Added a global exception handler to log unhandled exceptions and return a generic 500 JSON response without exposing details to the client.
- Updated the configuration to validate the `DATABASE_URL` format, ensuring it starts with `sqlite://` or `postgresql://`, and log warnings for invalid formats.
- Introduced safe parsing for numeric environment variables (`HTTP_PORT`, `INIT_DATA_MAX_AGE_SECONDS`) with defaults on invalid values, including logging warnings for out-of-range values.
- Enhanced the duty schedule parser to enforce limits on the number of schedule rows and the length of full names and duty strings, raising appropriate errors when exceeded.
- Updated internationalization messages to include generic error responses for import failures and parsing issues, improving user experience.
- Added unit tests to verify the new error handling and configuration validation behaviors.
2026-03-02 23:36:03 +03:00

113 lines
3.4 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}$")
# Maximum allowed date range in days (e.g. 731 = 2 years).
MAX_DATE_RANGE_DAYS = 731
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", "range_too_large"],
) -> 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, from_date <= to_date, and range <= MAX_DATE_RANGE_DAYS.
Raises:
DateRangeValidationError: bad_format if format invalid, from_after_to if from > to,
range_too_large if (to_date - from_date) > MAX_DATE_RANGE_DAYS.
"""
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")
try:
from_d = date.fromisoformat(from_date)
to_d = date.fromisoformat(to_date)
except ValueError:
raise DateRangeValidationError("bad_format") from None
if (to_d - from_d).days > MAX_DATE_RANGE_DAYS:
raise DateRangeValidationError("range_too_large")