{"config":{"lang":["en"],"separator":"[\\s\\-]+","pipeline":["stopWordFilter"],"fields":{"title":{"boost":1000.0},"text":{"boost":1.0},"tags":{"boost":1000000.0}}},"docs":[{"location":"","title":"Duty Teller","text":"
Telegram bot for team duty shift calendar and group reminder. The bot and web UI support Russian and English.
"},{"location":"#documentation","title":"Documentation","text":"For quick start, setup, and API overview see the main README.
"},{"location":"api-reference/","title":"API Reference","text":"Generated from the duty_teller package. The following subpackages and modules are included.
duty_teller.config","text":"Load configuration from environment (e.g. .env via python-dotenv).
BOT_TOKEN is not validated on import; call require_bot_token() in the entry point when running the bot.
"},{"location":"api-reference/#duty_teller.config.Settings","title":"Settings dataclass","text":"Injectable settings built from environment. Used in tests or when env is overridden.
Source code induty_teller/config.py @dataclass(frozen=True)\nclass Settings:\n \"\"\"Injectable settings built from environment. Used in tests or when env is overridden.\"\"\"\n\n bot_token: str\n database_url: str\n mini_app_base_url: str\n http_port: int\n allowed_usernames: set[str]\n admin_usernames: set[str]\n allowed_phones: set[str]\n admin_phones: set[str]\n mini_app_skip_auth: bool\n init_data_max_age_seconds: int\n cors_origins: list[str]\n external_calendar_ics_url: str\n duty_display_tz: str\n default_language: str\n\n @classmethod\n def from_env(cls) -> \"Settings\":\n \"\"\"Build Settings from current environment (same logic as module-level variables).\n\n Returns:\n Settings instance with all fields populated from env.\n \"\"\"\n bot_token = os.getenv(\"BOT_TOKEN\") or \"\"\n raw_allowed = os.getenv(\"ALLOWED_USERNAMES\", \"\").strip()\n allowed = {\n s.strip().lstrip(\"@\").lower() for s in raw_allowed.split(\",\") if s.strip()\n }\n raw_admin = os.getenv(\"ADMIN_USERNAMES\", \"\").strip()\n admin = {\n s.strip().lstrip(\"@\").lower() for s in raw_admin.split(\",\") if s.strip()\n }\n allowed_phones = _parse_phone_list(os.getenv(\"ALLOWED_PHONES\", \"\"))\n admin_phones = _parse_phone_list(os.getenv(\"ADMIN_PHONES\", \"\"))\n raw_cors = os.getenv(\"CORS_ORIGINS\", \"\").strip()\n cors = (\n [_o.strip() for _o in raw_cors.split(\",\") if _o.strip()]\n if raw_cors and raw_cors != \"*\"\n else [\"*\"]\n )\n return cls(\n bot_token=bot_token,\n database_url=os.getenv(\"DATABASE_URL\", \"sqlite:///data/duty_teller.db\"),\n mini_app_base_url=os.getenv(\"MINI_APP_BASE_URL\", \"\").rstrip(\"/\"),\n http_port=int(os.getenv(\"HTTP_PORT\", \"8080\")),\n allowed_usernames=allowed,\n admin_usernames=admin,\n allowed_phones=allowed_phones,\n admin_phones=admin_phones,\n mini_app_skip_auth=os.getenv(\"MINI_APP_SKIP_AUTH\", \"\").strip()\n in (\"1\", \"true\", \"yes\"),\n init_data_max_age_seconds=int(os.getenv(\"INIT_DATA_MAX_AGE_SECONDS\", \"0\")),\n cors_origins=cors,\n external_calendar_ics_url=os.getenv(\n \"EXTERNAL_CALENDAR_ICS_URL\", \"\"\n ).strip(),\n duty_display_tz=os.getenv(\"DUTY_DISPLAY_TZ\", \"Europe/Moscow\").strip()\n or \"Europe/Moscow\",\n default_language=_normalize_default_language(\n os.getenv(\"DEFAULT_LANGUAGE\", \"en\").strip()\n ),\n )\n"},{"location":"api-reference/#duty_teller.config.Settings.from_env","title":"from_env() classmethod","text":"Build Settings from current environment (same logic as module-level variables).
Returns:
Type DescriptionSettings Settings instance with all fields populated from env.
Source code induty_teller/config.py @classmethod\ndef from_env(cls) -> \"Settings\":\n \"\"\"Build Settings from current environment (same logic as module-level variables).\n\n Returns:\n Settings instance with all fields populated from env.\n \"\"\"\n bot_token = os.getenv(\"BOT_TOKEN\") or \"\"\n raw_allowed = os.getenv(\"ALLOWED_USERNAMES\", \"\").strip()\n allowed = {\n s.strip().lstrip(\"@\").lower() for s in raw_allowed.split(\",\") if s.strip()\n }\n raw_admin = os.getenv(\"ADMIN_USERNAMES\", \"\").strip()\n admin = {\n s.strip().lstrip(\"@\").lower() for s in raw_admin.split(\",\") if s.strip()\n }\n allowed_phones = _parse_phone_list(os.getenv(\"ALLOWED_PHONES\", \"\"))\n admin_phones = _parse_phone_list(os.getenv(\"ADMIN_PHONES\", \"\"))\n raw_cors = os.getenv(\"CORS_ORIGINS\", \"\").strip()\n cors = (\n [_o.strip() for _o in raw_cors.split(\",\") if _o.strip()]\n if raw_cors and raw_cors != \"*\"\n else [\"*\"]\n )\n return cls(\n bot_token=bot_token,\n database_url=os.getenv(\"DATABASE_URL\", \"sqlite:///data/duty_teller.db\"),\n mini_app_base_url=os.getenv(\"MINI_APP_BASE_URL\", \"\").rstrip(\"/\"),\n http_port=int(os.getenv(\"HTTP_PORT\", \"8080\")),\n allowed_usernames=allowed,\n admin_usernames=admin,\n allowed_phones=allowed_phones,\n admin_phones=admin_phones,\n mini_app_skip_auth=os.getenv(\"MINI_APP_SKIP_AUTH\", \"\").strip()\n in (\"1\", \"true\", \"yes\"),\n init_data_max_age_seconds=int(os.getenv(\"INIT_DATA_MAX_AGE_SECONDS\", \"0\")),\n cors_origins=cors,\n external_calendar_ics_url=os.getenv(\n \"EXTERNAL_CALENDAR_ICS_URL\", \"\"\n ).strip(),\n duty_display_tz=os.getenv(\"DUTY_DISPLAY_TZ\", \"Europe/Moscow\").strip()\n or \"Europe/Moscow\",\n default_language=_normalize_default_language(\n os.getenv(\"DEFAULT_LANGUAGE\", \"en\").strip()\n ),\n )\n"},{"location":"api-reference/#duty_teller.config.can_access_miniapp","title":"can_access_miniapp(username)","text":"Check if username is allowed to open the calendar Miniapp.
Parameters:
Name Type Description Defaultusername str Telegram username (with or without @; case-insensitive).
requiredReturns:
Type Descriptionbool True if in ALLOWED_USERNAMES or ADMIN_USERNAMES.
Source code induty_teller/config.py def can_access_miniapp(username: str) -> bool:\n \"\"\"Check if username is allowed to open the calendar Miniapp.\n\n Args:\n username: Telegram username (with or without @; case-insensitive).\n\n Returns:\n True if in ALLOWED_USERNAMES or ADMIN_USERNAMES.\n \"\"\"\n u = (username or \"\").strip().lower()\n return u in ALLOWED_USERNAMES or u in ADMIN_USERNAMES\n"},{"location":"api-reference/#duty_teller.config.can_access_miniapp_by_phone","title":"can_access_miniapp_by_phone(phone)","text":"Check if phone (set via /set_phone) is allowed to open the Miniapp.
Parameters:
Name Type Description Defaultphone str | None Raw phone string or None.
requiredReturns:
Type Descriptionbool True if normalized phone is in ALLOWED_PHONES or ADMIN_PHONES.
Source code induty_teller/config.py def can_access_miniapp_by_phone(phone: str | None) -> bool:\n \"\"\"Check if phone (set via /set_phone) is allowed to open the Miniapp.\n\n Args:\n phone: Raw phone string or None.\n\n Returns:\n True if normalized phone is in ALLOWED_PHONES or ADMIN_PHONES.\n \"\"\"\n normalized = normalize_phone(phone)\n if not normalized:\n return False\n return normalized in ALLOWED_PHONES or normalized in ADMIN_PHONES\n"},{"location":"api-reference/#duty_teller.config.is_admin","title":"is_admin(username)","text":"Check if Telegram username is in ADMIN_USERNAMES.
Parameters:
Name Type Description Defaultusername str Telegram username (with or without @; case-insensitive).
requiredReturns:
Type Descriptionbool True if in ADMIN_USERNAMES.
Source code induty_teller/config.py def is_admin(username: str) -> bool:\n \"\"\"Check if Telegram username is in ADMIN_USERNAMES.\n\n Args:\n username: Telegram username (with or without @; case-insensitive).\n\n Returns:\n True if in ADMIN_USERNAMES.\n \"\"\"\n return (username or \"\").strip().lower() in ADMIN_USERNAMES\n"},{"location":"api-reference/#duty_teller.config.is_admin_by_phone","title":"is_admin_by_phone(phone)","text":"Check if phone is in ADMIN_PHONES.
Parameters:
Name Type Description Defaultphone str | None Raw phone string or None.
requiredReturns:
Type Descriptionbool True if normalized phone is in ADMIN_PHONES.
Source code induty_teller/config.py def is_admin_by_phone(phone: str | None) -> bool:\n \"\"\"Check if phone is in ADMIN_PHONES.\n\n Args:\n phone: Raw phone string or None.\n\n Returns:\n True if normalized phone is in ADMIN_PHONES.\n \"\"\"\n normalized = normalize_phone(phone)\n return bool(normalized and normalized in ADMIN_PHONES)\n"},{"location":"api-reference/#duty_teller.config.normalize_phone","title":"normalize_phone(phone)","text":"Return phone as digits only (spaces, +, parentheses, dashes removed).
Parameters:
Name Type Description Defaultphone str | None Raw phone string or None.
requiredReturns:
Type Descriptionstr Digits-only string, or empty string if None or empty.
Source code induty_teller/config.py def normalize_phone(phone: str | None) -> str:\n \"\"\"Return phone as digits only (spaces, +, parentheses, dashes removed).\n\n Args:\n phone: Raw phone string or None.\n\n Returns:\n Digits-only string, or empty string if None or empty.\n \"\"\"\n if not phone or not isinstance(phone, str):\n return \"\"\n return _PHONE_DIGITS_RE.sub(\"\", phone.strip())\n"},{"location":"api-reference/#duty_teller.config.require_bot_token","title":"require_bot_token()","text":"Raise SystemExit with a clear message if BOT_TOKEN is not set.
Call from the application entry point (e.g. main.py or duty_teller.run) so the process exits with a helpful message instead of failing later.
Raises:
Type DescriptionSystemExit If BOT_TOKEN is empty.
Source code induty_teller/config.py def require_bot_token() -> None:\n \"\"\"Raise SystemExit with a clear message if BOT_TOKEN is not set.\n\n Call from the application entry point (e.g. main.py or duty_teller.run) so the\n process exits with a helpful message instead of failing later.\n\n Raises:\n SystemExit: If BOT_TOKEN is empty.\n \"\"\"\n if not BOT_TOKEN:\n raise SystemExit(\n \"BOT_TOKEN is not set. Copy .env.example to .env and set your token from @BotFather.\"\n )\n"},{"location":"api-reference/#api-fastapi-and-auth","title":"API (FastAPI and auth)","text":""},{"location":"api-reference/#duty_teller.api","title":"duty_teller.api","text":"HTTP API for the calendar Mini App: duties, calendar events, and static webapp.
"},{"location":"api-reference/#duty_teller.api.app","title":"duty_teller.api.app","text":"FastAPI app: /api/duties, /api/calendar-events, personal ICS, and static webapp at /app.
"},{"location":"api-reference/#duty_teller.api.app.get_personal_calendar_ical","title":"get_personal_calendar_ical(token, session=Depends(get_db_session))","text":"Return ICS calendar with only the subscribing user's duties. No Telegram auth; access is by secret token in the URL.
Source code induty_teller/api/app.py @app.get(\n \"/api/calendar/ical/{token}.ics\",\n summary=\"Personal calendar ICS\",\n description=\"Returns an ICS calendar with only the subscribing user's duties. No Telegram auth; access is by secret token in the URL.\",\n)\ndef get_personal_calendar_ical(\n token: str,\n session: Session = Depends(get_db_session),\n) -> Response:\n \"\"\"\n Return ICS calendar with only the subscribing user's duties.\n No Telegram auth; access is by secret token in the URL.\n \"\"\"\n user = get_user_by_calendar_token(session, token)\n if user is None:\n return Response(status_code=404, content=\"Not found\")\n today = date.today()\n from_date = (today - timedelta(days=365)).strftime(\"%Y-%m-%d\")\n to_date = (today + timedelta(days=365 * 2)).strftime(\"%Y-%m-%d\")\n duties_with_name = get_duties_for_user(\n session, user.id, from_date=from_date, to_date=to_date\n )\n ics_bytes = build_personal_ics(duties_with_name)\n return Response(\n content=ics_bytes,\n media_type=\"text/calendar; charset=utf-8\",\n )\n"},{"location":"api-reference/#duty_teller.api.dependencies","title":"duty_teller.api.dependencies","text":"FastAPI dependencies: DB session, Miniapp auth (initData/allowlist), date validation.
"},{"location":"api-reference/#duty_teller.api.dependencies.fetch_duties_response","title":"fetch_duties_response(session, from_date, to_date)","text":"Load duties in range and return as DutyWithUser list for API response.
Parameters:
Name Type Description Defaultsession Session DB session.
requiredfrom_date str Start date YYYY-MM-DD.
requiredto_date str End date YYYY-MM-DD.
requiredReturns:
Type Descriptionlist[DutyWithUser] List of DutyWithUser (id, user_id, start_at, end_at, full_name, event_type).
Source code induty_teller/api/dependencies.py def fetch_duties_response(\n session: Session, from_date: str, to_date: str\n) -> list[DutyWithUser]:\n \"\"\"Load duties in range and return as DutyWithUser list for API response.\n\n Args:\n session: DB session.\n from_date: Start date YYYY-MM-DD.\n to_date: End date YYYY-MM-DD.\n\n Returns:\n List of DutyWithUser (id, user_id, start_at, end_at, full_name, event_type).\n \"\"\"\n rows = get_duties(session, from_date=from_date, to_date=to_date)\n return [\n DutyWithUser(\n id=duty.id,\n user_id=duty.user_id,\n start_at=duty.start_at,\n end_at=duty.end_at,\n full_name=full_name,\n event_type=(\n duty.event_type\n if duty.event_type in (\"duty\", \"unavailable\", \"vacation\")\n else \"duty\"\n ),\n )\n for duty, full_name in rows\n ]\n"},{"location":"api-reference/#duty_teller.api.dependencies.get_authenticated_username","title":"get_authenticated_username(request, x_telegram_init_data, session)","text":"Return identifier for miniapp auth (username or full_name or id:...); empty if skip-auth.
Parameters:
Name Type Description Defaultrequest Request FastAPI request (client host for private-IP bypass).
requiredx_telegram_init_data str | None Raw X-Telegram-Init-Data header value.
requiredsession Session DB session (for phone allowlist lookup).
requiredReturns:
Type Descriptionstr Username, full_name, or \"id:\"; empty string if MINI_APP_SKIP_AUTH str
or private IP and no initData.
Raises:
Type DescriptionHTTPException 403 if initData missing/invalid or user not in allowlist.
Source code induty_teller/api/dependencies.py def get_authenticated_username(\n request: Request,\n x_telegram_init_data: str | None,\n session: Session,\n) -> str:\n \"\"\"Return identifier for miniapp auth (username or full_name or id:...); empty if skip-auth.\n\n Args:\n request: FastAPI request (client host for private-IP bypass).\n x_telegram_init_data: Raw X-Telegram-Init-Data header value.\n session: DB session (for phone allowlist lookup).\n\n Returns:\n Username, full_name, or \"id:<telegram_id>\"; empty string if MINI_APP_SKIP_AUTH\n or private IP and no initData.\n\n Raises:\n HTTPException: 403 if initData missing/invalid or user not in allowlist.\n \"\"\"\n if config.MINI_APP_SKIP_AUTH:\n log.warning(\"allowing without any auth check (MINI_APP_SKIP_AUTH is set)\")\n return \"\"\n init_data = (x_telegram_init_data or \"\").strip()\n if not init_data:\n client_host = request.client.host if request.client else None\n if _is_private_client(client_host):\n return \"\"\n log.warning(\"no X-Telegram-Init-Data header (client=%s)\", client_host)\n lang = _lang_from_accept_language(request.headers.get(\"Accept-Language\"))\n raise HTTPException(status_code=403, detail=t(lang, \"api.open_from_telegram\"))\n max_age = config.INIT_DATA_MAX_AGE_SECONDS or None\n telegram_user_id, username, auth_reason, lang = validate_init_data_with_reason(\n init_data, config.BOT_TOKEN, max_age_seconds=max_age\n )\n if auth_reason != \"ok\":\n log.warning(\"initData validation failed: %s\", auth_reason)\n raise HTTPException(\n status_code=403, detail=_auth_error_detail(auth_reason, lang)\n )\n if username and config.can_access_miniapp(username):\n return username\n failed_phone: str | None = None\n if telegram_user_id is not None:\n user = get_user_by_telegram_id(session, telegram_user_id)\n if user and user.phone and config.can_access_miniapp_by_phone(user.phone):\n return username or (user.full_name or \"\") or f\"id:{telegram_user_id}\"\n if user and user.phone:\n failed_phone = config.normalize_phone(user.phone)\n log.warning(\n \"username/phone not in allowlist (username=%s, telegram_id=%s, phone=%s)\",\n username,\n telegram_user_id,\n failed_phone if failed_phone else \"\u2014\",\n )\n raise HTTPException(status_code=403, detail=t(lang, \"api.access_denied\"))\n"},{"location":"api-reference/#duty_teller.api.dependencies.get_db_session","title":"get_db_session()","text":"Yield a DB session for the request; closed automatically by FastAPI.
Source code induty_teller/api/dependencies.py def get_db_session() -> Generator[Session, None, None]:\n \"\"\"Yield a DB session for the request; closed automatically by FastAPI.\"\"\"\n with session_scope(config.DATABASE_URL) as session:\n yield session\n"},{"location":"api-reference/#duty_teller.api.dependencies.get_validated_dates","title":"get_validated_dates(request, from_date=Query(..., description='ISO date YYYY-MM-DD', alias='from'), to_date=Query(..., description='ISO date YYYY-MM-DD', alias='to'))","text":"Validate from/to date query params; use Accept-Language for error messages.
Parameters:
Name Type Description Defaultrequest Request FastAPI request (for Accept-Language).
requiredfrom_date str Start date YYYY-MM-DD.
Query(..., description='ISO date YYYY-MM-DD', alias='from') to_date str End date YYYY-MM-DD.
Query(..., description='ISO date YYYY-MM-DD', alias='to') Returns:
Type Descriptiontuple[str, str] (from_date, to_date) as strings.
Raises:
Type DescriptionHTTPException 400 if format invalid or from_date > to_date.
Source code induty_teller/api/dependencies.py def get_validated_dates(\n request: Request,\n from_date: str = Query(..., description=\"ISO date YYYY-MM-DD\", alias=\"from\"),\n to_date: str = Query(..., description=\"ISO date YYYY-MM-DD\", alias=\"to\"),\n) -> tuple[str, str]:\n \"\"\"Validate from/to date query params; use Accept-Language for error messages.\n\n Args:\n request: FastAPI request (for Accept-Language).\n from_date: Start date YYYY-MM-DD.\n to_date: End date YYYY-MM-DD.\n\n Returns:\n (from_date, to_date) as strings.\n\n Raises:\n HTTPException: 400 if format invalid or from_date > to_date.\n \"\"\"\n lang = _lang_from_accept_language(request.headers.get(\"Accept-Language\"))\n _validate_duty_dates(from_date, to_date, lang)\n return (from_date, to_date)\n"},{"location":"api-reference/#duty_teller.api.dependencies.require_miniapp_username","title":"require_miniapp_username(request, x_telegram_init_data=None, session=Depends(get_db_session))","text":"FastAPI dependency: require valid Miniapp auth; return username/identifier.
Raises:
Type DescriptionHTTPException 403 if initData missing/invalid or user not in allowlist.
Source code induty_teller/api/dependencies.py def require_miniapp_username(\n request: Request,\n x_telegram_init_data: Annotated[\n str | None, Header(alias=\"X-Telegram-Init-Data\")\n ] = None,\n session: Session = Depends(get_db_session),\n) -> str:\n \"\"\"FastAPI dependency: require valid Miniapp auth; return username/identifier.\n\n Raises:\n HTTPException: 403 if initData missing/invalid or user not in allowlist.\n \"\"\"\n return get_authenticated_username(request, x_telegram_init_data, session)\n"},{"location":"api-reference/#duty_teller.api.telegram_auth","title":"duty_teller.api.telegram_auth","text":"Validate Telegram Web App initData and extract user username.
"},{"location":"api-reference/#duty_teller.api.telegram_auth.validate_init_data","title":"validate_init_data(init_data, bot_token, max_age_seconds=None)","text":"Validate Telegram Web App initData and return username if valid.
Parameters:
Name Type Description Defaultinit_data str Raw initData string from tgWebAppData.
requiredbot_token str Bot token (must match the bot that signed the data).
requiredmax_age_seconds int | None Reject if auth_date older than this; None to disable.
None Returns:
Type Descriptionstr | None Username (lowercase, no @) or None if validation fails.
Source code induty_teller/api/telegram_auth.py def validate_init_data(\n init_data: str,\n bot_token: str,\n max_age_seconds: int | None = None,\n) -> str | None:\n \"\"\"Validate Telegram Web App initData and return username if valid.\n\n Args:\n init_data: Raw initData string from tgWebAppData.\n bot_token: Bot token (must match the bot that signed the data).\n max_age_seconds: Reject if auth_date older than this; None to disable.\n\n Returns:\n Username (lowercase, no @) or None if validation fails.\n \"\"\"\n _, username, _, _ = validate_init_data_with_reason(\n init_data, bot_token, max_age_seconds\n )\n return username\n"},{"location":"api-reference/#duty_teller.api.telegram_auth.validate_init_data_with_reason","title":"validate_init_data_with_reason(init_data, bot_token, max_age_seconds=None)","text":"Validate initData signature and return user id, username, reason, and lang.
Parameters:
Name Type Description Defaultinit_data str Raw initData string from tgWebAppData.
requiredbot_token str Bot token (must match the bot that signed the data).
requiredmax_age_seconds int | None Reject if auth_date older than this; None to disable.
None Returns:
Type Descriptionint | None Tuple (telegram_user_id, username, reason, lang). reason is one of: \"ok\",
str | None \"empty\", \"no_hash\", \"hash_mismatch\", \"auth_date_expired\", \"no_user\",
str \"user_invalid\", \"no_user_id\". lang is from user.language_code normalized
str to 'ru' or 'en'; 'en' when no user. On success: (user.id, username or None,
tuple[int | None, str | None, str, str] \"ok\", lang).
Source code induty_teller/api/telegram_auth.py def validate_init_data_with_reason(\n init_data: str,\n bot_token: str,\n max_age_seconds: int | None = None,\n) -> tuple[int | None, str | None, str, str]:\n \"\"\"Validate initData signature and return user id, username, reason, and lang.\n\n Args:\n init_data: Raw initData string from tgWebAppData.\n bot_token: Bot token (must match the bot that signed the data).\n max_age_seconds: Reject if auth_date older than this; None to disable.\n\n Returns:\n Tuple (telegram_user_id, username, reason, lang). reason is one of: \"ok\",\n \"empty\", \"no_hash\", \"hash_mismatch\", \"auth_date_expired\", \"no_user\",\n \"user_invalid\", \"no_user_id\". lang is from user.language_code normalized\n to 'ru' or 'en'; 'en' when no user. On success: (user.id, username or None,\n \"ok\", lang).\n \"\"\"\n if not init_data or not bot_token:\n return (None, None, \"empty\", \"en\")\n init_data = init_data.strip()\n params = {}\n for part in init_data.split(\"&\"):\n if \"=\" not in part:\n continue\n key, _, value = part.partition(\"=\")\n if not key:\n continue\n params[key] = value\n hash_val = params.pop(\"hash\", None)\n if not hash_val:\n return (None, None, \"no_hash\", \"en\")\n data_pairs = sorted(params.items())\n # Data-check string: key=value with URL-decoded values (per Telegram example)\n data_string = \"\\n\".join(f\"{k}={unquote(v)}\" for k, v in data_pairs)\n # HMAC-SHA256(key=WebAppData, message=bot_token) per reference implementations\n secret_key = hmac.new(\n b\"WebAppData\",\n msg=bot_token.encode(),\n digestmod=hashlib.sha256,\n ).digest()\n computed = hmac.new(\n secret_key,\n msg=data_string.encode(),\n digestmod=hashlib.sha256,\n ).hexdigest()\n if not hmac.compare_digest(computed.lower(), hash_val.lower()):\n return (None, None, \"hash_mismatch\", \"en\")\n if max_age_seconds is not None and max_age_seconds > 0:\n auth_date_raw = params.get(\"auth_date\")\n if not auth_date_raw:\n return (None, None, \"auth_date_expired\", \"en\")\n try:\n auth_date = int(float(auth_date_raw))\n except (ValueError, TypeError):\n return (None, None, \"auth_date_expired\", \"en\")\n if time.time() - auth_date > max_age_seconds:\n return (None, None, \"auth_date_expired\", \"en\")\n user_raw = params.get(\"user\")\n if not user_raw:\n return (None, None, \"no_user\", \"en\")\n try:\n user = json.loads(unquote(user_raw))\n except (json.JSONDecodeError, TypeError):\n return (None, None, \"user_invalid\", \"en\")\n if not isinstance(user, dict):\n return (None, None, \"user_invalid\", \"en\")\n lang = _normalize_lang(user.get(\"language_code\"))\n raw_id = user.get(\"id\")\n if raw_id is None:\n return (None, None, \"no_user_id\", lang)\n try:\n telegram_user_id = int(raw_id)\n except (TypeError, ValueError):\n return (None, None, \"no_user_id\", lang)\n username = user.get(\"username\")\n if username and isinstance(username, str):\n username = username.strip().lstrip(\"@\").lower()\n else:\n username = None\n return (telegram_user_id, username, \"ok\", lang)\n"},{"location":"api-reference/#duty_teller.api.calendar_ics","title":"duty_teller.api.calendar_ics","text":"Fetch and parse external ICS calendar; in-memory cache with 7-day TTL.
"},{"location":"api-reference/#duty_teller.api.calendar_ics.get_calendar_events","title":"get_calendar_events(url, from_date, to_date)","text":"Fetch ICS from URL and return events in the given date range.
Uses in-memory cache with TTL 7 days. Recurring events are skipped. On fetch or parse error returns an empty list.
Parameters:
Name Type Description Defaulturl str URL of the ICS calendar.
requiredfrom_date str Start date YYYY-MM-DD.
requiredto_date str End date YYYY-MM-DD.
requiredReturns:
Type Descriptionlist[dict] List of dicts with keys \"date\" (YYYY-MM-DD) and \"summary\". Empty on error.
Source code induty_teller/api/calendar_ics.py def get_calendar_events(\n url: str,\n from_date: str,\n to_date: str,\n) -> list[dict]:\n \"\"\"Fetch ICS from URL and return events in the given date range.\n\n Uses in-memory cache with TTL 7 days. Recurring events are skipped.\n On fetch or parse error returns an empty list.\n\n Args:\n url: URL of the ICS calendar.\n from_date: Start date YYYY-MM-DD.\n to_date: End date YYYY-MM-DD.\n\n Returns:\n List of dicts with keys \"date\" (YYYY-MM-DD) and \"summary\". Empty on error.\n \"\"\"\n if not url or from_date > to_date:\n return []\n\n now = datetime.now().timestamp()\n raw: bytes | None = None\n if url in _ics_cache:\n cached_at, cached_raw = _ics_cache[url]\n if now - cached_at < CACHE_TTL_SECONDS:\n raw = cached_raw\n if raw is None:\n raw = _fetch_ics(url)\n if raw is None:\n return []\n _ics_cache[url] = (now, raw)\n\n return _get_events_from_ics(raw, from_date, to_date)\n"},{"location":"api-reference/#duty_teller.api.personal_calendar_ics","title":"duty_teller.api.personal_calendar_ics","text":"Generate ICS calendar containing only one user's duties (for subscription link).
"},{"location":"api-reference/#duty_teller.api.personal_calendar_ics.build_personal_ics","title":"build_personal_ics(duties_with_name)","text":"Build a VCALENDAR (ICS) with one VEVENT per duty.
Parameters:
Name Type Description Defaultduties_with_name list[tuple[Duty, str]] List of (Duty, full_name). full_name is available for DESCRIPTION; SUMMARY is taken from event_type (duty/unavailable/vacation).
requiredReturns:
Type Descriptionbytes ICS file content as bytes (UTF-8).
Source code induty_teller/api/personal_calendar_ics.py def build_personal_ics(duties_with_name: list[tuple[Duty, str]]) -> bytes:\n \"\"\"Build a VCALENDAR (ICS) with one VEVENT per duty.\n\n Args:\n duties_with_name: List of (Duty, full_name). full_name is available for\n DESCRIPTION; SUMMARY is taken from event_type (duty/unavailable/vacation).\n\n Returns:\n ICS file content as bytes (UTF-8).\n \"\"\"\n cal = Calendar()\n cal.add(\"prodid\", \"-//Duty Teller//Personal Calendar//EN\")\n cal.add(\"version\", \"2.0\")\n cal.add(\"calscale\", \"GREGORIAN\")\n\n for duty, _full_name in duties_with_name:\n event = Event()\n start_dt = _parse_utc_iso(duty.start_at)\n end_dt = _parse_utc_iso(duty.end_at)\n # Ensure timezone-aware for icalendar\n if start_dt.tzinfo is None:\n start_dt = start_dt.replace(tzinfo=timezone.utc)\n if end_dt.tzinfo is None:\n end_dt = end_dt.replace(tzinfo=timezone.utc)\n event.add(\"dtstart\", start_dt)\n event.add(\"dtend\", end_dt)\n summary = SUMMARY_BY_TYPE.get(\n duty.event_type if duty.event_type else \"duty\", \"Duty\"\n )\n event.add(\"summary\", summary)\n event.add(\"uid\", f\"duty-{duty.id}@duty-teller\")\n event.add(\"dtstamp\", datetime.now(timezone.utc))\n cal.add_component(event)\n\n return cal.to_ical()\n"},{"location":"api-reference/#database","title":"Database","text":""},{"location":"api-reference/#duty_teller.db","title":"duty_teller.db","text":"Database layer: SQLAlchemy models, Pydantic schemas, repository, init.
"},{"location":"api-reference/#duty_teller.db.Base","title":"Base","text":" Bases: DeclarativeBase
Declarative base for all models.
Source code induty_teller/db/models.py class Base(DeclarativeBase):\n \"\"\"Declarative base for all models.\"\"\"\n\n pass\n"},{"location":"api-reference/#duty_teller.db.Duty","title":"Duty","text":" Bases: Base
Single duty/unavailable/vacation slot (UTC start_at/end_at, event_type).
Source code induty_teller/db/models.py class Duty(Base):\n \"\"\"Single duty/unavailable/vacation slot (UTC start_at/end_at, event_type).\"\"\"\n\n __tablename__ = \"duties\"\n\n id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)\n user_id: Mapped[int] = mapped_column(\n Integer, ForeignKey(\"users.id\"), nullable=False\n )\n # UTC, ISO 8601 with Z suffix (e.g. 2025-01-15T09:00:00Z)\n start_at: Mapped[str] = mapped_column(Text, nullable=False)\n end_at: Mapped[str] = mapped_column(Text, nullable=False)\n # duty | unavailable | vacation\n event_type: Mapped[str] = mapped_column(Text, nullable=False, server_default=\"duty\")\n\n user: Mapped[\"User\"] = relationship(\"User\", back_populates=\"duties\")\n"},{"location":"api-reference/#duty_teller.db.DutyCreate","title":"DutyCreate","text":" Bases: DutyBase
Duty creation payload.
Source code induty_teller/db/schemas.py class DutyCreate(DutyBase):\n \"\"\"Duty creation payload.\"\"\"\n\n pass\n"},{"location":"api-reference/#duty_teller.db.DutyInDb","title":"DutyInDb","text":" Bases: DutyBase
Duty as stored in DB (includes id).
Source code induty_teller/db/schemas.py class DutyInDb(DutyBase):\n \"\"\"Duty as stored in DB (includes id).\"\"\"\n\n id: int\n\n model_config = ConfigDict(from_attributes=True)\n"},{"location":"api-reference/#duty_teller.db.DutyWithUser","title":"DutyWithUser","text":" Bases: DutyInDb
Duty with full_name and event_type for calendar display.
event_type: only these values are returned; unknown DB values are mapped to \"duty\" in the API.
Source code induty_teller/db/schemas.py class DutyWithUser(DutyInDb):\n \"\"\"Duty with full_name and event_type for calendar display.\n\n event_type: only these values are returned; unknown DB values are mapped to \"duty\" in the API.\n \"\"\"\n\n full_name: str\n event_type: Literal[\"duty\", \"unavailable\", \"vacation\"] = \"duty\"\n\n model_config = ConfigDict(from_attributes=True)\n"},{"location":"api-reference/#duty_teller.db.User","title":"User","text":" Bases: Base
Telegram user and display name; may have telegram_user_id=None for import-only users.
Source code induty_teller/db/models.py class User(Base):\n \"\"\"Telegram user and display name; may have telegram_user_id=None for import-only users.\"\"\"\n\n __tablename__ = \"users\"\n\n id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)\n telegram_user_id: Mapped[int | None] = mapped_column(\n BigInteger, unique=True, nullable=True\n )\n full_name: Mapped[str] = mapped_column(Text, nullable=False)\n username: Mapped[str | None] = mapped_column(Text, nullable=True)\n first_name: Mapped[str | None] = mapped_column(Text, nullable=True)\n last_name: Mapped[str | None] = mapped_column(Text, nullable=True)\n phone: Mapped[str | None] = mapped_column(Text, nullable=True)\n name_manually_edited: Mapped[bool] = mapped_column(\n Boolean, nullable=False, server_default=\"0\", default=False\n )\n\n duties: Mapped[list[\"Duty\"]] = relationship(\"Duty\", back_populates=\"user\")\n"},{"location":"api-reference/#duty_teller.db.UserCreate","title":"UserCreate","text":" Bases: UserBase
User creation payload including Telegram user id.
Source code induty_teller/db/schemas.py class UserCreate(UserBase):\n \"\"\"User creation payload including Telegram user id.\"\"\"\n\n telegram_user_id: int\n"},{"location":"api-reference/#duty_teller.db.UserInDb","title":"UserInDb","text":" Bases: UserBase
User as stored in DB (includes id and telegram_user_id).
Source code induty_teller/db/schemas.py class UserInDb(UserBase):\n \"\"\"User as stored in DB (includes id and telegram_user_id).\"\"\"\n\n id: int\n telegram_user_id: int\n\n model_config = ConfigDict(from_attributes=True)\n"},{"location":"api-reference/#duty_teller.db.delete_duties_in_range","title":"delete_duties_in_range(session, user_id, from_date, to_date)","text":"Delete all duties of the user that overlap the given date range.
Parameters:
Name Type Description Defaultsession Session DB session.
requireduser_id int User id.
requiredfrom_date str Start date YYYY-MM-DD.
requiredto_date str End date YYYY-MM-DD.
requiredReturns:
Type Descriptionint Number of duties deleted.
Source code induty_teller/db/repository.py def delete_duties_in_range(\n session: Session,\n user_id: int,\n from_date: str,\n to_date: str,\n) -> int:\n \"\"\"Delete all duties of the user that overlap the given date range.\n\n Args:\n session: DB session.\n user_id: User id.\n from_date: Start date YYYY-MM-DD.\n to_date: End date YYYY-MM-DD.\n\n Returns:\n Number of duties deleted.\n \"\"\"\n to_next = (\n datetime.fromisoformat(to_date + \"T00:00:00\") + timedelta(days=1)\n ).strftime(\"%Y-%m-%d\")\n q = session.query(Duty).filter(\n Duty.user_id == user_id,\n Duty.start_at < to_next,\n Duty.end_at >= from_date,\n )\n count = q.count()\n q.delete(synchronize_session=False)\n session.commit()\n return count\n"},{"location":"api-reference/#duty_teller.db.get_duties","title":"get_duties(session, from_date, to_date)","text":"Return duties overlapping the given date range with user full_name.
Parameters:
Name Type Description Defaultsession Session DB session.
requiredfrom_date str Start date YYYY-MM-DD.
requiredto_date str End date YYYY-MM-DD.
requiredReturns:
Type Descriptionlist[tuple[Duty, str]] List of (Duty, full_name) tuples.
Source code induty_teller/db/repository.py def get_duties(\n session: Session,\n from_date: str,\n to_date: str,\n) -> list[tuple[Duty, str]]:\n \"\"\"Return duties overlapping the given date range with user full_name.\n\n Args:\n session: DB session.\n from_date: Start date YYYY-MM-DD.\n to_date: End date YYYY-MM-DD.\n\n Returns:\n List of (Duty, full_name) tuples.\n \"\"\"\n to_date_next = (\n datetime.fromisoformat(to_date + \"T00:00:00\") + timedelta(days=1)\n ).strftime(\"%Y-%m-%d\")\n q = (\n session.query(Duty, User.full_name)\n .join(User, Duty.user_id == User.id)\n .filter(Duty.start_at < to_date_next, Duty.end_at >= from_date)\n )\n return list(q.all())\n"},{"location":"api-reference/#duty_teller.db.get_engine","title":"get_engine(database_url)","text":"Return cached SQLAlchemy engine for the given URL (one per process).
Source code induty_teller/db/session.py def get_engine(database_url: str):\n \"\"\"Return cached SQLAlchemy engine for the given URL (one per process).\"\"\"\n global _engine\n if _engine is None:\n _engine = create_engine(\n database_url,\n connect_args={\"check_same_thread\": False}\n if \"sqlite\" in database_url\n else {},\n echo=False,\n )\n return _engine\n"},{"location":"api-reference/#duty_teller.db.get_or_create_user","title":"get_or_create_user(session, telegram_user_id, full_name, username=None, first_name=None, last_name=None)","text":"Get or create user by Telegram user ID.
On create, name fields come from Telegram. On update: username is always synced; full_name, first_name, last_name are updated only if name_manually_edited is False (otherwise existing display name is kept).
Parameters:
Name Type Description Defaultsession Session DB session.
requiredtelegram_user_id int Telegram user id.
requiredfull_name str Display full name.
requiredusername str | None Telegram username (optional).
None first_name str | None Telegram first name (optional).
None last_name str | None Telegram last name (optional).
None Returns:
Type DescriptionUser User instance (created or updated).
Source code induty_teller/db/repository.py def get_or_create_user(\n session: Session,\n telegram_user_id: int,\n full_name: str,\n username: str | None = None,\n first_name: str | None = None,\n last_name: str | None = None,\n) -> User:\n \"\"\"Get or create user by Telegram user ID.\n\n On create, name fields come from Telegram. On update: username is always\n synced; full_name, first_name, last_name are updated only if\n name_manually_edited is False (otherwise existing display name is kept).\n\n Args:\n session: DB session.\n telegram_user_id: Telegram user id.\n full_name: Display full name.\n username: Telegram username (optional).\n first_name: Telegram first name (optional).\n last_name: Telegram last name (optional).\n\n Returns:\n User instance (created or updated).\n \"\"\"\n user = get_user_by_telegram_id(session, telegram_user_id)\n if user:\n user.username = username\n if not user.name_manually_edited:\n user.full_name = full_name\n user.first_name = first_name\n user.last_name = last_name\n session.commit()\n session.refresh(user)\n return user\n user = User(\n telegram_user_id=telegram_user_id,\n full_name=full_name,\n username=username,\n first_name=first_name,\n last_name=last_name,\n name_manually_edited=False,\n )\n session.add(user)\n session.commit()\n session.refresh(user)\n return user\n"},{"location":"api-reference/#duty_teller.db.get_or_create_user_by_full_name","title":"get_or_create_user_by_full_name(session, full_name)","text":"Find user by exact full_name or create one (for duty-schedule import).
New users have telegram_user_id=None and name_manually_edited=True.
Parameters:
Name Type Description Defaultsession Session DB session.
requiredfull_name str Exact full name to match or set.
requiredReturns:
Type DescriptionUser User instance (existing or newly created).
Source code induty_teller/db/repository.py def get_or_create_user_by_full_name(session: Session, full_name: str) -> User:\n \"\"\"Find user by exact full_name or create one (for duty-schedule import).\n\n New users have telegram_user_id=None and name_manually_edited=True.\n\n Args:\n session: DB session.\n full_name: Exact full name to match or set.\n\n Returns:\n User instance (existing or newly created).\n \"\"\"\n user = session.query(User).filter(User.full_name == full_name).first()\n if user:\n return user\n user = User(\n telegram_user_id=None,\n full_name=full_name,\n username=None,\n first_name=None,\n last_name=None,\n name_manually_edited=True,\n )\n session.add(user)\n session.commit()\n session.refresh(user)\n return user\n"},{"location":"api-reference/#duty_teller.db.get_session","title":"get_session(database_url)","text":"Create a new session from the factory for the given URL.
Source code induty_teller/db/session.py def get_session(database_url: str) -> Session:\n \"\"\"Create a new session from the factory for the given URL.\"\"\"\n return get_session_factory(database_url)()\n"},{"location":"api-reference/#duty_teller.db.get_session_factory","title":"get_session_factory(database_url)","text":"Return cached session factory for the given URL (one per process).
Source code induty_teller/db/session.py def get_session_factory(database_url: str) -> sessionmaker[Session]:\n \"\"\"Return cached session factory for the given URL (one per process).\"\"\"\n global _SessionLocal\n if _SessionLocal is None:\n engine = get_engine(database_url)\n _SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)\n return _SessionLocal\n"},{"location":"api-reference/#duty_teller.db.init_db","title":"init_db(database_url)","text":"Create all tables from SQLAlchemy metadata.
Prefer Alembic migrations for schema changes in production.
Parameters:
Name Type Description Defaultdatabase_url str SQLAlchemy database URL.
required Source code induty_teller/db/__init__.py def init_db(database_url: str) -> None:\n \"\"\"Create all tables from SQLAlchemy metadata.\n\n Prefer Alembic migrations for schema changes in production.\n\n Args:\n database_url: SQLAlchemy database URL.\n \"\"\"\n engine = get_engine(database_url)\n Base.metadata.create_all(bind=engine)\n"},{"location":"api-reference/#duty_teller.db.insert_duty","title":"insert_duty(session, user_id, start_at, end_at, event_type='duty')","text":"Create a duty record.
Parameters:
Name Type Description Defaultsession Session DB session.
requireduser_id int User id.
requiredstart_at str Start time UTC, ISO 8601 with Z (e.g. 2025-01-15T09:00:00Z).
requiredend_at str End time UTC, ISO 8601 with Z.
requiredevent_type str One of \"duty\", \"unavailable\", \"vacation\". Default \"duty\".
'duty' Returns:
Type DescriptionDuty Created Duty instance.
Source code induty_teller/db/repository.py def insert_duty(\n session: Session,\n user_id: int,\n start_at: str,\n end_at: str,\n event_type: str = \"duty\",\n) -> Duty:\n \"\"\"Create a duty record.\n\n Args:\n session: DB session.\n user_id: User id.\n start_at: Start time UTC, ISO 8601 with Z (e.g. 2025-01-15T09:00:00Z).\n end_at: End time UTC, ISO 8601 with Z.\n event_type: One of \"duty\", \"unavailable\", \"vacation\". Default \"duty\".\n\n Returns:\n Created Duty instance.\n \"\"\"\n duty = Duty(\n user_id=user_id,\n start_at=start_at,\n end_at=end_at,\n event_type=event_type,\n )\n session.add(duty)\n session.commit()\n session.refresh(duty)\n return duty\n"},{"location":"api-reference/#duty_teller.db.session_scope","title":"session_scope(database_url)","text":"Context manager that yields a session; rolls back on exception, closes on exit.
Parameters:
Name Type Description Defaultdatabase_url str SQLAlchemy database URL.
requiredYields:
Type DescriptionSession Session instance. Caller must not use it after exit.
Source code induty_teller/db/session.py @contextmanager\ndef session_scope(database_url: str) -> Generator[Session, None, None]:\n \"\"\"Context manager that yields a session; rolls back on exception, closes on exit.\n\n Args:\n database_url: SQLAlchemy database URL.\n\n Yields:\n Session instance. Caller must not use it after exit.\n \"\"\"\n session = get_session(database_url)\n try:\n yield session\n except Exception:\n session.rollback()\n raise\n finally:\n session.close()\n"},{"location":"api-reference/#duty_teller.db.set_user_phone","title":"set_user_phone(session, telegram_user_id, phone)","text":"Set or clear phone for user by Telegram user id.
Parameters:
Name Type Description Defaultsession Session DB session.
requiredtelegram_user_id int Telegram user id.
requiredphone str | None Phone string or None to clear.
requiredReturns:
Type DescriptionUser | None Updated User or None if not found.
Source code induty_teller/db/repository.py def set_user_phone(\n session: Session, telegram_user_id: int, phone: str | None\n) -> User | None:\n \"\"\"Set or clear phone for user by Telegram user id.\n\n Args:\n session: DB session.\n telegram_user_id: Telegram user id.\n phone: Phone string or None to clear.\n\n Returns:\n Updated User or None if not found.\n \"\"\"\n user = session.query(User).filter(User.telegram_user_id == telegram_user_id).first()\n if not user:\n return None\n user.phone = phone\n session.commit()\n session.refresh(user)\n return user\n"},{"location":"api-reference/#duty_teller.db.update_user_display_name","title":"update_user_display_name(session, telegram_user_id, full_name, first_name=None, last_name=None)","text":"Update display name and set name_manually_edited=True.
Use from API or admin when name is changed manually; subsequent get_or_create_user will not overwrite these fields.
Parameters:
Name Type Description Defaultsession Session DB session.
requiredtelegram_user_id int Telegram user id.
requiredfull_name str New full name.
requiredfirst_name str | None New first name (optional).
None last_name str | None New last name (optional).
None Returns:
Type DescriptionUser | None Updated User or None if not found.
Source code induty_teller/db/repository.py def update_user_display_name(\n session: Session,\n telegram_user_id: int,\n full_name: str,\n first_name: str | None = None,\n last_name: str | None = None,\n) -> User | None:\n \"\"\"Update display name and set name_manually_edited=True.\n\n Use from API or admin when name is changed manually; subsequent\n get_or_create_user will not overwrite these fields.\n\n Args:\n session: DB session.\n telegram_user_id: Telegram user id.\n full_name: New full name.\n first_name: New first name (optional).\n last_name: New last name (optional).\n\n Returns:\n Updated User or None if not found.\n \"\"\"\n user = session.query(User).filter(User.telegram_user_id == telegram_user_id).first()\n if not user:\n return None\n user.full_name = full_name\n user.first_name = first_name\n user.last_name = last_name\n user.name_manually_edited = True\n session.commit()\n session.refresh(user)\n return user\n"},{"location":"api-reference/#duty_teller.db.models","title":"duty_teller.db.models","text":"SQLAlchemy ORM models for users and duties.
"},{"location":"api-reference/#duty_teller.db.models.Base","title":"Base","text":" Bases: DeclarativeBase
Declarative base for all models.
Source code induty_teller/db/models.py class Base(DeclarativeBase):\n \"\"\"Declarative base for all models.\"\"\"\n\n pass\n"},{"location":"api-reference/#duty_teller.db.models.CalendarSubscriptionToken","title":"CalendarSubscriptionToken","text":" Bases: Base
One active calendar subscription token per user; token_hash is unique.
Source code induty_teller/db/models.py class CalendarSubscriptionToken(Base):\n \"\"\"One active calendar subscription token per user; token_hash is unique.\"\"\"\n\n __tablename__ = \"calendar_subscription_tokens\"\n\n id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)\n user_id: Mapped[int] = mapped_column(\n Integer, ForeignKey(\"users.id\"), nullable=False\n )\n token_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True)\n created_at: Mapped[str] = mapped_column(Text, nullable=False)\n"},{"location":"api-reference/#duty_teller.db.models.Duty","title":"Duty","text":" Bases: Base
Single duty/unavailable/vacation slot (UTC start_at/end_at, event_type).
Source code induty_teller/db/models.py class Duty(Base):\n \"\"\"Single duty/unavailable/vacation slot (UTC start_at/end_at, event_type).\"\"\"\n\n __tablename__ = \"duties\"\n\n id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)\n user_id: Mapped[int] = mapped_column(\n Integer, ForeignKey(\"users.id\"), nullable=False\n )\n # UTC, ISO 8601 with Z suffix (e.g. 2025-01-15T09:00:00Z)\n start_at: Mapped[str] = mapped_column(Text, nullable=False)\n end_at: Mapped[str] = mapped_column(Text, nullable=False)\n # duty | unavailable | vacation\n event_type: Mapped[str] = mapped_column(Text, nullable=False, server_default=\"duty\")\n\n user: Mapped[\"User\"] = relationship(\"User\", back_populates=\"duties\")\n"},{"location":"api-reference/#duty_teller.db.models.GroupDutyPin","title":"GroupDutyPin","text":" Bases: Base
Stores which message to update in each group for the pinned duty notice.
Source code induty_teller/db/models.py class GroupDutyPin(Base):\n \"\"\"Stores which message to update in each group for the pinned duty notice.\"\"\"\n\n __tablename__ = \"group_duty_pins\"\n\n chat_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)\n message_id: Mapped[int] = mapped_column(Integer, nullable=False)\n"},{"location":"api-reference/#duty_teller.db.models.User","title":"User","text":" Bases: Base
Telegram user and display name; may have telegram_user_id=None for import-only users.
Source code induty_teller/db/models.py class User(Base):\n \"\"\"Telegram user and display name; may have telegram_user_id=None for import-only users.\"\"\"\n\n __tablename__ = \"users\"\n\n id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)\n telegram_user_id: Mapped[int | None] = mapped_column(\n BigInteger, unique=True, nullable=True\n )\n full_name: Mapped[str] = mapped_column(Text, nullable=False)\n username: Mapped[str | None] = mapped_column(Text, nullable=True)\n first_name: Mapped[str | None] = mapped_column(Text, nullable=True)\n last_name: Mapped[str | None] = mapped_column(Text, nullable=True)\n phone: Mapped[str | None] = mapped_column(Text, nullable=True)\n name_manually_edited: Mapped[bool] = mapped_column(\n Boolean, nullable=False, server_default=\"0\", default=False\n )\n\n duties: Mapped[list[\"Duty\"]] = relationship(\"Duty\", back_populates=\"user\")\n"},{"location":"api-reference/#duty_teller.db.schemas","title":"duty_teller.db.schemas","text":"Pydantic schemas for API request/response and validation.
"},{"location":"api-reference/#duty_teller.db.schemas.CalendarEvent","title":"CalendarEvent","text":" Bases: BaseModel
External calendar event (e.g. holiday) for a single day.
Source code induty_teller/db/schemas.py class CalendarEvent(BaseModel):\n \"\"\"External calendar event (e.g. holiday) for a single day.\"\"\"\n\n date: str # YYYY-MM-DD\n summary: str\n"},{"location":"api-reference/#duty_teller.db.schemas.DutyBase","title":"DutyBase","text":" Bases: BaseModel
Duty fields: user_id, start_at, end_at (UTC ISO 8601 with Z).
Source code induty_teller/db/schemas.py class DutyBase(BaseModel):\n \"\"\"Duty fields: user_id, start_at, end_at (UTC ISO 8601 with Z).\"\"\"\n\n user_id: int\n start_at: str # UTC, ISO 8601 with Z\n end_at: str # UTC, ISO 8601 with Z\n"},{"location":"api-reference/#duty_teller.db.schemas.DutyCreate","title":"DutyCreate","text":" Bases: DutyBase
Duty creation payload.
Source code induty_teller/db/schemas.py class DutyCreate(DutyBase):\n \"\"\"Duty creation payload.\"\"\"\n\n pass\n"},{"location":"api-reference/#duty_teller.db.schemas.DutyInDb","title":"DutyInDb","text":" Bases: DutyBase
Duty as stored in DB (includes id).
Source code induty_teller/db/schemas.py class DutyInDb(DutyBase):\n \"\"\"Duty as stored in DB (includes id).\"\"\"\n\n id: int\n\n model_config = ConfigDict(from_attributes=True)\n"},{"location":"api-reference/#duty_teller.db.schemas.DutyWithUser","title":"DutyWithUser","text":" Bases: DutyInDb
Duty with full_name and event_type for calendar display.
event_type: only these values are returned; unknown DB values are mapped to \"duty\" in the API.
Source code induty_teller/db/schemas.py class DutyWithUser(DutyInDb):\n \"\"\"Duty with full_name and event_type for calendar display.\n\n event_type: only these values are returned; unknown DB values are mapped to \"duty\" in the API.\n \"\"\"\n\n full_name: str\n event_type: Literal[\"duty\", \"unavailable\", \"vacation\"] = \"duty\"\n\n model_config = ConfigDict(from_attributes=True)\n"},{"location":"api-reference/#duty_teller.db.schemas.UserBase","title":"UserBase","text":" Bases: BaseModel
Base user fields (full_name, username, first/last name).
Source code induty_teller/db/schemas.py class UserBase(BaseModel):\n \"\"\"Base user fields (full_name, username, first/last name).\"\"\"\n\n full_name: str\n username: str | None = None\n first_name: str | None = None\n last_name: str | None = None\n"},{"location":"api-reference/#duty_teller.db.schemas.UserCreate","title":"UserCreate","text":" Bases: UserBase
User creation payload including Telegram user id.
Source code induty_teller/db/schemas.py class UserCreate(UserBase):\n \"\"\"User creation payload including Telegram user id.\"\"\"\n\n telegram_user_id: int\n"},{"location":"api-reference/#duty_teller.db.schemas.UserInDb","title":"UserInDb","text":" Bases: UserBase
User as stored in DB (includes id and telegram_user_id).
Source code induty_teller/db/schemas.py class UserInDb(UserBase):\n \"\"\"User as stored in DB (includes id and telegram_user_id).\"\"\"\n\n id: int\n telegram_user_id: int\n\n model_config = ConfigDict(from_attributes=True)\n"},{"location":"api-reference/#duty_teller.db.session","title":"duty_teller.db.session","text":"SQLAlchemy engine and session factory.
Engine and session factory are cached globally per process. Only one DATABASE_URL is effectively used for the process lifetime. Using a different URL later (e.g. in tests with in-memory SQLite) would still use the first engine. To use a different URL in tests, set env (e.g. DATABASE_URL) before the first import of this module, or clear _engine and _SessionLocal in test fixtures. Prefer session_scope() for all callers so sessions are always closed and rolled back on error.
"},{"location":"api-reference/#duty_teller.db.session.get_engine","title":"get_engine(database_url)","text":"Return cached SQLAlchemy engine for the given URL (one per process).
Source code induty_teller/db/session.py def get_engine(database_url: str):\n \"\"\"Return cached SQLAlchemy engine for the given URL (one per process).\"\"\"\n global _engine\n if _engine is None:\n _engine = create_engine(\n database_url,\n connect_args={\"check_same_thread\": False}\n if \"sqlite\" in database_url\n else {},\n echo=False,\n )\n return _engine\n"},{"location":"api-reference/#duty_teller.db.session.get_session","title":"get_session(database_url)","text":"Create a new session from the factory for the given URL.
Source code induty_teller/db/session.py def get_session(database_url: str) -> Session:\n \"\"\"Create a new session from the factory for the given URL.\"\"\"\n return get_session_factory(database_url)()\n"},{"location":"api-reference/#duty_teller.db.session.get_session_factory","title":"get_session_factory(database_url)","text":"Return cached session factory for the given URL (one per process).
Source code induty_teller/db/session.py def get_session_factory(database_url: str) -> sessionmaker[Session]:\n \"\"\"Return cached session factory for the given URL (one per process).\"\"\"\n global _SessionLocal\n if _SessionLocal is None:\n engine = get_engine(database_url)\n _SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)\n return _SessionLocal\n"},{"location":"api-reference/#duty_teller.db.session.session_scope","title":"session_scope(database_url)","text":"Context manager that yields a session; rolls back on exception, closes on exit.
Parameters:
Name Type Description Defaultdatabase_url str SQLAlchemy database URL.
requiredYields:
Type DescriptionSession Session instance. Caller must not use it after exit.
Source code induty_teller/db/session.py @contextmanager\ndef session_scope(database_url: str) -> Generator[Session, None, None]:\n \"\"\"Context manager that yields a session; rolls back on exception, closes on exit.\n\n Args:\n database_url: SQLAlchemy database URL.\n\n Yields:\n Session instance. Caller must not use it after exit.\n \"\"\"\n session = get_session(database_url)\n try:\n yield session\n except Exception:\n session.rollback()\n raise\n finally:\n session.close()\n"},{"location":"api-reference/#duty_teller.db.repository","title":"duty_teller.db.repository","text":"Repository: get_or_create_user, get_duties, insert_duty, get_current_duty, group_duty_pins.
"},{"location":"api-reference/#duty_teller.db.repository.create_calendar_token","title":"create_calendar_token(session, user_id)","text":"Create a new calendar subscription token for the user.
Any existing tokens for this user are removed. The raw token is returned only once (not stored in plain text).
Parameters:
Name Type Description Defaultsession Session DB session.
requireduser_id int User id.
requiredReturns:
Type Descriptionstr Raw token string (e.g. for URL /api/calendar/ical/{token}.ics).
Source code induty_teller/db/repository.py def create_calendar_token(session: Session, user_id: int) -> str:\n \"\"\"Create a new calendar subscription token for the user.\n\n Any existing tokens for this user are removed. The raw token is returned\n only once (not stored in plain text).\n\n Args:\n session: DB session.\n user_id: User id.\n\n Returns:\n Raw token string (e.g. for URL /api/calendar/ical/{token}.ics).\n \"\"\"\n session.query(CalendarSubscriptionToken).filter(\n CalendarSubscriptionToken.user_id == user_id\n ).delete(synchronize_session=False)\n raw_token = secrets.token_urlsafe(32)\n token_hash_val = _token_hash(raw_token)\n now_iso = datetime.now(timezone.utc).strftime(\"%Y-%m-%dT%H:%M:%SZ\")\n record = CalendarSubscriptionToken(\n user_id=user_id,\n token_hash=token_hash_val,\n created_at=now_iso,\n )\n session.add(record)\n session.commit()\n return raw_token\n"},{"location":"api-reference/#duty_teller.db.repository.delete_duties_in_range","title":"delete_duties_in_range(session, user_id, from_date, to_date)","text":"Delete all duties of the user that overlap the given date range.
Parameters:
Name Type Description Defaultsession Session DB session.
requireduser_id int User id.
requiredfrom_date str Start date YYYY-MM-DD.
requiredto_date str End date YYYY-MM-DD.
requiredReturns:
Type Descriptionint Number of duties deleted.
Source code induty_teller/db/repository.py def delete_duties_in_range(\n session: Session,\n user_id: int,\n from_date: str,\n to_date: str,\n) -> int:\n \"\"\"Delete all duties of the user that overlap the given date range.\n\n Args:\n session: DB session.\n user_id: User id.\n from_date: Start date YYYY-MM-DD.\n to_date: End date YYYY-MM-DD.\n\n Returns:\n Number of duties deleted.\n \"\"\"\n to_next = (\n datetime.fromisoformat(to_date + \"T00:00:00\") + timedelta(days=1)\n ).strftime(\"%Y-%m-%d\")\n q = session.query(Duty).filter(\n Duty.user_id == user_id,\n Duty.start_at < to_next,\n Duty.end_at >= from_date,\n )\n count = q.count()\n q.delete(synchronize_session=False)\n session.commit()\n return count\n"},{"location":"api-reference/#duty_teller.db.repository.delete_group_duty_pin","title":"delete_group_duty_pin(session, chat_id)","text":"Remove the pinned duty message record for the chat (e.g. when bot leaves group).
Parameters:
Name Type Description Defaultsession Session DB session.
requiredchat_id int Telegram chat id.
required Source code induty_teller/db/repository.py def delete_group_duty_pin(session: Session, chat_id: int) -> None:\n \"\"\"Remove the pinned duty message record for the chat (e.g. when bot leaves group).\n\n Args:\n session: DB session.\n chat_id: Telegram chat id.\n \"\"\"\n session.query(GroupDutyPin).filter(GroupDutyPin.chat_id == chat_id).delete()\n session.commit()\n"},{"location":"api-reference/#duty_teller.db.repository.get_all_group_duty_pin_chat_ids","title":"get_all_group_duty_pin_chat_ids(session)","text":"Return all chat_ids that have a pinned duty message.
Used to restore update jobs on bot startup.
Parameters:
Name Type Description Defaultsession Session DB session.
requiredReturns:
Type Descriptionlist[int] List of chat ids.
Source code induty_teller/db/repository.py def get_all_group_duty_pin_chat_ids(session: Session) -> list[int]:\n \"\"\"Return all chat_ids that have a pinned duty message.\n\n Used to restore update jobs on bot startup.\n\n Args:\n session: DB session.\n\n Returns:\n List of chat ids.\n \"\"\"\n rows = session.query(GroupDutyPin.chat_id).all()\n return [r[0] for r in rows]\n"},{"location":"api-reference/#duty_teller.db.repository.get_current_duty","title":"get_current_duty(session, at_utc)","text":"Return the duty and user active at the given UTC time (event_type='duty').
Parameters:
Name Type Description Defaultsession Session DB session.
requiredat_utc datetime Point in time (timezone-aware or naive UTC).
requiredReturns:
Type Descriptiontuple[Duty, User] | None (Duty, User) or None if no duty at that time.
Source code induty_teller/db/repository.py def get_current_duty(session: Session, at_utc: datetime) -> tuple[Duty, User] | None:\n \"\"\"Return the duty and user active at the given UTC time (event_type='duty').\n\n Args:\n session: DB session.\n at_utc: Point in time (timezone-aware or naive UTC).\n\n Returns:\n (Duty, User) or None if no duty at that time.\n \"\"\"\n from datetime import timezone\n\n if at_utc.tzinfo is not None:\n at_utc = at_utc.astimezone(timezone.utc)\n now_iso = at_utc.strftime(\"%Y-%m-%dT%H:%M:%S\") + \"Z\"\n row = (\n session.query(Duty, User)\n .join(User, Duty.user_id == User.id)\n .filter(\n Duty.event_type == \"duty\",\n Duty.start_at <= now_iso,\n Duty.end_at > now_iso,\n )\n .first()\n )\n if row is None:\n return None\n return (row[0], row[1])\n"},{"location":"api-reference/#duty_teller.db.repository.get_duties","title":"get_duties(session, from_date, to_date)","text":"Return duties overlapping the given date range with user full_name.
Parameters:
Name Type Description Defaultsession Session DB session.
requiredfrom_date str Start date YYYY-MM-DD.
requiredto_date str End date YYYY-MM-DD.
requiredReturns:
Type Descriptionlist[tuple[Duty, str]] List of (Duty, full_name) tuples.
Source code induty_teller/db/repository.py def get_duties(\n session: Session,\n from_date: str,\n to_date: str,\n) -> list[tuple[Duty, str]]:\n \"\"\"Return duties overlapping the given date range with user full_name.\n\n Args:\n session: DB session.\n from_date: Start date YYYY-MM-DD.\n to_date: End date YYYY-MM-DD.\n\n Returns:\n List of (Duty, full_name) tuples.\n \"\"\"\n to_date_next = (\n datetime.fromisoformat(to_date + \"T00:00:00\") + timedelta(days=1)\n ).strftime(\"%Y-%m-%d\")\n q = (\n session.query(Duty, User.full_name)\n .join(User, Duty.user_id == User.id)\n .filter(Duty.start_at < to_date_next, Duty.end_at >= from_date)\n )\n return list(q.all())\n"},{"location":"api-reference/#duty_teller.db.repository.get_duties_for_user","title":"get_duties_for_user(session, user_id, from_date, to_date)","text":"Return duties for one user overlapping the date range.
Parameters:
Name Type Description Defaultsession Session DB session.
requireduser_id int User id.
requiredfrom_date str Start date YYYY-MM-DD.
requiredto_date str End date YYYY-MM-DD.
requiredReturns:
Type Descriptionlist[tuple[Duty, str]] List of (Duty, full_name) tuples.
Source code induty_teller/db/repository.py def get_duties_for_user(\n session: Session,\n user_id: int,\n from_date: str,\n to_date: str,\n) -> list[tuple[Duty, str]]:\n \"\"\"Return duties for one user overlapping the date range.\n\n Args:\n session: DB session.\n user_id: User id.\n from_date: Start date YYYY-MM-DD.\n to_date: End date YYYY-MM-DD.\n\n Returns:\n List of (Duty, full_name) tuples.\n \"\"\"\n to_date_next = (\n datetime.fromisoformat(to_date + \"T00:00:00\") + timedelta(days=1)\n ).strftime(\"%Y-%m-%d\")\n q = (\n session.query(Duty, User.full_name)\n .join(User, Duty.user_id == User.id)\n .filter(\n Duty.user_id == user_id,\n Duty.start_at < to_date_next,\n Duty.end_at >= from_date,\n )\n )\n return list(q.all())\n"},{"location":"api-reference/#duty_teller.db.repository.get_group_duty_pin","title":"get_group_duty_pin(session, chat_id)","text":"Get the pinned duty message record for a chat.
Parameters:
Name Type Description Defaultsession Session DB session.
requiredchat_id int Telegram chat id.
requiredReturns:
Type DescriptionGroupDutyPin | None GroupDutyPin or None.
Source code induty_teller/db/repository.py def get_group_duty_pin(session: Session, chat_id: int) -> GroupDutyPin | None:\n \"\"\"Get the pinned duty message record for a chat.\n\n Args:\n session: DB session.\n chat_id: Telegram chat id.\n\n Returns:\n GroupDutyPin or None.\n \"\"\"\n return session.query(GroupDutyPin).filter(GroupDutyPin.chat_id == chat_id).first()\n"},{"location":"api-reference/#duty_teller.db.repository.get_next_shift_end","title":"get_next_shift_end(session, after_utc)","text":"Return the end_at of the current or next duty (event_type='duty').
Parameters:
Name Type Description Defaultsession Session DB session.
requiredafter_utc datetime Point in time (timezone-aware or naive UTC).
requiredReturns:
Type Descriptiondatetime | None End datetime (naive UTC) or None if no current or future duty.
Source code induty_teller/db/repository.py def get_next_shift_end(session: Session, after_utc: datetime) -> datetime | None:\n \"\"\"Return the end_at of the current or next duty (event_type='duty').\n\n Args:\n session: DB session.\n after_utc: Point in time (timezone-aware or naive UTC).\n\n Returns:\n End datetime (naive UTC) or None if no current or future duty.\n \"\"\"\n from datetime import timezone\n\n if after_utc.tzinfo is not None:\n after_utc = after_utc.astimezone(timezone.utc)\n after_iso = after_utc.strftime(\"%Y-%m-%dT%H:%M:%S\") + \"Z\"\n current = (\n session.query(Duty)\n .filter(\n Duty.event_type == \"duty\",\n Duty.start_at <= after_iso,\n Duty.end_at > after_iso,\n )\n .first()\n )\n if current:\n return datetime.fromisoformat(current.end_at.replace(\"Z\", \"+00:00\")).replace(\n tzinfo=None\n )\n next_duty = (\n session.query(Duty)\n .filter(Duty.event_type == \"duty\", Duty.start_at > after_iso)\n .order_by(Duty.start_at)\n .first()\n )\n if next_duty:\n return datetime.fromisoformat(next_duty.end_at.replace(\"Z\", \"+00:00\")).replace(\n tzinfo=None\n )\n return None\n"},{"location":"api-reference/#duty_teller.db.repository.get_or_create_user","title":"get_or_create_user(session, telegram_user_id, full_name, username=None, first_name=None, last_name=None)","text":"Get or create user by Telegram user ID.
On create, name fields come from Telegram. On update: username is always synced; full_name, first_name, last_name are updated only if name_manually_edited is False (otherwise existing display name is kept).
Parameters:
Name Type Description Defaultsession Session DB session.
requiredtelegram_user_id int Telegram user id.
requiredfull_name str Display full name.
requiredusername str | None Telegram username (optional).
None first_name str | None Telegram first name (optional).
None last_name str | None Telegram last name (optional).
None Returns:
Type DescriptionUser User instance (created or updated).
Source code induty_teller/db/repository.py def get_or_create_user(\n session: Session,\n telegram_user_id: int,\n full_name: str,\n username: str | None = None,\n first_name: str | None = None,\n last_name: str | None = None,\n) -> User:\n \"\"\"Get or create user by Telegram user ID.\n\n On create, name fields come from Telegram. On update: username is always\n synced; full_name, first_name, last_name are updated only if\n name_manually_edited is False (otherwise existing display name is kept).\n\n Args:\n session: DB session.\n telegram_user_id: Telegram user id.\n full_name: Display full name.\n username: Telegram username (optional).\n first_name: Telegram first name (optional).\n last_name: Telegram last name (optional).\n\n Returns:\n User instance (created or updated).\n \"\"\"\n user = get_user_by_telegram_id(session, telegram_user_id)\n if user:\n user.username = username\n if not user.name_manually_edited:\n user.full_name = full_name\n user.first_name = first_name\n user.last_name = last_name\n session.commit()\n session.refresh(user)\n return user\n user = User(\n telegram_user_id=telegram_user_id,\n full_name=full_name,\n username=username,\n first_name=first_name,\n last_name=last_name,\n name_manually_edited=False,\n )\n session.add(user)\n session.commit()\n session.refresh(user)\n return user\n"},{"location":"api-reference/#duty_teller.db.repository.get_or_create_user_by_full_name","title":"get_or_create_user_by_full_name(session, full_name)","text":"Find user by exact full_name or create one (for duty-schedule import).
New users have telegram_user_id=None and name_manually_edited=True.
Parameters:
Name Type Description Defaultsession Session DB session.
requiredfull_name str Exact full name to match or set.
requiredReturns:
Type DescriptionUser User instance (existing or newly created).
Source code induty_teller/db/repository.py def get_or_create_user_by_full_name(session: Session, full_name: str) -> User:\n \"\"\"Find user by exact full_name or create one (for duty-schedule import).\n\n New users have telegram_user_id=None and name_manually_edited=True.\n\n Args:\n session: DB session.\n full_name: Exact full name to match or set.\n\n Returns:\n User instance (existing or newly created).\n \"\"\"\n user = session.query(User).filter(User.full_name == full_name).first()\n if user:\n return user\n user = User(\n telegram_user_id=None,\n full_name=full_name,\n username=None,\n first_name=None,\n last_name=None,\n name_manually_edited=True,\n )\n session.add(user)\n session.commit()\n session.refresh(user)\n return user\n"},{"location":"api-reference/#duty_teller.db.repository.get_user_by_calendar_token","title":"get_user_by_calendar_token(session, token)","text":"Find user by calendar subscription token.
Uses constant-time comparison to avoid timing leaks.
Parameters:
Name Type Description Defaultsession Session DB session.
requiredtoken str Raw token from URL.
requiredReturns:
Type DescriptionUser | None User or None if token is invalid or not found.
Source code induty_teller/db/repository.py def get_user_by_calendar_token(session: Session, token: str) -> User | None:\n \"\"\"Find user by calendar subscription token.\n\n Uses constant-time comparison to avoid timing leaks.\n\n Args:\n session: DB session.\n token: Raw token from URL.\n\n Returns:\n User or None if token is invalid or not found.\n \"\"\"\n token_hash_val = _token_hash(token)\n row = (\n session.query(CalendarSubscriptionToken, User)\n .join(User, CalendarSubscriptionToken.user_id == User.id)\n .filter(CalendarSubscriptionToken.token_hash == token_hash_val)\n .first()\n )\n if row is None:\n return None\n # Constant-time compare to avoid timing leaks (token_hash is already hashed).\n if not hmac.compare_digest(row[0].token_hash, token_hash_val):\n return None\n return row[1]\n"},{"location":"api-reference/#duty_teller.db.repository.get_user_by_telegram_id","title":"get_user_by_telegram_id(session, telegram_user_id)","text":"Find user by Telegram user ID.
Parameters:
Name Type Description Defaultsession Session DB session.
requiredtelegram_user_id int Telegram user id.
requiredReturns:
Type DescriptionUser | None User or None if not found. Does not create a user.
Source code induty_teller/db/repository.py def get_user_by_telegram_id(session: Session, telegram_user_id: int) -> User | None:\n \"\"\"Find user by Telegram user ID.\n\n Args:\n session: DB session.\n telegram_user_id: Telegram user id.\n\n Returns:\n User or None if not found. Does not create a user.\n \"\"\"\n return session.query(User).filter(User.telegram_user_id == telegram_user_id).first()\n"},{"location":"api-reference/#duty_teller.db.repository.insert_duty","title":"insert_duty(session, user_id, start_at, end_at, event_type='duty')","text":"Create a duty record.
Parameters:
Name Type Description Defaultsession Session DB session.
requireduser_id int User id.
requiredstart_at str Start time UTC, ISO 8601 with Z (e.g. 2025-01-15T09:00:00Z).
requiredend_at str End time UTC, ISO 8601 with Z.
requiredevent_type str One of \"duty\", \"unavailable\", \"vacation\". Default \"duty\".
'duty' Returns:
Type DescriptionDuty Created Duty instance.
Source code induty_teller/db/repository.py def insert_duty(\n session: Session,\n user_id: int,\n start_at: str,\n end_at: str,\n event_type: str = \"duty\",\n) -> Duty:\n \"\"\"Create a duty record.\n\n Args:\n session: DB session.\n user_id: User id.\n start_at: Start time UTC, ISO 8601 with Z (e.g. 2025-01-15T09:00:00Z).\n end_at: End time UTC, ISO 8601 with Z.\n event_type: One of \"duty\", \"unavailable\", \"vacation\". Default \"duty\".\n\n Returns:\n Created Duty instance.\n \"\"\"\n duty = Duty(\n user_id=user_id,\n start_at=start_at,\n end_at=end_at,\n event_type=event_type,\n )\n session.add(duty)\n session.commit()\n session.refresh(duty)\n return duty\n"},{"location":"api-reference/#duty_teller.db.repository.save_group_duty_pin","title":"save_group_duty_pin(session, chat_id, message_id)","text":"Save or update the pinned duty message for a chat.
Parameters:
Name Type Description Defaultsession Session DB session.
requiredchat_id int Telegram chat id.
requiredmessage_id int Message id to pin/update.
requiredReturns:
Type DescriptionGroupDutyPin GroupDutyPin instance (created or updated).
Source code induty_teller/db/repository.py def save_group_duty_pin(\n session: Session, chat_id: int, message_id: int\n) -> GroupDutyPin:\n \"\"\"Save or update the pinned duty message for a chat.\n\n Args:\n session: DB session.\n chat_id: Telegram chat id.\n message_id: Message id to pin/update.\n\n Returns:\n GroupDutyPin instance (created or updated).\n \"\"\"\n pin = session.query(GroupDutyPin).filter(GroupDutyPin.chat_id == chat_id).first()\n if pin:\n pin.message_id = message_id\n else:\n pin = GroupDutyPin(chat_id=chat_id, message_id=message_id)\n session.add(pin)\n session.commit()\n session.refresh(pin)\n return pin\n"},{"location":"api-reference/#duty_teller.db.repository.set_user_phone","title":"set_user_phone(session, telegram_user_id, phone)","text":"Set or clear phone for user by Telegram user id.
Parameters:
Name Type Description Defaultsession Session DB session.
requiredtelegram_user_id int Telegram user id.
requiredphone str | None Phone string or None to clear.
requiredReturns:
Type DescriptionUser | None Updated User or None if not found.
Source code induty_teller/db/repository.py def set_user_phone(\n session: Session, telegram_user_id: int, phone: str | None\n) -> User | None:\n \"\"\"Set or clear phone for user by Telegram user id.\n\n Args:\n session: DB session.\n telegram_user_id: Telegram user id.\n phone: Phone string or None to clear.\n\n Returns:\n Updated User or None if not found.\n \"\"\"\n user = session.query(User).filter(User.telegram_user_id == telegram_user_id).first()\n if not user:\n return None\n user.phone = phone\n session.commit()\n session.refresh(user)\n return user\n"},{"location":"api-reference/#duty_teller.db.repository.update_user_display_name","title":"update_user_display_name(session, telegram_user_id, full_name, first_name=None, last_name=None)","text":"Update display name and set name_manually_edited=True.
Use from API or admin when name is changed manually; subsequent get_or_create_user will not overwrite these fields.
Parameters:
Name Type Description Defaultsession Session DB session.
requiredtelegram_user_id int Telegram user id.
requiredfull_name str New full name.
requiredfirst_name str | None New first name (optional).
None last_name str | None New last name (optional).
None Returns:
Type DescriptionUser | None Updated User or None if not found.
Source code induty_teller/db/repository.py def update_user_display_name(\n session: Session,\n telegram_user_id: int,\n full_name: str,\n first_name: str | None = None,\n last_name: str | None = None,\n) -> User | None:\n \"\"\"Update display name and set name_manually_edited=True.\n\n Use from API or admin when name is changed manually; subsequent\n get_or_create_user will not overwrite these fields.\n\n Args:\n session: DB session.\n telegram_user_id: Telegram user id.\n full_name: New full name.\n first_name: New first name (optional).\n last_name: New last name (optional).\n\n Returns:\n Updated User or None if not found.\n \"\"\"\n user = session.query(User).filter(User.telegram_user_id == telegram_user_id).first()\n if not user:\n return None\n user.full_name = full_name\n user.first_name = first_name\n user.last_name = last_name\n user.name_manually_edited = True\n session.commit()\n session.refresh(user)\n return user\n"},{"location":"api-reference/#services","title":"Services","text":""},{"location":"api-reference/#duty_teller.services","title":"duty_teller.services","text":"Service layer: business logic and orchestration.
"},{"location":"api-reference/#duty_teller.services.delete_pin","title":"delete_pin(session, chat_id)","text":"Remove the pinned message record for the chat (e.g. when bot leaves).
Parameters:
Name Type Description Defaultsession Session DB session.
requiredchat_id int Telegram chat id.
required Source code induty_teller/services/group_duty_pin_service.py def delete_pin(session: Session, chat_id: int) -> None:\n \"\"\"Remove the pinned message record for the chat (e.g. when bot leaves).\n\n Args:\n session: DB session.\n chat_id: Telegram chat id.\n \"\"\"\n delete_group_duty_pin(session, chat_id)\n"},{"location":"api-reference/#duty_teller.services.format_duty_message","title":"format_duty_message(duty, user, tz_name, lang='en')","text":"Build the text for the pinned duty message.
Parameters:
Name Type Description Defaultduty Duty instance or None.
requireduser User instance or None.
requiredtz_name str Timezone name for display (e.g. Europe/Moscow).
requiredlang str Language code for i18n ('ru' or 'en').
'en' Returns:
Type Descriptionstr Formatted message string; \"No duty\" if duty or user is None.
Source code induty_teller/services/group_duty_pin_service.py def format_duty_message(duty, user, tz_name: str, lang: str = \"en\") -> str:\n \"\"\"Build the text for the pinned duty message.\n\n Args:\n duty: Duty instance or None.\n user: User instance or None.\n tz_name: Timezone name for display (e.g. Europe/Moscow).\n lang: Language code for i18n ('ru' or 'en').\n\n Returns:\n Formatted message string; \"No duty\" if duty or user is None.\n \"\"\"\n if duty is None or user is None:\n return t(lang, \"duty.no_duty\")\n try:\n tz = ZoneInfo(tz_name)\n except Exception:\n tz = ZoneInfo(\"Europe/Moscow\")\n tz_name = \"Europe/Moscow\"\n start_dt = datetime.fromisoformat(duty.start_at.replace(\"Z\", \"+00:00\"))\n end_dt = datetime.fromisoformat(duty.end_at.replace(\"Z\", \"+00:00\"))\n start_local = start_dt.astimezone(tz)\n end_local = end_dt.astimezone(tz)\n offset_sec = (\n start_local.utcoffset().total_seconds() if start_local.utcoffset() else 0\n )\n sign = \"+\" if offset_sec >= 0 else \"-\"\n h, r = divmod(abs(int(offset_sec)), 3600)\n m = r // 60\n tz_hint = f\"UTC{sign}{h:d}:{m:02d}, {tz_name}\"\n time_range = (\n f\"{start_local.strftime('%d.%m.%Y %H:%M')} \u2014 \"\n f\"{end_local.strftime('%d.%m.%Y %H:%M')} ({tz_hint})\"\n )\n label = t(lang, \"duty.label\")\n lines = [\n f\"\ud83d\udd50 {label} {time_range}\",\n f\"\ud83d\udc64 {user.full_name}\",\n ]\n if user.phone:\n lines.append(f\"\ud83d\udcde {user.phone}\")\n if user.username:\n lines.append(f\"@{user.username}\")\n return \"\\n\".join(lines)\n"},{"location":"api-reference/#duty_teller.services.get_all_pin_chat_ids","title":"get_all_pin_chat_ids(session)","text":"Return all chat_ids that have a pinned duty message.
Used to restore update jobs on bot startup.
Parameters:
Name Type Description Defaultsession Session DB session.
requiredReturns:
Type Descriptionlist[int] List of chat ids.
Source code induty_teller/services/group_duty_pin_service.py def get_all_pin_chat_ids(session: Session) -> list[int]:\n \"\"\"Return all chat_ids that have a pinned duty message.\n\n Used to restore update jobs on bot startup.\n\n Args:\n session: DB session.\n\n Returns:\n List of chat ids.\n \"\"\"\n return get_all_group_duty_pin_chat_ids(session)\n"},{"location":"api-reference/#duty_teller.services.get_duty_message_text","title":"get_duty_message_text(session, tz_name, lang='en')","text":"Get current duty from DB and return formatted message text.
Parameters:
Name Type Description Defaultsession Session DB session.
requiredtz_name str Timezone name for display.
requiredlang str Language code for i18n.
'en' Returns:
Type Descriptionstr Formatted duty message or \"No duty\" if none.
Source code induty_teller/services/group_duty_pin_service.py def get_duty_message_text(session: Session, tz_name: str, lang: str = \"en\") -> str:\n \"\"\"Get current duty from DB and return formatted message text.\n\n Args:\n session: DB session.\n tz_name: Timezone name for display.\n lang: Language code for i18n.\n\n Returns:\n Formatted duty message or \"No duty\" if none.\n \"\"\"\n now = datetime.now(timezone.utc)\n result = get_current_duty(session, now)\n if result is None:\n return t(lang, \"duty.no_duty\")\n duty, user = result\n return format_duty_message(duty, user, tz_name, lang)\n"},{"location":"api-reference/#duty_teller.services.get_message_id","title":"get_message_id(session, chat_id)","text":"Return message_id for the pinned duty message in this chat.
Parameters:
Name Type Description Defaultsession Session DB session.
requiredchat_id int Telegram chat id.
requiredReturns:
Type Descriptionint | None Message id or None if no pin record.
Source code induty_teller/services/group_duty_pin_service.py def get_message_id(session: Session, chat_id: int) -> int | None:\n \"\"\"Return message_id for the pinned duty message in this chat.\n\n Args:\n session: DB session.\n chat_id: Telegram chat id.\n\n Returns:\n Message id or None if no pin record.\n \"\"\"\n pin = get_group_duty_pin(session, chat_id)\n return pin.message_id if pin else None\n"},{"location":"api-reference/#duty_teller.services.get_next_shift_end_utc","title":"get_next_shift_end_utc(session)","text":"Return next shift end as naive UTC datetime for job scheduling.
Parameters:
Name Type Description Defaultsession Session DB session.
requiredReturns:
Type Descriptiondatetime | None Next shift end (naive UTC) or None.
Source code induty_teller/services/group_duty_pin_service.py def get_next_shift_end_utc(session: Session) -> datetime | None:\n \"\"\"Return next shift end as naive UTC datetime for job scheduling.\n\n Args:\n session: DB session.\n\n Returns:\n Next shift end (naive UTC) or None.\n \"\"\"\n return get_next_shift_end(session, datetime.now(timezone.utc))\n"},{"location":"api-reference/#duty_teller.services.run_import","title":"run_import(session, result, hour_utc, minute_utc)","text":"Run duty-schedule import: delete range per user, insert duty/unavailable/vacation.
For each entry: get_or_create_user_by_full_name, delete_duties_in_range for the result date range, then insert duties (handover time in UTC), unavailable (all-day), and vacation (consecutive ranges).
Parameters:
Name Type Description Defaultsession Session DB session.
requiredresult DutyScheduleResult Parsed duty schedule (start_date, end_date, entries).
requiredhour_utc int Handover hour in UTC (0-23).
requiredminute_utc int Handover minute in UTC (0-59).
requiredReturns:
Type Descriptiontuple[int, int, int, int] Tuple (num_users, num_duty, num_unavailable, num_vacation).
Source code induty_teller/services/import_service.py def run_import(\n session: Session,\n result: DutyScheduleResult,\n hour_utc: int,\n minute_utc: int,\n) -> tuple[int, int, int, int]:\n \"\"\"Run duty-schedule import: delete range per user, insert duty/unavailable/vacation.\n\n For each entry: get_or_create_user_by_full_name, delete_duties_in_range for\n the result date range, then insert duties (handover time in UTC), unavailable\n (all-day), and vacation (consecutive ranges).\n\n Args:\n session: DB session.\n result: Parsed duty schedule (start_date, end_date, entries).\n hour_utc: Handover hour in UTC (0-23).\n minute_utc: Handover minute in UTC (0-59).\n\n Returns:\n Tuple (num_users, num_duty, num_unavailable, num_vacation).\n \"\"\"\n from_date_str = result.start_date.isoformat()\n to_date_str = result.end_date.isoformat()\n num_duty = num_unavailable = num_vacation = 0\n for entry in result.entries:\n user = get_or_create_user_by_full_name(session, entry.full_name)\n delete_duties_in_range(session, user.id, from_date_str, to_date_str)\n for d in entry.duty_dates:\n start_at = duty_to_iso(d, hour_utc, minute_utc)\n d_next = d + timedelta(days=1)\n end_at = duty_to_iso(d_next, hour_utc, minute_utc)\n insert_duty(session, user.id, start_at, end_at, event_type=\"duty\")\n num_duty += 1\n for d in entry.unavailable_dates:\n insert_duty(\n session,\n user.id,\n day_start_iso(d),\n day_end_iso(d),\n event_type=\"unavailable\",\n )\n num_unavailable += 1\n for start_d, end_d in _consecutive_date_ranges(entry.vacation_dates):\n insert_duty(\n session,\n user.id,\n day_start_iso(start_d),\n day_end_iso(end_d),\n event_type=\"vacation\",\n )\n num_vacation += 1\n return (len(result.entries), num_duty, num_unavailable, num_vacation)\n"},{"location":"api-reference/#duty_teller.services.save_pin","title":"save_pin(session, chat_id, message_id)","text":"Save or update the pinned duty message record for a chat.
Parameters:
Name Type Description Defaultsession Session DB session.
requiredchat_id int Telegram chat id.
requiredmessage_id int Message id to store.
required Source code induty_teller/services/group_duty_pin_service.py def save_pin(session: Session, chat_id: int, message_id: int) -> None:\n \"\"\"Save or update the pinned duty message record for a chat.\n\n Args:\n session: DB session.\n chat_id: Telegram chat id.\n message_id: Message id to store.\n \"\"\"\n save_group_duty_pin(session, chat_id, message_id)\n"},{"location":"api-reference/#duty_teller.services.import_service","title":"duty_teller.services.import_service","text":"Import duty schedule: delete range, insert duties/unavailable/vacation. Accepts session.
"},{"location":"api-reference/#duty_teller.services.import_service.run_import","title":"run_import(session, result, hour_utc, minute_utc)","text":"Run duty-schedule import: delete range per user, insert duty/unavailable/vacation.
For each entry: get_or_create_user_by_full_name, delete_duties_in_range for the result date range, then insert duties (handover time in UTC), unavailable (all-day), and vacation (consecutive ranges).
Parameters:
Name Type Description Defaultsession Session DB session.
requiredresult DutyScheduleResult Parsed duty schedule (start_date, end_date, entries).
requiredhour_utc int Handover hour in UTC (0-23).
requiredminute_utc int Handover minute in UTC (0-59).
requiredReturns:
Type Descriptiontuple[int, int, int, int] Tuple (num_users, num_duty, num_unavailable, num_vacation).
Source code induty_teller/services/import_service.py def run_import(\n session: Session,\n result: DutyScheduleResult,\n hour_utc: int,\n minute_utc: int,\n) -> tuple[int, int, int, int]:\n \"\"\"Run duty-schedule import: delete range per user, insert duty/unavailable/vacation.\n\n For each entry: get_or_create_user_by_full_name, delete_duties_in_range for\n the result date range, then insert duties (handover time in UTC), unavailable\n (all-day), and vacation (consecutive ranges).\n\n Args:\n session: DB session.\n result: Parsed duty schedule (start_date, end_date, entries).\n hour_utc: Handover hour in UTC (0-23).\n minute_utc: Handover minute in UTC (0-59).\n\n Returns:\n Tuple (num_users, num_duty, num_unavailable, num_vacation).\n \"\"\"\n from_date_str = result.start_date.isoformat()\n to_date_str = result.end_date.isoformat()\n num_duty = num_unavailable = num_vacation = 0\n for entry in result.entries:\n user = get_or_create_user_by_full_name(session, entry.full_name)\n delete_duties_in_range(session, user.id, from_date_str, to_date_str)\n for d in entry.duty_dates:\n start_at = duty_to_iso(d, hour_utc, minute_utc)\n d_next = d + timedelta(days=1)\n end_at = duty_to_iso(d_next, hour_utc, minute_utc)\n insert_duty(session, user.id, start_at, end_at, event_type=\"duty\")\n num_duty += 1\n for d in entry.unavailable_dates:\n insert_duty(\n session,\n user.id,\n day_start_iso(d),\n day_end_iso(d),\n event_type=\"unavailable\",\n )\n num_unavailable += 1\n for start_d, end_d in _consecutive_date_ranges(entry.vacation_dates):\n insert_duty(\n session,\n user.id,\n day_start_iso(start_d),\n day_end_iso(end_d),\n event_type=\"vacation\",\n )\n num_vacation += 1\n return (len(result.entries), num_duty, num_unavailable, num_vacation)\n"},{"location":"api-reference/#duty_teller.services.group_duty_pin_service","title":"duty_teller.services.group_duty_pin_service","text":"Group duty pin: current duty message text, next shift end, pin CRUD. All accept session.
"},{"location":"api-reference/#duty_teller.services.group_duty_pin_service.delete_pin","title":"delete_pin(session, chat_id)","text":"Remove the pinned message record for the chat (e.g. when bot leaves).
Parameters:
Name Type Description Defaultsession Session DB session.
requiredchat_id int Telegram chat id.
required Source code induty_teller/services/group_duty_pin_service.py def delete_pin(session: Session, chat_id: int) -> None:\n \"\"\"Remove the pinned message record for the chat (e.g. when bot leaves).\n\n Args:\n session: DB session.\n chat_id: Telegram chat id.\n \"\"\"\n delete_group_duty_pin(session, chat_id)\n"},{"location":"api-reference/#duty_teller.services.group_duty_pin_service.format_duty_message","title":"format_duty_message(duty, user, tz_name, lang='en')","text":"Build the text for the pinned duty message.
Parameters:
Name Type Description Defaultduty Duty instance or None.
requireduser User instance or None.
requiredtz_name str Timezone name for display (e.g. Europe/Moscow).
requiredlang str Language code for i18n ('ru' or 'en').
'en' Returns:
Type Descriptionstr Formatted message string; \"No duty\" if duty or user is None.
Source code induty_teller/services/group_duty_pin_service.py def format_duty_message(duty, user, tz_name: str, lang: str = \"en\") -> str:\n \"\"\"Build the text for the pinned duty message.\n\n Args:\n duty: Duty instance or None.\n user: User instance or None.\n tz_name: Timezone name for display (e.g. Europe/Moscow).\n lang: Language code for i18n ('ru' or 'en').\n\n Returns:\n Formatted message string; \"No duty\" if duty or user is None.\n \"\"\"\n if duty is None or user is None:\n return t(lang, \"duty.no_duty\")\n try:\n tz = ZoneInfo(tz_name)\n except Exception:\n tz = ZoneInfo(\"Europe/Moscow\")\n tz_name = \"Europe/Moscow\"\n start_dt = datetime.fromisoformat(duty.start_at.replace(\"Z\", \"+00:00\"))\n end_dt = datetime.fromisoformat(duty.end_at.replace(\"Z\", \"+00:00\"))\n start_local = start_dt.astimezone(tz)\n end_local = end_dt.astimezone(tz)\n offset_sec = (\n start_local.utcoffset().total_seconds() if start_local.utcoffset() else 0\n )\n sign = \"+\" if offset_sec >= 0 else \"-\"\n h, r = divmod(abs(int(offset_sec)), 3600)\n m = r // 60\n tz_hint = f\"UTC{sign}{h:d}:{m:02d}, {tz_name}\"\n time_range = (\n f\"{start_local.strftime('%d.%m.%Y %H:%M')} \u2014 \"\n f\"{end_local.strftime('%d.%m.%Y %H:%M')} ({tz_hint})\"\n )\n label = t(lang, \"duty.label\")\n lines = [\n f\"\ud83d\udd50 {label} {time_range}\",\n f\"\ud83d\udc64 {user.full_name}\",\n ]\n if user.phone:\n lines.append(f\"\ud83d\udcde {user.phone}\")\n if user.username:\n lines.append(f\"@{user.username}\")\n return \"\\n\".join(lines)\n"},{"location":"api-reference/#duty_teller.services.group_duty_pin_service.get_all_pin_chat_ids","title":"get_all_pin_chat_ids(session)","text":"Return all chat_ids that have a pinned duty message.
Used to restore update jobs on bot startup.
Parameters:
Name Type Description Defaultsession Session DB session.
requiredReturns:
Type Descriptionlist[int] List of chat ids.
Source code induty_teller/services/group_duty_pin_service.py def get_all_pin_chat_ids(session: Session) -> list[int]:\n \"\"\"Return all chat_ids that have a pinned duty message.\n\n Used to restore update jobs on bot startup.\n\n Args:\n session: DB session.\n\n Returns:\n List of chat ids.\n \"\"\"\n return get_all_group_duty_pin_chat_ids(session)\n"},{"location":"api-reference/#duty_teller.services.group_duty_pin_service.get_duty_message_text","title":"get_duty_message_text(session, tz_name, lang='en')","text":"Get current duty from DB and return formatted message text.
Parameters:
Name Type Description Defaultsession Session DB session.
requiredtz_name str Timezone name for display.
requiredlang str Language code for i18n.
'en' Returns:
Type Descriptionstr Formatted duty message or \"No duty\" if none.
Source code induty_teller/services/group_duty_pin_service.py def get_duty_message_text(session: Session, tz_name: str, lang: str = \"en\") -> str:\n \"\"\"Get current duty from DB and return formatted message text.\n\n Args:\n session: DB session.\n tz_name: Timezone name for display.\n lang: Language code for i18n.\n\n Returns:\n Formatted duty message or \"No duty\" if none.\n \"\"\"\n now = datetime.now(timezone.utc)\n result = get_current_duty(session, now)\n if result is None:\n return t(lang, \"duty.no_duty\")\n duty, user = result\n return format_duty_message(duty, user, tz_name, lang)\n"},{"location":"api-reference/#duty_teller.services.group_duty_pin_service.get_message_id","title":"get_message_id(session, chat_id)","text":"Return message_id for the pinned duty message in this chat.
Parameters:
Name Type Description Defaultsession Session DB session.
requiredchat_id int Telegram chat id.
requiredReturns:
Type Descriptionint | None Message id or None if no pin record.
Source code induty_teller/services/group_duty_pin_service.py def get_message_id(session: Session, chat_id: int) -> int | None:\n \"\"\"Return message_id for the pinned duty message in this chat.\n\n Args:\n session: DB session.\n chat_id: Telegram chat id.\n\n Returns:\n Message id or None if no pin record.\n \"\"\"\n pin = get_group_duty_pin(session, chat_id)\n return pin.message_id if pin else None\n"},{"location":"api-reference/#duty_teller.services.group_duty_pin_service.get_next_shift_end_utc","title":"get_next_shift_end_utc(session)","text":"Return next shift end as naive UTC datetime for job scheduling.
Parameters:
Name Type Description Defaultsession Session DB session.
requiredReturns:
Type Descriptiondatetime | None Next shift end (naive UTC) or None.
Source code induty_teller/services/group_duty_pin_service.py def get_next_shift_end_utc(session: Session) -> datetime | None:\n \"\"\"Return next shift end as naive UTC datetime for job scheduling.\n\n Args:\n session: DB session.\n\n Returns:\n Next shift end (naive UTC) or None.\n \"\"\"\n return get_next_shift_end(session, datetime.now(timezone.utc))\n"},{"location":"api-reference/#duty_teller.services.group_duty_pin_service.save_pin","title":"save_pin(session, chat_id, message_id)","text":"Save or update the pinned duty message record for a chat.
Parameters:
Name Type Description Defaultsession Session DB session.
requiredchat_id int Telegram chat id.
requiredmessage_id int Message id to store.
required Source code induty_teller/services/group_duty_pin_service.py def save_pin(session: Session, chat_id: int, message_id: int) -> None:\n \"\"\"Save or update the pinned duty message record for a chat.\n\n Args:\n session: DB session.\n chat_id: Telegram chat id.\n message_id: Message id to store.\n \"\"\"\n save_group_duty_pin(session, chat_id, message_id)\n"},{"location":"api-reference/#handlers","title":"Handlers","text":""},{"location":"api-reference/#duty_teller.handlers","title":"duty_teller.handlers","text":"Expose a single register_handlers(app) that registers all handlers.
"},{"location":"api-reference/#duty_teller.handlers.register_handlers","title":"register_handlers(app)","text":"Register all Telegram handlers (commands, import, group pin, error handler) on the application.
Parameters:
Name Type Description Defaultapp Application python-telegram-bot Application instance.
required Source code induty_teller/handlers/__init__.py def register_handlers(app: Application) -> None:\n \"\"\"Register all Telegram handlers (commands, import, group pin, error handler) on the application.\n\n Args:\n app: python-telegram-bot Application instance.\n \"\"\"\n app.add_handler(commands.start_handler)\n app.add_handler(commands.help_handler)\n app.add_handler(commands.set_phone_handler)\n app.add_handler(commands.calendar_link_handler)\n app.add_handler(import_duty_schedule.import_duty_schedule_handler)\n app.add_handler(import_duty_schedule.handover_time_handler)\n app.add_handler(import_duty_schedule.duty_schedule_document_handler)\n app.add_handler(group_duty_pin.group_duty_pin_handler)\n app.add_handler(group_duty_pin.pin_duty_handler)\n app.add_error_handler(errors.error_handler)\n"},{"location":"api-reference/#duty_teller.handlers.commands","title":"duty_teller.handlers.commands","text":"Command handlers: /start, /help; /start registers user.
"},{"location":"api-reference/#duty_teller.handlers.commands.calendar_link","title":"calendar_link(update, context) async","text":"Handle /calendar_link: send personal ICS URL (private chat only; user must be in allowlist).
Source code induty_teller/handlers/commands.py async def calendar_link(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:\n \"\"\"Handle /calendar_link: send personal ICS URL (private chat only; user must be in allowlist).\"\"\"\n if not update.message or not update.effective_user:\n return\n lang = get_lang(update.effective_user)\n if update.effective_chat and update.effective_chat.type != \"private\":\n await update.message.reply_text(t(lang, \"calendar_link.private_only\"))\n return\n telegram_user_id = update.effective_user.id\n username = (update.effective_user.username or \"\").strip()\n full_name = build_full_name(\n update.effective_user.first_name, update.effective_user.last_name\n )\n\n def do_calendar_link() -> tuple[str | None, str | None]:\n with session_scope(config.DATABASE_URL) as session:\n user = get_or_create_user(\n session,\n telegram_user_id=telegram_user_id,\n full_name=full_name,\n username=update.effective_user.username,\n first_name=update.effective_user.first_name,\n last_name=update.effective_user.last_name,\n )\n if not config.can_access_miniapp(\n username\n ) and not config.can_access_miniapp_by_phone(user.phone):\n return (None, \"denied\")\n token = create_calendar_token(session, user.id)\n base = (config.MINI_APP_BASE_URL or \"\").rstrip(\"/\")\n url = f\"{base}/api/calendar/ical/{token}.ics\" if base else None\n return (url, None)\n\n result_url, error = await asyncio.get_running_loop().run_in_executor(\n None, do_calendar_link\n )\n if error == \"denied\":\n await update.message.reply_text(t(lang, \"calendar_link.access_denied\"))\n return\n if not result_url:\n await update.message.reply_text(t(lang, \"calendar_link.error\"))\n return\n await update.message.reply_text(\n t(lang, \"calendar_link.success\", url=result_url)\n + \"\\n\\n\"\n + t(lang, \"calendar_link.help_hint\")\n )\n"},{"location":"api-reference/#duty_teller.handlers.commands.help_cmd","title":"help_cmd(update, context) async","text":"Handle /help: send list of commands (admins see import_duty_schedule).
Source code induty_teller/handlers/commands.py async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:\n \"\"\"Handle /help: send list of commands (admins see import_duty_schedule).\"\"\"\n if not update.message or not update.effective_user:\n return\n lang = get_lang(update.effective_user)\n lines = [\n t(lang, \"help.title\"),\n t(lang, \"help.start\"),\n t(lang, \"help.help\"),\n t(lang, \"help.set_phone\"),\n t(lang, \"help.calendar_link\"),\n t(lang, \"help.pin_duty\"),\n ]\n if config.is_admin(update.effective_user.username or \"\"):\n lines.append(t(lang, \"help.import_schedule\"))\n await update.message.reply_text(\"\\n\".join(lines))\n"},{"location":"api-reference/#duty_teller.handlers.commands.set_phone","title":"set_phone(update, context) async","text":"Handle /set_phone [number]: set or clear phone (private chat only).
Source code induty_teller/handlers/commands.py async def set_phone(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:\n \"\"\"Handle /set_phone [number]: set or clear phone (private chat only).\"\"\"\n if not update.message or not update.effective_user:\n return\n lang = get_lang(update.effective_user)\n if update.effective_chat and update.effective_chat.type != \"private\":\n await update.message.reply_text(t(lang, \"set_phone.private_only\"))\n return\n args = context.args or []\n phone = \" \".join(args).strip() if args else None\n telegram_user_id = update.effective_user.id\n\n def do_set_phone() -> str | None:\n with session_scope(config.DATABASE_URL) as session:\n full_name = build_full_name(\n update.effective_user.first_name, update.effective_user.last_name\n )\n get_or_create_user(\n session,\n telegram_user_id=telegram_user_id,\n full_name=full_name,\n username=update.effective_user.username,\n first_name=update.effective_user.first_name,\n last_name=update.effective_user.last_name,\n )\n user = set_user_phone(session, telegram_user_id, phone or None)\n if user is None:\n return \"error\"\n if phone:\n return \"saved\"\n return \"cleared\"\n\n result = await asyncio.get_running_loop().run_in_executor(None, do_set_phone)\n if result == \"error\":\n await update.message.reply_text(t(lang, \"set_phone.error\"))\n elif result == \"saved\":\n await update.message.reply_text(t(lang, \"set_phone.saved\", phone=phone or \"\"))\n else:\n await update.message.reply_text(t(lang, \"set_phone.cleared\"))\n"},{"location":"api-reference/#duty_teller.handlers.commands.start","title":"start(update, context) async","text":"Handle /start: register user in DB and send greeting.
Source code induty_teller/handlers/commands.py async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:\n \"\"\"Handle /start: register user in DB and send greeting.\"\"\"\n if not update.message:\n return\n user = update.effective_user\n if not user:\n return\n full_name = build_full_name(user.first_name, user.last_name)\n telegram_user_id = user.id\n username = user.username\n first_name = user.first_name\n last_name = user.last_name\n\n def do_get_or_create() -> None:\n with session_scope(config.DATABASE_URL) as session:\n get_or_create_user(\n session,\n telegram_user_id=telegram_user_id,\n full_name=full_name,\n username=username,\n first_name=first_name,\n last_name=last_name,\n )\n\n await asyncio.get_running_loop().run_in_executor(None, do_get_or_create)\n\n lang = get_lang(user)\n text = t(lang, \"start.greeting\")\n await update.message.reply_text(text)\n"},{"location":"api-reference/#duty_teller.handlers.import_duty_schedule","title":"duty_teller.handlers.import_duty_schedule","text":"Import duty-schedule: /import_duty_schedule (admin only). Two steps: handover time -> JSON file.
"},{"location":"api-reference/#duty_teller.handlers.import_duty_schedule.handle_duty_schedule_document","title":"handle_duty_schedule_document(update, context) async","text":"Handle uploaded JSON file: parse duty-schedule and run import.
Source code induty_teller/handlers/import_duty_schedule.py async def handle_duty_schedule_document(\n update: Update, context: ContextTypes.DEFAULT_TYPE\n) -> None:\n \"\"\"Handle uploaded JSON file: parse duty-schedule and run import.\"\"\"\n if not update.message or not update.message.document or not update.effective_user:\n return\n if not context.user_data.get(\"awaiting_duty_schedule_file\"):\n return\n lang = get_lang(update.effective_user)\n handover = context.user_data.get(\"handover_utc_time\")\n if not handover or not config.is_admin(update.effective_user.username or \"\"):\n return\n if not (update.message.document.file_name or \"\").lower().endswith(\".json\"):\n await update.message.reply_text(t(lang, \"import.need_json\"))\n return\n\n hour_utc, minute_utc = handover\n file_id = update.message.document.file_id\n\n file = await context.bot.get_file(file_id)\n raw = bytes(await file.download_as_bytearray())\n try:\n result = parse_duty_schedule(raw)\n except DutyScheduleParseError as e:\n context.user_data.pop(\"awaiting_duty_schedule_file\", None)\n context.user_data.pop(\"handover_utc_time\", None)\n await update.message.reply_text(t(lang, \"import.parse_error\", error=str(e)))\n return\n\n def run_import_with_scope():\n with session_scope(config.DATABASE_URL) as session:\n return run_import(session, result, hour_utc, minute_utc)\n\n loop = asyncio.get_running_loop()\n try:\n num_users, num_duty, num_unavailable, num_vacation = await loop.run_in_executor(\n None, run_import_with_scope\n )\n except Exception as e:\n await update.message.reply_text(t(lang, \"import.import_error\", error=str(e)))\n else:\n total = num_duty + num_unavailable + num_vacation\n unavailable_suffix = (\n t(lang, \"import.done_unavailable\", count=str(num_unavailable))\n if num_unavailable\n else \"\"\n )\n vacation_suffix = (\n t(lang, \"import.done_vacation\", count=str(num_vacation))\n if num_vacation\n else \"\"\n )\n await update.message.reply_text(\n t(\n lang,\n \"import.done\",\n users=str(num_users),\n duties=str(num_duty),\n unavailable=unavailable_suffix,\n vacation=vacation_suffix,\n total=str(total),\n )\n )\n finally:\n context.user_data.pop(\"awaiting_duty_schedule_file\", None)\n context.user_data.pop(\"handover_utc_time\", None)\n"},{"location":"api-reference/#duty_teller.handlers.import_duty_schedule.handle_handover_time_text","title":"handle_handover_time_text(update, context) async","text":"Handle text message when awaiting handover time (e.g. 09:00 Europe/Moscow).
Source code induty_teller/handlers/import_duty_schedule.py async def handle_handover_time_text(\n update: Update, context: ContextTypes.DEFAULT_TYPE\n) -> None:\n \"\"\"Handle text message when awaiting handover time (e.g. 09:00 Europe/Moscow).\"\"\"\n if not update.message or not update.effective_user or not update.message.text:\n return\n if not context.user_data.get(\"awaiting_handover_time\"):\n return\n if not config.is_admin(update.effective_user.username or \"\"):\n return\n lang = get_lang(update.effective_user)\n text = update.message.text.strip()\n parsed = parse_handover_time(text)\n if parsed is None:\n await update.message.reply_text(t(lang, \"import.parse_time_error\"))\n return\n hour_utc, minute_utc = parsed\n context.user_data[\"handover_utc_time\"] = (hour_utc, minute_utc)\n context.user_data[\"awaiting_handover_time\"] = False\n context.user_data[\"awaiting_duty_schedule_file\"] = True\n await update.message.reply_text(t(lang, \"import.send_json\"))\n"},{"location":"api-reference/#duty_teller.handlers.import_duty_schedule.import_duty_schedule_cmd","title":"import_duty_schedule_cmd(update, context) async","text":"Handle /import_duty_schedule: start two-step import (admin only); asks for handover time.
Source code induty_teller/handlers/import_duty_schedule.py async def import_duty_schedule_cmd(\n update: Update, context: ContextTypes.DEFAULT_TYPE\n) -> None:\n \"\"\"Handle /import_duty_schedule: start two-step import (admin only); asks for handover time.\"\"\"\n if not update.message or not update.effective_user:\n return\n lang = get_lang(update.effective_user)\n if not config.is_admin(update.effective_user.username or \"\"):\n await update.message.reply_text(t(lang, \"import.admin_only\"))\n return\n context.user_data[\"awaiting_handover_time\"] = True\n await update.message.reply_text(t(lang, \"import.handover_format\"))\n"},{"location":"api-reference/#duty_teller.handlers.group_duty_pin","title":"duty_teller.handlers.group_duty_pin","text":"Pinned duty message in groups: handle bot add/remove, schedule updates at shift end.
"},{"location":"api-reference/#duty_teller.handlers.group_duty_pin.my_chat_member_handler","title":"my_chat_member_handler(update, context) async","text":"Handle bot added to or removed from group: send/pin duty message or delete pin record.
Source code induty_teller/handlers/group_duty_pin.py async def my_chat_member_handler(\n update: Update, context: ContextTypes.DEFAULT_TYPE\n) -> None:\n \"\"\"Handle bot added to or removed from group: send/pin duty message or delete pin record.\"\"\"\n if not update.my_chat_member or not update.effective_user:\n return\n old = update.my_chat_member.old_chat_member\n new = update.my_chat_member.new_chat_member\n chat = update.effective_chat\n if not chat or chat.type not in (\"group\", \"supergroup\"):\n return\n if new.user.id != context.bot.id:\n return\n chat_id = chat.id\n\n if new.status in (\n ChatMemberStatus.MEMBER,\n ChatMemberStatus.ADMINISTRATOR,\n ) and old.status in (\n ChatMemberStatus.LEFT,\n ChatMemberStatus.BANNED,\n ):\n loop = asyncio.get_running_loop()\n lang = get_lang(update.effective_user)\n text = await loop.run_in_executor(\n None, lambda: _get_duty_message_text_sync(lang)\n )\n try:\n msg = await context.bot.send_message(chat_id=chat_id, text=text)\n except (BadRequest, Forbidden) as e:\n logger.warning(\"Failed to send duty message in chat_id=%s: %s\", chat_id, e)\n return\n pinned = False\n try:\n await context.bot.pin_chat_message(\n chat_id=chat_id,\n message_id=msg.message_id,\n disable_notification=True,\n )\n pinned = True\n except (BadRequest, Forbidden) as e:\n logger.warning(\"Failed to pin message in chat_id=%s: %s\", chat_id, e)\n await loop.run_in_executor(None, _sync_save_pin, chat_id, msg.message_id)\n if not pinned:\n try:\n await context.bot.send_message(\n chat_id=chat_id,\n text=t(lang, \"pin_duty.could_not_pin_make_admin\"),\n )\n except (BadRequest, Forbidden):\n pass\n next_end = await loop.run_in_executor(None, _get_next_shift_end_sync)\n await _schedule_next_update(context.application, chat_id, next_end)\n return\n\n if new.status in (ChatMemberStatus.LEFT, ChatMemberStatus.BANNED):\n await asyncio.get_running_loop().run_in_executor(\n None, _sync_delete_pin, chat_id\n )\n name = f\"{JOB_NAME_PREFIX}{chat_id}\"\n if context.application.job_queue:\n for job in context.application.job_queue.get_jobs_by_name(name):\n job.schedule_removal()\n logger.info(\"Bot left chat_id=%s, removed pin record and jobs\", chat_id)\n"},{"location":"api-reference/#duty_teller.handlers.group_duty_pin.pin_duty_cmd","title":"pin_duty_cmd(update, context) async","text":"Handle /pin_duty: pin the current duty message in the group (reply to bot's message).
Source code induty_teller/handlers/group_duty_pin.py async def pin_duty_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:\n \"\"\"Handle /pin_duty: pin the current duty message in the group (reply to bot's message).\"\"\"\n if not update.message or not update.effective_chat or not update.effective_user:\n return\n chat = update.effective_chat\n lang = get_lang(update.effective_user)\n if chat.type not in (\"group\", \"supergroup\"):\n await update.message.reply_text(t(lang, \"pin_duty.group_only\"))\n return\n chat_id = chat.id\n loop = asyncio.get_running_loop()\n message_id = await loop.run_in_executor(None, _sync_get_message_id, chat_id)\n if message_id is None:\n await update.message.reply_text(t(lang, \"pin_duty.no_message\"))\n return\n try:\n await context.bot.pin_chat_message(\n chat_id=chat_id,\n message_id=message_id,\n disable_notification=True,\n )\n await update.message.reply_text(t(lang, \"pin_duty.pinned\"))\n except (BadRequest, Forbidden) as e:\n logger.warning(\"pin_duty failed chat_id=%s: %s\", chat_id, e)\n await update.message.reply_text(t(lang, \"pin_duty.failed\"))\n"},{"location":"api-reference/#duty_teller.handlers.group_duty_pin.restore_group_pin_jobs","title":"restore_group_pin_jobs(application) async","text":"Restore scheduled pin-update jobs for all chats that have a pinned message (on startup).
Source code induty_teller/handlers/group_duty_pin.py async def restore_group_pin_jobs(application) -> None:\n \"\"\"Restore scheduled pin-update jobs for all chats that have a pinned message (on startup).\"\"\"\n loop = asyncio.get_running_loop()\n chat_ids = await loop.run_in_executor(None, _get_all_pin_chat_ids_sync)\n for chat_id in chat_ids:\n next_end = await loop.run_in_executor(None, _get_next_shift_end_sync)\n await _schedule_next_update(application, chat_id, next_end)\n logger.info(\"Restored %s group pin jobs\", len(chat_ids))\n"},{"location":"api-reference/#duty_teller.handlers.group_duty_pin.update_group_pin","title":"update_group_pin(context) async","text":"Job callback: refresh pinned duty message and schedule next update at shift end.
Source code induty_teller/handlers/group_duty_pin.py async def update_group_pin(context: ContextTypes.DEFAULT_TYPE) -> None:\n \"\"\"Job callback: refresh pinned duty message and schedule next update at shift end.\"\"\"\n chat_id = context.job.data.get(\"chat_id\")\n if chat_id is None:\n return\n loop = asyncio.get_running_loop()\n message_id = await loop.run_in_executor(None, _sync_get_message_id, chat_id)\n if message_id is None:\n logger.info(\"No pin record for chat_id=%s, skipping update\", chat_id)\n return\n text = await loop.run_in_executor(\n None, lambda: _get_duty_message_text_sync(config.DEFAULT_LANGUAGE)\n )\n try:\n await context.bot.edit_message_text(\n chat_id=chat_id,\n message_id=message_id,\n text=text,\n )\n except (BadRequest, Forbidden) as e:\n logger.warning(\"Failed to edit pinned message chat_id=%s: %s\", chat_id, e)\n next_end = await loop.run_in_executor(None, _get_next_shift_end_sync)\n await _schedule_next_update(context.application, chat_id, next_end)\n"},{"location":"api-reference/#duty_teller.handlers.errors","title":"duty_teller.handlers.errors","text":"Global error handler: log exception and notify user.
"},{"location":"api-reference/#duty_teller.handlers.errors.error_handler","title":"error_handler(update, context) async","text":"Global error handler: log exception and reply with generic message if possible.
Parameters:
Name Type Description Defaultupdate Update | None Update that caused the error (may be None).
requiredcontext DEFAULT_TYPE Callback context.
required Source code induty_teller/handlers/errors.py async def error_handler(\n update: Update | None, context: ContextTypes.DEFAULT_TYPE\n) -> None:\n \"\"\"Global error handler: log exception and reply with generic message if possible.\n\n Args:\n update: Update that caused the error (may be None).\n context: Callback context.\n \"\"\"\n logger.exception(\"Exception while handling an update\")\n if isinstance(update, Update) and update.effective_message:\n user = getattr(update, \"effective_user\", None)\n lang = get_lang(user) if user else config.DEFAULT_LANGUAGE\n await update.effective_message.reply_text(t(lang, \"errors.generic\"))\n"},{"location":"api-reference/#importers","title":"Importers","text":""},{"location":"api-reference/#duty_teller.importers","title":"duty_teller.importers","text":"Importers for duty data (e.g. duty-schedule JSON).
"},{"location":"api-reference/#duty_teller.importers.duty_schedule","title":"duty_teller.importers.duty_schedule","text":"Parser for duty-schedule JSON format. No DB access.
"},{"location":"api-reference/#duty_teller.importers.duty_schedule.DutyScheduleEntry","title":"DutyScheduleEntry dataclass","text":"One person's schedule: full_name and three lists of dates by event type.
Source code induty_teller/importers/duty_schedule.py @dataclass\nclass DutyScheduleEntry:\n \"\"\"One person's schedule: full_name and three lists of dates by event type.\"\"\"\n\n full_name: str\n duty_dates: list[date]\n unavailable_dates: list[date]\n vacation_dates: list[date]\n"},{"location":"api-reference/#duty_teller.importers.duty_schedule.DutyScheduleParseError","title":"DutyScheduleParseError","text":" Bases: Exception
Invalid or missing fields in duty-schedule JSON.
Source code induty_teller/importers/duty_schedule.py class DutyScheduleParseError(Exception):\n \"\"\"Invalid or missing fields in duty-schedule JSON.\"\"\"\n\n pass\n"},{"location":"api-reference/#duty_teller.importers.duty_schedule.DutyScheduleResult","title":"DutyScheduleResult dataclass","text":"Parsed duty schedule: start_date, end_date, and per-person entries.
Source code induty_teller/importers/duty_schedule.py @dataclass\nclass DutyScheduleResult:\n \"\"\"Parsed duty schedule: start_date, end_date, and per-person entries.\"\"\"\n\n start_date: date\n end_date: date\n entries: list[DutyScheduleEntry]\n"},{"location":"api-reference/#duty_teller.importers.duty_schedule.parse_duty_schedule","title":"parse_duty_schedule(raw_bytes)","text":"Parse duty-schedule JSON into DutyScheduleResult.
Expects meta.start_date (YYYY-MM-DD) and schedule (array). For each schedule item: name (required), duty string with ';' separator; index i = start_date + i days. Cell values: \u0432/\u0412/\u0431/\u0411 => duty, \u041d => unavailable, \u041e => vacation; rest ignored.
Parameters:
Name Type Description Defaultraw_bytes bytes UTF-8 encoded JSON bytes.
requiredReturns:
Type DescriptionDutyScheduleResult DutyScheduleResult with start_date, end_date, and entries (per-person dates).
Raises:
Type DescriptionDutyScheduleParseError On invalid JSON, missing/invalid meta or schedule, or invalid item fields.
Source code induty_teller/importers/duty_schedule.py def parse_duty_schedule(raw_bytes: bytes) -> DutyScheduleResult:\n \"\"\"Parse duty-schedule JSON into DutyScheduleResult.\n\n Expects meta.start_date (YYYY-MM-DD) and schedule (array). For each schedule\n item: name (required), duty string with ';' separator; index i = start_date + i days.\n Cell values: \u0432/\u0412/\u0431/\u0411 => duty, \u041d => unavailable, \u041e => vacation; rest ignored.\n\n Args:\n raw_bytes: UTF-8 encoded JSON bytes.\n\n Returns:\n DutyScheduleResult with start_date, end_date, and entries (per-person dates).\n\n Raises:\n DutyScheduleParseError: On invalid JSON, missing/invalid meta or schedule,\n or invalid item fields.\n \"\"\"\n try:\n data = json.loads(raw_bytes.decode(\"utf-8\"))\n except (json.JSONDecodeError, UnicodeDecodeError) as e:\n raise DutyScheduleParseError(f\"Invalid JSON or encoding: {e}\") from e\n\n meta = data.get(\"meta\")\n if not meta or not isinstance(meta, dict):\n raise DutyScheduleParseError(\"Missing or invalid 'meta'\")\n\n start_str = meta.get(\"start_date\")\n if not start_str or not isinstance(start_str, str):\n raise DutyScheduleParseError(\"Missing or invalid meta.start_date\")\n try:\n start_date = date.fromisoformat(start_str.strip())\n except ValueError as e:\n raise DutyScheduleParseError(f\"Invalid meta.start_date: {start_str}\") from e\n\n schedule = data.get(\"schedule\")\n if not isinstance(schedule, list):\n raise DutyScheduleParseError(\"Missing or invalid 'schedule' (must be array)\")\n\n max_days = 0\n entries: list[DutyScheduleEntry] = []\n\n for row in schedule:\n if not isinstance(row, dict):\n raise DutyScheduleParseError(\"schedule item must be an object\")\n name = row.get(\"name\")\n if name is None or not isinstance(name, str):\n raise DutyScheduleParseError(\"schedule item must have 'name' (string)\")\n full_name = name.strip()\n if not full_name:\n raise DutyScheduleParseError(\"schedule item 'name' cannot be empty\")\n\n duty_str = row.get(\"duty\")\n if duty_str is None:\n duty_str = \"\"\n if not isinstance(duty_str, str):\n raise DutyScheduleParseError(\"schedule item 'duty' must be string\")\n\n cells = [c.strip() for c in duty_str.split(\";\")]\n max_days = max(max_days, len(cells))\n\n duty_dates: list[date] = []\n unavailable_dates: list[date] = []\n vacation_dates: list[date] = []\n for i, cell in enumerate(cells):\n d = start_date + timedelta(days=i)\n if cell in DUTY_MARKERS:\n duty_dates.append(d)\n elif cell == UNAVAILABLE_MARKER:\n unavailable_dates.append(d)\n elif cell == VACATION_MARKER:\n vacation_dates.append(d)\n entries.append(\n DutyScheduleEntry(\n full_name=full_name,\n duty_dates=duty_dates,\n unavailable_dates=unavailable_dates,\n vacation_dates=vacation_dates,\n )\n )\n\n if max_days == 0:\n end_date = start_date\n else:\n end_date = start_date + timedelta(days=max_days - 1)\n\n return DutyScheduleResult(start_date=start_date, end_date=end_date, entries=entries)\n"},{"location":"architecture/","title":"Architecture","text":"High-level architecture of Duty Teller: components, data flow, and package relationships.
"},{"location":"architecture/#components","title":"Components","text":"/api/duties, /api/calendar-events, /api/calendar/ical/{token}.ics) and static miniapp at /app. Runs in a separate thread alongside the bot.data/duty_teller.db). Stores users, duties (with event types: duty, unavailable, vacation), group duty pins, calendar subscription tokens.DUTY_DISPLAY_TZ. Pin state is restored on startup from the database.Telegram \u2192 bot User/group messages \u2192 handlers \u2192 services or DB. Handlers use duty_teller.services (e.g. import, group duty pin) and duty_teller.db (repository, session). Messages use duty_teller.i18n for Russian/English.
Miniapp \u2192 API Browser opens /app; frontend calls GET /api/duties and GET /api/calendar-events with date range. FastAPI dependencies: DB session, Telegram initData validation (require_miniapp_username), date validation. Data is read via duty_teller.db.repository.
Import Admin sends JSON file via /import_duty_schedule. Handler reads file \u2192 duty_teller.importers.duty_schedule.parse_duty_schedule() \u2192 DutyScheduleResult \u2192 duty_teller.services.import_service.run_import() \u2192 repository (get_or_create_user_by_full_name, delete_duties_in_range, insert_duty).
Personal calendar ICS GET /api/calendar/ical/{token}.ics uses the secret token only (no Telegram auth); repository resolves user by token and returns duties; personal_calendar_ics.build_personal_ics() produces ICS bytes.
flowchart LR\n subgraph entry\n main[main.py / duty-teller]\n end\n subgraph duty_teller\n run[run.py]\n config[config.py]\n handlers[handlers]\n api[api]\n db[db]\n services[services]\n importers[importers]\n i18n[i18n]\n utils[utils]\n end\n main --> run\n run --> config\n run --> handlers\n run --> api\n handlers --> services\n handlers --> db\n handlers --> i18n\n api --> db\n api --> config\n services --> db\n services --> importers\n importers --> .\n services and db, use i18n for user-facing text.db.repository and config.session_scope), repository (CRUD for users, duties, pins, calendar tokens), schemas for API.importers for parsing.See Project layout in README for file-level details.
"},{"location":"configuration/","title":"Configuration reference","text":"All configuration is read from the environment (e.g. .env via python-dotenv). Source of truth: duty_teller/config.py and Settings.from_env().
hash_mismatch. DATABASE_URL string (SQLAlchemy URL) sqlite:///data/duty_teller.db Database connection URL. Example: sqlite:///data/duty_teller.db. MINI_APP_BASE_URL string (URL, no trailing slash) (empty) Base URL of the miniapp (for documentation and CORS). Trailing slash is stripped. Example: https://your-domain.com/app. HTTP_PORT integer 8080 Port for the HTTP server (FastAPI + static webapp). ALLOWED_USERNAMES comma-separated list (empty) Telegram usernames allowed to open the calendar miniapp (without @; case-insensitive). If both this and ADMIN_USERNAMES are empty, no one can open the calendar. Example: alice,bob. ADMIN_USERNAMES comma-separated list (empty) Telegram usernames with admin role (access to miniapp + /import_duty_schedule and future admin features). Example: admin1,admin2. ALLOWED_PHONES comma-separated list (empty) Phone numbers allowed to access the miniapp (user sets via /set_phone). Comparison uses digits only (spaces, +, parentheses, dashes ignored). Example: +7 999 123-45-67,89001234567. ADMIN_PHONES comma-separated list (empty) Phone numbers with admin role; same format as ALLOWED_PHONES. MINI_APP_SKIP_AUTH 1, true, or yes (unset) If set, /api/duties is allowed without Telegram initData (dev only; insecure). INIT_DATA_MAX_AGE_SECONDS integer 0 Reject Telegram initData older than this many seconds. 0 = disabled. Example: 86400 for 24 hours. CORS_ORIGINS comma-separated list * Allowed origins for CORS. Leave unset or set to * for allow-all. Example: https://your-domain.com. EXTERNAL_CALENDAR_ICS_URL string (URL) (empty) URL of a public ICS calendar (e.g. holidays). If set, those days are highlighted on the duty grid; users can tap \u00abi\u00bb on a cell to see the event summary. Empty = no external calendar. DUTY_DISPLAY_TZ string (timezone name) Europe/Moscow Timezone for the pinned duty message in groups. Example: Europe/Moscow, UTC. DEFAULT_LANGUAGE en or ru (normalized) en Default UI language when the user's Telegram language is unknown. Values starting with ru are normalized to ru, otherwise en."},{"location":"configuration/#quick-setup","title":"Quick setup","text":".env.example to .env.BOT_TOKEN to the token from BotFather.ALLOWED_USERNAMES and/or ADMIN_USERNAMES (and optionally ALLOWED_PHONES / ADMIN_PHONES).For Mini App URL and production deployment notes (reverse proxy, initData), see the README Setup and Docker sections.
"},{"location":"import-format/","title":"Duty-schedule import format","text":"The duty-schedule format is used by the /import_duty_schedule command. Only users in ADMIN_USERNAMES or ADMIN_PHONES can import.
09:00 Europe/Moscow or 06:00 UTC). This is converted to UTC and used as the boundary between duty periods when creating records.YYYY-MM-DD.weeks (optional) \u2014 Not used to limit length; the number of days is derived from the longest duty string (see below).
schedule (required) \u2014 Array of objects. Each object:
;. Each cell corresponds to one day starting from meta.start_date (first cell = start_date, second = start_date + 1 day, etc.). Empty or whitespace = no event for that day.\u041d \u041e Vacation (\u043e\u0442\u043f\u0443\u0441\u043a) Exactly \u041e (empty/space/other) No event Ignored for import The number of days in the schedule is the maximum length of any duty string when split by ;. If duty is empty or missing, it is treated as an empty list of cells.
{\n \"meta\": {\n \"start_date\": \"2025-02-01\",\n \"weeks\": 4\n },\n \"schedule\": [\n {\n \"name\": \"\u0418\u0432\u0430\u043d\u043e\u0432 \u0418\u0432\u0430\u043d\",\n \"duty\": \";;\u0412;;;\u041d;;\u041e;;\u0412;;\"\n },\n {\n \"name\": \"Petrov Petr\",\n \"duty\": \";;;\u0412;;;;;;\u0412;;;\"\n }\n ]\n}\n duty has 14 cells (after splitting by ;), so the schedule spans 14 days (2025-02-01 \u2026 2025-02-14).meta and meta.start_date must be present and valid; start_date must parse as YYYY-MM-DD.schedule must be an array; each item must be an object with string name (non-empty after strip) and string duty (if missing, treated as \"\").duty_teller.importers.duty_schedule.DutyScheduleParseError).This document covers running the application, checking health, logs, common errors, and database operations.
"},{"location":"runbook/#starting-and-stopping","title":"Starting and stopping","text":""},{"location":"runbook/#local","title":"Local","text":"bash python main.py Or after pip install -e .: duty-tellerCtrl+CDev (code mounted; no rebuild needed for code changes): bash docker compose -f docker-compose.dev.yml up --build Stop: Ctrl+C or docker compose -f docker-compose.dev.yml down.
Prod (built image; restarts on failure): bash docker compose -f docker-compose.prod.yml up -d --build Stop: docker compose -f docker-compose.prod.yml down.
On container start, entrypoint.sh runs Alembic migrations then starts the app as user botuser. Ensure .env (or your orchestrator\u2019s env) contains BOT_TOKEN and any required variables; see configuration.md.
GET /docs (e.g. http://localhost:8080/docs). If that page loads, the server is running./health endpoint; use /docs or a lightweight API call (e.g. GET /api/duties?from=...&to=... with valid auth) as needed.docker compose logs -f (with the appropriate compose file) to follow application logs. Adjust log level via Python logging if needed (e.g. environment or code)./api/duties or Miniapp)","text":"BOT_TOKEN than the bot from which users open the Mini App (e.g. test vs production bot). Telegram signs initData with the bot token; if tokens differ, validation fails.BOT_TOKEN is set in .env (or equivalent) on the machine serving /api/duties as the one used by the bot instance whose menu button opens the Miniapp.tgWebAppData (initData). The API requires initData (or MINI_APP_SKIP_AUTH / private IP in dev)./api/duties (or calendar) without valid X-Telegram-Init-Data header. In production, only private IP clients can be allowed without initData (see _is_private_client in api/dependencies.py); behind a reverse proxy, request.client.host is often the proxy (e.g. 127.0.0.1), so the \u201cprivate IP\u201d bypass may not apply to the real user.https://your-domain.com/app) and the server redirects /app \u2192 /app/, the browser can drop the fragment Telegram sends, breaking authorization.https://your-domain.com/app/. See README \u201cMini App URL\u201d.ALLOWED_USERNAMES or ADMIN_USERNAMES, and (if using phone) their phone (set via /set_phone) is not in ALLOWED_PHONES or ADMIN_PHONES.ALLOWED_USERNAMES, ADMIN_USERNAMES, ALLOWED_PHONES, ADMIN_PHONES. Add the user or ask them to set phone and add it to the allowlist.data/duty_teller.db (relative to working directory when using default DATABASE_URL=sqlite:///data/duty_teller.db). In Docker, the entrypoint creates /app/data and runs migrations there.bash alembic -c pyproject.toml upgrade head Config: pyproject.toml \u2192 [tool.alembic]; script location alembic/; metadata and URL from duty_teller.config and duty_teller.db.models.Base.bash alembic -c pyproject.toml downgrade -1 Always backup the database before downgrading.For full list of env vars (including DATABASE_URL), see configuration.md. For reverse proxy and Mini App URL details, see the main README.