Refactor project structure and enhance Docker configuration

- Updated `.dockerignore` to exclude test and development artifacts, optimizing the Docker image size.
- Refactored `main.py` to delegate execution to `duty_teller.run.main()`, simplifying the entry point.
- Introduced a new `duty_teller` package to encapsulate core functionality, improving modularity and organization.
- Enhanced `pyproject.toml` to define a script for running the application, streamlining the execution process.
- Updated README documentation to reflect changes in project structure and usage instructions.
- Improved Alembic environment configuration to utilize the new package structure for database migrations.
This commit is contained in:
2026-02-18 13:03:14 +03:00
parent 5331fac334
commit 28973489a5
42 changed files with 361 additions and 363 deletions

View File

@@ -0,0 +1 @@
"""Importers for duty data (e.g. duty-schedule JSON)."""

View File

@@ -0,0 +1,114 @@
"""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 = "О"
@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. Returns start_date, end_date, and list of DutyScheduleEntry.
- 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: в/В/б/Б => duty, Н => unavailable, О => vacation; rest ignored.
"""
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[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")
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] = []
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)
return DutyScheduleResult(start_date=start_date, end_date=end_date, entries=entries)