Files
duty-teller/importers/duty_schedule.py
Nikolay Tatarinov ef5dbca5df Implement duty schedule import functionality and enhance user management
- Added a new command `/import_duty_schedule` for importing duty schedules via JSON, restricted to admin users.
- Introduced a two-step import process: specifying handover time and uploading a JSON file.
- Updated the database schema to allow `telegram_user_id` to be nullable for user creation by full name.
- Implemented repository functions for user management, including `get_or_create_user_by_full_name` and `delete_duties_in_range`.
- Enhanced README documentation with details on the new import command and JSON format requirements.
- Added comprehensive tests for the duty schedule parser and integration tests for the import functionality.
2026-02-17 21:45:23 +03:00

90 lines
3.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({"б", "Б", "в", "Н", "О"})
@dataclass
class DutyScheduleResult:
"""Parsed duty schedule: start_date, end_date, and per-person duty dates."""
start_date: date
end_date: date
entries: list[tuple[str, list[date]]] # (full_name, list of duty dates)
class DutyScheduleParseError(Exception):
"""Invalid or missing fields in duty-schedule JSON."""
pass
def parse_duty_schedule(raw_bytes: bytes) -> DutyScheduleResult:
"""Parse duty-schedule JSON. Returns start_date, end_date, and list of (full_name, duty_dates).
- meta.start_date (YYYY-MM-DD) and schedule (array) required.
- meta.weeks optional; number of days from max duty string length (split by ';').
- For each schedule item: name (required), duty = CSV with ';'; index i = start_date + i days.
- Cell value after strip in DUTY_MARKERS => duty day.
"""
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
schedule = data.get("schedule")
if not isinstance(schedule, list):
raise DutyScheduleParseError("Missing or invalid 'schedule' (must be array)")
max_days = 0
entries: list[tuple[str, list[date]]] = []
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")
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")
cells = [c.strip() for c in duty_str.split(";")]
max_days = max(max_days, len(cells))
duty_dates: list[date] = []
for i, cell in enumerate(cells):
if cell in DUTY_MARKERS:
d = start_date + timedelta(days=i)
duty_dates.append(d)
entries.append((full_name, duty_dates))
if max_days == 0:
end_date = start_date
else:
end_date = start_date + timedelta(days=max_days - 1)
return DutyScheduleResult(start_date=start_date, end_date=end_date, entries=entries)