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

@@ -10,3 +10,9 @@ __pycache__/
*.plan.md
data/
.curosr/
# Tests and dev artifacts (not needed in image)
tests/
.pytest_cache/
conftest.py
*.cover
htmlcov/

View File

@@ -20,11 +20,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends gosu \
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
# Application code
COPY config.py main.py alembic.ini entrypoint.sh ./
COPY db/ ./db/
COPY api/ ./api/
COPY handlers/ ./handlers/
# Application code (duty_teller package + entrypoint, migrations, webapp)
ENV PYTHONPATH=/app
COPY main.py alembic.ini entrypoint.sh ./
COPY duty_teller/ ./duty_teller/
COPY alembic/ ./alembic/
COPY webapp/ ./webapp/

View File

@@ -53,6 +53,12 @@ A minimal Telegram bot boilerplate using [python-telegram-bot](https://github.co
python main.py
```
Or after `pip install -e .`:
```bash
duty-teller
```
The bot runs in polling mode. Send `/start` or `/help` to your bot in Telegram to test.
## Run with Docker
@@ -75,18 +81,21 @@ Ensure `.env` exists (e.g. `cp .env.example .env`) and contains `BOT_TOKEN`.
## Project layout
- `main.py` Builds the `Application`, registers handlers, runs polling and FastAPI in a thread.
- `config.py` Loads `BOT_TOKEN`, `DATABASE_URL`, `ALLOWED_USERNAMES`, `ADMIN_USERNAMES`, `CORS_ORIGINS`, etc. from env; exits if `BOT_TOKEN` is missing. Optional `Settings` dataclass for tests.
- `api/` FastAPI app (`/api/duties`, `/api/calendar-events`), auth/session Depends, static webapp mount.
- `db/` SQLAlchemy models, session (use `session_scope` for all DB access), repository, schemas. One `DATABASE_URL` per process; set env before first import if you need a different URL in tests.
- `handlers/` Telegram command and chat handlers; thin layer that call services and utils.
- `services/` Business logic (group duty pin, import); accept session from caller.
- `utils/` Shared date, user, and handover helpers.
- `alembic/` Migrations (use `config.DATABASE_URL`).
- `main.py` Entry point: builds the `Application`, registers handlers, runs polling and FastAPI in a thread. Calls `duty_teller.config.require_bot_token()` so the app exits with a clear message if `BOT_TOKEN` is missing.
- `duty_teller/` Main package (install with `pip install -e .`). Contains:
- `config.py` Loads `BOT_TOKEN`, `DATABASE_URL`, `ALLOWED_USERNAMES`, etc. from env; no exit on import; use `require_bot_token()` in `main` when running the bot. Optional `Settings` dataclass for tests. `PROJECT_ROOT` for webapp path.
- `api/` FastAPI app (`/api/duties`, `/api/calendar-events`), `dependencies.py` (DB session, auth, date validation), static webapp mounted from `PROJECT_ROOT/webapp`.
- `db/` SQLAlchemy models, session (`session_scope`), repository, schemas.
- `handlers/` Telegram command and chat handlers; register via `register_handlers(app)`.
- `services/` Business logic (group duty pin, import); accept session from caller.
- `utils/` Shared date, user, and handover helpers.
- `importers/` Duty-schedule JSON parser.
- `alembic/` Migrations (use `duty_teller.config.DATABASE_URL` and `duty_teller.db.models.Base`).
- `webapp/` Miniapp UI (calendar, duty list); served at `/app`.
- `pyproject.toml` Installable package (`pip install -e .`); `requirements.txt` pinned deps.
- `tests/` Tests; `helpers.py` provides `make_init_data` for auth tests.
- `pyproject.toml` Installable package (`pip install -e .`).
To add commands, define async handlers in `handlers/commands.py` (or a new module) and register them in `handlers/__init__.py`.
To add commands, define async handlers in `duty_teller/handlers/commands.py` (or a new module) and register them in `duty_teller/handlers/__init__.py`.
## Импорт расписания дежурств (duty-schedule)

View File

@@ -1,4 +1,4 @@
"""Alembic env: use config DATABASE_URL and db.models.Base."""
"""Alembic env: use duty_teller config DATABASE_URL and db.models.Base."""
import os
import sys
@@ -9,8 +9,8 @@ from alembic import context
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import config
from db.models import Base
import duty_teller.config as config
from duty_teller.db.models import Base
config_alembic = context.config
if config_alembic.config_file_name is not None:

View File

@@ -1,7 +1,6 @@
"""Pytest configuration. Set BOT_TOKEN so config module can be imported."""
"""Pytest configuration. BOT_TOKEN no longer required at import; set for tests that need it."""
import os
# Set before any project code imports config (which requires BOT_TOKEN).
if not os.environ.get("BOT_TOKEN"):
os.environ["BOT_TOKEN"] = "test-token-for-pytest"

8
duty_teller/__init__.py Normal file
View File

@@ -0,0 +1,8 @@
"""Duty Teller: Telegram bot for team duty shift calendar and group reminder."""
from importlib.metadata import version, PackageNotFoundError
try:
__version__ = version("duty-teller")
except PackageNotFoundError:
__version__ = "0.1.0"

62
duty_teller/api/app.py Normal file
View File

@@ -0,0 +1,62 @@
"""FastAPI app: /api/duties and static webapp."""
import logging
import duty_teller.config as config
from fastapi import Depends, FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from sqlalchemy.orm import Session
from duty_teller.api.calendar_ics import get_calendar_events
from duty_teller.api.dependencies import (
fetch_duties_response,
get_db_session,
get_validated_dates,
require_miniapp_username,
)
from duty_teller.db.schemas import CalendarEvent, DutyWithUser
log = logging.getLogger(__name__)
app = FastAPI(title="Duty Teller API")
app.add_middleware(
CORSMiddleware,
allow_origins=config.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/api/duties", response_model=list[DutyWithUser])
def list_duties(
request: Request,
dates: tuple[str, str] = Depends(get_validated_dates),
_username: str = Depends(require_miniapp_username),
session: Session = Depends(get_db_session),
):
from_date_val, to_date_val = dates
log.info(
"GET /api/duties from %s",
request.client.host if request.client else "?",
)
return fetch_duties_response(session, from_date_val, to_date_val)
@app.get("/api/calendar-events", response_model=list[CalendarEvent])
def list_calendar_events(
dates: tuple[str, str] = Depends(get_validated_dates),
_username: str = Depends(require_miniapp_username),
):
from_date_val, to_date_val = dates
url = config.EXTERNAL_CALENDAR_ICS_URL
if not url:
return []
events = get_calendar_events(url, from_date=from_date_val, to_date=to_date_val)
return [CalendarEvent(date=e["date"], summary=e["summary"]) for e in events]
webapp_path = config.PROJECT_ROOT / "webapp"
if webapp_path.is_dir():
app.mount("/app", StaticFiles(directory=str(webapp_path), html=True), name="webapp")

View File

@@ -1,27 +1,22 @@
"""FastAPI app: /api/duties and static webapp."""
"""FastAPI dependencies: DB session, auth, date validation."""
import logging
from pathlib import Path
from typing import Annotated, Generator
import config
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi import Header, HTTPException, Query, Request
from sqlalchemy.orm import Session
from db.session import session_scope
from db.repository import get_duties
from db.schemas import DutyWithUser, CalendarEvent
from api.telegram_auth import validate_init_data_with_reason
from api.calendar_ics import get_calendar_events
from utils.dates import validate_date_range
import duty_teller.config as config
from duty_teller.api.telegram_auth import validate_init_data_with_reason
from duty_teller.db.repository import get_duties
from duty_teller.db.schemas import DutyWithUser
from duty_teller.db.session import session_scope
from duty_teller.utils.dates import validate_date_range
log = logging.getLogger(__name__)
def _validate_duty_dates(from_date: str, to_date: str) -> None:
"""Raise HTTPException 400 if dates are invalid or from_date > to_date."""
try:
validate_date_range(from_date, to_date)
except ValueError as e:
@@ -32,13 +27,11 @@ def get_validated_dates(
from_date: str = Query(..., description="ISO date YYYY-MM-DD", alias="from"),
to_date: str = Query(..., description="ISO date YYYY-MM-DD", alias="to"),
) -> tuple[str, str]:
"""FastAPI dependency: validate from_date/to_date and return (from_date, to_date). Raises 400 if invalid."""
_validate_duty_dates(from_date, to_date)
return (from_date, to_date)
def get_db_session() -> Generator[Session, None, None]:
"""FastAPI dependency: yield a DB session from session_scope."""
with session_scope(config.DATABASE_URL) as session:
yield session
@@ -49,34 +42,10 @@ def require_miniapp_username(
str | None, Header(alias="X-Telegram-Init-Data")
] = None,
) -> str:
"""FastAPI dependency: return authenticated username or raise 403."""
return get_authenticated_username(request, x_telegram_init_data)
def _fetch_duties_response(
session: Session, from_date: str, to_date: str
) -> list[DutyWithUser]:
"""Fetch duties in range and return list of DutyWithUser."""
rows = get_duties(session, from_date=from_date, to_date=to_date)
return [
DutyWithUser(
id=duty.id,
user_id=duty.user_id,
start_at=duty.start_at,
end_at=duty.end_at,
full_name=full_name,
event_type=(
duty.event_type
if duty.event_type in ("duty", "unavailable", "vacation")
else "duty"
),
)
for duty, full_name in rows
]
def _auth_error_detail(auth_reason: str) -> str:
"""Return user-facing detail message for 403 when initData validation fails."""
if auth_reason == "hash_mismatch":
return (
"Неверная подпись. Убедитесь, что BOT_TOKEN на сервере совпадает с токеном бота, "
@@ -86,21 +55,12 @@ def _auth_error_detail(auth_reason: str) -> str:
def _is_private_client(client_host: str | None) -> bool:
"""True if client is localhost or private LAN (dev / same-machine access).
Note: Behind a reverse proxy (e.g. nginx, Caddy), request.client.host is often
the proxy address (e.g. 127.0.0.1). Then "private client" would be true for all
requests when initData is missing. For production, either rely on the Mini App
always sending initData, or configure the proxy to forward the real client IP
(e.g. X-Forwarded-For) and use that for this check. Do not rely on the private-IP
bypass when deployed behind a proxy without one of these measures.
"""
if not client_host:
return False
if client_host in ("127.0.0.1", "::1"):
return True
parts = client_host.split(".")
if len(parts) == 4: # IPv4
if len(parts) == 4:
try:
a, b, c, d = (int(x) for x in parts)
if (a == 10) or (a == 172 and 16 <= b <= 31) or (a == 192 and b == 168):
@@ -111,10 +71,8 @@ def _is_private_client(client_host: str | None) -> bool:
def get_authenticated_username(
request: Request,
x_telegram_init_data: str | None,
request: Request, x_telegram_init_data: str | None
) -> str:
"""Validate Mini App auth. Returns username (or "" when bypass allowed); raises HTTPException 403 otherwise."""
init_data = (x_telegram_init_data or "").strip()
if not init_data:
client_host = request.client.host if request.client else None
@@ -137,46 +95,22 @@ def get_authenticated_username(
return username
app = FastAPI(title="Duty Teller API")
app.add_middleware(
CORSMiddleware,
allow_origins=config.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/api/duties", response_model=list[DutyWithUser])
def list_duties(
request: Request,
x_telegram_init_data: str | None = Header(None, alias="X-Telegram-Init-Data"),
dates: tuple[str, str] = Depends(get_validated_dates),
_username: str = Depends(require_miniapp_username),
session: Session = Depends(get_db_session),
def fetch_duties_response(
session: Session, from_date: str, to_date: str
) -> list[DutyWithUser]:
from_date_val, to_date_val = dates
log.info(
"GET /api/duties from %s, has initData: %s",
request.client.host if request.client else "?",
bool((x_telegram_init_data or "").strip()),
rows = get_duties(session, from_date=from_date, to_date=to_date)
return [
DutyWithUser(
id=duty.id,
user_id=duty.user_id,
start_at=duty.start_at,
end_at=duty.end_at,
full_name=full_name,
event_type=(
duty.event_type
if duty.event_type in ("duty", "unavailable", "vacation")
else "duty"
),
)
return _fetch_duties_response(session, from_date_val, to_date_val)
@app.get("/api/calendar-events", response_model=list[CalendarEvent])
def list_calendar_events(
dates: tuple[str, str] = Depends(get_validated_dates),
_username: str = Depends(require_miniapp_username),
) -> list[CalendarEvent]:
from_date_val, to_date_val = dates
url = config.EXTERNAL_CALENDAR_ICS_URL
if not url:
return []
events = get_calendar_events(url, from_date=from_date_val, to_date=to_date_val)
return [CalendarEvent(date=e["date"], summary=e["summary"]) for e in events]
webapp_path = Path(__file__).resolve().parent.parent / "webapp"
if webapp_path.is_dir():
app.mount("/app", StaticFiles(directory=str(webapp_path), html=True), name="webapp")
for duty, full_name in rows
]

View File

@@ -1,12 +1,16 @@
"""Load configuration from environment. Fail fast if BOT_TOKEN is missing."""
"""Load configuration from environment. BOT_TOKEN is not validated on import; check in main/entry point."""
import os
from dataclasses import dataclass
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
# Project root (parent of duty_teller package). Used for webapp path, etc.
PROJECT_ROOT = Path(__file__).resolve().parent.parent
@dataclass(frozen=True)
class Settings:
@@ -61,17 +65,12 @@ class Settings:
)
BOT_TOKEN = os.getenv("BOT_TOKEN")
if not BOT_TOKEN:
raise SystemExit(
"BOT_TOKEN is not set. Copy .env.example to .env and set your token from @BotFather."
)
# Module-level vars: no validation on import; entry point must check BOT_TOKEN when needed.
BOT_TOKEN = os.getenv("BOT_TOKEN") or ""
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///data/duty_teller.db")
MINI_APP_BASE_URL = os.getenv("MINI_APP_BASE_URL", "").rstrip("/")
HTTP_PORT = int(os.getenv("HTTP_PORT", "8080"))
# Miniapp access: comma-separated Telegram usernames (no @). Empty = no one allowed.
_raw_allowed = os.getenv("ALLOWED_USERNAMES", "").strip()
ALLOWED_USERNAMES = {
s.strip().lstrip("@").lower() for s in _raw_allowed.split(",") if s.strip()
@@ -82,13 +81,9 @@ ADMIN_USERNAMES = {
s.strip().lstrip("@").lower() for s in _raw_admin.split(",") if s.strip()
}
# Dev only: set to 1 to allow /api/duties without Telegram initData (insecure, no user check).
MINI_APP_SKIP_AUTH = os.getenv("MINI_APP_SKIP_AUTH", "").strip() in ("1", "true", "yes")
# Optional replay protection: reject initData older than this many seconds. 0 = disabled (default).
INIT_DATA_MAX_AGE_SECONDS = int(os.getenv("INIT_DATA_MAX_AGE_SECONDS", "0"))
# CORS: comma-separated origins, or empty/"*" for allow all. For production, set to MINI_APP_BASE_URL or specific origins.
_raw_cors = os.getenv("CORS_ORIGINS", "").strip()
CORS_ORIGINS = (
[_o.strip() for _o in _raw_cors.split(",") if _o.strip()]
@@ -96,10 +91,7 @@ CORS_ORIGINS = (
else ["*"]
)
# Optional: URL of a public ICS calendar (e.g. holidays). Empty = no external calendar; /api/calendar-events returns [].
EXTERNAL_CALENDAR_ICS_URL = os.getenv("EXTERNAL_CALENDAR_ICS_URL", "").strip()
# Timezone for displaying duty times in the pinned group message (e.g. Europe/Moscow).
DUTY_DISPLAY_TZ = (
os.getenv("DUTY_DISPLAY_TZ", "Europe/Moscow").strip() or "Europe/Moscow"
)
@@ -114,3 +106,11 @@ def can_access_miniapp(username: str) -> bool:
"""True if username is in ALLOWED_USERNAMES or ADMIN_USERNAMES."""
u = (username or "").strip().lower()
return u in ALLOWED_USERNAMES or u in ADMIN_USERNAMES
def require_bot_token() -> None:
"""Raise SystemExit with a clear message if BOT_TOKEN is not set. Call from entry point."""
if not BOT_TOKEN:
raise SystemExit(
"BOT_TOKEN is not set. Copy .env.example to .env and set your token from @BotFather."
)

View File

@@ -1,9 +1,20 @@
"""Database layer: SQLAlchemy models, Pydantic schemas, repository, init."""
from db.models import Base, User, Duty
from db.schemas import UserCreate, UserInDb, DutyCreate, DutyInDb, DutyWithUser
from db.session import get_engine, get_session_factory, get_session, session_scope
from db.repository import (
from duty_teller.db.models import Base, User, Duty
from duty_teller.db.schemas import (
UserCreate,
UserInDb,
DutyCreate,
DutyInDb,
DutyWithUser,
)
from duty_teller.db.session import (
get_engine,
get_session_factory,
get_session,
session_scope,
)
from duty_teller.db.repository import (
delete_duties_in_range,
get_or_create_user,
get_or_create_user_by_full_name,

View File

@@ -4,7 +4,7 @@ from datetime import datetime, timedelta
from sqlalchemy.orm import Session
from db.models import User, Duty, GroupDutyPin
from duty_teller.db.models import User, Duty, GroupDutyPin
def get_or_create_user(
@@ -62,7 +62,6 @@ def delete_duties_in_range(
to_date: str,
) -> int:
"""Delete all duties of the user that overlap [from_date, to_date] (YYYY-MM-DD). Returns count deleted."""
# start_at < to_date + 1 day so duties starting on to_date are included (start_at is ISO with T)
to_next = (
datetime.fromisoformat(to_date + "T00:00:00") + timedelta(days=1)
).strftime("%Y-%m-%d")
@@ -82,12 +81,7 @@ def get_duties(
from_date: str,
to_date: str,
) -> list[tuple[Duty, str]]:
"""Return list of (Duty, full_name) overlapping the given date range.
from_date/to_date are YYYY-MM-DD (inclusive). Duty.start_at and end_at are stored
in UTC (ISO 8601 with Z). Use to_date_next so duties starting on to_date are included
(start_at like 2025-01-31T09:00:00Z is > "2025-01-31" lexicographically).
"""
"""Return list of (Duty, full_name) overlapping the given date range."""
to_date_next = (
datetime.fromisoformat(to_date + "T00:00:00") + timedelta(days=1)
).strftime("%Y-%m-%d")
@@ -106,8 +100,7 @@ def insert_duty(
end_at: str,
event_type: str = "duty",
) -> Duty:
"""Create a duty. start_at and end_at must be UTC, ISO 8601 with Z.
event_type: 'duty' | 'unavailable' | 'vacation'."""
"""Create a duty. start_at and end_at must be UTC, ISO 8601 with Z."""
duty = Duty(
user_id=user_id,
start_at=start_at,
@@ -121,8 +114,7 @@ def insert_duty(
def get_current_duty(session: Session, at_utc: datetime) -> tuple[Duty, User] | None:
"""Return the duty (and user) for which start_at <= at_utc < end_at, event_type='duty'.
at_utc is in UTC (naive or aware); comparison uses ISO strings."""
"""Return the duty (and user) for which start_at <= at_utc < end_at, event_type='duty'."""
from datetime import timezone
if at_utc.tzinfo is not None:
@@ -144,14 +136,12 @@ def get_current_duty(session: Session, at_utc: datetime) -> tuple[Duty, User] |
def get_next_shift_end(session: Session, after_utc: datetime) -> datetime | None:
"""Return the end_at of the current duty (if after_utc is inside one) or of the next duty.
For scheduling the next pin update. Returns naive UTC datetime."""
"""Return the end_at of the current duty or of the next duty. Naive UTC."""
from datetime import timezone
if after_utc.tzinfo is not None:
after_utc = after_utc.astimezone(timezone.utc)
after_iso = after_utc.strftime("%Y-%m-%dT%H:%M:%S") + "Z"
# Current duty: start_at <= after_iso < end_at → use this end_at
current = (
session.query(Duty)
.filter(
@@ -165,7 +155,6 @@ def get_next_shift_end(session: Session, after_utc: datetime) -> datetime | None
return datetime.fromisoformat(current.end_at.replace("Z", "+00:00")).replace(
tzinfo=None
)
# Next future duty: start_at > after_iso, order by start_at
next_duty = (
session.query(Duty)
.filter(Duty.event_type == "duty", Duty.start_at > after_iso)

View File

@@ -2,13 +2,13 @@
import asyncio
import config
import duty_teller.config as config
from telegram import Update
from telegram.ext import CommandHandler, ContextTypes
from db.session import session_scope
from db.repository import get_or_create_user, set_user_phone
from utils.user import build_full_name
from duty_teller.db.session import session_scope
from duty_teller.db.repository import get_or_create_user, set_user_phone
from duty_teller.utils.user import build_full_name
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
@@ -41,13 +41,11 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
async def set_phone(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Set or clear phone for the current user (private chat only)."""
if not update.message or not update.effective_user:
return
if update.effective_chat and update.effective_chat.type != "private":
await update.message.reply_text("Команда /set_phone доступна только в личке.")
return
# Optional: restrict to allowed usernames; plan says "or without restrictions"
args = context.args or []
phone = " ".join(args).strip() if args else None
telegram_user_id = update.effective_user.id

View File

@@ -4,14 +4,14 @@ import asyncio
import logging
from datetime import datetime, timezone
import config
import duty_teller.config as config
from telegram import Update
from telegram.constants import ChatMemberStatus
from telegram.error import BadRequest, Forbidden
from telegram.ext import ChatMemberHandler, CommandHandler, ContextTypes
from db.session import session_scope
from services.group_duty_pin_service import (
from duty_teller.db.session import session_scope
from duty_teller.services.group_duty_pin_service import (
get_duty_message_text,
get_next_shift_end_utc,
save_pin,
@@ -27,13 +27,11 @@ RETRY_WHEN_NO_DUTY_MINUTES = 15
def _get_duty_message_text_sync() -> str:
"""Get current duty message (sync, for run_in_executor)."""
with session_scope(config.DATABASE_URL) as session:
return get_duty_message_text(session, config.DUTY_DISPLAY_TZ)
def _get_next_shift_end_sync():
"""Return next shift end as naive UTC (sync, for run_in_executor)."""
with session_scope(config.DATABASE_URL) as session:
return get_next_shift_end_utc(session)
@@ -56,7 +54,6 @@ def _sync_get_message_id(chat_id: int) -> int | None:
async def _schedule_next_update(
application, chat_id: int, when_utc: datetime | None
) -> None:
"""Schedule run_once for update_group_pin. Remove existing job with same name first."""
job_queue = application.job_queue
if job_queue is None:
logger.warning("Job queue not available, cannot schedule pin update")
@@ -93,7 +90,6 @@ async def _schedule_next_update(
async def update_group_pin(context: ContextTypes.DEFAULT_TYPE) -> None:
"""Job callback: edit pinned message with current duty and schedule next update."""
chat_id = context.job.data.get("chat_id")
if chat_id is None:
return
@@ -118,7 +114,6 @@ async def update_group_pin(context: ContextTypes.DEFAULT_TYPE) -> None:
async def my_chat_member_handler(
update: Update, context: ContextTypes.DEFAULT_TYPE
) -> None:
"""On bot added to group: send, pin, save, schedule. On removed: delete pin, cancel job."""
if not update.my_chat_member or not update.effective_user:
return
old = update.my_chat_member.old_chat_member
@@ -130,7 +125,6 @@ async def my_chat_member_handler(
return
chat_id = chat.id
# Bot added to group
if new.status in (
ChatMemberStatus.MEMBER,
ChatMemberStatus.ADMINISTRATOR,
@@ -145,7 +139,6 @@ async def my_chat_member_handler(
except (BadRequest, Forbidden) as e:
logger.warning("Failed to send duty message in chat_id=%s: %s", chat_id, e)
return
# Make it a pinned post (bot must be admin with "Pin messages" right)
pinned = False
try:
await context.bot.pin_chat_message(
@@ -171,7 +164,6 @@ async def my_chat_member_handler(
await _schedule_next_update(context.application, chat_id, next_end)
return
# Bot removed from group
if new.status in (ChatMemberStatus.LEFT, ChatMemberStatus.BANNED):
await asyncio.get_running_loop().run_in_executor(
None, _sync_delete_pin, chat_id
@@ -189,7 +181,6 @@ def _get_all_pin_chat_ids_sync() -> list[int]:
async def restore_group_pin_jobs(application) -> None:
"""On startup: for each chat_id in group_duty_pins, schedule next update at shift end."""
loop = asyncio.get_running_loop()
chat_ids = await loop.run_in_executor(None, _get_all_pin_chat_ids_sync)
for chat_id in chat_ids:
@@ -199,7 +190,6 @@ async def restore_group_pin_jobs(application) -> None:
async def pin_duty_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Pin the current duty message (use after granting the bot 'Pin messages' right)."""
if not update.message or not update.effective_chat:
return
chat = update.effective_chat

View File

@@ -2,14 +2,17 @@
import asyncio
import config
import duty_teller.config as config
from telegram import Update
from telegram.ext import CommandHandler, ContextTypes, MessageHandler, filters
from db.session import session_scope
from importers.duty_schedule import DutyScheduleParseError, parse_duty_schedule
from services.import_service import run_import
from utils.handover import parse_handover_time
from duty_teller.db.session import session_scope
from duty_teller.importers.duty_schedule import (
DutyScheduleParseError,
parse_duty_schedule,
)
from duty_teller.services.import_service import run_import
from duty_teller.utils.handover import parse_handover_time
async def import_duty_schedule_cmd(
@@ -67,7 +70,6 @@ async def handle_duty_schedule_document(
hour_utc, minute_utc = handover
file_id = update.message.document.file_id
# Download and parse in async context
file = await context.bot.get_file(file_id)
raw = bytes(await file.download_as_bytearray())
try:

84
duty_teller/run.py Normal file
View File

@@ -0,0 +1,84 @@
"""Application entry point: build bot Application, run HTTP server + polling."""
import asyncio
import json
import logging
import threading
import urllib.request
from telegram.ext import ApplicationBuilder
from duty_teller import config
from duty_teller.config import require_bot_token
from duty_teller.handlers import group_duty_pin, register_handlers
logging.basicConfig(
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
level=logging.INFO,
)
logger = logging.getLogger(__name__)
def _set_default_menu_button_webapp() -> None:
if not (config.MINI_APP_BASE_URL and config.BOT_TOKEN):
return
menu_url = (config.MINI_APP_BASE_URL.rstrip("/") + "/app/").strip()
if not menu_url.startswith("https://"):
return
payload = {
"menu_button": {
"type": "web_app",
"text": "Календарь",
"web_app": {"url": menu_url},
}
}
req = urllib.request.Request(
f"https://api.telegram.org/bot{config.BOT_TOKEN}/setChatMenuButton",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
if resp.status == 200:
logger.info("Default menu button set to Web App: %s", menu_url)
else:
logger.warning("setChatMenuButton returned %s", resp.status)
except Exception as e:
logger.warning("Could not set menu button: %s", e)
def _run_uvicorn(web_app, port: int) -> None:
import uvicorn
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
server = uvicorn.Server(
uvicorn.Config(web_app, host="0.0.0.0", port=port, log_level="info"),
)
loop.run_until_complete(server.serve())
def main() -> None:
"""Build the bot and FastAPI, start uvicorn in a thread, run polling."""
require_bot_token()
# _set_default_menu_button_webapp()
app = (
ApplicationBuilder()
.token(config.BOT_TOKEN)
.post_init(group_duty_pin.restore_group_pin_jobs)
.build()
)
register_handlers(app)
from duty_teller.api.app import app as web_app
t = threading.Thread(
target=_run_uvicorn,
args=(web_app, config.HTTP_PORT),
daemon=True,
)
t.start()
logger.info("Bot starting (polling)... HTTP API on port %s", config.HTTP_PORT)
app.run_polling(allowed_updates=["message", "my_chat_member"])

View File

@@ -1,10 +1,6 @@
"""Service layer: business logic and orchestration.
"""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 (
from duty_teller.services.group_duty_pin_service import (
format_duty_message,
get_duty_message_text,
get_next_shift_end_utc,
@@ -13,7 +9,7 @@ from services.group_duty_pin_service import (
get_message_id,
get_all_pin_chat_ids,
)
from services.import_service import run_import
from duty_teller.services.import_service import run_import
__all__ = [
"format_duty_message",

View File

@@ -5,7 +5,7 @@ from zoneinfo import ZoneInfo
from sqlalchemy.orm import Session
from db.repository import (
from duty_teller.db.repository import (
get_current_duty,
get_next_shift_end,
get_group_duty_pin,

View File

@@ -4,13 +4,13 @@ from datetime import date, timedelta
from sqlalchemy.orm import Session
from db.repository import (
from duty_teller.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
from duty_teller.importers.duty_schedule import DutyScheduleResult
from duty_teller.utils.dates import day_start_iso, day_end_iso, duty_to_iso
def _consecutive_date_ranges(dates: list[date]) -> list[tuple[date, date]]:

View File

@@ -1,17 +1,14 @@
"""Shared utilities: date/ISO helpers, user display names, handover time parsing.
"""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 (
from duty_teller.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
from duty_teller.utils.user import build_full_name
from duty_teller.utils.handover import parse_handover_time
__all__ = [
"day_start_iso",

View File

@@ -20,7 +20,6 @@ def duty_to_iso(d: date, hour_utc: int, minute_utc: int) -> str:
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}$")
@@ -35,11 +34,7 @@ def parse_iso_date(s: str) -> date | 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.
"""
"""Validate from_date and to_date are YYYY-MM-DD and from_date <= to_date. Raises ValueError 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:

86
main.py
View File

@@ -1,88 +1,6 @@
"""Single entry point: build Application, run HTTP server + polling. Migrations run in Docker entrypoint."""
import asyncio
import json
import logging
import threading
import urllib.request
import config
from telegram.ext import ApplicationBuilder
from handlers import group_duty_pin, register_handlers
logging.basicConfig(
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
level=logging.INFO,
)
logger = logging.getLogger(__name__)
def _set_default_menu_button_webapp() -> None:
"""Set the bot's default menu button to Web App so Telegram sends tgWebAppData when users open the app from the menu."""
if not (config.MINI_APP_BASE_URL and config.BOT_TOKEN):
return
menu_url = (config.MINI_APP_BASE_URL.rstrip("/") + "/app/").strip()
if not menu_url.startswith("https://"):
return
payload = {
"menu_button": {
"type": "web_app",
"text": "Календарь",
"web_app": {"url": menu_url},
}
}
req = urllib.request.Request(
f"https://api.telegram.org/bot{config.BOT_TOKEN}/setChatMenuButton",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
if resp.status == 200:
logger.info("Default menu button set to Web App: %s", menu_url)
else:
logger.warning("setChatMenuButton returned %s", resp.status)
except Exception as e:
logger.warning("Could not set menu button: %s", e)
def _run_uvicorn(web_app, port: int) -> None:
"""Run uvicorn in a dedicated thread with its own event loop."""
import uvicorn
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
server = uvicorn.Server(
uvicorn.Config(web_app, host="0.0.0.0", port=port, log_level="info"),
)
loop.run_until_complete(server.serve())
def main() -> None:
# Menu button (Календарь) and inline Calendar button are disabled; users open the app by link if needed.
# _set_default_menu_button_webapp()
app = (
ApplicationBuilder()
.token(config.BOT_TOKEN)
.post_init(group_duty_pin.restore_group_pin_jobs)
.build()
)
register_handlers(app)
from api.app import app as web_app
t = threading.Thread(
target=_run_uvicorn,
args=(web_app, config.HTTP_PORT),
daemon=True,
)
t.start()
logger.info("Bot starting (polling)... HTTP API on port %s", config.HTTP_PORT)
app.run_polling(allowed_updates=["message", "my_chat_member"])
"""Entry point: delegate to duty_teller.run.main(). Run with python main.py or duty-teller."""
from duty_teller.run import main
if __name__ == "__main__":
main()

View File

@@ -19,6 +19,9 @@ dependencies = [
"icalendar>=5.0,<6.0",
]
[project.scripts]
duty-teller = "duty_teller.run:main"
[project.optional-dependencies]
dev = [
"pytest>=8.0,<9.0",
@@ -28,7 +31,7 @@ dev = [
[tool.setuptools.packages.find]
where = ["."]
include = ["db", "handlers", "api", "importers", "utils", "services"]
include = ["duty_teller*"]
[tool.black]
line-length = 120

33
tests/helpers.py Normal file
View File

@@ -0,0 +1,33 @@
"""Shared test helpers (e.g. make_init_data for Telegram auth tests)."""
import hashlib
import hmac
import json
from urllib.parse import quote, unquote
def make_init_data(
user: dict | None,
bot_token: str,
auth_date: int | None = None,
) -> str:
"""Build initData string with valid HMAC for testing."""
params = {}
if user is not None:
params["user"] = quote(json.dumps(user))
if auth_date is not None:
params["auth_date"] = str(auth_date)
pairs = sorted(params.items())
data_string = "\n".join(f"{k}={unquote(v)}" for k, v in pairs)
secret_key = hmac.new(
b"WebAppData",
msg=bot_token.encode(),
digestmod=hashlib.sha256,
).digest()
computed = hmac.new(
secret_key,
msg=data_string.encode(),
digestmod=hashlib.sha256,
).hexdigest()
params["hash"] = computed
return "&".join(f"{k}={v}" for k, v in sorted(params.items()))

View File

@@ -6,9 +6,9 @@ from unittest.mock import ANY, patch
import pytest
from fastapi.testclient import TestClient
import config
from api.app import app
from tests.test_telegram_auth import _make_init_data
import duty_teller.config as config
from duty_teller.api.app import app
from tests.helpers import make_init_data
@pytest.fixture
@@ -28,11 +28,10 @@ def test_duties_from_after_to(client):
assert "from" in r.json()["detail"].lower() or "позже" in r.json()["detail"]
@patch("api.app._is_private_client")
@patch("api.app.config.MINI_APP_SKIP_AUTH", False)
@patch("duty_teller.api.dependencies._is_private_client")
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
def test_duties_403_without_init_data_from_public_client(mock_private, client):
"""Without initData and without private IP / skip-auth, should get 403."""
mock_private.return_value = False # simulate public client
mock_private.return_value = False
r = client.get(
"/api/duties",
params={"from": "2025-01-01", "to": "2025-01-31"},
@@ -40,8 +39,8 @@ def test_duties_403_without_init_data_from_public_client(mock_private, client):
assert r.status_code == 403
@patch("api.app.config.MINI_APP_SKIP_AUTH", True)
@patch("api.app._fetch_duties_response")
@patch("duty_teller.api.app.config.MINI_APP_SKIP_AUTH", True)
@patch("duty_teller.api.app.fetch_duties_response")
def test_duties_200_when_skip_auth(mock_fetch, client):
mock_fetch.return_value = []
r = client.get(
@@ -53,7 +52,7 @@ def test_duties_200_when_skip_auth(mock_fetch, client):
mock_fetch.assert_called_once_with(ANY, "2025-01-01", "2025-01-31")
@patch("api.app.validate_init_data_with_reason")
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
def test_duties_403_when_init_data_invalid(mock_validate, client):
mock_validate.return_value = (None, "hash_mismatch")
r = client.get(
@@ -66,12 +65,12 @@ def test_duties_403_when_init_data_invalid(mock_validate, client):
assert "авторизации" in detail or "Неверные" in detail or "Неверная" in detail
@patch("api.app.validate_init_data_with_reason")
@patch("api.app.config.can_access_miniapp")
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
@patch("duty_teller.api.dependencies.config.can_access_miniapp")
def test_duties_403_when_username_not_allowed(mock_can_access, mock_validate, client):
mock_validate.return_value = ("someuser", "ok")
mock_can_access.return_value = False
with patch("api.app._fetch_duties_response") as mock_fetch:
with patch("duty_teller.api.app.fetch_duties_response") as mock_fetch:
r = client.get(
"/api/duties",
params={"from": "2025-01-01", "to": "2025-01-31"},
@@ -82,12 +81,12 @@ def test_duties_403_when_username_not_allowed(mock_can_access, mock_validate, cl
mock_fetch.assert_not_called()
@patch("api.app.validate_init_data_with_reason")
@patch("api.app.config.can_access_miniapp")
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
@patch("duty_teller.api.dependencies.config.can_access_miniapp")
def test_duties_200_with_allowed_user(mock_can_access, mock_validate, client):
mock_validate.return_value = ("alloweduser", "ok")
mock_can_access.return_value = True
with patch("api.app._fetch_duties_response") as mock_fetch:
with patch("duty_teller.api.app.fetch_duties_response") as mock_fetch:
mock_fetch.return_value = [
{
"id": 1,
@@ -109,19 +108,18 @@ def test_duties_200_with_allowed_user(mock_can_access, mock_validate, client):
def test_duties_e2e_auth_real_validation(client, monkeypatch):
"""E2E: valid initData + allowlist, no mocks on validate_init_data_with_reason; full auth path."""
test_token = "123:ABC"
test_username = "e2euser"
monkeypatch.setattr(config, "BOT_TOKEN", test_token)
monkeypatch.setattr(config, "ALLOWED_USERNAMES", {test_username})
monkeypatch.setattr(config, "ADMIN_USERNAMES", set())
monkeypatch.setattr(config, "INIT_DATA_MAX_AGE_SECONDS", 0)
init_data = _make_init_data(
init_data = make_init_data(
{"id": 1, "username": test_username},
test_token,
auth_date=int(time.time()),
)
with patch("api.app._fetch_duties_response") as mock_fetch:
with patch("duty_teller.api.app.fetch_duties_response") as mock_fetch:
mock_fetch.return_value = []
r = client.get(
"/api/duties",
@@ -133,9 +131,8 @@ def test_duties_e2e_auth_real_validation(client, monkeypatch):
mock_fetch.assert_called_once_with(ANY, "2025-01-01", "2025-01-31")
@patch("api.app.config.MINI_APP_SKIP_AUTH", True)
@patch("duty_teller.api.app.config.MINI_APP_SKIP_AUTH", True)
def test_duties_200_with_unknown_event_type_mapped_to_duty(client):
"""When DB returns duty with event_type not in (duty, unavailable, vacation), API returns 200 with event_type='duty'."""
from types import SimpleNamespace
fake_duty = SimpleNamespace(
@@ -149,7 +146,7 @@ def test_duties_200_with_unknown_event_type_mapped_to_duty(client):
def fake_get_duties(session, from_date, to_date):
return [(fake_duty, "User A")]
with patch("api.app.get_duties", side_effect=fake_get_duties):
with patch("duty_teller.api.dependencies.get_duties", side_effect=fake_get_duties):
r = client.get(
"/api/duties",
params={"from": "2025-01-01", "to": "2025-01-31"},

View File

@@ -1,6 +1,6 @@
"""Tests for config.is_admin and config.can_access_miniapp."""
import config
import duty_teller.config as config
def test_is_admin_true_when_in_admin_list(monkeypatch):

View File

@@ -4,7 +4,7 @@ from datetime import date
import pytest
from importers.duty_schedule import (
from duty_teller.importers.duty_schedule import (
DUTY_MARKERS,
UNAVAILABLE_MARKER,
VACATION_MARKER,

View File

@@ -4,15 +4,15 @@ from datetime import date
import pytest
from db import init_db
from db.repository import get_duties
from db.session import get_session, session_scope
from importers.duty_schedule import (
from duty_teller.db import init_db
from duty_teller.db.repository import get_duties
from duty_teller.db.session import get_session, session_scope
from duty_teller.importers.duty_schedule import (
DutyScheduleEntry,
DutyScheduleResult,
parse_duty_schedule,
)
from services.import_service import run_import
from duty_teller.services.import_service import run_import
@pytest.fixture
@@ -23,7 +23,7 @@ def db_url():
@pytest.fixture(autouse=True)
def _reset_db_session(db_url):
"""Ensure each test uses a fresh engine for :memory: (clear global cache for test URL)."""
import db.session as session_module
import duty_teller.db.session as session_module
session_module._engine = None
session_module._SessionLocal = None

View File

@@ -4,8 +4,8 @@ import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from db.models import Base, User
from db.repository import (
from duty_teller.db.models import Base, User
from duty_teller.db.repository import (
delete_duties_in_range,
get_or_create_user_by_full_name,
get_duties,

View File

@@ -1,59 +1,27 @@
"""Tests for api.telegram_auth.validate_init_data."""
"""Tests for duty_teller.api.telegram_auth.validate_init_data."""
import hashlib
import hmac
import json
from urllib.parse import quote, unquote
from api.telegram_auth import validate_init_data
def _make_init_data(
user: dict | None,
bot_token: str,
auth_date: int | None = None,
) -> str:
"""Build initData string with valid HMAC for testing."""
params = {}
if user is not None:
params["user"] = quote(json.dumps(user))
if auth_date is not None:
params["auth_date"] = str(auth_date)
pairs = sorted(params.items())
data_string = "\n".join(f"{k}={unquote(v)}" for k, v in pairs)
secret_key = hmac.new(
b"WebAppData",
msg=bot_token.encode(),
digestmod=hashlib.sha256,
).digest()
computed = hmac.new(
secret_key,
msg=data_string.encode(),
digestmod=hashlib.sha256,
).hexdigest()
params["hash"] = computed
return "&".join(f"{k}={v}" for k, v in sorted(params.items()))
from duty_teller.api.telegram_auth import validate_init_data
from tests.helpers import make_init_data
def test_valid_payload_returns_username():
bot_token = "123:ABC"
user = {"id": 123, "username": "testuser", "first_name": "Test"}
init_data = _make_init_data(user, bot_token)
init_data = make_init_data(user, bot_token)
assert validate_init_data(init_data, bot_token) == "testuser"
def test_valid_payload_username_lowercase():
bot_token = "123:ABC"
user = {"id": 123, "username": "TestUser", "first_name": "Test"}
init_data = _make_init_data(user, bot_token)
init_data = make_init_data(user, bot_token)
assert validate_init_data(init_data, bot_token) == "testuser"
def test_invalid_hash_returns_none():
bot_token = "123:ABC"
user = {"id": 123, "username": "testuser"}
init_data = _make_init_data(user, bot_token)
init_data = make_init_data(user, bot_token)
# Tamper with hash
init_data = init_data.replace("hash=", "hash=x")
assert validate_init_data(init_data, bot_token) is None
@@ -62,20 +30,20 @@ def test_invalid_hash_returns_none():
def test_wrong_bot_token_returns_none():
bot_token = "123:ABC"
user = {"id": 123, "username": "testuser"}
init_data = _make_init_data(user, bot_token)
init_data = make_init_data(user, bot_token)
assert validate_init_data(init_data, "other:token") is None
def test_missing_user_returns_none():
bot_token = "123:ABC"
init_data = _make_init_data(None, bot_token) # no user key
init_data = make_init_data(None, bot_token) # no user key
assert validate_init_data(init_data, bot_token) is None
def test_user_without_username_returns_none():
bot_token = "123:ABC"
user = {"id": 123, "first_name": "Test"} # no username
init_data = _make_init_data(user, bot_token)
init_data = make_init_data(user, bot_token)
assert validate_init_data(init_data, bot_token) is None
@@ -86,7 +54,7 @@ def test_empty_init_data_returns_none():
def test_empty_bot_token_returns_none():
user = {"id": 1, "username": "u"}
init_data = _make_init_data(user, "token")
init_data = make_init_data(user, "token")
assert validate_init_data(init_data, "") is None
@@ -98,7 +66,7 @@ def test_auth_date_expiry_rejects_old_init_data():
user = {"id": 1, "username": "testuser"}
# auth_date 100 seconds ago
old_ts = int(t.time()) - 100
init_data = _make_init_data(user, bot_token, auth_date=old_ts)
init_data = make_init_data(user, bot_token, auth_date=old_ts)
assert validate_init_data(init_data, bot_token, max_age_seconds=60) is None
assert validate_init_data(init_data, bot_token, max_age_seconds=200) == "testuser"
@@ -110,7 +78,7 @@ def test_auth_date_expiry_accepts_fresh_init_data():
bot_token = "123:ABC"
user = {"id": 1, "username": "testuser"}
fresh_ts = int(t.time()) - 10
init_data = _make_init_data(user, bot_token, auth_date=fresh_ts)
init_data = make_init_data(user, bot_token, auth_date=fresh_ts)
assert validate_init_data(init_data, bot_token, max_age_seconds=60) == "testuser"
@@ -118,6 +86,6 @@ def test_auth_date_expiry_requires_auth_date_when_max_age_set():
"""When max_age_seconds is set but auth_date is missing, return None."""
bot_token = "123:ABC"
user = {"id": 1, "username": "testuser"}
init_data = _make_init_data(user, bot_token) # no auth_date
init_data = make_init_data(user, bot_token) # no auth_date
assert validate_init_data(init_data, bot_token, max_age_seconds=86400) is None
assert validate_init_data(init_data, bot_token) == "testuser"

View File

@@ -4,15 +4,15 @@ from datetime import date
import pytest
from utils.dates import (
from duty_teller.utils.dates import (
day_start_iso,
day_end_iso,
duty_to_iso,
parse_iso_date,
validate_date_range,
)
from utils.user import build_full_name
from utils.handover import parse_handover_time
from duty_teller.utils.user import build_full_name
from duty_teller.utils.handover import parse_handover_time
# --- dates ---