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:
27
services/__init__.py
Normal file
27
services/__init__.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Service layer: business logic and orchestration.
|
||||
|
||||
Services accept a DB session from the caller (handlers open session_scope and pass session).
|
||||
No Telegram or HTTP dependencies; repository handles persistence.
|
||||
"""
|
||||
|
||||
from services.group_duty_pin_service import (
|
||||
format_duty_message,
|
||||
get_duty_message_text,
|
||||
get_next_shift_end_utc,
|
||||
save_pin,
|
||||
delete_pin,
|
||||
get_message_id,
|
||||
get_all_pin_chat_ids,
|
||||
)
|
||||
from services.import_service import run_import
|
||||
|
||||
__all__ = [
|
||||
"format_duty_message",
|
||||
"get_duty_message_text",
|
||||
"get_next_shift_end_utc",
|
||||
"save_pin",
|
||||
"delete_pin",
|
||||
"get_message_id",
|
||||
"get_all_pin_chat_ids",
|
||||
"run_import",
|
||||
]
|
||||
86
services/group_duty_pin_service.py
Normal file
86
services/group_duty_pin_service.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Group duty pin: current duty message text, next shift end, pin CRUD. All accept session."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from db.repository import (
|
||||
get_current_duty,
|
||||
get_next_shift_end,
|
||||
get_group_duty_pin,
|
||||
save_group_duty_pin,
|
||||
delete_group_duty_pin,
|
||||
get_all_group_duty_pin_chat_ids,
|
||||
)
|
||||
|
||||
|
||||
def format_duty_message(duty, user, tz_name: str) -> str:
|
||||
"""Build the text for the pinned message. duty, user may be None."""
|
||||
if duty is None or user is None:
|
||||
return "Сейчас дежурства нет."
|
||||
try:
|
||||
tz = ZoneInfo(tz_name)
|
||||
except Exception:
|
||||
tz = ZoneInfo("Europe/Moscow")
|
||||
tz_name = "Europe/Moscow"
|
||||
start_dt = datetime.fromisoformat(duty.start_at.replace("Z", "+00:00"))
|
||||
end_dt = datetime.fromisoformat(duty.end_at.replace("Z", "+00:00"))
|
||||
start_local = start_dt.astimezone(tz)
|
||||
end_local = end_dt.astimezone(tz)
|
||||
offset_sec = (
|
||||
start_local.utcoffset().total_seconds() if start_local.utcoffset() else 0
|
||||
)
|
||||
sign = "+" if offset_sec >= 0 else "-"
|
||||
h, r = divmod(abs(int(offset_sec)), 3600)
|
||||
m = r // 60
|
||||
tz_hint = f"UTC{sign}{h:d}:{m:02d}, {tz_name}"
|
||||
time_range = (
|
||||
f"{start_local.strftime('%d.%m.%Y %H:%M')} — "
|
||||
f"{end_local.strftime('%d.%m.%Y %H:%M')} ({tz_hint})"
|
||||
)
|
||||
lines = [
|
||||
f"🕐 Дежурство: {time_range}",
|
||||
f"👤 {user.full_name}",
|
||||
]
|
||||
if user.phone:
|
||||
lines.append(f"📞 {user.phone}")
|
||||
if user.username:
|
||||
lines.append(f"@{user.username}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def get_duty_message_text(session: Session, tz_name: str) -> str:
|
||||
"""Get current duty from DB and return formatted message."""
|
||||
now = datetime.now(timezone.utc)
|
||||
result = get_current_duty(session, now)
|
||||
if result is None:
|
||||
return "Сейчас дежурства нет."
|
||||
duty, user = result
|
||||
return format_duty_message(duty, user, tz_name)
|
||||
|
||||
|
||||
def get_next_shift_end_utc(session: Session) -> datetime | None:
|
||||
"""Return next shift end as naive UTC datetime for job scheduling."""
|
||||
return get_next_shift_end(session, datetime.now(timezone.utc))
|
||||
|
||||
|
||||
def save_pin(session: Session, chat_id: int, message_id: int) -> None:
|
||||
"""Save or update the pinned message record for a chat."""
|
||||
save_group_duty_pin(session, chat_id, message_id)
|
||||
|
||||
|
||||
def delete_pin(session: Session, chat_id: int) -> None:
|
||||
"""Remove the pinned message record when the bot leaves the group."""
|
||||
delete_group_duty_pin(session, chat_id)
|
||||
|
||||
|
||||
def get_message_id(session: Session, chat_id: int) -> int | None:
|
||||
"""Return message_id for the pin in this chat, or None."""
|
||||
pin = get_group_duty_pin(session, chat_id)
|
||||
return pin.message_id if pin else None
|
||||
|
||||
|
||||
def get_all_pin_chat_ids(session: Session) -> list[int]:
|
||||
"""Return all chat_ids that have a pinned duty message (for restoring jobs on startup)."""
|
||||
return get_all_group_duty_pin_chat_ids(session)
|
||||
70
services/import_service.py
Normal file
70
services/import_service.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Import duty schedule: delete range, insert duties/unavailable/vacation. Accepts session."""
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from db.repository import (
|
||||
get_or_create_user_by_full_name,
|
||||
delete_duties_in_range,
|
||||
insert_duty,
|
||||
)
|
||||
from importers.duty_schedule import DutyScheduleResult
|
||||
from utils.dates import day_start_iso, day_end_iso, duty_to_iso
|
||||
|
||||
|
||||
def _consecutive_date_ranges(dates: list[date]) -> list[tuple[date, date]]:
|
||||
"""Sort dates and merge consecutive ones into (first, last) ranges. Empty list -> []."""
|
||||
if not dates:
|
||||
return []
|
||||
sorted_dates = sorted(set(dates))
|
||||
ranges: list[tuple[date, date]] = []
|
||||
start_d = end_d = sorted_dates[0]
|
||||
for d in sorted_dates[1:]:
|
||||
if (d - end_d).days == 1:
|
||||
end_d = d
|
||||
else:
|
||||
ranges.append((start_d, end_d))
|
||||
start_d = end_d = d
|
||||
ranges.append((start_d, end_d))
|
||||
return ranges
|
||||
|
||||
|
||||
def run_import(
|
||||
session: Session,
|
||||
result: DutyScheduleResult,
|
||||
hour_utc: int,
|
||||
minute_utc: int,
|
||||
) -> tuple[int, int, int, int]:
|
||||
"""Run import: delete range per user, insert duty/unavailable/vacation. Returns (num_users, num_duty, num_unavailable, num_vacation)."""
|
||||
from_date_str = result.start_date.isoformat()
|
||||
to_date_str = result.end_date.isoformat()
|
||||
num_duty = num_unavailable = num_vacation = 0
|
||||
for entry in result.entries:
|
||||
user = get_or_create_user_by_full_name(session, entry.full_name)
|
||||
delete_duties_in_range(session, user.id, from_date_str, to_date_str)
|
||||
for d in entry.duty_dates:
|
||||
start_at = duty_to_iso(d, hour_utc, minute_utc)
|
||||
d_next = d + timedelta(days=1)
|
||||
end_at = duty_to_iso(d_next, hour_utc, minute_utc)
|
||||
insert_duty(session, user.id, start_at, end_at, event_type="duty")
|
||||
num_duty += 1
|
||||
for d in entry.unavailable_dates:
|
||||
insert_duty(
|
||||
session,
|
||||
user.id,
|
||||
day_start_iso(d),
|
||||
day_end_iso(d),
|
||||
event_type="unavailable",
|
||||
)
|
||||
num_unavailable += 1
|
||||
for start_d, end_d in _consecutive_date_ranges(entry.vacation_dates):
|
||||
insert_duty(
|
||||
session,
|
||||
user.id,
|
||||
day_start_iso(start_d),
|
||||
day_end_iso(end_d),
|
||||
event_type="vacation",
|
||||
)
|
||||
num_vacation += 1
|
||||
return (len(result.entries), num_duty, num_unavailable, num_vacation)
|
||||
Reference in New Issue
Block a user