Add configuration rules, refactor settings management, and enhance import functionality

- Introduced a new configuration file `.cursorrules` to define coding standards, error handling, testing requirements, and project-specific guidelines.
- Refactored `config.py` to implement a `Settings` dataclass for better management of environment variables, improving testability and maintainability.
- Updated the import duty schedule handler to utilize session management with `session_scope`, ensuring proper database session handling.
- Enhanced the import service to streamline the duty schedule import process, improving code organization and readability.
- Added new service layer functions to encapsulate business logic related to group duty pinning and duty schedule imports.
- Updated README documentation to reflect the new configuration structure and improved import functionality.
This commit is contained in:
2026-02-18 12:35:11 +03:00
parent 8697b9e30b
commit 5331fac334
25 changed files with 1032 additions and 397 deletions

24
utils/__init__.py Normal file
View File

@@ -0,0 +1,24 @@
"""Shared utilities: date/ISO helpers, user display names, handover time parsing.
Used by handlers, API, and services. No DB or Telegram dependencies.
"""
from utils.dates import (
day_end_iso,
day_start_iso,
duty_to_iso,
parse_iso_date,
validate_date_range,
)
from utils.user import build_full_name
from utils.handover import parse_handover_time
__all__ = [
"day_start_iso",
"day_end_iso",
"duty_to_iso",
"parse_iso_date",
"validate_date_range",
"build_full_name",
"parse_handover_time",
]

46
utils/dates.py Normal file
View File

@@ -0,0 +1,46 @@
"""Date and ISO helpers for duty ranges and API validation."""
import re
from datetime import date, datetime, timezone
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 YYYY-MM-DD
_ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
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 and from_date <= to_date.
Raises:
ValueError: With a user-facing message if invalid.
"""
if not _ISO_DATE_RE.match(from_date or "") or not _ISO_DATE_RE.match(to_date or ""):
raise ValueError("Параметры from и to должны быть в формате YYYY-MM-DD")
if from_date > to_date:
raise ValueError("Дата from не должна быть позже to")

37
utils/handover.py Normal file
View File

@@ -0,0 +1,37 @@
"""Handover time parsing for duty schedule import."""
import re
from datetime import datetime, timezone
# HH:MM or HH:MM:SS, optional space + timezone (IANA or "UTC")
HANDOVER_TIME_RE = re.compile(
r"^\s*(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(?:\s+(\S+))?\s*$", re.IGNORECASE
)
def parse_handover_time(text: str) -> tuple[int, int] | None:
"""Parse handover time string to (hour_utc, minute_utc). Returns None on failure."""
m = HANDOVER_TIME_RE.match(text)
if not m:
return None
hour = int(m.group(1))
minute = int(m.group(2))
# second = m.group(3) ignored
tz_str = (m.group(4) or "").strip()
if not tz_str or tz_str.upper() == "UTC":
return (hour % 24, minute)
try:
from zoneinfo import ZoneInfo
except ImportError:
try:
from backports.zoneinfo import ZoneInfo # type: ignore
except ImportError:
return None
try:
tz = ZoneInfo(tz_str)
except Exception:
return None
# Build datetime in that tz and convert to UTC
dt = datetime(2000, 1, 1, hour, minute, 0, tzinfo=tz)
utc = dt.astimezone(timezone.utc)
return (utc.hour, utc.minute)

8
utils/user.py Normal file
View File

@@ -0,0 +1,8 @@
"""User display name helpers."""
def build_full_name(first_name: str | None, last_name: str | None) -> str:
"""Build display full name from first and last name. Returns 'User' if both empty."""
parts = [first_name or "", last_name or ""]
full = " ".join(filter(None, parts)).strip()
return full or "User"