Files
duty-teller/site/search/search_index.json
Nikolay Tatarinov 86f6d66865
All checks were successful
CI / lint-and-test (push) Successful in 17s
chore: add changelog and documentation updates
- Created a new `CHANGELOG.md` file to document all notable changes to the project, adhering to the Keep a Changelog format.
- Updated `CONTRIBUTING.md` to include instructions for building and previewing documentation using MkDocs.
- Added `mkdocs.yml` configuration for documentation generation, including navigation structure and theme settings.
- Enhanced various documentation files, including API reference, architecture overview, configuration reference, and runbook, to provide comprehensive guidance for users and developers.
- Included new sections in the README for changelog and documentation links, improving accessibility to project information.
2026-02-20 15:32:10 +03:00

1 line
160 KiB
JSON

{"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":"<p>Telegram bot for team duty shift calendar and group reminder. The bot and web UI support Russian and English.</p>"},{"location":"#documentation","title":"Documentation","text":"<ul> <li>Configuration \u2014 Environment variables (types, defaults, examples).</li> <li>Architecture \u2014 Components, data flow, package relationships.</li> <li>Import format \u2014 Duty-schedule JSON format and example.</li> <li>Runbook \u2014 Running the app, logs, common errors, DB and migrations.</li> <li>API Reference \u2014 Generated from code (api, db, services, handlers, importers, config).</li> </ul> <p>For quick start, setup, and API overview see the main README.</p>"},{"location":"api-reference/","title":"API Reference","text":"<p>Generated from the <code>duty_teller</code> package. The following subpackages and modules are included.</p>"},{"location":"api-reference/#configuration","title":"Configuration","text":""},{"location":"api-reference/#duty_teller.config","title":"<code>duty_teller.config</code>","text":"<p>Load configuration from environment (e.g. .env via python-dotenv).</p> <p>BOT_TOKEN is not validated on import; call require_bot_token() in the entry point when running the bot.</p>"},{"location":"api-reference/#duty_teller.config.Settings","title":"<code>Settings</code> <code>dataclass</code>","text":"<p>Injectable settings built from environment. Used in tests or when env is overridden.</p> Source code in <code>duty_teller/config.py</code> <pre><code>@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) -&gt; \"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</code></pre>"},{"location":"api-reference/#duty_teller.config.Settings.from_env","title":"<code>from_env()</code> <code>classmethod</code>","text":"<p>Build Settings from current environment (same logic as module-level variables).</p> <p>Returns:</p> Type Description <code>Settings</code> <p>Settings instance with all fields populated from env.</p> Source code in <code>duty_teller/config.py</code> <pre><code>@classmethod\ndef from_env(cls) -&gt; \"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</code></pre>"},{"location":"api-reference/#duty_teller.config.can_access_miniapp","title":"<code>can_access_miniapp(username)</code>","text":"<p>Check if username is allowed to open the calendar Miniapp.</p> <p>Parameters:</p> Name Type Description Default <code>username</code> <code>str</code> <p>Telegram username (with or without @; case-insensitive).</p> required <p>Returns:</p> Type Description <code>bool</code> <p>True if in ALLOWED_USERNAMES or ADMIN_USERNAMES.</p> Source code in <code>duty_teller/config.py</code> <pre><code>def can_access_miniapp(username: str) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.config.can_access_miniapp_by_phone","title":"<code>can_access_miniapp_by_phone(phone)</code>","text":"<p>Check if phone (set via /set_phone) is allowed to open the Miniapp.</p> <p>Parameters:</p> Name Type Description Default <code>phone</code> <code>str | None</code> <p>Raw phone string or None.</p> required <p>Returns:</p> Type Description <code>bool</code> <p>True if normalized phone is in ALLOWED_PHONES or ADMIN_PHONES.</p> Source code in <code>duty_teller/config.py</code> <pre><code>def can_access_miniapp_by_phone(phone: str | None) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.config.is_admin","title":"<code>is_admin(username)</code>","text":"<p>Check if Telegram username is in ADMIN_USERNAMES.</p> <p>Parameters:</p> Name Type Description Default <code>username</code> <code>str</code> <p>Telegram username (with or without @; case-insensitive).</p> required <p>Returns:</p> Type Description <code>bool</code> <p>True if in ADMIN_USERNAMES.</p> Source code in <code>duty_teller/config.py</code> <pre><code>def is_admin(username: str) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.config.is_admin_by_phone","title":"<code>is_admin_by_phone(phone)</code>","text":"<p>Check if phone is in ADMIN_PHONES.</p> <p>Parameters:</p> Name Type Description Default <code>phone</code> <code>str | None</code> <p>Raw phone string or None.</p> required <p>Returns:</p> Type Description <code>bool</code> <p>True if normalized phone is in ADMIN_PHONES.</p> Source code in <code>duty_teller/config.py</code> <pre><code>def is_admin_by_phone(phone: str | None) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.config.normalize_phone","title":"<code>normalize_phone(phone)</code>","text":"<p>Return phone as digits only (spaces, +, parentheses, dashes removed).</p> <p>Parameters:</p> Name Type Description Default <code>phone</code> <code>str | None</code> <p>Raw phone string or None.</p> required <p>Returns:</p> Type Description <code>str</code> <p>Digits-only string, or empty string if None or empty.</p> Source code in <code>duty_teller/config.py</code> <pre><code>def normalize_phone(phone: str | None) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.config.require_bot_token","title":"<code>require_bot_token()</code>","text":"<p>Raise SystemExit with a clear message if BOT_TOKEN is not set.</p> <p>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.</p> <p>Raises:</p> Type Description <code>SystemExit</code> <p>If BOT_TOKEN is empty.</p> Source code in <code>duty_teller/config.py</code> <pre><code>def require_bot_token() -&gt; 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</code></pre>"},{"location":"api-reference/#api-fastapi-and-auth","title":"API (FastAPI and auth)","text":""},{"location":"api-reference/#duty_teller.api","title":"<code>duty_teller.api</code>","text":"<p>HTTP API for the calendar Mini App: duties, calendar events, and static webapp.</p>"},{"location":"api-reference/#duty_teller.api.app","title":"<code>duty_teller.api.app</code>","text":"<p>FastAPI app: /api/duties, /api/calendar-events, personal ICS, and static webapp at /app.</p>"},{"location":"api-reference/#duty_teller.api.app.get_personal_calendar_ical","title":"<code>get_personal_calendar_ical(token, session=Depends(get_db_session))</code>","text":"<p>Return ICS calendar with only the subscribing user's duties. No Telegram auth; access is by secret token in the URL.</p> Source code in <code>duty_teller/api/app.py</code> <pre><code>@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) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.api.dependencies","title":"<code>duty_teller.api.dependencies</code>","text":"<p>FastAPI dependencies: DB session, Miniapp auth (initData/allowlist), date validation.</p>"},{"location":"api-reference/#duty_teller.api.dependencies.fetch_duties_response","title":"<code>fetch_duties_response(session, from_date, to_date)</code>","text":"<p>Load duties in range and return as DutyWithUser list for API response.</p> <p>Parameters:</p> Name Type Description Default <code>session</code> <code>Session</code> <p>DB session.</p> required <code>from_date</code> <code>str</code> <p>Start date YYYY-MM-DD.</p> required <code>to_date</code> <code>str</code> <p>End date YYYY-MM-DD.</p> required <p>Returns:</p> Type Description <code>list[DutyWithUser]</code> <p>List of DutyWithUser (id, user_id, start_at, end_at, full_name, event_type).</p> Source code in <code>duty_teller/api/dependencies.py</code> <pre><code>def fetch_duties_response(\n session: Session, from_date: str, to_date: str\n) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.api.dependencies.get_authenticated_username","title":"<code>get_authenticated_username(request, x_telegram_init_data, session)</code>","text":"<p>Return identifier for miniapp auth (username or full_name or id:...); empty if skip-auth.</p> <p>Parameters:</p> Name Type Description Default <code>request</code> <code>Request</code> <p>FastAPI request (client host for private-IP bypass).</p> required <code>x_telegram_init_data</code> <code>str | None</code> <p>Raw X-Telegram-Init-Data header value.</p> required <code>session</code> <code>Session</code> <p>DB session (for phone allowlist lookup).</p> required <p>Returns:</p> Type Description <code>str</code> <p>Username, full_name, or \"id:\"; empty string if MINI_APP_SKIP_AUTH <code>str</code> <p>or private IP and no initData.</p> <p>Raises:</p> Type Description <code>HTTPException</code> <p>403 if initData missing/invalid or user not in allowlist.</p> Source code in <code>duty_teller/api/dependencies.py</code> <pre><code>def get_authenticated_username(\n request: Request,\n x_telegram_init_data: str | None,\n session: Session,\n) -&gt; 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:&lt;telegram_id&gt;\"; 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</code></pre>"},{"location":"api-reference/#duty_teller.api.dependencies.get_db_session","title":"<code>get_db_session()</code>","text":"<p>Yield a DB session for the request; closed automatically by FastAPI.</p> Source code in <code>duty_teller/api/dependencies.py</code> <pre><code>def get_db_session() -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.api.dependencies.get_validated_dates","title":"<code>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'))</code>","text":"<p>Validate from/to date query params; use Accept-Language for error messages.</p> <p>Parameters:</p> Name Type Description Default <code>request</code> <code>Request</code> <p>FastAPI request (for Accept-Language).</p> required <code>from_date</code> <code>str</code> <p>Start date YYYY-MM-DD.</p> <code>Query(..., description='ISO date YYYY-MM-DD', alias='from')</code> <code>to_date</code> <code>str</code> <p>End date YYYY-MM-DD.</p> <code>Query(..., description='ISO date YYYY-MM-DD', alias='to')</code> <p>Returns:</p> Type Description <code>tuple[str, str]</code> <p>(from_date, to_date) as strings.</p> <p>Raises:</p> Type Description <code>HTTPException</code> <p>400 if format invalid or from_date &gt; to_date.</p> Source code in <code>duty_teller/api/dependencies.py</code> <pre><code>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) -&gt; 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 &gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.api.dependencies.require_miniapp_username","title":"<code>require_miniapp_username(request, x_telegram_init_data=None, session=Depends(get_db_session))</code>","text":"<p>FastAPI dependency: require valid Miniapp auth; return username/identifier.</p> <p>Raises:</p> Type Description <code>HTTPException</code> <p>403 if initData missing/invalid or user not in allowlist.</p> Source code in <code>duty_teller/api/dependencies.py</code> <pre><code>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) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.api.telegram_auth","title":"<code>duty_teller.api.telegram_auth</code>","text":"<p>Validate Telegram Web App initData and extract user username.</p>"},{"location":"api-reference/#duty_teller.api.telegram_auth.validate_init_data","title":"<code>validate_init_data(init_data, bot_token, max_age_seconds=None)</code>","text":"<p>Validate Telegram Web App initData and return username if valid.</p> <p>Parameters:</p> Name Type Description Default <code>init_data</code> <code>str</code> <p>Raw initData string from tgWebAppData.</p> required <code>bot_token</code> <code>str</code> <p>Bot token (must match the bot that signed the data).</p> required <code>max_age_seconds</code> <code>int | None</code> <p>Reject if auth_date older than this; None to disable.</p> <code>None</code> <p>Returns:</p> Type Description <code>str | None</code> <p>Username (lowercase, no @) or None if validation fails.</p> Source code in <code>duty_teller/api/telegram_auth.py</code> <pre><code>def validate_init_data(\n init_data: str,\n bot_token: str,\n max_age_seconds: int | None = None,\n) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.api.telegram_auth.validate_init_data_with_reason","title":"<code>validate_init_data_with_reason(init_data, bot_token, max_age_seconds=None)</code>","text":"<p>Validate initData signature and return user id, username, reason, and lang.</p> <p>Parameters:</p> Name Type Description Default <code>init_data</code> <code>str</code> <p>Raw initData string from tgWebAppData.</p> required <code>bot_token</code> <code>str</code> <p>Bot token (must match the bot that signed the data).</p> required <code>max_age_seconds</code> <code>int | None</code> <p>Reject if auth_date older than this; None to disable.</p> <code>None</code> <p>Returns:</p> Type Description <code>int | None</code> <p>Tuple (telegram_user_id, username, reason, lang). reason is one of: \"ok\",</p> <code>str | None</code> <p>\"empty\", \"no_hash\", \"hash_mismatch\", \"auth_date_expired\", \"no_user\",</p> <code>str</code> <p>\"user_invalid\", \"no_user_id\". lang is from user.language_code normalized</p> <code>str</code> <p>to 'ru' or 'en'; 'en' when no user. On success: (user.id, username or None,</p> <code>tuple[int | None, str | None, str, str]</code> <p>\"ok\", lang).</p> Source code in <code>duty_teller/api/telegram_auth.py</code> <pre><code>def validate_init_data_with_reason(\n init_data: str,\n bot_token: str,\n max_age_seconds: int | None = None,\n) -&gt; 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(\"&amp;\"):\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 &gt; 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 &gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.api.calendar_ics","title":"<code>duty_teller.api.calendar_ics</code>","text":"<p>Fetch and parse external ICS calendar; in-memory cache with 7-day TTL.</p>"},{"location":"api-reference/#duty_teller.api.calendar_ics.get_calendar_events","title":"<code>get_calendar_events(url, from_date, to_date)</code>","text":"<p>Fetch ICS from URL and return events in the given date range.</p> <p>Uses in-memory cache with TTL 7 days. Recurring events are skipped. On fetch or parse error returns an empty list.</p> <p>Parameters:</p> Name Type Description Default <code>url</code> <code>str</code> <p>URL of the ICS calendar.</p> required <code>from_date</code> <code>str</code> <p>Start date YYYY-MM-DD.</p> required <code>to_date</code> <code>str</code> <p>End date YYYY-MM-DD.</p> required <p>Returns:</p> Type Description <code>list[dict]</code> <p>List of dicts with keys \"date\" (YYYY-MM-DD) and \"summary\". Empty on error.</p> Source code in <code>duty_teller/api/calendar_ics.py</code> <pre><code>def get_calendar_events(\n url: str,\n from_date: str,\n to_date: str,\n) -&gt; 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 &gt; 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 &lt; 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</code></pre>"},{"location":"api-reference/#duty_teller.api.personal_calendar_ics","title":"<code>duty_teller.api.personal_calendar_ics</code>","text":"<p>Generate ICS calendar containing only one user's duties (for subscription link).</p>"},{"location":"api-reference/#duty_teller.api.personal_calendar_ics.build_personal_ics","title":"<code>build_personal_ics(duties_with_name)</code>","text":"<p>Build a VCALENDAR (ICS) with one VEVENT per duty.</p> <p>Parameters:</p> Name Type Description Default <code>duties_with_name</code> <code>list[tuple[Duty, str]]</code> <p>List of (Duty, full_name). full_name is available for DESCRIPTION; SUMMARY is taken from event_type (duty/unavailable/vacation).</p> required <p>Returns:</p> Type Description <code>bytes</code> <p>ICS file content as bytes (UTF-8).</p> Source code in <code>duty_teller/api/personal_calendar_ics.py</code> <pre><code>def build_personal_ics(duties_with_name: list[tuple[Duty, str]]) -&gt; 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</code></pre>"},{"location":"api-reference/#database","title":"Database","text":""},{"location":"api-reference/#duty_teller.db","title":"<code>duty_teller.db</code>","text":"<p>Database layer: SQLAlchemy models, Pydantic schemas, repository, init.</p>"},{"location":"api-reference/#duty_teller.db.Base","title":"<code>Base</code>","text":"<p> Bases: <code>DeclarativeBase</code></p> <p>Declarative base for all models.</p> Source code in <code>duty_teller/db/models.py</code> <pre><code>class Base(DeclarativeBase):\n \"\"\"Declarative base for all models.\"\"\"\n\n pass\n</code></pre>"},{"location":"api-reference/#duty_teller.db.Duty","title":"<code>Duty</code>","text":"<p> Bases: <code>Base</code></p> <p>Single duty/unavailable/vacation slot (UTC start_at/end_at, event_type).</p> Source code in <code>duty_teller/db/models.py</code> <pre><code>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</code></pre>"},{"location":"api-reference/#duty_teller.db.DutyCreate","title":"<code>DutyCreate</code>","text":"<p> Bases: <code>DutyBase</code></p> <p>Duty creation payload.</p> Source code in <code>duty_teller/db/schemas.py</code> <pre><code>class DutyCreate(DutyBase):\n \"\"\"Duty creation payload.\"\"\"\n\n pass\n</code></pre>"},{"location":"api-reference/#duty_teller.db.DutyInDb","title":"<code>DutyInDb</code>","text":"<p> Bases: <code>DutyBase</code></p> <p>Duty as stored in DB (includes id).</p> Source code in <code>duty_teller/db/schemas.py</code> <pre><code>class DutyInDb(DutyBase):\n \"\"\"Duty as stored in DB (includes id).\"\"\"\n\n id: int\n\n model_config = ConfigDict(from_attributes=True)\n</code></pre>"},{"location":"api-reference/#duty_teller.db.DutyWithUser","title":"<code>DutyWithUser</code>","text":"<p> Bases: <code>DutyInDb</code></p> <p>Duty with full_name and event_type for calendar display.</p> <p>event_type: only these values are returned; unknown DB values are mapped to \"duty\" in the API.</p> Source code in <code>duty_teller/db/schemas.py</code> <pre><code>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</code></pre>"},{"location":"api-reference/#duty_teller.db.User","title":"<code>User</code>","text":"<p> Bases: <code>Base</code></p> <p>Telegram user and display name; may have telegram_user_id=None for import-only users.</p> Source code in <code>duty_teller/db/models.py</code> <pre><code>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</code></pre>"},{"location":"api-reference/#duty_teller.db.UserCreate","title":"<code>UserCreate</code>","text":"<p> Bases: <code>UserBase</code></p> <p>User creation payload including Telegram user id.</p> Source code in <code>duty_teller/db/schemas.py</code> <pre><code>class UserCreate(UserBase):\n \"\"\"User creation payload including Telegram user id.\"\"\"\n\n telegram_user_id: int\n</code></pre>"},{"location":"api-reference/#duty_teller.db.UserInDb","title":"<code>UserInDb</code>","text":"<p> Bases: <code>UserBase</code></p> <p>User as stored in DB (includes id and telegram_user_id).</p> Source code in <code>duty_teller/db/schemas.py</code> <pre><code>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</code></pre>"},{"location":"api-reference/#duty_teller.db.delete_duties_in_range","title":"<code>delete_duties_in_range(session, user_id, from_date, to_date)</code>","text":"<p>Delete all duties of the user that overlap the given date range.</p> <p>Parameters:</p> Name Type Description Default <code>session</code> <code>Session</code> <p>DB session.</p> required <code>user_id</code> <code>int</code> <p>User id.</p> required <code>from_date</code> <code>str</code> <p>Start date YYYY-MM-DD.</p> required <code>to_date</code> <code>str</code> <p>End date YYYY-MM-DD.</p> required <p>Returns:</p> Type Description <code>int</code> <p>Number of duties deleted.</p> Source code in <code>duty_teller/db/repository.py</code> <pre><code>def delete_duties_in_range(\n session: Session,\n user_id: int,\n from_date: str,\n to_date: str,\n) -&gt; 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 &lt; to_next,\n Duty.end_at &gt;= from_date,\n )\n count = q.count()\n q.delete(synchronize_session=False)\n session.commit()\n return count\n</code></pre>"},{"location":"api-reference/#duty_teller.db.get_duties","title":"<code>get_duties(session, from_date, to_date)</code>","text":"<p>Return duties overlapping the given date range with user full_name.</p> <p>Parameters:</p> Name Type Description Default <code>session</code> <code>Session</code> <p>DB session.</p> required <code>from_date</code> <code>str</code> <p>Start date YYYY-MM-DD.</p> required <code>to_date</code> <code>str</code> <p>End date YYYY-MM-DD.</p> required <p>Returns:</p> Type Description <code>list[tuple[Duty, str]]</code> <p>List of (Duty, full_name) tuples.</p> Source code in <code>duty_teller/db/repository.py</code> <pre><code>def get_duties(\n session: Session,\n from_date: str,\n to_date: str,\n) -&gt; 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 &lt; to_date_next, Duty.end_at &gt;= from_date)\n )\n return list(q.all())\n</code></pre>"},{"location":"api-reference/#duty_teller.db.get_engine","title":"<code>get_engine(database_url)</code>","text":"<p>Return cached SQLAlchemy engine for the given URL (one per process).</p> Source code in <code>duty_teller/db/session.py</code> <pre><code>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</code></pre>"},{"location":"api-reference/#duty_teller.db.get_or_create_user","title":"<code>get_or_create_user(session, telegram_user_id, full_name, username=None, first_name=None, last_name=None)</code>","text":"<p>Get or create user by Telegram user ID.</p> <p>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).</p> <p>Parameters:</p> Name Type Description Default <code>session</code> <code>Session</code> <p>DB session.</p> required <code>telegram_user_id</code> <code>int</code> <p>Telegram user id.</p> required <code>full_name</code> <code>str</code> <p>Display full name.</p> required <code>username</code> <code>str | None</code> <p>Telegram username (optional).</p> <code>None</code> <code>first_name</code> <code>str | None</code> <p>Telegram first name (optional).</p> <code>None</code> <code>last_name</code> <code>str | None</code> <p>Telegram last name (optional).</p> <code>None</code> <p>Returns:</p> Type Description <code>User</code> <p>User instance (created or updated).</p> Source code in <code>duty_teller/db/repository.py</code> <pre><code>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) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.db.get_or_create_user_by_full_name","title":"<code>get_or_create_user_by_full_name(session, full_name)</code>","text":"<p>Find user by exact full_name or create one (for duty-schedule import).</p> <p>New users have telegram_user_id=None and name_manually_edited=True.</p> <p>Parameters:</p> Name Type Description Default <code>session</code> <code>Session</code> <p>DB session.</p> required <code>full_name</code> <code>str</code> <p>Exact full name to match or set.</p> required <p>Returns:</p> Type Description <code>User</code> <p>User instance (existing or newly created).</p> Source code in <code>duty_teller/db/repository.py</code> <pre><code>def get_or_create_user_by_full_name(session: Session, full_name: str) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.db.get_session","title":"<code>get_session(database_url)</code>","text":"<p>Create a new session from the factory for the given URL.</p> Source code in <code>duty_teller/db/session.py</code> <pre><code>def get_session(database_url: str) -&gt; Session:\n \"\"\"Create a new session from the factory for the given URL.\"\"\"\n return get_session_factory(database_url)()\n</code></pre>"},{"location":"api-reference/#duty_teller.db.get_session_factory","title":"<code>get_session_factory(database_url)</code>","text":"<p>Return cached session factory for the given URL (one per process).</p> Source code in <code>duty_teller/db/session.py</code> <pre><code>def get_session_factory(database_url: str) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.db.init_db","title":"<code>init_db(database_url)</code>","text":"<p>Create all tables from SQLAlchemy metadata.</p> <p>Prefer Alembic migrations for schema changes in production.</p> <p>Parameters:</p> Name Type Description Default <code>database_url</code> <code>str</code> <p>SQLAlchemy database URL.</p> required Source code in <code>duty_teller/db/__init__.py</code> <pre><code>def init_db(database_url: str) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.db.insert_duty","title":"<code>insert_duty(session, user_id, start_at, end_at, event_type='duty')</code>","text":"<p>Create a duty record.</p> <p>Parameters:</p> Name Type Description Default <code>session</code> <code>Session</code> <p>DB session.</p> required <code>user_id</code> <code>int</code> <p>User id.</p> required <code>start_at</code> <code>str</code> <p>Start time UTC, ISO 8601 with Z (e.g. 2025-01-15T09:00:00Z).</p> required <code>end_at</code> <code>str</code> <p>End time UTC, ISO 8601 with Z.</p> required <code>event_type</code> <code>str</code> <p>One of \"duty\", \"unavailable\", \"vacation\". Default \"duty\".</p> <code>'duty'</code> <p>Returns:</p> Type Description <code>Duty</code> <p>Created Duty instance.</p> Source code in <code>duty_teller/db/repository.py</code> <pre><code>def insert_duty(\n session: Session,\n user_id: int,\n start_at: str,\n end_at: str,\n event_type: str = \"duty\",\n) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.db.session_scope","title":"<code>session_scope(database_url)</code>","text":"<p>Context manager that yields a session; rolls back on exception, closes on exit.</p> <p>Parameters:</p> Name Type Description Default <code>database_url</code> <code>str</code> <p>SQLAlchemy database URL.</p> required <p>Yields:</p> Type Description <code>Session</code> <p>Session instance. Caller must not use it after exit.</p> Source code in <code>duty_teller/db/session.py</code> <pre><code>@contextmanager\ndef session_scope(database_url: str) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.db.set_user_phone","title":"<code>set_user_phone(session, telegram_user_id, phone)</code>","text":"<p>Set or clear phone for user by Telegram user id.</p> <p>Parameters:</p> Name Type Description Default <code>session</code> <code>Session</code> <p>DB session.</p> required <code>telegram_user_id</code> <code>int</code> <p>Telegram user id.</p> required <code>phone</code> <code>str | None</code> <p>Phone string or None to clear.</p> required <p>Returns:</p> Type Description <code>User | None</code> <p>Updated User or None if not found.</p> Source code in <code>duty_teller/db/repository.py</code> <pre><code>def set_user_phone(\n session: Session, telegram_user_id: int, phone: str | None\n) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.db.update_user_display_name","title":"<code>update_user_display_name(session, telegram_user_id, full_name, first_name=None, last_name=None)</code>","text":"<p>Update display name and set name_manually_edited=True.</p> <p>Use from API or admin when name is changed manually; subsequent get_or_create_user will not overwrite these fields.</p> <p>Parameters:</p> Name Type Description Default <code>session</code> <code>Session</code> <p>DB session.</p> required <code>telegram_user_id</code> <code>int</code> <p>Telegram user id.</p> required <code>full_name</code> <code>str</code> <p>New full name.</p> required <code>first_name</code> <code>str | None</code> <p>New first name (optional).</p> <code>None</code> <code>last_name</code> <code>str | None</code> <p>New last name (optional).</p> <code>None</code> <p>Returns:</p> Type Description <code>User | None</code> <p>Updated User or None if not found.</p> Source code in <code>duty_teller/db/repository.py</code> <pre><code>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) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.db.models","title":"<code>duty_teller.db.models</code>","text":"<p>SQLAlchemy ORM models for users and duties.</p>"},{"location":"api-reference/#duty_teller.db.models.Base","title":"<code>Base</code>","text":"<p> Bases: <code>DeclarativeBase</code></p> <p>Declarative base for all models.</p> Source code in <code>duty_teller/db/models.py</code> <pre><code>class Base(DeclarativeBase):\n \"\"\"Declarative base for all models.\"\"\"\n\n pass\n</code></pre>"},{"location":"api-reference/#duty_teller.db.models.CalendarSubscriptionToken","title":"<code>CalendarSubscriptionToken</code>","text":"<p> Bases: <code>Base</code></p> <p>One active calendar subscription token per user; token_hash is unique.</p> Source code in <code>duty_teller/db/models.py</code> <pre><code>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</code></pre>"},{"location":"api-reference/#duty_teller.db.models.Duty","title":"<code>Duty</code>","text":"<p> Bases: <code>Base</code></p> <p>Single duty/unavailable/vacation slot (UTC start_at/end_at, event_type).</p> Source code in <code>duty_teller/db/models.py</code> <pre><code>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</code></pre>"},{"location":"api-reference/#duty_teller.db.models.GroupDutyPin","title":"<code>GroupDutyPin</code>","text":"<p> Bases: <code>Base</code></p> <p>Stores which message to update in each group for the pinned duty notice.</p> Source code in <code>duty_teller/db/models.py</code> <pre><code>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</code></pre>"},{"location":"api-reference/#duty_teller.db.models.User","title":"<code>User</code>","text":"<p> Bases: <code>Base</code></p> <p>Telegram user and display name; may have telegram_user_id=None for import-only users.</p> Source code in <code>duty_teller/db/models.py</code> <pre><code>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</code></pre>"},{"location":"api-reference/#duty_teller.db.schemas","title":"<code>duty_teller.db.schemas</code>","text":"<p>Pydantic schemas for API request/response and validation.</p>"},{"location":"api-reference/#duty_teller.db.schemas.CalendarEvent","title":"<code>CalendarEvent</code>","text":"<p> Bases: <code>BaseModel</code></p> <p>External calendar event (e.g. holiday) for a single day.</p> Source code in <code>duty_teller/db/schemas.py</code> <pre><code>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</code></pre>"},{"location":"api-reference/#duty_teller.db.schemas.DutyBase","title":"<code>DutyBase</code>","text":"<p> Bases: <code>BaseModel</code></p> <p>Duty fields: user_id, start_at, end_at (UTC ISO 8601 with Z).</p> Source code in <code>duty_teller/db/schemas.py</code> <pre><code>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</code></pre>"},{"location":"api-reference/#duty_teller.db.schemas.DutyCreate","title":"<code>DutyCreate</code>","text":"<p> Bases: <code>DutyBase</code></p> <p>Duty creation payload.</p> Source code in <code>duty_teller/db/schemas.py</code> <pre><code>class DutyCreate(DutyBase):\n \"\"\"Duty creation payload.\"\"\"\n\n pass\n</code></pre>"},{"location":"api-reference/#duty_teller.db.schemas.DutyInDb","title":"<code>DutyInDb</code>","text":"<p> Bases: <code>DutyBase</code></p> <p>Duty as stored in DB (includes id).</p> Source code in <code>duty_teller/db/schemas.py</code> <pre><code>class DutyInDb(DutyBase):\n \"\"\"Duty as stored in DB (includes id).\"\"\"\n\n id: int\n\n model_config = ConfigDict(from_attributes=True)\n</code></pre>"},{"location":"api-reference/#duty_teller.db.schemas.DutyWithUser","title":"<code>DutyWithUser</code>","text":"<p> Bases: <code>DutyInDb</code></p> <p>Duty with full_name and event_type for calendar display.</p> <p>event_type: only these values are returned; unknown DB values are mapped to \"duty\" in the API.</p> Source code in <code>duty_teller/db/schemas.py</code> <pre><code>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</code></pre>"},{"location":"api-reference/#duty_teller.db.schemas.UserBase","title":"<code>UserBase</code>","text":"<p> Bases: <code>BaseModel</code></p> <p>Base user fields (full_name, username, first/last name).</p> Source code in <code>duty_teller/db/schemas.py</code> <pre><code>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</code></pre>"},{"location":"api-reference/#duty_teller.db.schemas.UserCreate","title":"<code>UserCreate</code>","text":"<p> Bases: <code>UserBase</code></p> <p>User creation payload including Telegram user id.</p> Source code in <code>duty_teller/db/schemas.py</code> <pre><code>class UserCreate(UserBase):\n \"\"\"User creation payload including Telegram user id.\"\"\"\n\n telegram_user_id: int\n</code></pre>"},{"location":"api-reference/#duty_teller.db.schemas.UserInDb","title":"<code>UserInDb</code>","text":"<p> Bases: <code>UserBase</code></p> <p>User as stored in DB (includes id and telegram_user_id).</p> Source code in <code>duty_teller/db/schemas.py</code> <pre><code>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</code></pre>"},{"location":"api-reference/#duty_teller.db.session","title":"<code>duty_teller.db.session</code>","text":"<p>SQLAlchemy engine and session factory.</p> <p>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.</p>"},{"location":"api-reference/#duty_teller.db.session.get_engine","title":"<code>get_engine(database_url)</code>","text":"<p>Return cached SQLAlchemy engine for the given URL (one per process).</p> Source code in <code>duty_teller/db/session.py</code> <pre><code>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</code></pre>"},{"location":"api-reference/#duty_teller.db.session.get_session","title":"<code>get_session(database_url)</code>","text":"<p>Create a new session from the factory for the given URL.</p> Source code in <code>duty_teller/db/session.py</code> <pre><code>def get_session(database_url: str) -&gt; Session:\n \"\"\"Create a new session from the factory for the given URL.\"\"\"\n return get_session_factory(database_url)()\n</code></pre>"},{"location":"api-reference/#duty_teller.db.session.get_session_factory","title":"<code>get_session_factory(database_url)</code>","text":"<p>Return cached session factory for the given URL (one per process).</p> Source code in <code>duty_teller/db/session.py</code> <pre><code>def get_session_factory(database_url: str) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.db.session.session_scope","title":"<code>session_scope(database_url)</code>","text":"<p>Context manager that yields a session; rolls back on exception, closes on exit.</p> <p>Parameters:</p> Name Type Description Default <code>database_url</code> <code>str</code> <p>SQLAlchemy database URL.</p> required <p>Yields:</p> Type Description <code>Session</code> <p>Session instance. Caller must not use it after exit.</p> Source code in <code>duty_teller/db/session.py</code> <pre><code>@contextmanager\ndef session_scope(database_url: str) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.db.repository","title":"<code>duty_teller.db.repository</code>","text":"<p>Repository: get_or_create_user, get_duties, insert_duty, get_current_duty, group_duty_pins.</p>"},{"location":"api-reference/#duty_teller.db.repository.create_calendar_token","title":"<code>create_calendar_token(session, user_id)</code>","text":"<p>Create a new calendar subscription token for the user.</p> <p>Any existing tokens for this user are removed. The raw token is returned only once (not stored in plain text).</p> <p>Parameters:</p> Name Type Description Default <code>session</code> <code>Session</code> <p>DB session.</p> required <code>user_id</code> <code>int</code> <p>User id.</p> required <p>Returns:</p> Type Description <code>str</code> <p>Raw token string (e.g. for URL /api/calendar/ical/{token}.ics).</p> Source code in <code>duty_teller/db/repository.py</code> <pre><code>def create_calendar_token(session: Session, user_id: int) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.db.repository.delete_duties_in_range","title":"<code>delete_duties_in_range(session, user_id, from_date, to_date)</code>","text":"<p>Delete all duties of the user that overlap the given date range.</p> <p>Parameters:</p> Name Type Description Default <code>session</code> <code>Session</code> <p>DB session.</p> required <code>user_id</code> <code>int</code> <p>User id.</p> required <code>from_date</code> <code>str</code> <p>Start date YYYY-MM-DD.</p> required <code>to_date</code> <code>str</code> <p>End date YYYY-MM-DD.</p> required <p>Returns:</p> Type Description <code>int</code> <p>Number of duties deleted.</p> Source code in <code>duty_teller/db/repository.py</code> <pre><code>def delete_duties_in_range(\n session: Session,\n user_id: int,\n from_date: str,\n to_date: str,\n) -&gt; 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 &lt; to_next,\n Duty.end_at &gt;= from_date,\n )\n count = q.count()\n q.delete(synchronize_session=False)\n session.commit()\n return count\n</code></pre>"},{"location":"api-reference/#duty_teller.db.repository.delete_group_duty_pin","title":"<code>delete_group_duty_pin(session, chat_id)</code>","text":"<p>Remove the pinned duty message record for the chat (e.g. when bot leaves group).</p> <p>Parameters:</p> Name Type Description Default <code>session</code> <code>Session</code> <p>DB session.</p> required <code>chat_id</code> <code>int</code> <p>Telegram chat id.</p> required Source code in <code>duty_teller/db/repository.py</code> <pre><code>def delete_group_duty_pin(session: Session, chat_id: int) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.db.repository.get_all_group_duty_pin_chat_ids","title":"<code>get_all_group_duty_pin_chat_ids(session)</code>","text":"<p>Return all chat_ids that have a pinned duty message.</p> <p>Used to restore update jobs on bot startup.</p> <p>Parameters:</p> Name Type Description Default <code>session</code> <code>Session</code> <p>DB session.</p> required <p>Returns:</p> Type Description <code>list[int]</code> <p>List of chat ids.</p> Source code in <code>duty_teller/db/repository.py</code> <pre><code>def get_all_group_duty_pin_chat_ids(session: Session) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.db.repository.get_current_duty","title":"<code>get_current_duty(session, at_utc)</code>","text":"<p>Return the duty and user active at the given UTC time (event_type='duty').</p> <p>Parameters:</p> Name Type Description Default <code>session</code> <code>Session</code> <p>DB session.</p> required <code>at_utc</code> <code>datetime</code> <p>Point in time (timezone-aware or naive UTC).</p> required <p>Returns:</p> Type Description <code>tuple[Duty, User] | None</code> <p>(Duty, User) or None if no duty at that time.</p> Source code in <code>duty_teller/db/repository.py</code> <pre><code>def get_current_duty(session: Session, at_utc: datetime) -&gt; 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 &lt;= now_iso,\n Duty.end_at &gt; now_iso,\n )\n .first()\n )\n if row is None:\n return None\n return (row[0], row[1])\n</code></pre>"},{"location":"api-reference/#duty_teller.db.repository.get_duties","title":"<code>get_duties(session, from_date, to_date)</code>","text":"<p>Return duties overlapping the given date range with user full_name.</p> <p>Parameters:</p> Name Type Description Default <code>session</code> <code>Session</code> <p>DB session.</p> required <code>from_date</code> <code>str</code> <p>Start date YYYY-MM-DD.</p> required <code>to_date</code> <code>str</code> <p>End date YYYY-MM-DD.</p> required <p>Returns:</p> Type Description <code>list[tuple[Duty, str]]</code> <p>List of (Duty, full_name) tuples.</p> Source code in <code>duty_teller/db/repository.py</code> <pre><code>def get_duties(\n session: Session,\n from_date: str,\n to_date: str,\n) -&gt; 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 &lt; to_date_next, Duty.end_at &gt;= from_date)\n )\n return list(q.all())\n</code></pre>"},{"location":"api-reference/#duty_teller.db.repository.get_duties_for_user","title":"<code>get_duties_for_user(session, user_id, from_date, to_date)</code>","text":"<p>Return duties for one user overlapping the date range.</p> <p>Parameters:</p> Name Type Description Default <code>session</code> <code>Session</code> <p>DB session.</p> required <code>user_id</code> <code>int</code> <p>User id.</p> required <code>from_date</code> <code>str</code> <p>Start date YYYY-MM-DD.</p> required <code>to_date</code> <code>str</code> <p>End date YYYY-MM-DD.</p> required <p>Returns:</p> Type Description <code>list[tuple[Duty, str]]</code> <p>List of (Duty, full_name) tuples.</p> Source code in <code>duty_teller/db/repository.py</code> <pre><code>def get_duties_for_user(\n session: Session,\n user_id: int,\n from_date: str,\n to_date: str,\n) -&gt; 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 &lt; to_date_next,\n Duty.end_at &gt;= from_date,\n )\n )\n return list(q.all())\n</code></pre>"},{"location":"api-reference/#duty_teller.db.repository.get_group_duty_pin","title":"<code>get_group_duty_pin(session, chat_id)</code>","text":"<p>Get the pinned duty message record for a chat.</p> <p>Parameters:</p> Name Type Description Default <code>session</code> <code>Session</code> <p>DB session.</p> required <code>chat_id</code> <code>int</code> <p>Telegram chat id.</p> required <p>Returns:</p> Type Description <code>GroupDutyPin | None</code> <p>GroupDutyPin or None.</p> Source code in <code>duty_teller/db/repository.py</code> <pre><code>def get_group_duty_pin(session: Session, chat_id: int) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.db.repository.get_next_shift_end","title":"<code>get_next_shift_end(session, after_utc)</code>","text":"<p>Return the end_at of the current or next duty (event_type='duty').</p> <p>Parameters:</p> Name Type Description Default <code>session</code> <code>Session</code> <p>DB session.</p> required <code>after_utc</code> <code>datetime</code> <p>Point in time (timezone-aware or naive UTC).</p> required <p>Returns:</p> Type Description <code>datetime | None</code> <p>End datetime (naive UTC) or None if no current or future duty.</p> Source code in <code>duty_teller/db/repository.py</code> <pre><code>def get_next_shift_end(session: Session, after_utc: datetime) -&gt; 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 &lt;= after_iso,\n Duty.end_at &gt; 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 &gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.db.repository.get_or_create_user","title":"<code>get_or_create_user(session, telegram_user_id, full_name, username=None, first_name=None, last_name=None)</code>","text":"<p>Get or create user by Telegram user ID.</p> <p>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).</p> <p>Parameters:</p> Name Type Description Default <code>session</code> <code>Session</code> <p>DB session.</p> required <code>telegram_user_id</code> <code>int</code> <p>Telegram user id.</p> required <code>full_name</code> <code>str</code> <p>Display full name.</p> required <code>username</code> <code>str | None</code> <p>Telegram username (optional).</p> <code>None</code> <code>first_name</code> <code>str | None</code> <p>Telegram first name (optional).</p> <code>None</code> <code>last_name</code> <code>str | None</code> <p>Telegram last name (optional).</p> <code>None</code> <p>Returns:</p> Type Description <code>User</code> <p>User instance (created or updated).</p> Source code in <code>duty_teller/db/repository.py</code> <pre><code>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) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.db.repository.get_or_create_user_by_full_name","title":"<code>get_or_create_user_by_full_name(session, full_name)</code>","text":"<p>Find user by exact full_name or create one (for duty-schedule import).</p> <p>New users have telegram_user_id=None and name_manually_edited=True.</p> <p>Parameters:</p> Name Type Description Default <code>session</code> <code>Session</code> <p>DB session.</p> required <code>full_name</code> <code>str</code> <p>Exact full name to match or set.</p> required <p>Returns:</p> Type Description <code>User</code> <p>User instance (existing or newly created).</p> Source code in <code>duty_teller/db/repository.py</code> <pre><code>def get_or_create_user_by_full_name(session: Session, full_name: str) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.db.repository.get_user_by_calendar_token","title":"<code>get_user_by_calendar_token(session, token)</code>","text":"<p>Find user by calendar subscription token.</p> <p>Uses constant-time comparison to avoid timing leaks.</p> <p>Parameters:</p> Name Type Description Default <code>session</code> <code>Session</code> <p>DB session.</p> required <code>token</code> <code>str</code> <p>Raw token from URL.</p> required <p>Returns:</p> Type Description <code>User | None</code> <p>User or None if token is invalid or not found.</p> Source code in <code>duty_teller/db/repository.py</code> <pre><code>def get_user_by_calendar_token(session: Session, token: str) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.db.repository.get_user_by_telegram_id","title":"<code>get_user_by_telegram_id(session, telegram_user_id)</code>","text":"<p>Find user by Telegram user ID.</p> <p>Parameters:</p> Name Type Description Default <code>session</code> <code>Session</code> <p>DB session.</p> required <code>telegram_user_id</code> <code>int</code> <p>Telegram user id.</p> required <p>Returns:</p> Type Description <code>User | None</code> <p>User or None if not found. Does not create a user.</p> Source code in <code>duty_teller/db/repository.py</code> <pre><code>def get_user_by_telegram_id(session: Session, telegram_user_id: int) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.db.repository.insert_duty","title":"<code>insert_duty(session, user_id, start_at, end_at, event_type='duty')</code>","text":"<p>Create a duty record.</p> <p>Parameters:</p> Name Type Description Default <code>session</code> <code>Session</code> <p>DB session.</p> required <code>user_id</code> <code>int</code> <p>User id.</p> required <code>start_at</code> <code>str</code> <p>Start time UTC, ISO 8601 with Z (e.g. 2025-01-15T09:00:00Z).</p> required <code>end_at</code> <code>str</code> <p>End time UTC, ISO 8601 with Z.</p> required <code>event_type</code> <code>str</code> <p>One of \"duty\", \"unavailable\", \"vacation\". Default \"duty\".</p> <code>'duty'</code> <p>Returns:</p> Type Description <code>Duty</code> <p>Created Duty instance.</p> Source code in <code>duty_teller/db/repository.py</code> <pre><code>def insert_duty(\n session: Session,\n user_id: int,\n start_at: str,\n end_at: str,\n event_type: str = \"duty\",\n) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.db.repository.save_group_duty_pin","title":"<code>save_group_duty_pin(session, chat_id, message_id)</code>","text":"<p>Save or update the pinned duty message for a chat.</p> <p>Parameters:</p> Name Type Description Default <code>session</code> <code>Session</code> <p>DB session.</p> required <code>chat_id</code> <code>int</code> <p>Telegram chat id.</p> required <code>message_id</code> <code>int</code> <p>Message id to pin/update.</p> required <p>Returns:</p> Type Description <code>GroupDutyPin</code> <p>GroupDutyPin instance (created or updated).</p> Source code in <code>duty_teller/db/repository.py</code> <pre><code>def save_group_duty_pin(\n session: Session, chat_id: int, message_id: int\n) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.db.repository.set_user_phone","title":"<code>set_user_phone(session, telegram_user_id, phone)</code>","text":"<p>Set or clear phone for user by Telegram user id.</p> <p>Parameters:</p> Name Type Description Default <code>session</code> <code>Session</code> <p>DB session.</p> required <code>telegram_user_id</code> <code>int</code> <p>Telegram user id.</p> required <code>phone</code> <code>str | None</code> <p>Phone string or None to clear.</p> required <p>Returns:</p> Type Description <code>User | None</code> <p>Updated User or None if not found.</p> Source code in <code>duty_teller/db/repository.py</code> <pre><code>def set_user_phone(\n session: Session, telegram_user_id: int, phone: str | None\n) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.db.repository.update_user_display_name","title":"<code>update_user_display_name(session, telegram_user_id, full_name, first_name=None, last_name=None)</code>","text":"<p>Update display name and set name_manually_edited=True.</p> <p>Use from API or admin when name is changed manually; subsequent get_or_create_user will not overwrite these fields.</p> <p>Parameters:</p> Name Type Description Default <code>session</code> <code>Session</code> <p>DB session.</p> required <code>telegram_user_id</code> <code>int</code> <p>Telegram user id.</p> required <code>full_name</code> <code>str</code> <p>New full name.</p> required <code>first_name</code> <code>str | None</code> <p>New first name (optional).</p> <code>None</code> <code>last_name</code> <code>str | None</code> <p>New last name (optional).</p> <code>None</code> <p>Returns:</p> Type Description <code>User | None</code> <p>Updated User or None if not found.</p> Source code in <code>duty_teller/db/repository.py</code> <pre><code>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) -&gt; 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</code></pre>"},{"location":"api-reference/#services","title":"Services","text":""},{"location":"api-reference/#duty_teller.services","title":"<code>duty_teller.services</code>","text":"<p>Service layer: business logic and orchestration.</p>"},{"location":"api-reference/#duty_teller.services.delete_pin","title":"<code>delete_pin(session, chat_id)</code>","text":"<p>Remove the pinned message record for the chat (e.g. when bot leaves).</p> <p>Parameters:</p> Name Type Description Default <code>session</code> <code>Session</code> <p>DB session.</p> required <code>chat_id</code> <code>int</code> <p>Telegram chat id.</p> required Source code in <code>duty_teller/services/group_duty_pin_service.py</code> <pre><code>def delete_pin(session: Session, chat_id: int) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.services.format_duty_message","title":"<code>format_duty_message(duty, user, tz_name, lang='en')</code>","text":"<p>Build the text for the pinned duty message.</p> <p>Parameters:</p> Name Type Description Default <code>duty</code> <p>Duty instance or None.</p> required <code>user</code> <p>User instance or None.</p> required <code>tz_name</code> <code>str</code> <p>Timezone name for display (e.g. Europe/Moscow).</p> required <code>lang</code> <code>str</code> <p>Language code for i18n ('ru' or 'en').</p> <code>'en'</code> <p>Returns:</p> Type Description <code>str</code> <p>Formatted message string; \"No duty\" if duty or user is None.</p> Source code in <code>duty_teller/services/group_duty_pin_service.py</code> <pre><code>def format_duty_message(duty, user, tz_name: str, lang: str = \"en\") -&gt; 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 &gt;= 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</code></pre>"},{"location":"api-reference/#duty_teller.services.get_all_pin_chat_ids","title":"<code>get_all_pin_chat_ids(session)</code>","text":"<p>Return all chat_ids that have a pinned duty message.</p> <p>Used to restore update jobs on bot startup.</p> <p>Parameters:</p> Name Type Description Default <code>session</code> <code>Session</code> <p>DB session.</p> required <p>Returns:</p> Type Description <code>list[int]</code> <p>List of chat ids.</p> Source code in <code>duty_teller/services/group_duty_pin_service.py</code> <pre><code>def get_all_pin_chat_ids(session: Session) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.services.get_duty_message_text","title":"<code>get_duty_message_text(session, tz_name, lang='en')</code>","text":"<p>Get current duty from DB and return formatted message text.</p> <p>Parameters:</p> Name Type Description Default <code>session</code> <code>Session</code> <p>DB session.</p> required <code>tz_name</code> <code>str</code> <p>Timezone name for display.</p> required <code>lang</code> <code>str</code> <p>Language code for i18n.</p> <code>'en'</code> <p>Returns:</p> Type Description <code>str</code> <p>Formatted duty message or \"No duty\" if none.</p> Source code in <code>duty_teller/services/group_duty_pin_service.py</code> <pre><code>def get_duty_message_text(session: Session, tz_name: str, lang: str = \"en\") -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.services.get_message_id","title":"<code>get_message_id(session, chat_id)</code>","text":"<p>Return message_id for the pinned duty message in this chat.</p> <p>Parameters:</p> Name Type Description Default <code>session</code> <code>Session</code> <p>DB session.</p> required <code>chat_id</code> <code>int</code> <p>Telegram chat id.</p> required <p>Returns:</p> Type Description <code>int | None</code> <p>Message id or None if no pin record.</p> Source code in <code>duty_teller/services/group_duty_pin_service.py</code> <pre><code>def get_message_id(session: Session, chat_id: int) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.services.get_next_shift_end_utc","title":"<code>get_next_shift_end_utc(session)</code>","text":"<p>Return next shift end as naive UTC datetime for job scheduling.</p> <p>Parameters:</p> Name Type Description Default <code>session</code> <code>Session</code> <p>DB session.</p> required <p>Returns:</p> Type Description <code>datetime | None</code> <p>Next shift end (naive UTC) or None.</p> Source code in <code>duty_teller/services/group_duty_pin_service.py</code> <pre><code>def get_next_shift_end_utc(session: Session) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.services.run_import","title":"<code>run_import(session, result, hour_utc, minute_utc)</code>","text":"<p>Run duty-schedule import: delete range per user, insert duty/unavailable/vacation.</p> <p>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).</p> <p>Parameters:</p> Name Type Description Default <code>session</code> <code>Session</code> <p>DB session.</p> required <code>result</code> <code>DutyScheduleResult</code> <p>Parsed duty schedule (start_date, end_date, entries).</p> required <code>hour_utc</code> <code>int</code> <p>Handover hour in UTC (0-23).</p> required <code>minute_utc</code> <code>int</code> <p>Handover minute in UTC (0-59).</p> required <p>Returns:</p> Type Description <code>tuple[int, int, int, int]</code> <p>Tuple (num_users, num_duty, num_unavailable, num_vacation).</p> Source code in <code>duty_teller/services/import_service.py</code> <pre><code>def run_import(\n session: Session,\n result: DutyScheduleResult,\n hour_utc: int,\n minute_utc: int,\n) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.services.save_pin","title":"<code>save_pin(session, chat_id, message_id)</code>","text":"<p>Save or update the pinned duty message record for a chat.</p> <p>Parameters:</p> Name Type Description Default <code>session</code> <code>Session</code> <p>DB session.</p> required <code>chat_id</code> <code>int</code> <p>Telegram chat id.</p> required <code>message_id</code> <code>int</code> <p>Message id to store.</p> required Source code in <code>duty_teller/services/group_duty_pin_service.py</code> <pre><code>def save_pin(session: Session, chat_id: int, message_id: int) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.services.import_service","title":"<code>duty_teller.services.import_service</code>","text":"<p>Import duty schedule: delete range, insert duties/unavailable/vacation. Accepts session.</p>"},{"location":"api-reference/#duty_teller.services.import_service.run_import","title":"<code>run_import(session, result, hour_utc, minute_utc)</code>","text":"<p>Run duty-schedule import: delete range per user, insert duty/unavailable/vacation.</p> <p>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).</p> <p>Parameters:</p> Name Type Description Default <code>session</code> <code>Session</code> <p>DB session.</p> required <code>result</code> <code>DutyScheduleResult</code> <p>Parsed duty schedule (start_date, end_date, entries).</p> required <code>hour_utc</code> <code>int</code> <p>Handover hour in UTC (0-23).</p> required <code>minute_utc</code> <code>int</code> <p>Handover minute in UTC (0-59).</p> required <p>Returns:</p> Type Description <code>tuple[int, int, int, int]</code> <p>Tuple (num_users, num_duty, num_unavailable, num_vacation).</p> Source code in <code>duty_teller/services/import_service.py</code> <pre><code>def run_import(\n session: Session,\n result: DutyScheduleResult,\n hour_utc: int,\n minute_utc: int,\n) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.services.group_duty_pin_service","title":"<code>duty_teller.services.group_duty_pin_service</code>","text":"<p>Group duty pin: current duty message text, next shift end, pin CRUD. All accept session.</p>"},{"location":"api-reference/#duty_teller.services.group_duty_pin_service.delete_pin","title":"<code>delete_pin(session, chat_id)</code>","text":"<p>Remove the pinned message record for the chat (e.g. when bot leaves).</p> <p>Parameters:</p> Name Type Description Default <code>session</code> <code>Session</code> <p>DB session.</p> required <code>chat_id</code> <code>int</code> <p>Telegram chat id.</p> required Source code in <code>duty_teller/services/group_duty_pin_service.py</code> <pre><code>def delete_pin(session: Session, chat_id: int) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.services.group_duty_pin_service.format_duty_message","title":"<code>format_duty_message(duty, user, tz_name, lang='en')</code>","text":"<p>Build the text for the pinned duty message.</p> <p>Parameters:</p> Name Type Description Default <code>duty</code> <p>Duty instance or None.</p> required <code>user</code> <p>User instance or None.</p> required <code>tz_name</code> <code>str</code> <p>Timezone name for display (e.g. Europe/Moscow).</p> required <code>lang</code> <code>str</code> <p>Language code for i18n ('ru' or 'en').</p> <code>'en'</code> <p>Returns:</p> Type Description <code>str</code> <p>Formatted message string; \"No duty\" if duty or user is None.</p> Source code in <code>duty_teller/services/group_duty_pin_service.py</code> <pre><code>def format_duty_message(duty, user, tz_name: str, lang: str = \"en\") -&gt; 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 &gt;= 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</code></pre>"},{"location":"api-reference/#duty_teller.services.group_duty_pin_service.get_all_pin_chat_ids","title":"<code>get_all_pin_chat_ids(session)</code>","text":"<p>Return all chat_ids that have a pinned duty message.</p> <p>Used to restore update jobs on bot startup.</p> <p>Parameters:</p> Name Type Description Default <code>session</code> <code>Session</code> <p>DB session.</p> required <p>Returns:</p> Type Description <code>list[int]</code> <p>List of chat ids.</p> Source code in <code>duty_teller/services/group_duty_pin_service.py</code> <pre><code>def get_all_pin_chat_ids(session: Session) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.services.group_duty_pin_service.get_duty_message_text","title":"<code>get_duty_message_text(session, tz_name, lang='en')</code>","text":"<p>Get current duty from DB and return formatted message text.</p> <p>Parameters:</p> Name Type Description Default <code>session</code> <code>Session</code> <p>DB session.</p> required <code>tz_name</code> <code>str</code> <p>Timezone name for display.</p> required <code>lang</code> <code>str</code> <p>Language code for i18n.</p> <code>'en'</code> <p>Returns:</p> Type Description <code>str</code> <p>Formatted duty message or \"No duty\" if none.</p> Source code in <code>duty_teller/services/group_duty_pin_service.py</code> <pre><code>def get_duty_message_text(session: Session, tz_name: str, lang: str = \"en\") -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.services.group_duty_pin_service.get_message_id","title":"<code>get_message_id(session, chat_id)</code>","text":"<p>Return message_id for the pinned duty message in this chat.</p> <p>Parameters:</p> Name Type Description Default <code>session</code> <code>Session</code> <p>DB session.</p> required <code>chat_id</code> <code>int</code> <p>Telegram chat id.</p> required <p>Returns:</p> Type Description <code>int | None</code> <p>Message id or None if no pin record.</p> Source code in <code>duty_teller/services/group_duty_pin_service.py</code> <pre><code>def get_message_id(session: Session, chat_id: int) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.services.group_duty_pin_service.get_next_shift_end_utc","title":"<code>get_next_shift_end_utc(session)</code>","text":"<p>Return next shift end as naive UTC datetime for job scheduling.</p> <p>Parameters:</p> Name Type Description Default <code>session</code> <code>Session</code> <p>DB session.</p> required <p>Returns:</p> Type Description <code>datetime | None</code> <p>Next shift end (naive UTC) or None.</p> Source code in <code>duty_teller/services/group_duty_pin_service.py</code> <pre><code>def get_next_shift_end_utc(session: Session) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.services.group_duty_pin_service.save_pin","title":"<code>save_pin(session, chat_id, message_id)</code>","text":"<p>Save or update the pinned duty message record for a chat.</p> <p>Parameters:</p> Name Type Description Default <code>session</code> <code>Session</code> <p>DB session.</p> required <code>chat_id</code> <code>int</code> <p>Telegram chat id.</p> required <code>message_id</code> <code>int</code> <p>Message id to store.</p> required Source code in <code>duty_teller/services/group_duty_pin_service.py</code> <pre><code>def save_pin(session: Session, chat_id: int, message_id: int) -&gt; 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</code></pre>"},{"location":"api-reference/#handlers","title":"Handlers","text":""},{"location":"api-reference/#duty_teller.handlers","title":"<code>duty_teller.handlers</code>","text":"<p>Expose a single register_handlers(app) that registers all handlers.</p>"},{"location":"api-reference/#duty_teller.handlers.register_handlers","title":"<code>register_handlers(app)</code>","text":"<p>Register all Telegram handlers (commands, import, group pin, error handler) on the application.</p> <p>Parameters:</p> Name Type Description Default <code>app</code> <code>Application</code> <p>python-telegram-bot Application instance.</p> required Source code in <code>duty_teller/handlers/__init__.py</code> <pre><code>def register_handlers(app: Application) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.handlers.commands","title":"<code>duty_teller.handlers.commands</code>","text":"<p>Command handlers: /start, /help; /start registers user.</p>"},{"location":"api-reference/#duty_teller.handlers.commands.calendar_link","title":"<code>calendar_link(update, context)</code> <code>async</code>","text":"<p>Handle /calendar_link: send personal ICS URL (private chat only; user must be in allowlist).</p> Source code in <code>duty_teller/handlers/commands.py</code> <pre><code>async def calendar_link(update: Update, context: ContextTypes.DEFAULT_TYPE) -&gt; 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() -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.handlers.commands.help_cmd","title":"<code>help_cmd(update, context)</code> <code>async</code>","text":"<p>Handle /help: send list of commands (admins see import_duty_schedule).</p> Source code in <code>duty_teller/handlers/commands.py</code> <pre><code>async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.handlers.commands.set_phone","title":"<code>set_phone(update, context)</code> <code>async</code>","text":"<p>Handle /set_phone [number]: set or clear phone (private chat only).</p> Source code in <code>duty_teller/handlers/commands.py</code> <pre><code>async def set_phone(update: Update, context: ContextTypes.DEFAULT_TYPE) -&gt; 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() -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.handlers.commands.start","title":"<code>start(update, context)</code> <code>async</code>","text":"<p>Handle /start: register user in DB and send greeting.</p> Source code in <code>duty_teller/handlers/commands.py</code> <pre><code>async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -&gt; 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() -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.handlers.import_duty_schedule","title":"<code>duty_teller.handlers.import_duty_schedule</code>","text":"<p>Import duty-schedule: /import_duty_schedule (admin only). Two steps: handover time -&gt; JSON file.</p>"},{"location":"api-reference/#duty_teller.handlers.import_duty_schedule.handle_duty_schedule_document","title":"<code>handle_duty_schedule_document(update, context)</code> <code>async</code>","text":"<p>Handle uploaded JSON file: parse duty-schedule and run import.</p> Source code in <code>duty_teller/handlers/import_duty_schedule.py</code> <pre><code>async def handle_duty_schedule_document(\n update: Update, context: ContextTypes.DEFAULT_TYPE\n) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.handlers.import_duty_schedule.handle_handover_time_text","title":"<code>handle_handover_time_text(update, context)</code> <code>async</code>","text":"<p>Handle text message when awaiting handover time (e.g. 09:00 Europe/Moscow).</p> Source code in <code>duty_teller/handlers/import_duty_schedule.py</code> <pre><code>async def handle_handover_time_text(\n update: Update, context: ContextTypes.DEFAULT_TYPE\n) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.handlers.import_duty_schedule.import_duty_schedule_cmd","title":"<code>import_duty_schedule_cmd(update, context)</code> <code>async</code>","text":"<p>Handle /import_duty_schedule: start two-step import (admin only); asks for handover time.</p> Source code in <code>duty_teller/handlers/import_duty_schedule.py</code> <pre><code>async def import_duty_schedule_cmd(\n update: Update, context: ContextTypes.DEFAULT_TYPE\n) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.handlers.group_duty_pin","title":"<code>duty_teller.handlers.group_duty_pin</code>","text":"<p>Pinned duty message in groups: handle bot add/remove, schedule updates at shift end.</p>"},{"location":"api-reference/#duty_teller.handlers.group_duty_pin.my_chat_member_handler","title":"<code>my_chat_member_handler(update, context)</code> <code>async</code>","text":"<p>Handle bot added to or removed from group: send/pin duty message or delete pin record.</p> Source code in <code>duty_teller/handlers/group_duty_pin.py</code> <pre><code>async def my_chat_member_handler(\n update: Update, context: ContextTypes.DEFAULT_TYPE\n) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.handlers.group_duty_pin.pin_duty_cmd","title":"<code>pin_duty_cmd(update, context)</code> <code>async</code>","text":"<p>Handle /pin_duty: pin the current duty message in the group (reply to bot's message).</p> Source code in <code>duty_teller/handlers/group_duty_pin.py</code> <pre><code>async def pin_duty_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.handlers.group_duty_pin.restore_group_pin_jobs","title":"<code>restore_group_pin_jobs(application)</code> <code>async</code>","text":"<p>Restore scheduled pin-update jobs for all chats that have a pinned message (on startup).</p> Source code in <code>duty_teller/handlers/group_duty_pin.py</code> <pre><code>async def restore_group_pin_jobs(application) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.handlers.group_duty_pin.update_group_pin","title":"<code>update_group_pin(context)</code> <code>async</code>","text":"<p>Job callback: refresh pinned duty message and schedule next update at shift end.</p> Source code in <code>duty_teller/handlers/group_duty_pin.py</code> <pre><code>async def update_group_pin(context: ContextTypes.DEFAULT_TYPE) -&gt; 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</code></pre>"},{"location":"api-reference/#duty_teller.handlers.errors","title":"<code>duty_teller.handlers.errors</code>","text":"<p>Global error handler: log exception and notify user.</p>"},{"location":"api-reference/#duty_teller.handlers.errors.error_handler","title":"<code>error_handler(update, context)</code> <code>async</code>","text":"<p>Global error handler: log exception and reply with generic message if possible.</p> <p>Parameters:</p> Name Type Description Default <code>update</code> <code>Update | None</code> <p>Update that caused the error (may be None).</p> required <code>context</code> <code>DEFAULT_TYPE</code> <p>Callback context.</p> required Source code in <code>duty_teller/handlers/errors.py</code> <pre><code>async def error_handler(\n update: Update | None, context: ContextTypes.DEFAULT_TYPE\n) -&gt; 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</code></pre>"},{"location":"api-reference/#importers","title":"Importers","text":""},{"location":"api-reference/#duty_teller.importers","title":"<code>duty_teller.importers</code>","text":"<p>Importers for duty data (e.g. duty-schedule JSON).</p>"},{"location":"api-reference/#duty_teller.importers.duty_schedule","title":"<code>duty_teller.importers.duty_schedule</code>","text":"<p>Parser for duty-schedule JSON format. No DB access.</p>"},{"location":"api-reference/#duty_teller.importers.duty_schedule.DutyScheduleEntry","title":"<code>DutyScheduleEntry</code> <code>dataclass</code>","text":"<p>One person's schedule: full_name and three lists of dates by event type.</p> Source code in <code>duty_teller/importers/duty_schedule.py</code> <pre><code>@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</code></pre>"},{"location":"api-reference/#duty_teller.importers.duty_schedule.DutyScheduleParseError","title":"<code>DutyScheduleParseError</code>","text":"<p> Bases: <code>Exception</code></p> <p>Invalid or missing fields in duty-schedule JSON.</p> Source code in <code>duty_teller/importers/duty_schedule.py</code> <pre><code>class DutyScheduleParseError(Exception):\n \"\"\"Invalid or missing fields in duty-schedule JSON.\"\"\"\n\n pass\n</code></pre>"},{"location":"api-reference/#duty_teller.importers.duty_schedule.DutyScheduleResult","title":"<code>DutyScheduleResult</code> <code>dataclass</code>","text":"<p>Parsed duty schedule: start_date, end_date, and per-person entries.</p> Source code in <code>duty_teller/importers/duty_schedule.py</code> <pre><code>@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</code></pre>"},{"location":"api-reference/#duty_teller.importers.duty_schedule.parse_duty_schedule","title":"<code>parse_duty_schedule(raw_bytes)</code>","text":"<p>Parse duty-schedule JSON into DutyScheduleResult.</p> <p>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 =&gt; duty, \u041d =&gt; unavailable, \u041e =&gt; vacation; rest ignored.</p> <p>Parameters:</p> Name Type Description Default <code>raw_bytes</code> <code>bytes</code> <p>UTF-8 encoded JSON bytes.</p> required <p>Returns:</p> Type Description <code>DutyScheduleResult</code> <p>DutyScheduleResult with start_date, end_date, and entries (per-person dates).</p> <p>Raises:</p> Type Description <code>DutyScheduleParseError</code> <p>On invalid JSON, missing/invalid meta or schedule, or invalid item fields.</p> Source code in <code>duty_teller/importers/duty_schedule.py</code> <pre><code>def parse_duty_schedule(raw_bytes: bytes) -&gt; 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 =&gt; duty, \u041d =&gt; unavailable, \u041e =&gt; 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</code></pre>"},{"location":"architecture/","title":"Architecture","text":"<p>High-level architecture of Duty Teller: components, data flow, and package relationships.</p>"},{"location":"architecture/#components","title":"Components","text":"<ul> <li>Bot \u2014 python-telegram-bot v22 (Application API). Handles commands and group messages; runs in polling mode.</li> <li>FastAPI \u2014 HTTP server: REST API (<code>/api/duties</code>, <code>/api/calendar-events</code>, <code>/api/calendar/ical/{token}.ics</code>) and static miniapp at <code>/app</code>. Runs in a separate thread alongside the bot.</li> <li>Database \u2014 SQLAlchemy ORM with Alembic migrations. Default backend: SQLite (<code>data/duty_teller.db</code>). Stores users, duties (with event types: duty, unavailable, vacation), group duty pins, calendar subscription tokens.</li> <li>Duty-schedule import \u2014 Two-step admin flow: handover time (timezone \u2192 UTC), then JSON file. Parser produces per-person date lists; import service deletes existing duties in range and inserts new ones.</li> <li>Group duty pin \u2014 In groups, the bot can pin the current duty message; time/timezone for the pinned text come from <code>DUTY_DISPLAY_TZ</code>. Pin state is restored on startup from the database.</li> </ul>"},{"location":"architecture/#data-flow","title":"Data flow","text":"<ul> <li> <p>Telegram \u2192 bot User/group messages \u2192 handlers \u2192 services or DB. Handlers use <code>duty_teller.services</code> (e.g. import, group duty pin) and <code>duty_teller.db</code> (repository, session). Messages use <code>duty_teller.i18n</code> for Russian/English.</p> </li> <li> <p>Miniapp \u2192 API Browser opens <code>/app</code>; frontend calls <code>GET /api/duties</code> and <code>GET /api/calendar-events</code> with date range. FastAPI dependencies: DB session, Telegram initData validation (<code>require_miniapp_username</code>), date validation. Data is read via <code>duty_teller.db.repository</code>.</p> </li> <li> <p>Import Admin sends JSON file via <code>/import_duty_schedule</code>. Handler reads file \u2192 <code>duty_teller.importers.duty_schedule.parse_duty_schedule()</code> \u2192 <code>DutyScheduleResult</code> \u2192 <code>duty_teller.services.import_service.run_import()</code> \u2192 repository (<code>get_or_create_user_by_full_name</code>, <code>delete_duties_in_range</code>, <code>insert_duty</code>).</p> </li> <li> <p>Personal calendar ICS <code>GET /api/calendar/ical/{token}.ics</code> uses the secret token only (no Telegram auth); repository resolves user by token and returns duties; <code>personal_calendar_ics.build_personal_ics()</code> produces ICS bytes.</p> </li> </ul>"},{"location":"architecture/#package-layout","title":"Package layout","text":"<pre><code>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 --&gt; run\n run --&gt; config\n run --&gt; handlers\n run --&gt; api\n handlers --&gt; services\n handlers --&gt; db\n handlers --&gt; i18n\n api --&gt; db\n api --&gt; config\n services --&gt; db\n services --&gt; importers\n importers --&gt; .\n</code></pre> <ul> <li>handlers \u2014 Telegram command and message handlers; call <code>services</code> and <code>db</code>, use <code>i18n</code> for user-facing text.</li> <li>api \u2014 FastAPI app, dependencies (auth, DB session, date validation), calendar ICS builders; uses <code>db.repository</code> and <code>config</code>.</li> <li>db \u2014 Models, session (<code>session_scope</code>), repository (CRUD for users, duties, pins, calendar tokens), schemas for API.</li> <li>services \u2014 Business logic (import, group duty pin); receive DB session from caller, use <code>importers</code> for parsing.</li> <li>importers \u2014 Duty-schedule JSON parser; no DB access, returns structured result.</li> <li>i18n \u2014 Translations and language detection (ru/en) for bot and API.</li> <li>utils \u2014 Shared helpers (dates, user, handover).</li> </ul> <p>See Project layout in README for file-level details.</p>"},{"location":"configuration/","title":"Configuration reference","text":"<p>All configuration is read from the environment (e.g. <code>.env</code> via python-dotenv). Source of truth: <code>duty_teller/config.py</code> and <code>Settings.from_env()</code>.</p> Variable Type / format Default Description BOT_TOKEN string (empty) Telegram bot token from @BotFather. Required for the bot to run; if unset, the entry point exits with a clear message. The server that serves the Mini App API must use the same token as the bot; otherwise initData validation returns <code>hash_mismatch</code>. DATABASE_URL string (SQLAlchemy URL) <code>sqlite:///data/duty_teller.db</code> Database connection URL. Example: <code>sqlite:///data/duty_teller.db</code>. MINI_APP_BASE_URL string (URL, no trailing slash) (empty) Base URL of the miniapp (for documentation and CORS). Trailing slash is stripped. Example: <code>https://your-domain.com/app</code>. HTTP_PORT integer <code>8080</code> Port for the HTTP server (FastAPI + static webapp). ALLOWED_USERNAMES comma-separated list (empty) Telegram usernames allowed to open the calendar miniapp (without <code>@</code>; case-insensitive). If both this and <code>ADMIN_USERNAMES</code> are empty, no one can open the calendar. Example: <code>alice,bob</code>. ADMIN_USERNAMES comma-separated list (empty) Telegram usernames with admin role (access to miniapp + <code>/import_duty_schedule</code> and future admin features). Example: <code>admin1,admin2</code>. ALLOWED_PHONES comma-separated list (empty) Phone numbers allowed to access the miniapp (user sets via <code>/set_phone</code>). Comparison uses digits only (spaces, <code>+</code>, parentheses, dashes ignored). Example: <code>+7 999 123-45-67,89001234567</code>. ADMIN_PHONES comma-separated list (empty) Phone numbers with admin role; same format as <code>ALLOWED_PHONES</code>. MINI_APP_SKIP_AUTH <code>1</code>, <code>true</code>, or <code>yes</code> (unset) If set, <code>/api/duties</code> is allowed without Telegram initData (dev only; insecure). INIT_DATA_MAX_AGE_SECONDS integer <code>0</code> Reject Telegram initData older than this many seconds. <code>0</code> = disabled. Example: <code>86400</code> for 24 hours. CORS_ORIGINS comma-separated list <code>*</code> Allowed origins for CORS. Leave unset or set to <code>*</code> for allow-all. Example: <code>https://your-domain.com</code>. 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) <code>Europe/Moscow</code> Timezone for the pinned duty message in groups. Example: <code>Europe/Moscow</code>, <code>UTC</code>. DEFAULT_LANGUAGE <code>en</code> or <code>ru</code> (normalized) <code>en</code> Default UI language when the user's Telegram language is unknown. Values starting with <code>ru</code> are normalized to <code>ru</code>, otherwise <code>en</code>."},{"location":"configuration/#quick-setup","title":"Quick setup","text":"<ol> <li>Copy <code>.env.example</code> to <code>.env</code>.</li> <li>Set <code>BOT_TOKEN</code> to the token from BotFather.</li> <li>For miniapp access, set <code>ALLOWED_USERNAMES</code> and/or <code>ADMIN_USERNAMES</code> (and optionally <code>ALLOWED_PHONES</code> / <code>ADMIN_PHONES</code>).</li> </ol> <p>For Mini App URL and production deployment notes (reverse proxy, initData), see the README Setup and Docker sections.</p>"},{"location":"import-format/","title":"Duty-schedule import format","text":"<p>The duty-schedule format is used by the <code>/import_duty_schedule</code> command. Only users in <code>ADMIN_USERNAMES</code> or <code>ADMIN_PHONES</code> can import.</p>"},{"location":"import-format/#import-flow","title":"Import flow","text":"<ol> <li>Handover time \u2014 The bot asks for the shift handover time and optional timezone (e.g. <code>09:00 Europe/Moscow</code> or <code>06:00 UTC</code>). This is converted to UTC and used as the boundary between duty periods when creating records.</li> <li>JSON file \u2014 Send a file in duty-schedule format (see below). On re-import, duties in the same date range for each user are replaced by the new data.</li> </ol>"},{"location":"import-format/#format-specification","title":"Format specification","text":"<ul> <li>meta (required) \u2014 Object with:</li> <li>start_date (required) \u2014 First day of the schedule, <code>YYYY-MM-DD</code>.</li> <li> <p>weeks (optional) \u2014 Not used to limit length; the number of days is derived from the longest <code>duty</code> string (see below).</p> </li> <li> <p>schedule (required) \u2014 Array of objects. Each object:</p> </li> <li>name (required) \u2014 Full name of the person (string).</li> <li>duty (required) \u2014 String of cells separated by <code>;</code>. Each cell corresponds to one day starting from <code>meta.start_date</code> (first cell = start_date, second = start_date + 1 day, etc.). Empty or whitespace = no event for that day.</li> </ul>"},{"location":"import-format/#cell-values-single-character-case-sensitive-where-noted","title":"Cell values (single character, case-sensitive where noted)","text":"Value Meaning Notes \u0432, \u0412, \u0431, \u0411 Duty (\u0434\u0435\u0436\u0443\u0440\u0441\u0442\u0432\u043e) Any of these four \u041d Unavailable (\u043d\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u0435\u043d) Exactly <code>\u041d</code> \u041e Vacation (\u043e\u0442\u043f\u0443\u0441\u043a) Exactly <code>\u041e</code> (empty/space/other) No event Ignored for import <p>The number of days in the schedule is the maximum length of any <code>duty</code> string when split by <code>;</code>. If <code>duty</code> is empty or missing, it is treated as an empty list of cells.</p>"},{"location":"import-format/#example-json","title":"Example JSON","text":"<pre><code>{\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</code></pre> <ul> <li>start_date is 2025-02-01; the longest <code>duty</code> has 14 cells (after splitting by <code>;</code>), so the schedule spans 14 days (2025-02-01 \u2026 2025-02-14).</li> <li>First person: duty on day index 2 (\u0412), unavailable on 6 (\u041d), vacation on 8 (\u041e), duty on 11 (\u0412). Other cells are empty.</li> <li>Second person: duty on day indices 3 and 10.</li> </ul>"},{"location":"import-format/#validation","title":"Validation","text":"<ul> <li><code>meta</code> and <code>meta.start_date</code> must be present and valid; <code>start_date</code> must parse as <code>YYYY-MM-DD</code>.</li> <li><code>schedule</code> must be an array; each item must be an object with string <code>name</code> (non-empty after strip) and string <code>duty</code> (if missing, treated as <code>\"\"</code>).</li> <li>Invalid JSON or encoding raises an error; the parser reports missing or invalid fields (see <code>duty_teller.importers.duty_schedule.DutyScheduleParseError</code>).</li> </ul>"},{"location":"runbook/","title":"Runbook (operational guide)","text":"<p>This document covers running the application, checking health, logs, common errors, and database operations.</p>"},{"location":"runbook/#starting-and-stopping","title":"Starting and stopping","text":""},{"location":"runbook/#local","title":"Local","text":"<ul> <li>Start: From the repository root, with virtualenv activated: <code>bash python main.py</code> Or after <code>pip install -e .</code>: <code>duty-teller</code></li> <li>Stop: <code>Ctrl+C</code></li> </ul>"},{"location":"runbook/#docker","title":"Docker","text":"<ul> <li> <p>Dev (code mounted; no rebuild needed for code changes): <code>bash docker compose -f docker-compose.dev.yml up --build</code> Stop: <code>Ctrl+C</code> or <code>docker compose -f docker-compose.dev.yml down</code>.</p> </li> <li> <p>Prod (built image; restarts on failure): <code>bash docker compose -f docker-compose.prod.yml up -d --build</code> Stop: <code>docker compose -f docker-compose.prod.yml down</code>.</p> </li> </ul> <p>On container start, <code>entrypoint.sh</code> runs Alembic migrations then starts the app as user <code>botuser</code>. Ensure <code>.env</code> (or your orchestrator\u2019s env) contains <code>BOT_TOKEN</code> and any required variables; see configuration.md.</p>"},{"location":"runbook/#health-check","title":"Health check","text":"<ul> <li>HTTP: The FastAPI app serves the API and static webapp. A simple way to verify it is up is to open the interactive API docs: <code>GET /docs</code> (e.g. <code>http://localhost:8080/docs</code>). If that page loads, the server is running.</li> <li>There is no dedicated <code>/health</code> endpoint; use <code>/docs</code> or a lightweight API call (e.g. <code>GET /api/duties?from=...&amp;to=...</code> with valid auth) as needed.</li> </ul>"},{"location":"runbook/#logs","title":"Logs","text":"<ul> <li>Local: Output goes to stdout/stderr; redirect or use your process manager\u2019s logging (e.g. systemd, supervisord).</li> <li>Docker: Use <code>docker compose logs -f</code> (with the appropriate compose file) to follow application logs. Adjust log level via Python <code>logging</code> if needed (e.g. environment or code).</li> </ul>"},{"location":"runbook/#common-errors-and-what-to-check","title":"Common errors and what to check","text":""},{"location":"runbook/#hash_mismatch-403-from-apiduties-or-miniapp","title":"\"hash_mismatch\" (403 from <code>/api/duties</code> or Miniapp)","text":"<ul> <li>Cause: The server that serves the Mini App (e.g. production host) uses a different <code>BOT_TOKEN</code> 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.</li> <li>Check: Ensure the same <code>BOT_TOKEN</code> is set in <code>.env</code> (or equivalent) on the machine serving <code>/api/duties</code> as the one used by the bot instance whose menu button opens the Miniapp.</li> </ul>"},{"location":"runbook/#miniapp-open-in-browser-or-direct-link-access-denied","title":"Miniapp \"Open in browser\" or direct link \u2014 access denied","text":"<ul> <li>Cause: When users open the calendar via \u201cOpen in browser\u201d or a direct URL, Telegram may not send <code>tgWebAppData</code> (initData). The API requires initData (or <code>MINI_APP_SKIP_AUTH</code> / private IP in dev).</li> <li>Action: Users should open the calendar via the bot\u2019s menu button (e.g. \u22ee \u2192 \u00ab\u041a\u0430\u043b\u0435\u043d\u0434\u0430\u0440\u044c\u00bb) or a Web App inline button so Telegram sends user data.</li> </ul>"},{"location":"runbook/#403-open-from-telegram-no-initdata","title":"403 \"Open from Telegram\" / no initData","text":"<ul> <li>Cause: Request to <code>/api/duties</code> (or calendar) without valid <code>X-Telegram-Init-Data</code> header. In production, only private IP clients can be allowed without initData (see <code>_is_private_client</code> in <code>api/dependencies.py</code>); behind a reverse proxy, <code>request.client.host</code> is often the proxy (e.g. 127.0.0.1), so the \u201cprivate IP\u201d bypass may not apply to the real user.</li> <li>Check: Ensure the Mini App is opened from Telegram (menu or inline button). If behind a reverse proxy, see README \u201cProduction behind a reverse proxy\u201d (forward real client IP or rely on initData).</li> </ul>"},{"location":"runbook/#mini-app-url-redirect-and-broken-auth","title":"Mini App URL \u2014 redirect and broken auth","text":"<ul> <li>Cause: If the Mini App URL is configured without a trailing slash (e.g. <code>https://your-domain.com/app</code>) and the server redirects <code>/app</code> \u2192 <code>/app/</code>, the browser can drop the fragment Telegram sends, breaking authorization.</li> <li>Action: Configure the bot\u2019s menu button / Web App URL with a trailing slash, e.g. <code>https://your-domain.com/app/</code>. See README \u201cMini App URL\u201d.</li> </ul>"},{"location":"runbook/#user-not-in-allowlist-403","title":"User not in allowlist (403)","text":"<ul> <li>Cause: Telegram user\u2019s username is not in <code>ALLOWED_USERNAMES</code> or <code>ADMIN_USERNAMES</code>, and (if using phone) their phone (set via <code>/set_phone</code>) is not in <code>ALLOWED_PHONES</code> or <code>ADMIN_PHONES</code>.</li> <li>Check: configuration.md for <code>ALLOWED_USERNAMES</code>, <code>ADMIN_USERNAMES</code>, <code>ALLOWED_PHONES</code>, <code>ADMIN_PHONES</code>. Add the user or ask them to set phone and add it to the allowlist.</li> </ul>"},{"location":"runbook/#database-and-migrations","title":"Database and migrations","text":"<ul> <li>Default DB path (SQLite): <code>data/duty_teller.db</code> (relative to working directory when using default <code>DATABASE_URL=sqlite:///data/duty_teller.db</code>). In Docker, the entrypoint creates <code>/app/data</code> and runs migrations there.</li> <li>Migrations (Alembic): From the repository root: <code>bash alembic -c pyproject.toml upgrade head</code> Config: <code>pyproject.toml</code> \u2192 <code>[tool.alembic]</code>; script location <code>alembic/</code>; metadata and URL from <code>duty_teller.config</code> and <code>duty_teller.db.models.Base</code>.</li> <li>Rollback: Use with care; test in a copy of the DB first. Example to go back one revision: <code>bash alembic -c pyproject.toml downgrade -1</code> Always backup the database before downgrading.</li> </ul> <p>For full list of env vars (including <code>DATABASE_URL</code>), see configuration.md. For reverse proxy and Mini App URL details, see the main README.</p>"}]}