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.
156 lines
5.2 KiB
Python
156 lines
5.2 KiB
Python
"""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)
|