Files
duty-teller/duty_teller/importers/duty_schedule.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

156 lines
5.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Parser for duty-schedule JSON format. No DB access."""
import json
from dataclasses import dataclass
from datetime import date, timedelta
# Символы дежурства в ячейке duty (CSV с разделителем ;)
DUTY_MARKERS = frozenset({"б", "Б", "в", "В"})
UNAVAILABLE_MARKER = "Н"
VACATION_MARKER = "О"
# Limits to avoid abuse and unreasonable input.
MAX_SCHEDULE_ROWS = 500
MAX_FULL_NAME_LENGTH = 200
MAX_DUTY_STRING_LENGTH = 10000
@dataclass
class DutyScheduleEntry:
"""One person's schedule: full_name and three lists of dates by event type."""
full_name: str
duty_dates: list[date]
unavailable_dates: list[date]
vacation_dates: list[date]
@dataclass
class DutyScheduleResult:
"""Parsed duty schedule: start_date, end_date, and per-person entries."""
start_date: date
end_date: date
entries: list[DutyScheduleEntry]
class DutyScheduleParseError(Exception):
"""Invalid or missing fields in duty-schedule JSON."""
pass
def parse_duty_schedule(raw_bytes: bytes) -> DutyScheduleResult:
"""Parse duty-schedule JSON into DutyScheduleResult.
Expects meta.start_date (YYYY-MM-DD) and schedule (array). For each schedule
item: name (required), duty string with ';' separator; index i = start_date + i days.
Cell values: в/В/б/Б => duty, Н => unavailable, О => vacation; rest ignored.
Args:
raw_bytes: UTF-8 encoded JSON bytes.
Returns:
DutyScheduleResult with start_date, end_date, and entries (per-person dates).
Raises:
DutyScheduleParseError: On invalid JSON, missing/invalid meta or schedule,
or invalid item fields.
"""
try:
data = json.loads(raw_bytes.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError) as e:
raise DutyScheduleParseError(f"Invalid JSON or encoding: {e}") from e
meta = data.get("meta")
if not meta or not isinstance(meta, dict):
raise DutyScheduleParseError("Missing or invalid 'meta'")
start_str = meta.get("start_date")
if not start_str or not isinstance(start_str, str):
raise DutyScheduleParseError("Missing or invalid meta.start_date")
try:
start_date = date.fromisoformat(start_str.strip())
except ValueError as e:
raise DutyScheduleParseError(f"Invalid meta.start_date: {start_str}") from e
# Reject dates outside current year ± 1.
today = date.today()
min_year = today.year - 1
max_year = today.year + 1
if not (min_year <= start_date.year <= max_year):
raise DutyScheduleParseError(
f"meta.start_date year must be between {min_year} and {max_year}"
)
schedule = data.get("schedule")
if not isinstance(schedule, list):
raise DutyScheduleParseError("Missing or invalid 'schedule' (must be array)")
if len(schedule) > MAX_SCHEDULE_ROWS:
raise DutyScheduleParseError(
f"schedule has too many rows (max {MAX_SCHEDULE_ROWS})"
)
max_days = 0
entries: list[DutyScheduleEntry] = []
for row in schedule:
if not isinstance(row, dict):
raise DutyScheduleParseError("schedule item must be an object")
name = row.get("name")
if name is None or not isinstance(name, str):
raise DutyScheduleParseError("schedule item must have 'name' (string)")
full_name = name.strip()
if not full_name:
raise DutyScheduleParseError("schedule item 'name' cannot be empty")
if len(full_name) > MAX_FULL_NAME_LENGTH:
raise DutyScheduleParseError(
f"schedule item 'name' must not exceed {MAX_FULL_NAME_LENGTH} characters"
)
duty_str = row.get("duty")
if duty_str is None:
duty_str = ""
if not isinstance(duty_str, str):
raise DutyScheduleParseError("schedule item 'duty' must be string")
if len(duty_str) > MAX_DUTY_STRING_LENGTH:
raise DutyScheduleParseError(
f"schedule item 'duty' must not exceed {MAX_DUTY_STRING_LENGTH} characters"
)
cells = [c.strip() for c in duty_str.split(";")]
max_days = max(max_days, len(cells))
duty_dates: list[date] = []
unavailable_dates: list[date] = []
vacation_dates: list[date] = []
for i, cell in enumerate(cells):
d = start_date + timedelta(days=i)
if cell in DUTY_MARKERS:
duty_dates.append(d)
elif cell == UNAVAILABLE_MARKER:
unavailable_dates.append(d)
elif cell == VACATION_MARKER:
vacation_dates.append(d)
entries.append(
DutyScheduleEntry(
full_name=full_name,
duty_dates=duty_dates,
unavailable_dates=unavailable_dates,
vacation_dates=vacation_dates,
)
)
if max_days == 0:
end_date = start_date
else:
end_date = start_date + timedelta(days=max_days - 1)
if not (min_year <= end_date.year <= max_year):
raise DutyScheduleParseError(
f"Computed end_date year must be between {min_year} and {max_year}"
)
return DutyScheduleResult(start_date=start_date, end_date=end_date, entries=entries)