Some checks failed
CI / lint-and-test (push) Failing after 27s
- 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.
113 lines
3.4 KiB
Python
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")
|