{"config":{"lang":["en"],"separator":"[\\s\\-]+","pipeline":["stopWordFilter"],"fields":{"title":{"boost":1000.0},"text":{"boost":1.0},"tags":{"boost":1000000.0}}},"docs":[{"location":"","title":"Duty Teller","text":"

Telegram bot for team duty shift calendar and group reminder. The bot and web UI support Russian and English.

"},{"location":"#documentation","title":"Documentation","text":"

For quick start, setup, and API overview see the main README.

"},{"location":"api-reference/","title":"API Reference","text":"

Generated from the duty_teller package. The following subpackages and modules are included.

"},{"location":"api-reference/#configuration","title":"Configuration","text":""},{"location":"api-reference/#duty_teller.config","title":"duty_teller.config","text":"

Load configuration from environment (e.g. .env via python-dotenv).

BOT_TOKEN is not validated on import; call require_bot_token() in the entry point when running the bot.

"},{"location":"api-reference/#duty_teller.config.Settings","title":"Settings dataclass","text":"

Injectable settings built from environment. Used in tests or when env is overridden.

Source code in duty_teller/config.py
@dataclass(frozen=True)\nclass Settings:\n    \"\"\"Injectable settings built from environment. Used in tests or when env is overridden.\"\"\"\n\n    bot_token: str\n    database_url: str\n    mini_app_base_url: str\n    http_port: int\n    allowed_usernames: set[str]\n    admin_usernames: set[str]\n    allowed_phones: set[str]\n    admin_phones: set[str]\n    mini_app_skip_auth: bool\n    init_data_max_age_seconds: int\n    cors_origins: list[str]\n    external_calendar_ics_url: str\n    duty_display_tz: str\n    default_language: str\n\n    @classmethod\n    def from_env(cls) -> \"Settings\":\n        \"\"\"Build Settings from current environment (same logic as module-level variables).\n\n        Returns:\n            Settings instance with all fields populated from env.\n        \"\"\"\n        bot_token = os.getenv(\"BOT_TOKEN\") or \"\"\n        raw_allowed = os.getenv(\"ALLOWED_USERNAMES\", \"\").strip()\n        allowed = {\n            s.strip().lstrip(\"@\").lower() for s in raw_allowed.split(\",\") if s.strip()\n        }\n        raw_admin = os.getenv(\"ADMIN_USERNAMES\", \"\").strip()\n        admin = {\n            s.strip().lstrip(\"@\").lower() for s in raw_admin.split(\",\") if s.strip()\n        }\n        allowed_phones = _parse_phone_list(os.getenv(\"ALLOWED_PHONES\", \"\"))\n        admin_phones = _parse_phone_list(os.getenv(\"ADMIN_PHONES\", \"\"))\n        raw_cors = os.getenv(\"CORS_ORIGINS\", \"\").strip()\n        cors = (\n            [_o.strip() for _o in raw_cors.split(\",\") if _o.strip()]\n            if raw_cors and raw_cors != \"*\"\n            else [\"*\"]\n        )\n        return cls(\n            bot_token=bot_token,\n            database_url=os.getenv(\"DATABASE_URL\", \"sqlite:///data/duty_teller.db\"),\n            mini_app_base_url=os.getenv(\"MINI_APP_BASE_URL\", \"\").rstrip(\"/\"),\n            http_port=int(os.getenv(\"HTTP_PORT\", \"8080\")),\n            allowed_usernames=allowed,\n            admin_usernames=admin,\n            allowed_phones=allowed_phones,\n            admin_phones=admin_phones,\n            mini_app_skip_auth=os.getenv(\"MINI_APP_SKIP_AUTH\", \"\").strip()\n            in (\"1\", \"true\", \"yes\"),\n            init_data_max_age_seconds=int(os.getenv(\"INIT_DATA_MAX_AGE_SECONDS\", \"0\")),\n            cors_origins=cors,\n            external_calendar_ics_url=os.getenv(\n                \"EXTERNAL_CALENDAR_ICS_URL\", \"\"\n            ).strip(),\n            duty_display_tz=os.getenv(\"DUTY_DISPLAY_TZ\", \"Europe/Moscow\").strip()\n            or \"Europe/Moscow\",\n            default_language=_normalize_default_language(\n                os.getenv(\"DEFAULT_LANGUAGE\", \"en\").strip()\n            ),\n        )\n
"},{"location":"api-reference/#duty_teller.config.Settings.from_env","title":"from_env() classmethod","text":"

Build Settings from current environment (same logic as module-level variables).

Returns:

Type Description Settings

Settings instance with all fields populated from env.

Source code in duty_teller/config.py
@classmethod\ndef from_env(cls) -> \"Settings\":\n    \"\"\"Build Settings from current environment (same logic as module-level variables).\n\n    Returns:\n        Settings instance with all fields populated from env.\n    \"\"\"\n    bot_token = os.getenv(\"BOT_TOKEN\") or \"\"\n    raw_allowed = os.getenv(\"ALLOWED_USERNAMES\", \"\").strip()\n    allowed = {\n        s.strip().lstrip(\"@\").lower() for s in raw_allowed.split(\",\") if s.strip()\n    }\n    raw_admin = os.getenv(\"ADMIN_USERNAMES\", \"\").strip()\n    admin = {\n        s.strip().lstrip(\"@\").lower() for s in raw_admin.split(\",\") if s.strip()\n    }\n    allowed_phones = _parse_phone_list(os.getenv(\"ALLOWED_PHONES\", \"\"))\n    admin_phones = _parse_phone_list(os.getenv(\"ADMIN_PHONES\", \"\"))\n    raw_cors = os.getenv(\"CORS_ORIGINS\", \"\").strip()\n    cors = (\n        [_o.strip() for _o in raw_cors.split(\",\") if _o.strip()]\n        if raw_cors and raw_cors != \"*\"\n        else [\"*\"]\n    )\n    return cls(\n        bot_token=bot_token,\n        database_url=os.getenv(\"DATABASE_URL\", \"sqlite:///data/duty_teller.db\"),\n        mini_app_base_url=os.getenv(\"MINI_APP_BASE_URL\", \"\").rstrip(\"/\"),\n        http_port=int(os.getenv(\"HTTP_PORT\", \"8080\")),\n        allowed_usernames=allowed,\n        admin_usernames=admin,\n        allowed_phones=allowed_phones,\n        admin_phones=admin_phones,\n        mini_app_skip_auth=os.getenv(\"MINI_APP_SKIP_AUTH\", \"\").strip()\n        in (\"1\", \"true\", \"yes\"),\n        init_data_max_age_seconds=int(os.getenv(\"INIT_DATA_MAX_AGE_SECONDS\", \"0\")),\n        cors_origins=cors,\n        external_calendar_ics_url=os.getenv(\n            \"EXTERNAL_CALENDAR_ICS_URL\", \"\"\n        ).strip(),\n        duty_display_tz=os.getenv(\"DUTY_DISPLAY_TZ\", \"Europe/Moscow\").strip()\n        or \"Europe/Moscow\",\n        default_language=_normalize_default_language(\n            os.getenv(\"DEFAULT_LANGUAGE\", \"en\").strip()\n        ),\n    )\n
"},{"location":"api-reference/#duty_teller.config.can_access_miniapp","title":"can_access_miniapp(username)","text":"

Check if username is allowed to open the calendar Miniapp.

Parameters:

Name Type Description Default username str

Telegram username (with or without @; case-insensitive).

required

Returns:

Type Description bool

True if in ALLOWED_USERNAMES or ADMIN_USERNAMES.

Source code in duty_teller/config.py
def can_access_miniapp(username: str) -> bool:\n    \"\"\"Check if username is allowed to open the calendar Miniapp.\n\n    Args:\n        username: Telegram username (with or without @; case-insensitive).\n\n    Returns:\n        True if in ALLOWED_USERNAMES or ADMIN_USERNAMES.\n    \"\"\"\n    u = (username or \"\").strip().lower()\n    return u in ALLOWED_USERNAMES or u in ADMIN_USERNAMES\n
"},{"location":"api-reference/#duty_teller.config.can_access_miniapp_by_phone","title":"can_access_miniapp_by_phone(phone)","text":"

Check if phone (set via /set_phone) is allowed to open the Miniapp.

Parameters:

Name Type Description Default phone str | None

Raw phone string or None.

required

Returns:

Type Description bool

True if normalized phone is in ALLOWED_PHONES or ADMIN_PHONES.

Source code in duty_teller/config.py
def can_access_miniapp_by_phone(phone: str | None) -> bool:\n    \"\"\"Check if phone (set via /set_phone) is allowed to open the Miniapp.\n\n    Args:\n        phone: Raw phone string or None.\n\n    Returns:\n        True if normalized phone is in ALLOWED_PHONES or ADMIN_PHONES.\n    \"\"\"\n    normalized = normalize_phone(phone)\n    if not normalized:\n        return False\n    return normalized in ALLOWED_PHONES or normalized in ADMIN_PHONES\n
"},{"location":"api-reference/#duty_teller.config.is_admin","title":"is_admin(username)","text":"

Check if Telegram username is in ADMIN_USERNAMES.

Parameters:

Name Type Description Default username str

Telegram username (with or without @; case-insensitive).

required

Returns:

Type Description bool

True if in ADMIN_USERNAMES.

Source code in duty_teller/config.py
def is_admin(username: str) -> bool:\n    \"\"\"Check if Telegram username is in ADMIN_USERNAMES.\n\n    Args:\n        username: Telegram username (with or without @; case-insensitive).\n\n    Returns:\n        True if in ADMIN_USERNAMES.\n    \"\"\"\n    return (username or \"\").strip().lower() in ADMIN_USERNAMES\n
"},{"location":"api-reference/#duty_teller.config.is_admin_by_phone","title":"is_admin_by_phone(phone)","text":"

Check if phone is in ADMIN_PHONES.

Parameters:

Name Type Description Default phone str | None

Raw phone string or None.

required

Returns:

Type Description bool

True if normalized phone is in ADMIN_PHONES.

Source code in duty_teller/config.py
def is_admin_by_phone(phone: str | None) -> bool:\n    \"\"\"Check if phone is in ADMIN_PHONES.\n\n    Args:\n        phone: Raw phone string or None.\n\n    Returns:\n        True if normalized phone is in ADMIN_PHONES.\n    \"\"\"\n    normalized = normalize_phone(phone)\n    return bool(normalized and normalized in ADMIN_PHONES)\n
"},{"location":"api-reference/#duty_teller.config.normalize_phone","title":"normalize_phone(phone)","text":"

Return phone as digits only (spaces, +, parentheses, dashes removed).

Parameters:

Name Type Description Default phone str | None

Raw phone string or None.

required

Returns:

Type Description str

Digits-only string, or empty string if None or empty.

Source code in duty_teller/config.py
def normalize_phone(phone: str | None) -> str:\n    \"\"\"Return phone as digits only (spaces, +, parentheses, dashes removed).\n\n    Args:\n        phone: Raw phone string or None.\n\n    Returns:\n        Digits-only string, or empty string if None or empty.\n    \"\"\"\n    if not phone or not isinstance(phone, str):\n        return \"\"\n    return _PHONE_DIGITS_RE.sub(\"\", phone.strip())\n
"},{"location":"api-reference/#duty_teller.config.require_bot_token","title":"require_bot_token()","text":"

Raise SystemExit with a clear message if BOT_TOKEN is not set.

Call from the application entry point (e.g. main.py or duty_teller.run) so the process exits with a helpful message instead of failing later.

Raises:

Type Description SystemExit

If BOT_TOKEN is empty.

Source code in duty_teller/config.py
def require_bot_token() -> None:\n    \"\"\"Raise SystemExit with a clear message if BOT_TOKEN is not set.\n\n    Call from the application entry point (e.g. main.py or duty_teller.run) so the\n    process exits with a helpful message instead of failing later.\n\n    Raises:\n        SystemExit: If BOT_TOKEN is empty.\n    \"\"\"\n    if not BOT_TOKEN:\n        raise SystemExit(\n            \"BOT_TOKEN is not set. Copy .env.example to .env and set your token from @BotFather.\"\n        )\n
"},{"location":"api-reference/#api-fastapi-and-auth","title":"API (FastAPI and auth)","text":""},{"location":"api-reference/#duty_teller.api","title":"duty_teller.api","text":"

HTTP API for the calendar Mini App: duties, calendar events, and static webapp.

"},{"location":"api-reference/#duty_teller.api.app","title":"duty_teller.api.app","text":"

FastAPI app: /api/duties, /api/calendar-events, personal ICS, and static webapp at /app.

"},{"location":"api-reference/#duty_teller.api.app.get_personal_calendar_ical","title":"get_personal_calendar_ical(token, session=Depends(get_db_session))","text":"

Return ICS calendar with only the subscribing user's duties. No Telegram auth; access is by secret token in the URL.

Source code in duty_teller/api/app.py
@app.get(\n    \"/api/calendar/ical/{token}.ics\",\n    summary=\"Personal calendar ICS\",\n    description=\"Returns an ICS calendar with only the subscribing user's duties. No Telegram auth; access is by secret token in the URL.\",\n)\ndef get_personal_calendar_ical(\n    token: str,\n    session: Session = Depends(get_db_session),\n) -> Response:\n    \"\"\"\n    Return ICS calendar with only the subscribing user's duties.\n    No Telegram auth; access is by secret token in the URL.\n    \"\"\"\n    user = get_user_by_calendar_token(session, token)\n    if user is None:\n        return Response(status_code=404, content=\"Not found\")\n    today = date.today()\n    from_date = (today - timedelta(days=365)).strftime(\"%Y-%m-%d\")\n    to_date = (today + timedelta(days=365 * 2)).strftime(\"%Y-%m-%d\")\n    duties_with_name = get_duties_for_user(\n        session, user.id, from_date=from_date, to_date=to_date\n    )\n    ics_bytes = build_personal_ics(duties_with_name)\n    return Response(\n        content=ics_bytes,\n        media_type=\"text/calendar; charset=utf-8\",\n    )\n
"},{"location":"api-reference/#duty_teller.api.dependencies","title":"duty_teller.api.dependencies","text":"

FastAPI dependencies: DB session, Miniapp auth (initData/allowlist), date validation.

"},{"location":"api-reference/#duty_teller.api.dependencies.fetch_duties_response","title":"fetch_duties_response(session, from_date, to_date)","text":"

Load duties in range and return as DutyWithUser list for API response.

Parameters:

Name Type Description Default session Session

DB session.

required from_date str

Start date YYYY-MM-DD.

required to_date str

End date YYYY-MM-DD.

required

Returns:

Type Description list[DutyWithUser]

List of DutyWithUser (id, user_id, start_at, end_at, full_name, event_type).

Source code in duty_teller/api/dependencies.py
def fetch_duties_response(\n    session: Session, from_date: str, to_date: str\n) -> list[DutyWithUser]:\n    \"\"\"Load duties in range and return as DutyWithUser list for API response.\n\n    Args:\n        session: DB session.\n        from_date: Start date YYYY-MM-DD.\n        to_date: End date YYYY-MM-DD.\n\n    Returns:\n        List of DutyWithUser (id, user_id, start_at, end_at, full_name, event_type).\n    \"\"\"\n    rows = get_duties(session, from_date=from_date, to_date=to_date)\n    return [\n        DutyWithUser(\n            id=duty.id,\n            user_id=duty.user_id,\n            start_at=duty.start_at,\n            end_at=duty.end_at,\n            full_name=full_name,\n            event_type=(\n                duty.event_type\n                if duty.event_type in (\"duty\", \"unavailable\", \"vacation\")\n                else \"duty\"\n            ),\n        )\n        for duty, full_name in rows\n    ]\n
"},{"location":"api-reference/#duty_teller.api.dependencies.get_authenticated_username","title":"get_authenticated_username(request, x_telegram_init_data, session)","text":"

Return identifier for miniapp auth (username or full_name or id:...); empty if skip-auth.

Parameters:

Name Type Description Default request Request

FastAPI request (client host for private-IP bypass).

required x_telegram_init_data str | None

Raw X-Telegram-Init-Data header value.

required session Session

DB session (for phone allowlist lookup).

required

Returns:

Type Description str

Username, full_name, or \"id:\"; empty string if MINI_APP_SKIP_AUTH str

or private IP and no initData.

Raises:

Type Description HTTPException

403 if initData missing/invalid or user not in allowlist.

Source code in duty_teller/api/dependencies.py
def get_authenticated_username(\n    request: Request,\n    x_telegram_init_data: str | None,\n    session: Session,\n) -> str:\n    \"\"\"Return identifier for miniapp auth (username or full_name or id:...); empty if skip-auth.\n\n    Args:\n        request: FastAPI request (client host for private-IP bypass).\n        x_telegram_init_data: Raw X-Telegram-Init-Data header value.\n        session: DB session (for phone allowlist lookup).\n\n    Returns:\n        Username, full_name, or \"id:<telegram_id>\"; empty string if MINI_APP_SKIP_AUTH\n        or private IP and no initData.\n\n    Raises:\n        HTTPException: 403 if initData missing/invalid or user not in allowlist.\n    \"\"\"\n    if config.MINI_APP_SKIP_AUTH:\n        log.warning(\"allowing without any auth check (MINI_APP_SKIP_AUTH is set)\")\n        return \"\"\n    init_data = (x_telegram_init_data or \"\").strip()\n    if not init_data:\n        client_host = request.client.host if request.client else None\n        if _is_private_client(client_host):\n            return \"\"\n        log.warning(\"no X-Telegram-Init-Data header (client=%s)\", client_host)\n        lang = _lang_from_accept_language(request.headers.get(\"Accept-Language\"))\n        raise HTTPException(status_code=403, detail=t(lang, \"api.open_from_telegram\"))\n    max_age = config.INIT_DATA_MAX_AGE_SECONDS or None\n    telegram_user_id, username, auth_reason, lang = validate_init_data_with_reason(\n        init_data, config.BOT_TOKEN, max_age_seconds=max_age\n    )\n    if auth_reason != \"ok\":\n        log.warning(\"initData validation failed: %s\", auth_reason)\n        raise HTTPException(\n            status_code=403, detail=_auth_error_detail(auth_reason, lang)\n        )\n    if username and config.can_access_miniapp(username):\n        return username\n    failed_phone: str | None = None\n    if telegram_user_id is not None:\n        user = get_user_by_telegram_id(session, telegram_user_id)\n        if user and user.phone and config.can_access_miniapp_by_phone(user.phone):\n            return username or (user.full_name or \"\") or f\"id:{telegram_user_id}\"\n        if user and user.phone:\n            failed_phone = config.normalize_phone(user.phone)\n    log.warning(\n        \"username/phone not in allowlist (username=%s, telegram_id=%s, phone=%s)\",\n        username,\n        telegram_user_id,\n        failed_phone if failed_phone else \"\u2014\",\n    )\n    raise HTTPException(status_code=403, detail=t(lang, \"api.access_denied\"))\n
"},{"location":"api-reference/#duty_teller.api.dependencies.get_db_session","title":"get_db_session()","text":"

Yield a DB session for the request; closed automatically by FastAPI.

Source code in duty_teller/api/dependencies.py
def get_db_session() -> Generator[Session, None, None]:\n    \"\"\"Yield a DB session for the request; closed automatically by FastAPI.\"\"\"\n    with session_scope(config.DATABASE_URL) as session:\n        yield session\n
"},{"location":"api-reference/#duty_teller.api.dependencies.get_validated_dates","title":"get_validated_dates(request, from_date=Query(..., description='ISO date YYYY-MM-DD', alias='from'), to_date=Query(..., description='ISO date YYYY-MM-DD', alias='to'))","text":"

Validate from/to date query params; use Accept-Language for error messages.

Parameters:

Name Type Description Default request Request

FastAPI request (for Accept-Language).

required from_date str

Start date YYYY-MM-DD.

Query(..., description='ISO date YYYY-MM-DD', alias='from') to_date str

End date YYYY-MM-DD.

Query(..., description='ISO date YYYY-MM-DD', alias='to')

Returns:

Type Description tuple[str, str]

(from_date, to_date) as strings.

Raises:

Type Description HTTPException

400 if format invalid or from_date > to_date.

Source code in duty_teller/api/dependencies.py
def get_validated_dates(\n    request: Request,\n    from_date: str = Query(..., description=\"ISO date YYYY-MM-DD\", alias=\"from\"),\n    to_date: str = Query(..., description=\"ISO date YYYY-MM-DD\", alias=\"to\"),\n) -> tuple[str, str]:\n    \"\"\"Validate from/to date query params; use Accept-Language for error messages.\n\n    Args:\n        request: FastAPI request (for Accept-Language).\n        from_date: Start date YYYY-MM-DD.\n        to_date: End date YYYY-MM-DD.\n\n    Returns:\n        (from_date, to_date) as strings.\n\n    Raises:\n        HTTPException: 400 if format invalid or from_date > to_date.\n    \"\"\"\n    lang = _lang_from_accept_language(request.headers.get(\"Accept-Language\"))\n    _validate_duty_dates(from_date, to_date, lang)\n    return (from_date, to_date)\n
"},{"location":"api-reference/#duty_teller.api.dependencies.require_miniapp_username","title":"require_miniapp_username(request, x_telegram_init_data=None, session=Depends(get_db_session))","text":"

FastAPI dependency: require valid Miniapp auth; return username/identifier.

Raises:

Type Description HTTPException

403 if initData missing/invalid or user not in allowlist.

Source code in duty_teller/api/dependencies.py
def require_miniapp_username(\n    request: Request,\n    x_telegram_init_data: Annotated[\n        str | None, Header(alias=\"X-Telegram-Init-Data\")\n    ] = None,\n    session: Session = Depends(get_db_session),\n) -> str:\n    \"\"\"FastAPI dependency: require valid Miniapp auth; return username/identifier.\n\n    Raises:\n        HTTPException: 403 if initData missing/invalid or user not in allowlist.\n    \"\"\"\n    return get_authenticated_username(request, x_telegram_init_data, session)\n
"},{"location":"api-reference/#duty_teller.api.telegram_auth","title":"duty_teller.api.telegram_auth","text":"

Validate Telegram Web App initData and extract user username.

"},{"location":"api-reference/#duty_teller.api.telegram_auth.validate_init_data","title":"validate_init_data(init_data, bot_token, max_age_seconds=None)","text":"

Validate Telegram Web App initData and return username if valid.

Parameters:

Name Type Description Default init_data str

Raw initData string from tgWebAppData.

required bot_token str

Bot token (must match the bot that signed the data).

required max_age_seconds int | None

Reject if auth_date older than this; None to disable.

None

Returns:

Type Description str | None

Username (lowercase, no @) or None if validation fails.

Source code in duty_teller/api/telegram_auth.py
def validate_init_data(\n    init_data: str,\n    bot_token: str,\n    max_age_seconds: int | None = None,\n) -> str | None:\n    \"\"\"Validate Telegram Web App initData and return username if valid.\n\n    Args:\n        init_data: Raw initData string from tgWebAppData.\n        bot_token: Bot token (must match the bot that signed the data).\n        max_age_seconds: Reject if auth_date older than this; None to disable.\n\n    Returns:\n        Username (lowercase, no @) or None if validation fails.\n    \"\"\"\n    _, username, _, _ = validate_init_data_with_reason(\n        init_data, bot_token, max_age_seconds\n    )\n    return username\n
"},{"location":"api-reference/#duty_teller.api.telegram_auth.validate_init_data_with_reason","title":"validate_init_data_with_reason(init_data, bot_token, max_age_seconds=None)","text":"

Validate initData signature and return user id, username, reason, and lang.

Parameters:

Name Type Description Default init_data str

Raw initData string from tgWebAppData.

required bot_token str

Bot token (must match the bot that signed the data).

required max_age_seconds int | None

Reject if auth_date older than this; None to disable.

None

Returns:

Type Description int | None

Tuple (telegram_user_id, username, reason, lang). reason is one of: \"ok\",

str | None

\"empty\", \"no_hash\", \"hash_mismatch\", \"auth_date_expired\", \"no_user\",

str

\"user_invalid\", \"no_user_id\". lang is from user.language_code normalized

str

to 'ru' or 'en'; 'en' when no user. On success: (user.id, username or None,

tuple[int | None, str | None, str, str]

\"ok\", lang).

Source code in duty_teller/api/telegram_auth.py
def validate_init_data_with_reason(\n    init_data: str,\n    bot_token: str,\n    max_age_seconds: int | None = None,\n) -> tuple[int | None, str | None, str, str]:\n    \"\"\"Validate initData signature and return user id, username, reason, and lang.\n\n    Args:\n        init_data: Raw initData string from tgWebAppData.\n        bot_token: Bot token (must match the bot that signed the data).\n        max_age_seconds: Reject if auth_date older than this; None to disable.\n\n    Returns:\n        Tuple (telegram_user_id, username, reason, lang). reason is one of: \"ok\",\n        \"empty\", \"no_hash\", \"hash_mismatch\", \"auth_date_expired\", \"no_user\",\n        \"user_invalid\", \"no_user_id\". lang is from user.language_code normalized\n        to 'ru' or 'en'; 'en' when no user. On success: (user.id, username or None,\n        \"ok\", lang).\n    \"\"\"\n    if not init_data or not bot_token:\n        return (None, None, \"empty\", \"en\")\n    init_data = init_data.strip()\n    params = {}\n    for part in init_data.split(\"&\"):\n        if \"=\" not in part:\n            continue\n        key, _, value = part.partition(\"=\")\n        if not key:\n            continue\n        params[key] = value\n    hash_val = params.pop(\"hash\", None)\n    if not hash_val:\n        return (None, None, \"no_hash\", \"en\")\n    data_pairs = sorted(params.items())\n    # Data-check string: key=value with URL-decoded values (per Telegram example)\n    data_string = \"\\n\".join(f\"{k}={unquote(v)}\" for k, v in data_pairs)\n    # HMAC-SHA256(key=WebAppData, message=bot_token) per reference implementations\n    secret_key = hmac.new(\n        b\"WebAppData\",\n        msg=bot_token.encode(),\n        digestmod=hashlib.sha256,\n    ).digest()\n    computed = hmac.new(\n        secret_key,\n        msg=data_string.encode(),\n        digestmod=hashlib.sha256,\n    ).hexdigest()\n    if not hmac.compare_digest(computed.lower(), hash_val.lower()):\n        return (None, None, \"hash_mismatch\", \"en\")\n    if max_age_seconds is not None and max_age_seconds > 0:\n        auth_date_raw = params.get(\"auth_date\")\n        if not auth_date_raw:\n            return (None, None, \"auth_date_expired\", \"en\")\n        try:\n            auth_date = int(float(auth_date_raw))\n        except (ValueError, TypeError):\n            return (None, None, \"auth_date_expired\", \"en\")\n        if time.time() - auth_date > max_age_seconds:\n            return (None, None, \"auth_date_expired\", \"en\")\n    user_raw = params.get(\"user\")\n    if not user_raw:\n        return (None, None, \"no_user\", \"en\")\n    try:\n        user = json.loads(unquote(user_raw))\n    except (json.JSONDecodeError, TypeError):\n        return (None, None, \"user_invalid\", \"en\")\n    if not isinstance(user, dict):\n        return (None, None, \"user_invalid\", \"en\")\n    lang = _normalize_lang(user.get(\"language_code\"))\n    raw_id = user.get(\"id\")\n    if raw_id is None:\n        return (None, None, \"no_user_id\", lang)\n    try:\n        telegram_user_id = int(raw_id)\n    except (TypeError, ValueError):\n        return (None, None, \"no_user_id\", lang)\n    username = user.get(\"username\")\n    if username and isinstance(username, str):\n        username = username.strip().lstrip(\"@\").lower()\n    else:\n        username = None\n    return (telegram_user_id, username, \"ok\", lang)\n
"},{"location":"api-reference/#duty_teller.api.calendar_ics","title":"duty_teller.api.calendar_ics","text":"

Fetch and parse external ICS calendar; in-memory cache with 7-day TTL.

"},{"location":"api-reference/#duty_teller.api.calendar_ics.get_calendar_events","title":"get_calendar_events(url, from_date, to_date)","text":"

Fetch ICS from URL and return events in the given date range.

Uses in-memory cache with TTL 7 days. Recurring events are skipped. On fetch or parse error returns an empty list.

Parameters:

Name Type Description Default url str

URL of the ICS calendar.

required from_date str

Start date YYYY-MM-DD.

required to_date str

End date YYYY-MM-DD.

required

Returns:

Type Description list[dict]

List of dicts with keys \"date\" (YYYY-MM-DD) and \"summary\". Empty on error.

Source code in duty_teller/api/calendar_ics.py
def get_calendar_events(\n    url: str,\n    from_date: str,\n    to_date: str,\n) -> list[dict]:\n    \"\"\"Fetch ICS from URL and return events in the given date range.\n\n    Uses in-memory cache with TTL 7 days. Recurring events are skipped.\n    On fetch or parse error returns an empty list.\n\n    Args:\n        url: URL of the ICS calendar.\n        from_date: Start date YYYY-MM-DD.\n        to_date: End date YYYY-MM-DD.\n\n    Returns:\n        List of dicts with keys \"date\" (YYYY-MM-DD) and \"summary\". Empty on error.\n    \"\"\"\n    if not url or from_date > to_date:\n        return []\n\n    now = datetime.now().timestamp()\n    raw: bytes | None = None\n    if url in _ics_cache:\n        cached_at, cached_raw = _ics_cache[url]\n        if now - cached_at < CACHE_TTL_SECONDS:\n            raw = cached_raw\n    if raw is None:\n        raw = _fetch_ics(url)\n        if raw is None:\n            return []\n        _ics_cache[url] = (now, raw)\n\n    return _get_events_from_ics(raw, from_date, to_date)\n
"},{"location":"api-reference/#duty_teller.api.personal_calendar_ics","title":"duty_teller.api.personal_calendar_ics","text":"

Generate ICS calendar containing only one user's duties (for subscription link).

"},{"location":"api-reference/#duty_teller.api.personal_calendar_ics.build_personal_ics","title":"build_personal_ics(duties_with_name)","text":"

Build a VCALENDAR (ICS) with one VEVENT per duty.

Parameters:

Name Type Description Default duties_with_name list[tuple[Duty, str]]

List of (Duty, full_name). full_name is available for DESCRIPTION; SUMMARY is taken from event_type (duty/unavailable/vacation).

required

Returns:

Type Description bytes

ICS file content as bytes (UTF-8).

Source code in duty_teller/api/personal_calendar_ics.py
def build_personal_ics(duties_with_name: list[tuple[Duty, str]]) -> bytes:\n    \"\"\"Build a VCALENDAR (ICS) with one VEVENT per duty.\n\n    Args:\n        duties_with_name: List of (Duty, full_name). full_name is available for\n            DESCRIPTION; SUMMARY is taken from event_type (duty/unavailable/vacation).\n\n    Returns:\n        ICS file content as bytes (UTF-8).\n    \"\"\"\n    cal = Calendar()\n    cal.add(\"prodid\", \"-//Duty Teller//Personal Calendar//EN\")\n    cal.add(\"version\", \"2.0\")\n    cal.add(\"calscale\", \"GREGORIAN\")\n\n    for duty, _full_name in duties_with_name:\n        event = Event()\n        start_dt = _parse_utc_iso(duty.start_at)\n        end_dt = _parse_utc_iso(duty.end_at)\n        # Ensure timezone-aware for icalendar\n        if start_dt.tzinfo is None:\n            start_dt = start_dt.replace(tzinfo=timezone.utc)\n        if end_dt.tzinfo is None:\n            end_dt = end_dt.replace(tzinfo=timezone.utc)\n        event.add(\"dtstart\", start_dt)\n        event.add(\"dtend\", end_dt)\n        summary = SUMMARY_BY_TYPE.get(\n            duty.event_type if duty.event_type else \"duty\", \"Duty\"\n        )\n        event.add(\"summary\", summary)\n        event.add(\"uid\", f\"duty-{duty.id}@duty-teller\")\n        event.add(\"dtstamp\", datetime.now(timezone.utc))\n        cal.add_component(event)\n\n    return cal.to_ical()\n
"},{"location":"api-reference/#database","title":"Database","text":""},{"location":"api-reference/#duty_teller.db","title":"duty_teller.db","text":"

Database layer: SQLAlchemy models, Pydantic schemas, repository, init.

"},{"location":"api-reference/#duty_teller.db.Base","title":"Base","text":"

Bases: DeclarativeBase

Declarative base for all models.

Source code in duty_teller/db/models.py
class Base(DeclarativeBase):\n    \"\"\"Declarative base for all models.\"\"\"\n\n    pass\n
"},{"location":"api-reference/#duty_teller.db.Duty","title":"Duty","text":"

Bases: Base

Single duty/unavailable/vacation slot (UTC start_at/end_at, event_type).

Source code in duty_teller/db/models.py
class Duty(Base):\n    \"\"\"Single duty/unavailable/vacation slot (UTC start_at/end_at, event_type).\"\"\"\n\n    __tablename__ = \"duties\"\n\n    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)\n    user_id: Mapped[int] = mapped_column(\n        Integer, ForeignKey(\"users.id\"), nullable=False\n    )\n    # UTC, ISO 8601 with Z suffix (e.g. 2025-01-15T09:00:00Z)\n    start_at: Mapped[str] = mapped_column(Text, nullable=False)\n    end_at: Mapped[str] = mapped_column(Text, nullable=False)\n    # duty | unavailable | vacation\n    event_type: Mapped[str] = mapped_column(Text, nullable=False, server_default=\"duty\")\n\n    user: Mapped[\"User\"] = relationship(\"User\", back_populates=\"duties\")\n
"},{"location":"api-reference/#duty_teller.db.DutyCreate","title":"DutyCreate","text":"

Bases: DutyBase

Duty creation payload.

Source code in duty_teller/db/schemas.py
class DutyCreate(DutyBase):\n    \"\"\"Duty creation payload.\"\"\"\n\n    pass\n
"},{"location":"api-reference/#duty_teller.db.DutyInDb","title":"DutyInDb","text":"

Bases: DutyBase

Duty as stored in DB (includes id).

Source code in duty_teller/db/schemas.py
class DutyInDb(DutyBase):\n    \"\"\"Duty as stored in DB (includes id).\"\"\"\n\n    id: int\n\n    model_config = ConfigDict(from_attributes=True)\n
"},{"location":"api-reference/#duty_teller.db.DutyWithUser","title":"DutyWithUser","text":"

Bases: DutyInDb

Duty with full_name and event_type for calendar display.

event_type: only these values are returned; unknown DB values are mapped to \"duty\" in the API.

Source code in duty_teller/db/schemas.py
class DutyWithUser(DutyInDb):\n    \"\"\"Duty with full_name and event_type for calendar display.\n\n    event_type: only these values are returned; unknown DB values are mapped to \"duty\" in the API.\n    \"\"\"\n\n    full_name: str\n    event_type: Literal[\"duty\", \"unavailable\", \"vacation\"] = \"duty\"\n\n    model_config = ConfigDict(from_attributes=True)\n
"},{"location":"api-reference/#duty_teller.db.User","title":"User","text":"

Bases: Base

Telegram user and display name; may have telegram_user_id=None for import-only users.

Source code in duty_teller/db/models.py
class User(Base):\n    \"\"\"Telegram user and display name; may have telegram_user_id=None for import-only users.\"\"\"\n\n    __tablename__ = \"users\"\n\n    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)\n    telegram_user_id: Mapped[int | None] = mapped_column(\n        BigInteger, unique=True, nullable=True\n    )\n    full_name: Mapped[str] = mapped_column(Text, nullable=False)\n    username: Mapped[str | None] = mapped_column(Text, nullable=True)\n    first_name: Mapped[str | None] = mapped_column(Text, nullable=True)\n    last_name: Mapped[str | None] = mapped_column(Text, nullable=True)\n    phone: Mapped[str | None] = mapped_column(Text, nullable=True)\n    name_manually_edited: Mapped[bool] = mapped_column(\n        Boolean, nullable=False, server_default=\"0\", default=False\n    )\n\n    duties: Mapped[list[\"Duty\"]] = relationship(\"Duty\", back_populates=\"user\")\n
"},{"location":"api-reference/#duty_teller.db.UserCreate","title":"UserCreate","text":"

Bases: UserBase

User creation payload including Telegram user id.

Source code in duty_teller/db/schemas.py
class UserCreate(UserBase):\n    \"\"\"User creation payload including Telegram user id.\"\"\"\n\n    telegram_user_id: int\n
"},{"location":"api-reference/#duty_teller.db.UserInDb","title":"UserInDb","text":"

Bases: UserBase

User as stored in DB (includes id and telegram_user_id).

Source code in duty_teller/db/schemas.py
class UserInDb(UserBase):\n    \"\"\"User as stored in DB (includes id and telegram_user_id).\"\"\"\n\n    id: int\n    telegram_user_id: int\n\n    model_config = ConfigDict(from_attributes=True)\n
"},{"location":"api-reference/#duty_teller.db.delete_duties_in_range","title":"delete_duties_in_range(session, user_id, from_date, to_date)","text":"

Delete all duties of the user that overlap the given date range.

Parameters:

Name Type Description Default session Session

DB session.

required user_id int

User id.

required from_date str

Start date YYYY-MM-DD.

required to_date str

End date YYYY-MM-DD.

required

Returns:

Type Description int

Number of duties deleted.

Source code in duty_teller/db/repository.py
def delete_duties_in_range(\n    session: Session,\n    user_id: int,\n    from_date: str,\n    to_date: str,\n) -> int:\n    \"\"\"Delete all duties of the user that overlap the given date range.\n\n    Args:\n        session: DB session.\n        user_id: User id.\n        from_date: Start date YYYY-MM-DD.\n        to_date: End date YYYY-MM-DD.\n\n    Returns:\n        Number of duties deleted.\n    \"\"\"\n    to_next = (\n        datetime.fromisoformat(to_date + \"T00:00:00\") + timedelta(days=1)\n    ).strftime(\"%Y-%m-%d\")\n    q = session.query(Duty).filter(\n        Duty.user_id == user_id,\n        Duty.start_at < to_next,\n        Duty.end_at >= from_date,\n    )\n    count = q.count()\n    q.delete(synchronize_session=False)\n    session.commit()\n    return count\n
"},{"location":"api-reference/#duty_teller.db.get_duties","title":"get_duties(session, from_date, to_date)","text":"

Return duties overlapping the given date range with user full_name.

Parameters:

Name Type Description Default session Session

DB session.

required from_date str

Start date YYYY-MM-DD.

required to_date str

End date YYYY-MM-DD.

required

Returns:

Type Description list[tuple[Duty, str]]

List of (Duty, full_name) tuples.

Source code in duty_teller/db/repository.py
def get_duties(\n    session: Session,\n    from_date: str,\n    to_date: str,\n) -> list[tuple[Duty, str]]:\n    \"\"\"Return duties overlapping the given date range with user full_name.\n\n    Args:\n        session: DB session.\n        from_date: Start date YYYY-MM-DD.\n        to_date: End date YYYY-MM-DD.\n\n    Returns:\n        List of (Duty, full_name) tuples.\n    \"\"\"\n    to_date_next = (\n        datetime.fromisoformat(to_date + \"T00:00:00\") + timedelta(days=1)\n    ).strftime(\"%Y-%m-%d\")\n    q = (\n        session.query(Duty, User.full_name)\n        .join(User, Duty.user_id == User.id)\n        .filter(Duty.start_at < to_date_next, Duty.end_at >= from_date)\n    )\n    return list(q.all())\n
"},{"location":"api-reference/#duty_teller.db.get_engine","title":"get_engine(database_url)","text":"

Return cached SQLAlchemy engine for the given URL (one per process).

Source code in duty_teller/db/session.py
def get_engine(database_url: str):\n    \"\"\"Return cached SQLAlchemy engine for the given URL (one per process).\"\"\"\n    global _engine\n    if _engine is None:\n        _engine = create_engine(\n            database_url,\n            connect_args={\"check_same_thread\": False}\n            if \"sqlite\" in database_url\n            else {},\n            echo=False,\n        )\n    return _engine\n
"},{"location":"api-reference/#duty_teller.db.get_or_create_user","title":"get_or_create_user(session, telegram_user_id, full_name, username=None, first_name=None, last_name=None)","text":"

Get or create user by Telegram user ID.

On create, name fields come from Telegram. On update: username is always synced; full_name, first_name, last_name are updated only if name_manually_edited is False (otherwise existing display name is kept).

Parameters:

Name Type Description Default session Session

DB session.

required telegram_user_id int

Telegram user id.

required full_name str

Display full name.

required username str | None

Telegram username (optional).

None first_name str | None

Telegram first name (optional).

None last_name str | None

Telegram last name (optional).

None

Returns:

Type Description User

User instance (created or updated).

Source code in duty_teller/db/repository.py
def get_or_create_user(\n    session: Session,\n    telegram_user_id: int,\n    full_name: str,\n    username: str | None = None,\n    first_name: str | None = None,\n    last_name: str | None = None,\n) -> User:\n    \"\"\"Get or create user by Telegram user ID.\n\n    On create, name fields come from Telegram. On update: username is always\n    synced; full_name, first_name, last_name are updated only if\n    name_manually_edited is False (otherwise existing display name is kept).\n\n    Args:\n        session: DB session.\n        telegram_user_id: Telegram user id.\n        full_name: Display full name.\n        username: Telegram username (optional).\n        first_name: Telegram first name (optional).\n        last_name: Telegram last name (optional).\n\n    Returns:\n        User instance (created or updated).\n    \"\"\"\n    user = get_user_by_telegram_id(session, telegram_user_id)\n    if user:\n        user.username = username\n        if not user.name_manually_edited:\n            user.full_name = full_name\n            user.first_name = first_name\n            user.last_name = last_name\n        session.commit()\n        session.refresh(user)\n        return user\n    user = User(\n        telegram_user_id=telegram_user_id,\n        full_name=full_name,\n        username=username,\n        first_name=first_name,\n        last_name=last_name,\n        name_manually_edited=False,\n    )\n    session.add(user)\n    session.commit()\n    session.refresh(user)\n    return user\n
"},{"location":"api-reference/#duty_teller.db.get_or_create_user_by_full_name","title":"get_or_create_user_by_full_name(session, full_name)","text":"

Find user by exact full_name or create one (for duty-schedule import).

New users have telegram_user_id=None and name_manually_edited=True.

Parameters:

Name Type Description Default session Session

DB session.

required full_name str

Exact full name to match or set.

required

Returns:

Type Description User

User instance (existing or newly created).

Source code in duty_teller/db/repository.py
def get_or_create_user_by_full_name(session: Session, full_name: str) -> User:\n    \"\"\"Find user by exact full_name or create one (for duty-schedule import).\n\n    New users have telegram_user_id=None and name_manually_edited=True.\n\n    Args:\n        session: DB session.\n        full_name: Exact full name to match or set.\n\n    Returns:\n        User instance (existing or newly created).\n    \"\"\"\n    user = session.query(User).filter(User.full_name == full_name).first()\n    if user:\n        return user\n    user = User(\n        telegram_user_id=None,\n        full_name=full_name,\n        username=None,\n        first_name=None,\n        last_name=None,\n        name_manually_edited=True,\n    )\n    session.add(user)\n    session.commit()\n    session.refresh(user)\n    return user\n
"},{"location":"api-reference/#duty_teller.db.get_session","title":"get_session(database_url)","text":"

Create a new session from the factory for the given URL.

Source code in duty_teller/db/session.py
def get_session(database_url: str) -> Session:\n    \"\"\"Create a new session from the factory for the given URL.\"\"\"\n    return get_session_factory(database_url)()\n
"},{"location":"api-reference/#duty_teller.db.get_session_factory","title":"get_session_factory(database_url)","text":"

Return cached session factory for the given URL (one per process).

Source code in duty_teller/db/session.py
def get_session_factory(database_url: str) -> sessionmaker[Session]:\n    \"\"\"Return cached session factory for the given URL (one per process).\"\"\"\n    global _SessionLocal\n    if _SessionLocal is None:\n        engine = get_engine(database_url)\n        _SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)\n    return _SessionLocal\n
"},{"location":"api-reference/#duty_teller.db.init_db","title":"init_db(database_url)","text":"

Create all tables from SQLAlchemy metadata.

Prefer Alembic migrations for schema changes in production.

Parameters:

Name Type Description Default database_url str

SQLAlchemy database URL.

required Source code in duty_teller/db/__init__.py
def init_db(database_url: str) -> None:\n    \"\"\"Create all tables from SQLAlchemy metadata.\n\n    Prefer Alembic migrations for schema changes in production.\n\n    Args:\n        database_url: SQLAlchemy database URL.\n    \"\"\"\n    engine = get_engine(database_url)\n    Base.metadata.create_all(bind=engine)\n
"},{"location":"api-reference/#duty_teller.db.insert_duty","title":"insert_duty(session, user_id, start_at, end_at, event_type='duty')","text":"

Create a duty record.

Parameters:

Name Type Description Default session Session

DB session.

required user_id int

User id.

required start_at str

Start time UTC, ISO 8601 with Z (e.g. 2025-01-15T09:00:00Z).

required end_at str

End time UTC, ISO 8601 with Z.

required event_type str

One of \"duty\", \"unavailable\", \"vacation\". Default \"duty\".

'duty'

Returns:

Type Description Duty

Created Duty instance.

Source code in duty_teller/db/repository.py
def insert_duty(\n    session: Session,\n    user_id: int,\n    start_at: str,\n    end_at: str,\n    event_type: str = \"duty\",\n) -> Duty:\n    \"\"\"Create a duty record.\n\n    Args:\n        session: DB session.\n        user_id: User id.\n        start_at: Start time UTC, ISO 8601 with Z (e.g. 2025-01-15T09:00:00Z).\n        end_at: End time UTC, ISO 8601 with Z.\n        event_type: One of \"duty\", \"unavailable\", \"vacation\". Default \"duty\".\n\n    Returns:\n        Created Duty instance.\n    \"\"\"\n    duty = Duty(\n        user_id=user_id,\n        start_at=start_at,\n        end_at=end_at,\n        event_type=event_type,\n    )\n    session.add(duty)\n    session.commit()\n    session.refresh(duty)\n    return duty\n
"},{"location":"api-reference/#duty_teller.db.session_scope","title":"session_scope(database_url)","text":"

Context manager that yields a session; rolls back on exception, closes on exit.

Parameters:

Name Type Description Default database_url str

SQLAlchemy database URL.

required

Yields:

Type Description Session

Session instance. Caller must not use it after exit.

Source code in duty_teller/db/session.py
@contextmanager\ndef session_scope(database_url: str) -> Generator[Session, None, None]:\n    \"\"\"Context manager that yields a session; rolls back on exception, closes on exit.\n\n    Args:\n        database_url: SQLAlchemy database URL.\n\n    Yields:\n        Session instance. Caller must not use it after exit.\n    \"\"\"\n    session = get_session(database_url)\n    try:\n        yield session\n    except Exception:\n        session.rollback()\n        raise\n    finally:\n        session.close()\n
"},{"location":"api-reference/#duty_teller.db.set_user_phone","title":"set_user_phone(session, telegram_user_id, phone)","text":"

Set or clear phone for user by Telegram user id.

Parameters:

Name Type Description Default session Session

DB session.

required telegram_user_id int

Telegram user id.

required phone str | None

Phone string or None to clear.

required

Returns:

Type Description User | None

Updated User or None if not found.

Source code in duty_teller/db/repository.py
def set_user_phone(\n    session: Session, telegram_user_id: int, phone: str | None\n) -> User | None:\n    \"\"\"Set or clear phone for user by Telegram user id.\n\n    Args:\n        session: DB session.\n        telegram_user_id: Telegram user id.\n        phone: Phone string or None to clear.\n\n    Returns:\n        Updated User or None if not found.\n    \"\"\"\n    user = session.query(User).filter(User.telegram_user_id == telegram_user_id).first()\n    if not user:\n        return None\n    user.phone = phone\n    session.commit()\n    session.refresh(user)\n    return user\n
"},{"location":"api-reference/#duty_teller.db.update_user_display_name","title":"update_user_display_name(session, telegram_user_id, full_name, first_name=None, last_name=None)","text":"

Update display name and set name_manually_edited=True.

Use from API or admin when name is changed manually; subsequent get_or_create_user will not overwrite these fields.

Parameters:

Name Type Description Default session Session

DB session.

required telegram_user_id int

Telegram user id.

required full_name str

New full name.

required first_name str | None

New first name (optional).

None last_name str | None

New last name (optional).

None

Returns:

Type Description User | None

Updated User or None if not found.

Source code in duty_teller/db/repository.py
def update_user_display_name(\n    session: Session,\n    telegram_user_id: int,\n    full_name: str,\n    first_name: str | None = None,\n    last_name: str | None = None,\n) -> User | None:\n    \"\"\"Update display name and set name_manually_edited=True.\n\n    Use from API or admin when name is changed manually; subsequent\n    get_or_create_user will not overwrite these fields.\n\n    Args:\n        session: DB session.\n        telegram_user_id: Telegram user id.\n        full_name: New full name.\n        first_name: New first name (optional).\n        last_name: New last name (optional).\n\n    Returns:\n        Updated User or None if not found.\n    \"\"\"\n    user = session.query(User).filter(User.telegram_user_id == telegram_user_id).first()\n    if not user:\n        return None\n    user.full_name = full_name\n    user.first_name = first_name\n    user.last_name = last_name\n    user.name_manually_edited = True\n    session.commit()\n    session.refresh(user)\n    return user\n
"},{"location":"api-reference/#duty_teller.db.models","title":"duty_teller.db.models","text":"

SQLAlchemy ORM models for users and duties.

"},{"location":"api-reference/#duty_teller.db.models.Base","title":"Base","text":"

Bases: DeclarativeBase

Declarative base for all models.

Source code in duty_teller/db/models.py
class Base(DeclarativeBase):\n    \"\"\"Declarative base for all models.\"\"\"\n\n    pass\n
"},{"location":"api-reference/#duty_teller.db.models.CalendarSubscriptionToken","title":"CalendarSubscriptionToken","text":"

Bases: Base

One active calendar subscription token per user; token_hash is unique.

Source code in duty_teller/db/models.py
class CalendarSubscriptionToken(Base):\n    \"\"\"One active calendar subscription token per user; token_hash is unique.\"\"\"\n\n    __tablename__ = \"calendar_subscription_tokens\"\n\n    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)\n    user_id: Mapped[int] = mapped_column(\n        Integer, ForeignKey(\"users.id\"), nullable=False\n    )\n    token_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True)\n    created_at: Mapped[str] = mapped_column(Text, nullable=False)\n
"},{"location":"api-reference/#duty_teller.db.models.Duty","title":"Duty","text":"

Bases: Base

Single duty/unavailable/vacation slot (UTC start_at/end_at, event_type).

Source code in duty_teller/db/models.py
class Duty(Base):\n    \"\"\"Single duty/unavailable/vacation slot (UTC start_at/end_at, event_type).\"\"\"\n\n    __tablename__ = \"duties\"\n\n    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)\n    user_id: Mapped[int] = mapped_column(\n        Integer, ForeignKey(\"users.id\"), nullable=False\n    )\n    # UTC, ISO 8601 with Z suffix (e.g. 2025-01-15T09:00:00Z)\n    start_at: Mapped[str] = mapped_column(Text, nullable=False)\n    end_at: Mapped[str] = mapped_column(Text, nullable=False)\n    # duty | unavailable | vacation\n    event_type: Mapped[str] = mapped_column(Text, nullable=False, server_default=\"duty\")\n\n    user: Mapped[\"User\"] = relationship(\"User\", back_populates=\"duties\")\n
"},{"location":"api-reference/#duty_teller.db.models.GroupDutyPin","title":"GroupDutyPin","text":"

Bases: Base

Stores which message to update in each group for the pinned duty notice.

Source code in duty_teller/db/models.py
class GroupDutyPin(Base):\n    \"\"\"Stores which message to update in each group for the pinned duty notice.\"\"\"\n\n    __tablename__ = \"group_duty_pins\"\n\n    chat_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)\n    message_id: Mapped[int] = mapped_column(Integer, nullable=False)\n
"},{"location":"api-reference/#duty_teller.db.models.User","title":"User","text":"

Bases: Base

Telegram user and display name; may have telegram_user_id=None for import-only users.

Source code in duty_teller/db/models.py
class User(Base):\n    \"\"\"Telegram user and display name; may have telegram_user_id=None for import-only users.\"\"\"\n\n    __tablename__ = \"users\"\n\n    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)\n    telegram_user_id: Mapped[int | None] = mapped_column(\n        BigInteger, unique=True, nullable=True\n    )\n    full_name: Mapped[str] = mapped_column(Text, nullable=False)\n    username: Mapped[str | None] = mapped_column(Text, nullable=True)\n    first_name: Mapped[str | None] = mapped_column(Text, nullable=True)\n    last_name: Mapped[str | None] = mapped_column(Text, nullable=True)\n    phone: Mapped[str | None] = mapped_column(Text, nullable=True)\n    name_manually_edited: Mapped[bool] = mapped_column(\n        Boolean, nullable=False, server_default=\"0\", default=False\n    )\n\n    duties: Mapped[list[\"Duty\"]] = relationship(\"Duty\", back_populates=\"user\")\n
"},{"location":"api-reference/#duty_teller.db.schemas","title":"duty_teller.db.schemas","text":"

Pydantic schemas for API request/response and validation.

"},{"location":"api-reference/#duty_teller.db.schemas.CalendarEvent","title":"CalendarEvent","text":"

Bases: BaseModel

External calendar event (e.g. holiday) for a single day.

Source code in duty_teller/db/schemas.py
class CalendarEvent(BaseModel):\n    \"\"\"External calendar event (e.g. holiday) for a single day.\"\"\"\n\n    date: str  # YYYY-MM-DD\n    summary: str\n
"},{"location":"api-reference/#duty_teller.db.schemas.DutyBase","title":"DutyBase","text":"

Bases: BaseModel

Duty fields: user_id, start_at, end_at (UTC ISO 8601 with Z).

Source code in duty_teller/db/schemas.py
class DutyBase(BaseModel):\n    \"\"\"Duty fields: user_id, start_at, end_at (UTC ISO 8601 with Z).\"\"\"\n\n    user_id: int\n    start_at: str  # UTC, ISO 8601 with Z\n    end_at: str  # UTC, ISO 8601 with Z\n
"},{"location":"api-reference/#duty_teller.db.schemas.DutyCreate","title":"DutyCreate","text":"

Bases: DutyBase

Duty creation payload.

Source code in duty_teller/db/schemas.py
class DutyCreate(DutyBase):\n    \"\"\"Duty creation payload.\"\"\"\n\n    pass\n
"},{"location":"api-reference/#duty_teller.db.schemas.DutyInDb","title":"DutyInDb","text":"

Bases: DutyBase

Duty as stored in DB (includes id).

Source code in duty_teller/db/schemas.py
class DutyInDb(DutyBase):\n    \"\"\"Duty as stored in DB (includes id).\"\"\"\n\n    id: int\n\n    model_config = ConfigDict(from_attributes=True)\n
"},{"location":"api-reference/#duty_teller.db.schemas.DutyWithUser","title":"DutyWithUser","text":"

Bases: DutyInDb

Duty with full_name and event_type for calendar display.

event_type: only these values are returned; unknown DB values are mapped to \"duty\" in the API.

Source code in duty_teller/db/schemas.py
class DutyWithUser(DutyInDb):\n    \"\"\"Duty with full_name and event_type for calendar display.\n\n    event_type: only these values are returned; unknown DB values are mapped to \"duty\" in the API.\n    \"\"\"\n\n    full_name: str\n    event_type: Literal[\"duty\", \"unavailable\", \"vacation\"] = \"duty\"\n\n    model_config = ConfigDict(from_attributes=True)\n
"},{"location":"api-reference/#duty_teller.db.schemas.UserBase","title":"UserBase","text":"

Bases: BaseModel

Base user fields (full_name, username, first/last name).

Source code in duty_teller/db/schemas.py
class UserBase(BaseModel):\n    \"\"\"Base user fields (full_name, username, first/last name).\"\"\"\n\n    full_name: str\n    username: str | None = None\n    first_name: str | None = None\n    last_name: str | None = None\n
"},{"location":"api-reference/#duty_teller.db.schemas.UserCreate","title":"UserCreate","text":"

Bases: UserBase

User creation payload including Telegram user id.

Source code in duty_teller/db/schemas.py
class UserCreate(UserBase):\n    \"\"\"User creation payload including Telegram user id.\"\"\"\n\n    telegram_user_id: int\n
"},{"location":"api-reference/#duty_teller.db.schemas.UserInDb","title":"UserInDb","text":"

Bases: UserBase

User as stored in DB (includes id and telegram_user_id).

Source code in duty_teller/db/schemas.py
class UserInDb(UserBase):\n    \"\"\"User as stored in DB (includes id and telegram_user_id).\"\"\"\n\n    id: int\n    telegram_user_id: int\n\n    model_config = ConfigDict(from_attributes=True)\n
"},{"location":"api-reference/#duty_teller.db.session","title":"duty_teller.db.session","text":"

SQLAlchemy engine and session factory.

Engine and session factory are cached globally per process. Only one DATABASE_URL is effectively used for the process lifetime. Using a different URL later (e.g. in tests with in-memory SQLite) would still use the first engine. To use a different URL in tests, set env (e.g. DATABASE_URL) before the first import of this module, or clear _engine and _SessionLocal in test fixtures. Prefer session_scope() for all callers so sessions are always closed and rolled back on error.

"},{"location":"api-reference/#duty_teller.db.session.get_engine","title":"get_engine(database_url)","text":"

Return cached SQLAlchemy engine for the given URL (one per process).

Source code in duty_teller/db/session.py
def get_engine(database_url: str):\n    \"\"\"Return cached SQLAlchemy engine for the given URL (one per process).\"\"\"\n    global _engine\n    if _engine is None:\n        _engine = create_engine(\n            database_url,\n            connect_args={\"check_same_thread\": False}\n            if \"sqlite\" in database_url\n            else {},\n            echo=False,\n        )\n    return _engine\n
"},{"location":"api-reference/#duty_teller.db.session.get_session","title":"get_session(database_url)","text":"

Create a new session from the factory for the given URL.

Source code in duty_teller/db/session.py
def get_session(database_url: str) -> Session:\n    \"\"\"Create a new session from the factory for the given URL.\"\"\"\n    return get_session_factory(database_url)()\n
"},{"location":"api-reference/#duty_teller.db.session.get_session_factory","title":"get_session_factory(database_url)","text":"

Return cached session factory for the given URL (one per process).

Source code in duty_teller/db/session.py
def get_session_factory(database_url: str) -> sessionmaker[Session]:\n    \"\"\"Return cached session factory for the given URL (one per process).\"\"\"\n    global _SessionLocal\n    if _SessionLocal is None:\n        engine = get_engine(database_url)\n        _SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)\n    return _SessionLocal\n
"},{"location":"api-reference/#duty_teller.db.session.session_scope","title":"session_scope(database_url)","text":"

Context manager that yields a session; rolls back on exception, closes on exit.

Parameters:

Name Type Description Default database_url str

SQLAlchemy database URL.

required

Yields:

Type Description Session

Session instance. Caller must not use it after exit.

Source code in duty_teller/db/session.py
@contextmanager\ndef session_scope(database_url: str) -> Generator[Session, None, None]:\n    \"\"\"Context manager that yields a session; rolls back on exception, closes on exit.\n\n    Args:\n        database_url: SQLAlchemy database URL.\n\n    Yields:\n        Session instance. Caller must not use it after exit.\n    \"\"\"\n    session = get_session(database_url)\n    try:\n        yield session\n    except Exception:\n        session.rollback()\n        raise\n    finally:\n        session.close()\n
"},{"location":"api-reference/#duty_teller.db.repository","title":"duty_teller.db.repository","text":"

Repository: get_or_create_user, get_duties, insert_duty, get_current_duty, group_duty_pins.

"},{"location":"api-reference/#duty_teller.db.repository.create_calendar_token","title":"create_calendar_token(session, user_id)","text":"

Create a new calendar subscription token for the user.

Any existing tokens for this user are removed. The raw token is returned only once (not stored in plain text).

Parameters:

Name Type Description Default session Session

DB session.

required user_id int

User id.

required

Returns:

Type Description str

Raw token string (e.g. for URL /api/calendar/ical/{token}.ics).

Source code in duty_teller/db/repository.py
def create_calendar_token(session: Session, user_id: int) -> str:\n    \"\"\"Create a new calendar subscription token for the user.\n\n    Any existing tokens for this user are removed. The raw token is returned\n    only once (not stored in plain text).\n\n    Args:\n        session: DB session.\n        user_id: User id.\n\n    Returns:\n        Raw token string (e.g. for URL /api/calendar/ical/{token}.ics).\n    \"\"\"\n    session.query(CalendarSubscriptionToken).filter(\n        CalendarSubscriptionToken.user_id == user_id\n    ).delete(synchronize_session=False)\n    raw_token = secrets.token_urlsafe(32)\n    token_hash_val = _token_hash(raw_token)\n    now_iso = datetime.now(timezone.utc).strftime(\"%Y-%m-%dT%H:%M:%SZ\")\n    record = CalendarSubscriptionToken(\n        user_id=user_id,\n        token_hash=token_hash_val,\n        created_at=now_iso,\n    )\n    session.add(record)\n    session.commit()\n    return raw_token\n
"},{"location":"api-reference/#duty_teller.db.repository.delete_duties_in_range","title":"delete_duties_in_range(session, user_id, from_date, to_date)","text":"

Delete all duties of the user that overlap the given date range.

Parameters:

Name Type Description Default session Session

DB session.

required user_id int

User id.

required from_date str

Start date YYYY-MM-DD.

required to_date str

End date YYYY-MM-DD.

required

Returns:

Type Description int

Number of duties deleted.

Source code in duty_teller/db/repository.py
def delete_duties_in_range(\n    session: Session,\n    user_id: int,\n    from_date: str,\n    to_date: str,\n) -> int:\n    \"\"\"Delete all duties of the user that overlap the given date range.\n\n    Args:\n        session: DB session.\n        user_id: User id.\n        from_date: Start date YYYY-MM-DD.\n        to_date: End date YYYY-MM-DD.\n\n    Returns:\n        Number of duties deleted.\n    \"\"\"\n    to_next = (\n        datetime.fromisoformat(to_date + \"T00:00:00\") + timedelta(days=1)\n    ).strftime(\"%Y-%m-%d\")\n    q = session.query(Duty).filter(\n        Duty.user_id == user_id,\n        Duty.start_at < to_next,\n        Duty.end_at >= from_date,\n    )\n    count = q.count()\n    q.delete(synchronize_session=False)\n    session.commit()\n    return count\n
"},{"location":"api-reference/#duty_teller.db.repository.delete_group_duty_pin","title":"delete_group_duty_pin(session, chat_id)","text":"

Remove the pinned duty message record for the chat (e.g. when bot leaves group).

Parameters:

Name Type Description Default session Session

DB session.

required chat_id int

Telegram chat id.

required Source code in duty_teller/db/repository.py
def delete_group_duty_pin(session: Session, chat_id: int) -> None:\n    \"\"\"Remove the pinned duty message record for the chat (e.g. when bot leaves group).\n\n    Args:\n        session: DB session.\n        chat_id: Telegram chat id.\n    \"\"\"\n    session.query(GroupDutyPin).filter(GroupDutyPin.chat_id == chat_id).delete()\n    session.commit()\n
"},{"location":"api-reference/#duty_teller.db.repository.get_all_group_duty_pin_chat_ids","title":"get_all_group_duty_pin_chat_ids(session)","text":"

Return all chat_ids that have a pinned duty message.

Used to restore update jobs on bot startup.

Parameters:

Name Type Description Default session Session

DB session.

required

Returns:

Type Description list[int]

List of chat ids.

Source code in duty_teller/db/repository.py
def get_all_group_duty_pin_chat_ids(session: Session) -> list[int]:\n    \"\"\"Return all chat_ids that have a pinned duty message.\n\n    Used to restore update jobs on bot startup.\n\n    Args:\n        session: DB session.\n\n    Returns:\n        List of chat ids.\n    \"\"\"\n    rows = session.query(GroupDutyPin.chat_id).all()\n    return [r[0] for r in rows]\n
"},{"location":"api-reference/#duty_teller.db.repository.get_current_duty","title":"get_current_duty(session, at_utc)","text":"

Return the duty and user active at the given UTC time (event_type='duty').

Parameters:

Name Type Description Default session Session

DB session.

required at_utc datetime

Point in time (timezone-aware or naive UTC).

required

Returns:

Type Description tuple[Duty, User] | None

(Duty, User) or None if no duty at that time.

Source code in duty_teller/db/repository.py
def get_current_duty(session: Session, at_utc: datetime) -> tuple[Duty, User] | None:\n    \"\"\"Return the duty and user active at the given UTC time (event_type='duty').\n\n    Args:\n        session: DB session.\n        at_utc: Point in time (timezone-aware or naive UTC).\n\n    Returns:\n        (Duty, User) or None if no duty at that time.\n    \"\"\"\n    from datetime import timezone\n\n    if at_utc.tzinfo is not None:\n        at_utc = at_utc.astimezone(timezone.utc)\n    now_iso = at_utc.strftime(\"%Y-%m-%dT%H:%M:%S\") + \"Z\"\n    row = (\n        session.query(Duty, User)\n        .join(User, Duty.user_id == User.id)\n        .filter(\n            Duty.event_type == \"duty\",\n            Duty.start_at <= now_iso,\n            Duty.end_at > now_iso,\n        )\n        .first()\n    )\n    if row is None:\n        return None\n    return (row[0], row[1])\n
"},{"location":"api-reference/#duty_teller.db.repository.get_duties","title":"get_duties(session, from_date, to_date)","text":"

Return duties overlapping the given date range with user full_name.

Parameters:

Name Type Description Default session Session

DB session.

required from_date str

Start date YYYY-MM-DD.

required to_date str

End date YYYY-MM-DD.

required

Returns:

Type Description list[tuple[Duty, str]]

List of (Duty, full_name) tuples.

Source code in duty_teller/db/repository.py
def get_duties(\n    session: Session,\n    from_date: str,\n    to_date: str,\n) -> list[tuple[Duty, str]]:\n    \"\"\"Return duties overlapping the given date range with user full_name.\n\n    Args:\n        session: DB session.\n        from_date: Start date YYYY-MM-DD.\n        to_date: End date YYYY-MM-DD.\n\n    Returns:\n        List of (Duty, full_name) tuples.\n    \"\"\"\n    to_date_next = (\n        datetime.fromisoformat(to_date + \"T00:00:00\") + timedelta(days=1)\n    ).strftime(\"%Y-%m-%d\")\n    q = (\n        session.query(Duty, User.full_name)\n        .join(User, Duty.user_id == User.id)\n        .filter(Duty.start_at < to_date_next, Duty.end_at >= from_date)\n    )\n    return list(q.all())\n
"},{"location":"api-reference/#duty_teller.db.repository.get_duties_for_user","title":"get_duties_for_user(session, user_id, from_date, to_date)","text":"

Return duties for one user overlapping the date range.

Parameters:

Name Type Description Default session Session

DB session.

required user_id int

User id.

required from_date str

Start date YYYY-MM-DD.

required to_date str

End date YYYY-MM-DD.

required

Returns:

Type Description list[tuple[Duty, str]]

List of (Duty, full_name) tuples.

Source code in duty_teller/db/repository.py
def get_duties_for_user(\n    session: Session,\n    user_id: int,\n    from_date: str,\n    to_date: str,\n) -> list[tuple[Duty, str]]:\n    \"\"\"Return duties for one user overlapping the date range.\n\n    Args:\n        session: DB session.\n        user_id: User id.\n        from_date: Start date YYYY-MM-DD.\n        to_date: End date YYYY-MM-DD.\n\n    Returns:\n        List of (Duty, full_name) tuples.\n    \"\"\"\n    to_date_next = (\n        datetime.fromisoformat(to_date + \"T00:00:00\") + timedelta(days=1)\n    ).strftime(\"%Y-%m-%d\")\n    q = (\n        session.query(Duty, User.full_name)\n        .join(User, Duty.user_id == User.id)\n        .filter(\n            Duty.user_id == user_id,\n            Duty.start_at < to_date_next,\n            Duty.end_at >= from_date,\n        )\n    )\n    return list(q.all())\n
"},{"location":"api-reference/#duty_teller.db.repository.get_group_duty_pin","title":"get_group_duty_pin(session, chat_id)","text":"

Get the pinned duty message record for a chat.

Parameters:

Name Type Description Default session Session

DB session.

required chat_id int

Telegram chat id.

required

Returns:

Type Description GroupDutyPin | None

GroupDutyPin or None.

Source code in duty_teller/db/repository.py
def get_group_duty_pin(session: Session, chat_id: int) -> GroupDutyPin | None:\n    \"\"\"Get the pinned duty message record for a chat.\n\n    Args:\n        session: DB session.\n        chat_id: Telegram chat id.\n\n    Returns:\n        GroupDutyPin or None.\n    \"\"\"\n    return session.query(GroupDutyPin).filter(GroupDutyPin.chat_id == chat_id).first()\n
"},{"location":"api-reference/#duty_teller.db.repository.get_next_shift_end","title":"get_next_shift_end(session, after_utc)","text":"

Return the end_at of the current or next duty (event_type='duty').

Parameters:

Name Type Description Default session Session

DB session.

required after_utc datetime

Point in time (timezone-aware or naive UTC).

required

Returns:

Type Description datetime | None

End datetime (naive UTC) or None if no current or future duty.

Source code in duty_teller/db/repository.py
def get_next_shift_end(session: Session, after_utc: datetime) -> datetime | None:\n    \"\"\"Return the end_at of the current or next duty (event_type='duty').\n\n    Args:\n        session: DB session.\n        after_utc: Point in time (timezone-aware or naive UTC).\n\n    Returns:\n        End datetime (naive UTC) or None if no current or future duty.\n    \"\"\"\n    from datetime import timezone\n\n    if after_utc.tzinfo is not None:\n        after_utc = after_utc.astimezone(timezone.utc)\n    after_iso = after_utc.strftime(\"%Y-%m-%dT%H:%M:%S\") + \"Z\"\n    current = (\n        session.query(Duty)\n        .filter(\n            Duty.event_type == \"duty\",\n            Duty.start_at <= after_iso,\n            Duty.end_at > after_iso,\n        )\n        .first()\n    )\n    if current:\n        return datetime.fromisoformat(current.end_at.replace(\"Z\", \"+00:00\")).replace(\n            tzinfo=None\n        )\n    next_duty = (\n        session.query(Duty)\n        .filter(Duty.event_type == \"duty\", Duty.start_at > after_iso)\n        .order_by(Duty.start_at)\n        .first()\n    )\n    if next_duty:\n        return datetime.fromisoformat(next_duty.end_at.replace(\"Z\", \"+00:00\")).replace(\n            tzinfo=None\n        )\n    return None\n
"},{"location":"api-reference/#duty_teller.db.repository.get_or_create_user","title":"get_or_create_user(session, telegram_user_id, full_name, username=None, first_name=None, last_name=None)","text":"

Get or create user by Telegram user ID.

On create, name fields come from Telegram. On update: username is always synced; full_name, first_name, last_name are updated only if name_manually_edited is False (otherwise existing display name is kept).

Parameters:

Name Type Description Default session Session

DB session.

required telegram_user_id int

Telegram user id.

required full_name str

Display full name.

required username str | None

Telegram username (optional).

None first_name str | None

Telegram first name (optional).

None last_name str | None

Telegram last name (optional).

None

Returns:

Type Description User

User instance (created or updated).

Source code in duty_teller/db/repository.py
def get_or_create_user(\n    session: Session,\n    telegram_user_id: int,\n    full_name: str,\n    username: str | None = None,\n    first_name: str | None = None,\n    last_name: str | None = None,\n) -> User:\n    \"\"\"Get or create user by Telegram user ID.\n\n    On create, name fields come from Telegram. On update: username is always\n    synced; full_name, first_name, last_name are updated only if\n    name_manually_edited is False (otherwise existing display name is kept).\n\n    Args:\n        session: DB session.\n        telegram_user_id: Telegram user id.\n        full_name: Display full name.\n        username: Telegram username (optional).\n        first_name: Telegram first name (optional).\n        last_name: Telegram last name (optional).\n\n    Returns:\n        User instance (created or updated).\n    \"\"\"\n    user = get_user_by_telegram_id(session, telegram_user_id)\n    if user:\n        user.username = username\n        if not user.name_manually_edited:\n            user.full_name = full_name\n            user.first_name = first_name\n            user.last_name = last_name\n        session.commit()\n        session.refresh(user)\n        return user\n    user = User(\n        telegram_user_id=telegram_user_id,\n        full_name=full_name,\n        username=username,\n        first_name=first_name,\n        last_name=last_name,\n        name_manually_edited=False,\n    )\n    session.add(user)\n    session.commit()\n    session.refresh(user)\n    return user\n
"},{"location":"api-reference/#duty_teller.db.repository.get_or_create_user_by_full_name","title":"get_or_create_user_by_full_name(session, full_name)","text":"

Find user by exact full_name or create one (for duty-schedule import).

New users have telegram_user_id=None and name_manually_edited=True.

Parameters:

Name Type Description Default session Session

DB session.

required full_name str

Exact full name to match or set.

required

Returns:

Type Description User

User instance (existing or newly created).

Source code in duty_teller/db/repository.py
def get_or_create_user_by_full_name(session: Session, full_name: str) -> User:\n    \"\"\"Find user by exact full_name or create one (for duty-schedule import).\n\n    New users have telegram_user_id=None and name_manually_edited=True.\n\n    Args:\n        session: DB session.\n        full_name: Exact full name to match or set.\n\n    Returns:\n        User instance (existing or newly created).\n    \"\"\"\n    user = session.query(User).filter(User.full_name == full_name).first()\n    if user:\n        return user\n    user = User(\n        telegram_user_id=None,\n        full_name=full_name,\n        username=None,\n        first_name=None,\n        last_name=None,\n        name_manually_edited=True,\n    )\n    session.add(user)\n    session.commit()\n    session.refresh(user)\n    return user\n
"},{"location":"api-reference/#duty_teller.db.repository.get_user_by_calendar_token","title":"get_user_by_calendar_token(session, token)","text":"

Find user by calendar subscription token.

Uses constant-time comparison to avoid timing leaks.

Parameters:

Name Type Description Default session Session

DB session.

required token str

Raw token from URL.

required

Returns:

Type Description User | None

User or None if token is invalid or not found.

Source code in duty_teller/db/repository.py
def get_user_by_calendar_token(session: Session, token: str) -> User | None:\n    \"\"\"Find user by calendar subscription token.\n\n    Uses constant-time comparison to avoid timing leaks.\n\n    Args:\n        session: DB session.\n        token: Raw token from URL.\n\n    Returns:\n        User or None if token is invalid or not found.\n    \"\"\"\n    token_hash_val = _token_hash(token)\n    row = (\n        session.query(CalendarSubscriptionToken, User)\n        .join(User, CalendarSubscriptionToken.user_id == User.id)\n        .filter(CalendarSubscriptionToken.token_hash == token_hash_val)\n        .first()\n    )\n    if row is None:\n        return None\n    # Constant-time compare to avoid timing leaks (token_hash is already hashed).\n    if not hmac.compare_digest(row[0].token_hash, token_hash_val):\n        return None\n    return row[1]\n
"},{"location":"api-reference/#duty_teller.db.repository.get_user_by_telegram_id","title":"get_user_by_telegram_id(session, telegram_user_id)","text":"

Find user by Telegram user ID.

Parameters:

Name Type Description Default session Session

DB session.

required telegram_user_id int

Telegram user id.

required

Returns:

Type Description User | None

User or None if not found. Does not create a user.

Source code in duty_teller/db/repository.py
def get_user_by_telegram_id(session: Session, telegram_user_id: int) -> User | None:\n    \"\"\"Find user by Telegram user ID.\n\n    Args:\n        session: DB session.\n        telegram_user_id: Telegram user id.\n\n    Returns:\n        User or None if not found. Does not create a user.\n    \"\"\"\n    return session.query(User).filter(User.telegram_user_id == telegram_user_id).first()\n
"},{"location":"api-reference/#duty_teller.db.repository.insert_duty","title":"insert_duty(session, user_id, start_at, end_at, event_type='duty')","text":"

Create a duty record.

Parameters:

Name Type Description Default session Session

DB session.

required user_id int

User id.

required start_at str

Start time UTC, ISO 8601 with Z (e.g. 2025-01-15T09:00:00Z).

required end_at str

End time UTC, ISO 8601 with Z.

required event_type str

One of \"duty\", \"unavailable\", \"vacation\". Default \"duty\".

'duty'

Returns:

Type Description Duty

Created Duty instance.

Source code in duty_teller/db/repository.py
def insert_duty(\n    session: Session,\n    user_id: int,\n    start_at: str,\n    end_at: str,\n    event_type: str = \"duty\",\n) -> Duty:\n    \"\"\"Create a duty record.\n\n    Args:\n        session: DB session.\n        user_id: User id.\n        start_at: Start time UTC, ISO 8601 with Z (e.g. 2025-01-15T09:00:00Z).\n        end_at: End time UTC, ISO 8601 with Z.\n        event_type: One of \"duty\", \"unavailable\", \"vacation\". Default \"duty\".\n\n    Returns:\n        Created Duty instance.\n    \"\"\"\n    duty = Duty(\n        user_id=user_id,\n        start_at=start_at,\n        end_at=end_at,\n        event_type=event_type,\n    )\n    session.add(duty)\n    session.commit()\n    session.refresh(duty)\n    return duty\n
"},{"location":"api-reference/#duty_teller.db.repository.save_group_duty_pin","title":"save_group_duty_pin(session, chat_id, message_id)","text":"

Save or update the pinned duty message for a chat.

Parameters:

Name Type Description Default session Session

DB session.

required chat_id int

Telegram chat id.

required message_id int

Message id to pin/update.

required

Returns:

Type Description GroupDutyPin

GroupDutyPin instance (created or updated).

Source code in duty_teller/db/repository.py
def save_group_duty_pin(\n    session: Session, chat_id: int, message_id: int\n) -> GroupDutyPin:\n    \"\"\"Save or update the pinned duty message for a chat.\n\n    Args:\n        session: DB session.\n        chat_id: Telegram chat id.\n        message_id: Message id to pin/update.\n\n    Returns:\n        GroupDutyPin instance (created or updated).\n    \"\"\"\n    pin = session.query(GroupDutyPin).filter(GroupDutyPin.chat_id == chat_id).first()\n    if pin:\n        pin.message_id = message_id\n    else:\n        pin = GroupDutyPin(chat_id=chat_id, message_id=message_id)\n        session.add(pin)\n    session.commit()\n    session.refresh(pin)\n    return pin\n
"},{"location":"api-reference/#duty_teller.db.repository.set_user_phone","title":"set_user_phone(session, telegram_user_id, phone)","text":"

Set or clear phone for user by Telegram user id.

Parameters:

Name Type Description Default session Session

DB session.

required telegram_user_id int

Telegram user id.

required phone str | None

Phone string or None to clear.

required

Returns:

Type Description User | None

Updated User or None if not found.

Source code in duty_teller/db/repository.py
def set_user_phone(\n    session: Session, telegram_user_id: int, phone: str | None\n) -> User | None:\n    \"\"\"Set or clear phone for user by Telegram user id.\n\n    Args:\n        session: DB session.\n        telegram_user_id: Telegram user id.\n        phone: Phone string or None to clear.\n\n    Returns:\n        Updated User or None if not found.\n    \"\"\"\n    user = session.query(User).filter(User.telegram_user_id == telegram_user_id).first()\n    if not user:\n        return None\n    user.phone = phone\n    session.commit()\n    session.refresh(user)\n    return user\n
"},{"location":"api-reference/#duty_teller.db.repository.update_user_display_name","title":"update_user_display_name(session, telegram_user_id, full_name, first_name=None, last_name=None)","text":"

Update display name and set name_manually_edited=True.

Use from API or admin when name is changed manually; subsequent get_or_create_user will not overwrite these fields.

Parameters:

Name Type Description Default session Session

DB session.

required telegram_user_id int

Telegram user id.

required full_name str

New full name.

required first_name str | None

New first name (optional).

None last_name str | None

New last name (optional).

None

Returns:

Type Description User | None

Updated User or None if not found.

Source code in duty_teller/db/repository.py
def update_user_display_name(\n    session: Session,\n    telegram_user_id: int,\n    full_name: str,\n    first_name: str | None = None,\n    last_name: str | None = None,\n) -> User | None:\n    \"\"\"Update display name and set name_manually_edited=True.\n\n    Use from API or admin when name is changed manually; subsequent\n    get_or_create_user will not overwrite these fields.\n\n    Args:\n        session: DB session.\n        telegram_user_id: Telegram user id.\n        full_name: New full name.\n        first_name: New first name (optional).\n        last_name: New last name (optional).\n\n    Returns:\n        Updated User or None if not found.\n    \"\"\"\n    user = session.query(User).filter(User.telegram_user_id == telegram_user_id).first()\n    if not user:\n        return None\n    user.full_name = full_name\n    user.first_name = first_name\n    user.last_name = last_name\n    user.name_manually_edited = True\n    session.commit()\n    session.refresh(user)\n    return user\n
"},{"location":"api-reference/#services","title":"Services","text":""},{"location":"api-reference/#duty_teller.services","title":"duty_teller.services","text":"

Service layer: business logic and orchestration.

"},{"location":"api-reference/#duty_teller.services.delete_pin","title":"delete_pin(session, chat_id)","text":"

Remove the pinned message record for the chat (e.g. when bot leaves).

Parameters:

Name Type Description Default session Session

DB session.

required chat_id int

Telegram chat id.

required Source code in duty_teller/services/group_duty_pin_service.py
def delete_pin(session: Session, chat_id: int) -> None:\n    \"\"\"Remove the pinned message record for the chat (e.g. when bot leaves).\n\n    Args:\n        session: DB session.\n        chat_id: Telegram chat id.\n    \"\"\"\n    delete_group_duty_pin(session, chat_id)\n
"},{"location":"api-reference/#duty_teller.services.format_duty_message","title":"format_duty_message(duty, user, tz_name, lang='en')","text":"

Build the text for the pinned duty message.

Parameters:

Name Type Description Default duty

Duty instance or None.

required user

User instance or None.

required tz_name str

Timezone name for display (e.g. Europe/Moscow).

required lang str

Language code for i18n ('ru' or 'en').

'en'

Returns:

Type Description str

Formatted message string; \"No duty\" if duty or user is None.

Source code in duty_teller/services/group_duty_pin_service.py
def format_duty_message(duty, user, tz_name: str, lang: str = \"en\") -> str:\n    \"\"\"Build the text for the pinned duty message.\n\n    Args:\n        duty: Duty instance or None.\n        user: User instance or None.\n        tz_name: Timezone name for display (e.g. Europe/Moscow).\n        lang: Language code for i18n ('ru' or 'en').\n\n    Returns:\n        Formatted message string; \"No duty\" if duty or user is None.\n    \"\"\"\n    if duty is None or user is None:\n        return t(lang, \"duty.no_duty\")\n    try:\n        tz = ZoneInfo(tz_name)\n    except Exception:\n        tz = ZoneInfo(\"Europe/Moscow\")\n        tz_name = \"Europe/Moscow\"\n    start_dt = datetime.fromisoformat(duty.start_at.replace(\"Z\", \"+00:00\"))\n    end_dt = datetime.fromisoformat(duty.end_at.replace(\"Z\", \"+00:00\"))\n    start_local = start_dt.astimezone(tz)\n    end_local = end_dt.astimezone(tz)\n    offset_sec = (\n        start_local.utcoffset().total_seconds() if start_local.utcoffset() else 0\n    )\n    sign = \"+\" if offset_sec >= 0 else \"-\"\n    h, r = divmod(abs(int(offset_sec)), 3600)\n    m = r // 60\n    tz_hint = f\"UTC{sign}{h:d}:{m:02d}, {tz_name}\"\n    time_range = (\n        f\"{start_local.strftime('%d.%m.%Y %H:%M')} \u2014 \"\n        f\"{end_local.strftime('%d.%m.%Y %H:%M')} ({tz_hint})\"\n    )\n    label = t(lang, \"duty.label\")\n    lines = [\n        f\"\ud83d\udd50 {label} {time_range}\",\n        f\"\ud83d\udc64 {user.full_name}\",\n    ]\n    if user.phone:\n        lines.append(f\"\ud83d\udcde {user.phone}\")\n    if user.username:\n        lines.append(f\"@{user.username}\")\n    return \"\\n\".join(lines)\n
"},{"location":"api-reference/#duty_teller.services.get_all_pin_chat_ids","title":"get_all_pin_chat_ids(session)","text":"

Return all chat_ids that have a pinned duty message.

Used to restore update jobs on bot startup.

Parameters:

Name Type Description Default session Session

DB session.

required

Returns:

Type Description list[int]

List of chat ids.

Source code in duty_teller/services/group_duty_pin_service.py
def get_all_pin_chat_ids(session: Session) -> list[int]:\n    \"\"\"Return all chat_ids that have a pinned duty message.\n\n    Used to restore update jobs on bot startup.\n\n    Args:\n        session: DB session.\n\n    Returns:\n        List of chat ids.\n    \"\"\"\n    return get_all_group_duty_pin_chat_ids(session)\n
"},{"location":"api-reference/#duty_teller.services.get_duty_message_text","title":"get_duty_message_text(session, tz_name, lang='en')","text":"

Get current duty from DB and return formatted message text.

Parameters:

Name Type Description Default session Session

DB session.

required tz_name str

Timezone name for display.

required lang str

Language code for i18n.

'en'

Returns:

Type Description str

Formatted duty message or \"No duty\" if none.

Source code in duty_teller/services/group_duty_pin_service.py
def get_duty_message_text(session: Session, tz_name: str, lang: str = \"en\") -> str:\n    \"\"\"Get current duty from DB and return formatted message text.\n\n    Args:\n        session: DB session.\n        tz_name: Timezone name for display.\n        lang: Language code for i18n.\n\n    Returns:\n        Formatted duty message or \"No duty\" if none.\n    \"\"\"\n    now = datetime.now(timezone.utc)\n    result = get_current_duty(session, now)\n    if result is None:\n        return t(lang, \"duty.no_duty\")\n    duty, user = result\n    return format_duty_message(duty, user, tz_name, lang)\n
"},{"location":"api-reference/#duty_teller.services.get_message_id","title":"get_message_id(session, chat_id)","text":"

Return message_id for the pinned duty message in this chat.

Parameters:

Name Type Description Default session Session

DB session.

required chat_id int

Telegram chat id.

required

Returns:

Type Description int | None

Message id or None if no pin record.

Source code in duty_teller/services/group_duty_pin_service.py
def get_message_id(session: Session, chat_id: int) -> int | None:\n    \"\"\"Return message_id for the pinned duty message in this chat.\n\n    Args:\n        session: DB session.\n        chat_id: Telegram chat id.\n\n    Returns:\n        Message id or None if no pin record.\n    \"\"\"\n    pin = get_group_duty_pin(session, chat_id)\n    return pin.message_id if pin else None\n
"},{"location":"api-reference/#duty_teller.services.get_next_shift_end_utc","title":"get_next_shift_end_utc(session)","text":"

Return next shift end as naive UTC datetime for job scheduling.

Parameters:

Name Type Description Default session Session

DB session.

required

Returns:

Type Description datetime | None

Next shift end (naive UTC) or None.

Source code in duty_teller/services/group_duty_pin_service.py
def get_next_shift_end_utc(session: Session) -> datetime | None:\n    \"\"\"Return next shift end as naive UTC datetime for job scheduling.\n\n    Args:\n        session: DB session.\n\n    Returns:\n        Next shift end (naive UTC) or None.\n    \"\"\"\n    return get_next_shift_end(session, datetime.now(timezone.utc))\n
"},{"location":"api-reference/#duty_teller.services.run_import","title":"run_import(session, result, hour_utc, minute_utc)","text":"

Run duty-schedule import: delete range per user, insert duty/unavailable/vacation.

For each entry: get_or_create_user_by_full_name, delete_duties_in_range for the result date range, then insert duties (handover time in UTC), unavailable (all-day), and vacation (consecutive ranges).

Parameters:

Name Type Description Default session Session

DB session.

required result DutyScheduleResult

Parsed duty schedule (start_date, end_date, entries).

required hour_utc int

Handover hour in UTC (0-23).

required minute_utc int

Handover minute in UTC (0-59).

required

Returns:

Type Description tuple[int, int, int, int]

Tuple (num_users, num_duty, num_unavailable, num_vacation).

Source code in duty_teller/services/import_service.py
def run_import(\n    session: Session,\n    result: DutyScheduleResult,\n    hour_utc: int,\n    minute_utc: int,\n) -> tuple[int, int, int, int]:\n    \"\"\"Run duty-schedule import: delete range per user, insert duty/unavailable/vacation.\n\n    For each entry: get_or_create_user_by_full_name, delete_duties_in_range for\n    the result date range, then insert duties (handover time in UTC), unavailable\n    (all-day), and vacation (consecutive ranges).\n\n    Args:\n        session: DB session.\n        result: Parsed duty schedule (start_date, end_date, entries).\n        hour_utc: Handover hour in UTC (0-23).\n        minute_utc: Handover minute in UTC (0-59).\n\n    Returns:\n        Tuple (num_users, num_duty, num_unavailable, num_vacation).\n    \"\"\"\n    from_date_str = result.start_date.isoformat()\n    to_date_str = result.end_date.isoformat()\n    num_duty = num_unavailable = num_vacation = 0\n    for entry in result.entries:\n        user = get_or_create_user_by_full_name(session, entry.full_name)\n        delete_duties_in_range(session, user.id, from_date_str, to_date_str)\n        for d in entry.duty_dates:\n            start_at = duty_to_iso(d, hour_utc, minute_utc)\n            d_next = d + timedelta(days=1)\n            end_at = duty_to_iso(d_next, hour_utc, minute_utc)\n            insert_duty(session, user.id, start_at, end_at, event_type=\"duty\")\n            num_duty += 1\n        for d in entry.unavailable_dates:\n            insert_duty(\n                session,\n                user.id,\n                day_start_iso(d),\n                day_end_iso(d),\n                event_type=\"unavailable\",\n            )\n            num_unavailable += 1\n        for start_d, end_d in _consecutive_date_ranges(entry.vacation_dates):\n            insert_duty(\n                session,\n                user.id,\n                day_start_iso(start_d),\n                day_end_iso(end_d),\n                event_type=\"vacation\",\n            )\n            num_vacation += 1\n    return (len(result.entries), num_duty, num_unavailable, num_vacation)\n
"},{"location":"api-reference/#duty_teller.services.save_pin","title":"save_pin(session, chat_id, message_id)","text":"

Save or update the pinned duty message record for a chat.

Parameters:

Name Type Description Default session Session

DB session.

required chat_id int

Telegram chat id.

required message_id int

Message id to store.

required Source code in duty_teller/services/group_duty_pin_service.py
def save_pin(session: Session, chat_id: int, message_id: int) -> None:\n    \"\"\"Save or update the pinned duty message record for a chat.\n\n    Args:\n        session: DB session.\n        chat_id: Telegram chat id.\n        message_id: Message id to store.\n    \"\"\"\n    save_group_duty_pin(session, chat_id, message_id)\n
"},{"location":"api-reference/#duty_teller.services.import_service","title":"duty_teller.services.import_service","text":"

Import duty schedule: delete range, insert duties/unavailable/vacation. Accepts session.

"},{"location":"api-reference/#duty_teller.services.import_service.run_import","title":"run_import(session, result, hour_utc, minute_utc)","text":"

Run duty-schedule import: delete range per user, insert duty/unavailable/vacation.

For each entry: get_or_create_user_by_full_name, delete_duties_in_range for the result date range, then insert duties (handover time in UTC), unavailable (all-day), and vacation (consecutive ranges).

Parameters:

Name Type Description Default session Session

DB session.

required result DutyScheduleResult

Parsed duty schedule (start_date, end_date, entries).

required hour_utc int

Handover hour in UTC (0-23).

required minute_utc int

Handover minute in UTC (0-59).

required

Returns:

Type Description tuple[int, int, int, int]

Tuple (num_users, num_duty, num_unavailable, num_vacation).

Source code in duty_teller/services/import_service.py
def run_import(\n    session: Session,\n    result: DutyScheduleResult,\n    hour_utc: int,\n    minute_utc: int,\n) -> tuple[int, int, int, int]:\n    \"\"\"Run duty-schedule import: delete range per user, insert duty/unavailable/vacation.\n\n    For each entry: get_or_create_user_by_full_name, delete_duties_in_range for\n    the result date range, then insert duties (handover time in UTC), unavailable\n    (all-day), and vacation (consecutive ranges).\n\n    Args:\n        session: DB session.\n        result: Parsed duty schedule (start_date, end_date, entries).\n        hour_utc: Handover hour in UTC (0-23).\n        minute_utc: Handover minute in UTC (0-59).\n\n    Returns:\n        Tuple (num_users, num_duty, num_unavailable, num_vacation).\n    \"\"\"\n    from_date_str = result.start_date.isoformat()\n    to_date_str = result.end_date.isoformat()\n    num_duty = num_unavailable = num_vacation = 0\n    for entry in result.entries:\n        user = get_or_create_user_by_full_name(session, entry.full_name)\n        delete_duties_in_range(session, user.id, from_date_str, to_date_str)\n        for d in entry.duty_dates:\n            start_at = duty_to_iso(d, hour_utc, minute_utc)\n            d_next = d + timedelta(days=1)\n            end_at = duty_to_iso(d_next, hour_utc, minute_utc)\n            insert_duty(session, user.id, start_at, end_at, event_type=\"duty\")\n            num_duty += 1\n        for d in entry.unavailable_dates:\n            insert_duty(\n                session,\n                user.id,\n                day_start_iso(d),\n                day_end_iso(d),\n                event_type=\"unavailable\",\n            )\n            num_unavailable += 1\n        for start_d, end_d in _consecutive_date_ranges(entry.vacation_dates):\n            insert_duty(\n                session,\n                user.id,\n                day_start_iso(start_d),\n                day_end_iso(end_d),\n                event_type=\"vacation\",\n            )\n            num_vacation += 1\n    return (len(result.entries), num_duty, num_unavailable, num_vacation)\n
"},{"location":"api-reference/#duty_teller.services.group_duty_pin_service","title":"duty_teller.services.group_duty_pin_service","text":"

Group duty pin: current duty message text, next shift end, pin CRUD. All accept session.

"},{"location":"api-reference/#duty_teller.services.group_duty_pin_service.delete_pin","title":"delete_pin(session, chat_id)","text":"

Remove the pinned message record for the chat (e.g. when bot leaves).

Parameters:

Name Type Description Default session Session

DB session.

required chat_id int

Telegram chat id.

required Source code in duty_teller/services/group_duty_pin_service.py
def delete_pin(session: Session, chat_id: int) -> None:\n    \"\"\"Remove the pinned message record for the chat (e.g. when bot leaves).\n\n    Args:\n        session: DB session.\n        chat_id: Telegram chat id.\n    \"\"\"\n    delete_group_duty_pin(session, chat_id)\n
"},{"location":"api-reference/#duty_teller.services.group_duty_pin_service.format_duty_message","title":"format_duty_message(duty, user, tz_name, lang='en')","text":"

Build the text for the pinned duty message.

Parameters:

Name Type Description Default duty

Duty instance or None.

required user

User instance or None.

required tz_name str

Timezone name for display (e.g. Europe/Moscow).

required lang str

Language code for i18n ('ru' or 'en').

'en'

Returns:

Type Description str

Formatted message string; \"No duty\" if duty or user is None.

Source code in duty_teller/services/group_duty_pin_service.py
def format_duty_message(duty, user, tz_name: str, lang: str = \"en\") -> str:\n    \"\"\"Build the text for the pinned duty message.\n\n    Args:\n        duty: Duty instance or None.\n        user: User instance or None.\n        tz_name: Timezone name for display (e.g. Europe/Moscow).\n        lang: Language code for i18n ('ru' or 'en').\n\n    Returns:\n        Formatted message string; \"No duty\" if duty or user is None.\n    \"\"\"\n    if duty is None or user is None:\n        return t(lang, \"duty.no_duty\")\n    try:\n        tz = ZoneInfo(tz_name)\n    except Exception:\n        tz = ZoneInfo(\"Europe/Moscow\")\n        tz_name = \"Europe/Moscow\"\n    start_dt = datetime.fromisoformat(duty.start_at.replace(\"Z\", \"+00:00\"))\n    end_dt = datetime.fromisoformat(duty.end_at.replace(\"Z\", \"+00:00\"))\n    start_local = start_dt.astimezone(tz)\n    end_local = end_dt.astimezone(tz)\n    offset_sec = (\n        start_local.utcoffset().total_seconds() if start_local.utcoffset() else 0\n    )\n    sign = \"+\" if offset_sec >= 0 else \"-\"\n    h, r = divmod(abs(int(offset_sec)), 3600)\n    m = r // 60\n    tz_hint = f\"UTC{sign}{h:d}:{m:02d}, {tz_name}\"\n    time_range = (\n        f\"{start_local.strftime('%d.%m.%Y %H:%M')} \u2014 \"\n        f\"{end_local.strftime('%d.%m.%Y %H:%M')} ({tz_hint})\"\n    )\n    label = t(lang, \"duty.label\")\n    lines = [\n        f\"\ud83d\udd50 {label} {time_range}\",\n        f\"\ud83d\udc64 {user.full_name}\",\n    ]\n    if user.phone:\n        lines.append(f\"\ud83d\udcde {user.phone}\")\n    if user.username:\n        lines.append(f\"@{user.username}\")\n    return \"\\n\".join(lines)\n
"},{"location":"api-reference/#duty_teller.services.group_duty_pin_service.get_all_pin_chat_ids","title":"get_all_pin_chat_ids(session)","text":"

Return all chat_ids that have a pinned duty message.

Used to restore update jobs on bot startup.

Parameters:

Name Type Description Default session Session

DB session.

required

Returns:

Type Description list[int]

List of chat ids.

Source code in duty_teller/services/group_duty_pin_service.py
def get_all_pin_chat_ids(session: Session) -> list[int]:\n    \"\"\"Return all chat_ids that have a pinned duty message.\n\n    Used to restore update jobs on bot startup.\n\n    Args:\n        session: DB session.\n\n    Returns:\n        List of chat ids.\n    \"\"\"\n    return get_all_group_duty_pin_chat_ids(session)\n
"},{"location":"api-reference/#duty_teller.services.group_duty_pin_service.get_duty_message_text","title":"get_duty_message_text(session, tz_name, lang='en')","text":"

Get current duty from DB and return formatted message text.

Parameters:

Name Type Description Default session Session

DB session.

required tz_name str

Timezone name for display.

required lang str

Language code for i18n.

'en'

Returns:

Type Description str

Formatted duty message or \"No duty\" if none.

Source code in duty_teller/services/group_duty_pin_service.py
def get_duty_message_text(session: Session, tz_name: str, lang: str = \"en\") -> str:\n    \"\"\"Get current duty from DB and return formatted message text.\n\n    Args:\n        session: DB session.\n        tz_name: Timezone name for display.\n        lang: Language code for i18n.\n\n    Returns:\n        Formatted duty message or \"No duty\" if none.\n    \"\"\"\n    now = datetime.now(timezone.utc)\n    result = get_current_duty(session, now)\n    if result is None:\n        return t(lang, \"duty.no_duty\")\n    duty, user = result\n    return format_duty_message(duty, user, tz_name, lang)\n
"},{"location":"api-reference/#duty_teller.services.group_duty_pin_service.get_message_id","title":"get_message_id(session, chat_id)","text":"

Return message_id for the pinned duty message in this chat.

Parameters:

Name Type Description Default session Session

DB session.

required chat_id int

Telegram chat id.

required

Returns:

Type Description int | None

Message id or None if no pin record.

Source code in duty_teller/services/group_duty_pin_service.py
def get_message_id(session: Session, chat_id: int) -> int | None:\n    \"\"\"Return message_id for the pinned duty message in this chat.\n\n    Args:\n        session: DB session.\n        chat_id: Telegram chat id.\n\n    Returns:\n        Message id or None if no pin record.\n    \"\"\"\n    pin = get_group_duty_pin(session, chat_id)\n    return pin.message_id if pin else None\n
"},{"location":"api-reference/#duty_teller.services.group_duty_pin_service.get_next_shift_end_utc","title":"get_next_shift_end_utc(session)","text":"

Return next shift end as naive UTC datetime for job scheduling.

Parameters:

Name Type Description Default session Session

DB session.

required

Returns:

Type Description datetime | None

Next shift end (naive UTC) or None.

Source code in duty_teller/services/group_duty_pin_service.py
def get_next_shift_end_utc(session: Session) -> datetime | None:\n    \"\"\"Return next shift end as naive UTC datetime for job scheduling.\n\n    Args:\n        session: DB session.\n\n    Returns:\n        Next shift end (naive UTC) or None.\n    \"\"\"\n    return get_next_shift_end(session, datetime.now(timezone.utc))\n
"},{"location":"api-reference/#duty_teller.services.group_duty_pin_service.save_pin","title":"save_pin(session, chat_id, message_id)","text":"

Save or update the pinned duty message record for a chat.

Parameters:

Name Type Description Default session Session

DB session.

required chat_id int

Telegram chat id.

required message_id int

Message id to store.

required Source code in duty_teller/services/group_duty_pin_service.py
def save_pin(session: Session, chat_id: int, message_id: int) -> None:\n    \"\"\"Save or update the pinned duty message record for a chat.\n\n    Args:\n        session: DB session.\n        chat_id: Telegram chat id.\n        message_id: Message id to store.\n    \"\"\"\n    save_group_duty_pin(session, chat_id, message_id)\n
"},{"location":"api-reference/#handlers","title":"Handlers","text":""},{"location":"api-reference/#duty_teller.handlers","title":"duty_teller.handlers","text":"

Expose a single register_handlers(app) that registers all handlers.

"},{"location":"api-reference/#duty_teller.handlers.register_handlers","title":"register_handlers(app)","text":"

Register all Telegram handlers (commands, import, group pin, error handler) on the application.

Parameters:

Name Type Description Default app Application

python-telegram-bot Application instance.

required Source code in duty_teller/handlers/__init__.py
def register_handlers(app: Application) -> None:\n    \"\"\"Register all Telegram handlers (commands, import, group pin, error handler) on the application.\n\n    Args:\n        app: python-telegram-bot Application instance.\n    \"\"\"\n    app.add_handler(commands.start_handler)\n    app.add_handler(commands.help_handler)\n    app.add_handler(commands.set_phone_handler)\n    app.add_handler(commands.calendar_link_handler)\n    app.add_handler(import_duty_schedule.import_duty_schedule_handler)\n    app.add_handler(import_duty_schedule.handover_time_handler)\n    app.add_handler(import_duty_schedule.duty_schedule_document_handler)\n    app.add_handler(group_duty_pin.group_duty_pin_handler)\n    app.add_handler(group_duty_pin.pin_duty_handler)\n    app.add_error_handler(errors.error_handler)\n
"},{"location":"api-reference/#duty_teller.handlers.commands","title":"duty_teller.handlers.commands","text":"

Command handlers: /start, /help; /start registers user.

"},{"location":"api-reference/#duty_teller.handlers.commands.calendar_link","title":"calendar_link(update, context) async","text":"

Handle /calendar_link: send personal ICS URL (private chat only; user must be in allowlist).

Source code in duty_teller/handlers/commands.py
async def calendar_link(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:\n    \"\"\"Handle /calendar_link: send personal ICS URL (private chat only; user must be in allowlist).\"\"\"\n    if not update.message or not update.effective_user:\n        return\n    lang = get_lang(update.effective_user)\n    if update.effective_chat and update.effective_chat.type != \"private\":\n        await update.message.reply_text(t(lang, \"calendar_link.private_only\"))\n        return\n    telegram_user_id = update.effective_user.id\n    username = (update.effective_user.username or \"\").strip()\n    full_name = build_full_name(\n        update.effective_user.first_name, update.effective_user.last_name\n    )\n\n    def do_calendar_link() -> tuple[str | None, str | None]:\n        with session_scope(config.DATABASE_URL) as session:\n            user = get_or_create_user(\n                session,\n                telegram_user_id=telegram_user_id,\n                full_name=full_name,\n                username=update.effective_user.username,\n                first_name=update.effective_user.first_name,\n                last_name=update.effective_user.last_name,\n            )\n            if not config.can_access_miniapp(\n                username\n            ) and not config.can_access_miniapp_by_phone(user.phone):\n                return (None, \"denied\")\n            token = create_calendar_token(session, user.id)\n            base = (config.MINI_APP_BASE_URL or \"\").rstrip(\"/\")\n            url = f\"{base}/api/calendar/ical/{token}.ics\" if base else None\n            return (url, None)\n\n    result_url, error = await asyncio.get_running_loop().run_in_executor(\n        None, do_calendar_link\n    )\n    if error == \"denied\":\n        await update.message.reply_text(t(lang, \"calendar_link.access_denied\"))\n        return\n    if not result_url:\n        await update.message.reply_text(t(lang, \"calendar_link.error\"))\n        return\n    await update.message.reply_text(\n        t(lang, \"calendar_link.success\", url=result_url)\n        + \"\\n\\n\"\n        + t(lang, \"calendar_link.help_hint\")\n    )\n
"},{"location":"api-reference/#duty_teller.handlers.commands.help_cmd","title":"help_cmd(update, context) async","text":"

Handle /help: send list of commands (admins see import_duty_schedule).

Source code in duty_teller/handlers/commands.py
async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:\n    \"\"\"Handle /help: send list of commands (admins see import_duty_schedule).\"\"\"\n    if not update.message or not update.effective_user:\n        return\n    lang = get_lang(update.effective_user)\n    lines = [\n        t(lang, \"help.title\"),\n        t(lang, \"help.start\"),\n        t(lang, \"help.help\"),\n        t(lang, \"help.set_phone\"),\n        t(lang, \"help.calendar_link\"),\n        t(lang, \"help.pin_duty\"),\n    ]\n    if config.is_admin(update.effective_user.username or \"\"):\n        lines.append(t(lang, \"help.import_schedule\"))\n    await update.message.reply_text(\"\\n\".join(lines))\n
"},{"location":"api-reference/#duty_teller.handlers.commands.set_phone","title":"set_phone(update, context) async","text":"

Handle /set_phone [number]: set or clear phone (private chat only).

Source code in duty_teller/handlers/commands.py
async def set_phone(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:\n    \"\"\"Handle /set_phone [number]: set or clear phone (private chat only).\"\"\"\n    if not update.message or not update.effective_user:\n        return\n    lang = get_lang(update.effective_user)\n    if update.effective_chat and update.effective_chat.type != \"private\":\n        await update.message.reply_text(t(lang, \"set_phone.private_only\"))\n        return\n    args = context.args or []\n    phone = \" \".join(args).strip() if args else None\n    telegram_user_id = update.effective_user.id\n\n    def do_set_phone() -> str | None:\n        with session_scope(config.DATABASE_URL) as session:\n            full_name = build_full_name(\n                update.effective_user.first_name, update.effective_user.last_name\n            )\n            get_or_create_user(\n                session,\n                telegram_user_id=telegram_user_id,\n                full_name=full_name,\n                username=update.effective_user.username,\n                first_name=update.effective_user.first_name,\n                last_name=update.effective_user.last_name,\n            )\n            user = set_user_phone(session, telegram_user_id, phone or None)\n            if user is None:\n                return \"error\"\n            if phone:\n                return \"saved\"\n            return \"cleared\"\n\n    result = await asyncio.get_running_loop().run_in_executor(None, do_set_phone)\n    if result == \"error\":\n        await update.message.reply_text(t(lang, \"set_phone.error\"))\n    elif result == \"saved\":\n        await update.message.reply_text(t(lang, \"set_phone.saved\", phone=phone or \"\"))\n    else:\n        await update.message.reply_text(t(lang, \"set_phone.cleared\"))\n
"},{"location":"api-reference/#duty_teller.handlers.commands.start","title":"start(update, context) async","text":"

Handle /start: register user in DB and send greeting.

Source code in duty_teller/handlers/commands.py
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:\n    \"\"\"Handle /start: register user in DB and send greeting.\"\"\"\n    if not update.message:\n        return\n    user = update.effective_user\n    if not user:\n        return\n    full_name = build_full_name(user.first_name, user.last_name)\n    telegram_user_id = user.id\n    username = user.username\n    first_name = user.first_name\n    last_name = user.last_name\n\n    def do_get_or_create() -> None:\n        with session_scope(config.DATABASE_URL) as session:\n            get_or_create_user(\n                session,\n                telegram_user_id=telegram_user_id,\n                full_name=full_name,\n                username=username,\n                first_name=first_name,\n                last_name=last_name,\n            )\n\n    await asyncio.get_running_loop().run_in_executor(None, do_get_or_create)\n\n    lang = get_lang(user)\n    text = t(lang, \"start.greeting\")\n    await update.message.reply_text(text)\n
"},{"location":"api-reference/#duty_teller.handlers.import_duty_schedule","title":"duty_teller.handlers.import_duty_schedule","text":"

Import duty-schedule: /import_duty_schedule (admin only). Two steps: handover time -> JSON file.

"},{"location":"api-reference/#duty_teller.handlers.import_duty_schedule.handle_duty_schedule_document","title":"handle_duty_schedule_document(update, context) async","text":"

Handle uploaded JSON file: parse duty-schedule and run import.

Source code in duty_teller/handlers/import_duty_schedule.py
async def handle_duty_schedule_document(\n    update: Update, context: ContextTypes.DEFAULT_TYPE\n) -> None:\n    \"\"\"Handle uploaded JSON file: parse duty-schedule and run import.\"\"\"\n    if not update.message or not update.message.document or not update.effective_user:\n        return\n    if not context.user_data.get(\"awaiting_duty_schedule_file\"):\n        return\n    lang = get_lang(update.effective_user)\n    handover = context.user_data.get(\"handover_utc_time\")\n    if not handover or not config.is_admin(update.effective_user.username or \"\"):\n        return\n    if not (update.message.document.file_name or \"\").lower().endswith(\".json\"):\n        await update.message.reply_text(t(lang, \"import.need_json\"))\n        return\n\n    hour_utc, minute_utc = handover\n    file_id = update.message.document.file_id\n\n    file = await context.bot.get_file(file_id)\n    raw = bytes(await file.download_as_bytearray())\n    try:\n        result = parse_duty_schedule(raw)\n    except DutyScheduleParseError as e:\n        context.user_data.pop(\"awaiting_duty_schedule_file\", None)\n        context.user_data.pop(\"handover_utc_time\", None)\n        await update.message.reply_text(t(lang, \"import.parse_error\", error=str(e)))\n        return\n\n    def run_import_with_scope():\n        with session_scope(config.DATABASE_URL) as session:\n            return run_import(session, result, hour_utc, minute_utc)\n\n    loop = asyncio.get_running_loop()\n    try:\n        num_users, num_duty, num_unavailable, num_vacation = await loop.run_in_executor(\n            None, run_import_with_scope\n        )\n    except Exception as e:\n        await update.message.reply_text(t(lang, \"import.import_error\", error=str(e)))\n    else:\n        total = num_duty + num_unavailable + num_vacation\n        unavailable_suffix = (\n            t(lang, \"import.done_unavailable\", count=str(num_unavailable))\n            if num_unavailable\n            else \"\"\n        )\n        vacation_suffix = (\n            t(lang, \"import.done_vacation\", count=str(num_vacation))\n            if num_vacation\n            else \"\"\n        )\n        await update.message.reply_text(\n            t(\n                lang,\n                \"import.done\",\n                users=str(num_users),\n                duties=str(num_duty),\n                unavailable=unavailable_suffix,\n                vacation=vacation_suffix,\n                total=str(total),\n            )\n        )\n    finally:\n        context.user_data.pop(\"awaiting_duty_schedule_file\", None)\n        context.user_data.pop(\"handover_utc_time\", None)\n
"},{"location":"api-reference/#duty_teller.handlers.import_duty_schedule.handle_handover_time_text","title":"handle_handover_time_text(update, context) async","text":"

Handle text message when awaiting handover time (e.g. 09:00 Europe/Moscow).

Source code in duty_teller/handlers/import_duty_schedule.py
async def handle_handover_time_text(\n    update: Update, context: ContextTypes.DEFAULT_TYPE\n) -> None:\n    \"\"\"Handle text message when awaiting handover time (e.g. 09:00 Europe/Moscow).\"\"\"\n    if not update.message or not update.effective_user or not update.message.text:\n        return\n    if not context.user_data.get(\"awaiting_handover_time\"):\n        return\n    if not config.is_admin(update.effective_user.username or \"\"):\n        return\n    lang = get_lang(update.effective_user)\n    text = update.message.text.strip()\n    parsed = parse_handover_time(text)\n    if parsed is None:\n        await update.message.reply_text(t(lang, \"import.parse_time_error\"))\n        return\n    hour_utc, minute_utc = parsed\n    context.user_data[\"handover_utc_time\"] = (hour_utc, minute_utc)\n    context.user_data[\"awaiting_handover_time\"] = False\n    context.user_data[\"awaiting_duty_schedule_file\"] = True\n    await update.message.reply_text(t(lang, \"import.send_json\"))\n
"},{"location":"api-reference/#duty_teller.handlers.import_duty_schedule.import_duty_schedule_cmd","title":"import_duty_schedule_cmd(update, context) async","text":"

Handle /import_duty_schedule: start two-step import (admin only); asks for handover time.

Source code in duty_teller/handlers/import_duty_schedule.py
async def import_duty_schedule_cmd(\n    update: Update, context: ContextTypes.DEFAULT_TYPE\n) -> None:\n    \"\"\"Handle /import_duty_schedule: start two-step import (admin only); asks for handover time.\"\"\"\n    if not update.message or not update.effective_user:\n        return\n    lang = get_lang(update.effective_user)\n    if not config.is_admin(update.effective_user.username or \"\"):\n        await update.message.reply_text(t(lang, \"import.admin_only\"))\n        return\n    context.user_data[\"awaiting_handover_time\"] = True\n    await update.message.reply_text(t(lang, \"import.handover_format\"))\n
"},{"location":"api-reference/#duty_teller.handlers.group_duty_pin","title":"duty_teller.handlers.group_duty_pin","text":"

Pinned duty message in groups: handle bot add/remove, schedule updates at shift end.

"},{"location":"api-reference/#duty_teller.handlers.group_duty_pin.my_chat_member_handler","title":"my_chat_member_handler(update, context) async","text":"

Handle bot added to or removed from group: send/pin duty message or delete pin record.

Source code in duty_teller/handlers/group_duty_pin.py
async def my_chat_member_handler(\n    update: Update, context: ContextTypes.DEFAULT_TYPE\n) -> None:\n    \"\"\"Handle bot added to or removed from group: send/pin duty message or delete pin record.\"\"\"\n    if not update.my_chat_member or not update.effective_user:\n        return\n    old = update.my_chat_member.old_chat_member\n    new = update.my_chat_member.new_chat_member\n    chat = update.effective_chat\n    if not chat or chat.type not in (\"group\", \"supergroup\"):\n        return\n    if new.user.id != context.bot.id:\n        return\n    chat_id = chat.id\n\n    if new.status in (\n        ChatMemberStatus.MEMBER,\n        ChatMemberStatus.ADMINISTRATOR,\n    ) and old.status in (\n        ChatMemberStatus.LEFT,\n        ChatMemberStatus.BANNED,\n    ):\n        loop = asyncio.get_running_loop()\n        lang = get_lang(update.effective_user)\n        text = await loop.run_in_executor(\n            None, lambda: _get_duty_message_text_sync(lang)\n        )\n        try:\n            msg = await context.bot.send_message(chat_id=chat_id, text=text)\n        except (BadRequest, Forbidden) as e:\n            logger.warning(\"Failed to send duty message in chat_id=%s: %s\", chat_id, e)\n            return\n        pinned = False\n        try:\n            await context.bot.pin_chat_message(\n                chat_id=chat_id,\n                message_id=msg.message_id,\n                disable_notification=True,\n            )\n            pinned = True\n        except (BadRequest, Forbidden) as e:\n            logger.warning(\"Failed to pin message in chat_id=%s: %s\", chat_id, e)\n        await loop.run_in_executor(None, _sync_save_pin, chat_id, msg.message_id)\n        if not pinned:\n            try:\n                await context.bot.send_message(\n                    chat_id=chat_id,\n                    text=t(lang, \"pin_duty.could_not_pin_make_admin\"),\n                )\n            except (BadRequest, Forbidden):\n                pass\n        next_end = await loop.run_in_executor(None, _get_next_shift_end_sync)\n        await _schedule_next_update(context.application, chat_id, next_end)\n        return\n\n    if new.status in (ChatMemberStatus.LEFT, ChatMemberStatus.BANNED):\n        await asyncio.get_running_loop().run_in_executor(\n            None, _sync_delete_pin, chat_id\n        )\n        name = f\"{JOB_NAME_PREFIX}{chat_id}\"\n        if context.application.job_queue:\n            for job in context.application.job_queue.get_jobs_by_name(name):\n                job.schedule_removal()\n        logger.info(\"Bot left chat_id=%s, removed pin record and jobs\", chat_id)\n
"},{"location":"api-reference/#duty_teller.handlers.group_duty_pin.pin_duty_cmd","title":"pin_duty_cmd(update, context) async","text":"

Handle /pin_duty: pin the current duty message in the group (reply to bot's message).

Source code in duty_teller/handlers/group_duty_pin.py
async def pin_duty_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:\n    \"\"\"Handle /pin_duty: pin the current duty message in the group (reply to bot's message).\"\"\"\n    if not update.message or not update.effective_chat or not update.effective_user:\n        return\n    chat = update.effective_chat\n    lang = get_lang(update.effective_user)\n    if chat.type not in (\"group\", \"supergroup\"):\n        await update.message.reply_text(t(lang, \"pin_duty.group_only\"))\n        return\n    chat_id = chat.id\n    loop = asyncio.get_running_loop()\n    message_id = await loop.run_in_executor(None, _sync_get_message_id, chat_id)\n    if message_id is None:\n        await update.message.reply_text(t(lang, \"pin_duty.no_message\"))\n        return\n    try:\n        await context.bot.pin_chat_message(\n            chat_id=chat_id,\n            message_id=message_id,\n            disable_notification=True,\n        )\n        await update.message.reply_text(t(lang, \"pin_duty.pinned\"))\n    except (BadRequest, Forbidden) as e:\n        logger.warning(\"pin_duty failed chat_id=%s: %s\", chat_id, e)\n        await update.message.reply_text(t(lang, \"pin_duty.failed\"))\n
"},{"location":"api-reference/#duty_teller.handlers.group_duty_pin.restore_group_pin_jobs","title":"restore_group_pin_jobs(application) async","text":"

Restore scheduled pin-update jobs for all chats that have a pinned message (on startup).

Source code in duty_teller/handlers/group_duty_pin.py
async def restore_group_pin_jobs(application) -> None:\n    \"\"\"Restore scheduled pin-update jobs for all chats that have a pinned message (on startup).\"\"\"\n    loop = asyncio.get_running_loop()\n    chat_ids = await loop.run_in_executor(None, _get_all_pin_chat_ids_sync)\n    for chat_id in chat_ids:\n        next_end = await loop.run_in_executor(None, _get_next_shift_end_sync)\n        await _schedule_next_update(application, chat_id, next_end)\n    logger.info(\"Restored %s group pin jobs\", len(chat_ids))\n
"},{"location":"api-reference/#duty_teller.handlers.group_duty_pin.update_group_pin","title":"update_group_pin(context) async","text":"

Job callback: refresh pinned duty message and schedule next update at shift end.

Source code in duty_teller/handlers/group_duty_pin.py
async def update_group_pin(context: ContextTypes.DEFAULT_TYPE) -> None:\n    \"\"\"Job callback: refresh pinned duty message and schedule next update at shift end.\"\"\"\n    chat_id = context.job.data.get(\"chat_id\")\n    if chat_id is None:\n        return\n    loop = asyncio.get_running_loop()\n    message_id = await loop.run_in_executor(None, _sync_get_message_id, chat_id)\n    if message_id is None:\n        logger.info(\"No pin record for chat_id=%s, skipping update\", chat_id)\n        return\n    text = await loop.run_in_executor(\n        None, lambda: _get_duty_message_text_sync(config.DEFAULT_LANGUAGE)\n    )\n    try:\n        await context.bot.edit_message_text(\n            chat_id=chat_id,\n            message_id=message_id,\n            text=text,\n        )\n    except (BadRequest, Forbidden) as e:\n        logger.warning(\"Failed to edit pinned message chat_id=%s: %s\", chat_id, e)\n    next_end = await loop.run_in_executor(None, _get_next_shift_end_sync)\n    await _schedule_next_update(context.application, chat_id, next_end)\n
"},{"location":"api-reference/#duty_teller.handlers.errors","title":"duty_teller.handlers.errors","text":"

Global error handler: log exception and notify user.

"},{"location":"api-reference/#duty_teller.handlers.errors.error_handler","title":"error_handler(update, context) async","text":"

Global error handler: log exception and reply with generic message if possible.

Parameters:

Name Type Description Default update Update | None

Update that caused the error (may be None).

required context DEFAULT_TYPE

Callback context.

required Source code in duty_teller/handlers/errors.py
async def error_handler(\n    update: Update | None, context: ContextTypes.DEFAULT_TYPE\n) -> None:\n    \"\"\"Global error handler: log exception and reply with generic message if possible.\n\n    Args:\n        update: Update that caused the error (may be None).\n        context: Callback context.\n    \"\"\"\n    logger.exception(\"Exception while handling an update\")\n    if isinstance(update, Update) and update.effective_message:\n        user = getattr(update, \"effective_user\", None)\n        lang = get_lang(user) if user else config.DEFAULT_LANGUAGE\n        await update.effective_message.reply_text(t(lang, \"errors.generic\"))\n
"},{"location":"api-reference/#importers","title":"Importers","text":""},{"location":"api-reference/#duty_teller.importers","title":"duty_teller.importers","text":"

Importers for duty data (e.g. duty-schedule JSON).

"},{"location":"api-reference/#duty_teller.importers.duty_schedule","title":"duty_teller.importers.duty_schedule","text":"

Parser for duty-schedule JSON format. No DB access.

"},{"location":"api-reference/#duty_teller.importers.duty_schedule.DutyScheduleEntry","title":"DutyScheduleEntry dataclass","text":"

One person's schedule: full_name and three lists of dates by event type.

Source code in duty_teller/importers/duty_schedule.py
@dataclass\nclass DutyScheduleEntry:\n    \"\"\"One person's schedule: full_name and three lists of dates by event type.\"\"\"\n\n    full_name: str\n    duty_dates: list[date]\n    unavailable_dates: list[date]\n    vacation_dates: list[date]\n
"},{"location":"api-reference/#duty_teller.importers.duty_schedule.DutyScheduleParseError","title":"DutyScheduleParseError","text":"

Bases: Exception

Invalid or missing fields in duty-schedule JSON.

Source code in duty_teller/importers/duty_schedule.py
class DutyScheduleParseError(Exception):\n    \"\"\"Invalid or missing fields in duty-schedule JSON.\"\"\"\n\n    pass\n
"},{"location":"api-reference/#duty_teller.importers.duty_schedule.DutyScheduleResult","title":"DutyScheduleResult dataclass","text":"

Parsed duty schedule: start_date, end_date, and per-person entries.

Source code in duty_teller/importers/duty_schedule.py
@dataclass\nclass DutyScheduleResult:\n    \"\"\"Parsed duty schedule: start_date, end_date, and per-person entries.\"\"\"\n\n    start_date: date\n    end_date: date\n    entries: list[DutyScheduleEntry]\n
"},{"location":"api-reference/#duty_teller.importers.duty_schedule.parse_duty_schedule","title":"parse_duty_schedule(raw_bytes)","text":"

Parse duty-schedule JSON into DutyScheduleResult.

Expects meta.start_date (YYYY-MM-DD) and schedule (array). For each schedule item: name (required), duty string with ';' separator; index i = start_date + i days. Cell values: \u0432/\u0412/\u0431/\u0411 => duty, \u041d => unavailable, \u041e => vacation; rest ignored.

Parameters:

Name Type Description Default raw_bytes bytes

UTF-8 encoded JSON bytes.

required

Returns:

Type Description DutyScheduleResult

DutyScheduleResult with start_date, end_date, and entries (per-person dates).

Raises:

Type Description DutyScheduleParseError

On invalid JSON, missing/invalid meta or schedule, or invalid item fields.

Source code in duty_teller/importers/duty_schedule.py
def parse_duty_schedule(raw_bytes: bytes) -> DutyScheduleResult:\n    \"\"\"Parse duty-schedule JSON into DutyScheduleResult.\n\n    Expects meta.start_date (YYYY-MM-DD) and schedule (array). For each schedule\n    item: name (required), duty string with ';' separator; index i = start_date + i days.\n    Cell values: \u0432/\u0412/\u0431/\u0411 => duty, \u041d => unavailable, \u041e => vacation; rest ignored.\n\n    Args:\n        raw_bytes: UTF-8 encoded JSON bytes.\n\n    Returns:\n        DutyScheduleResult with start_date, end_date, and entries (per-person dates).\n\n    Raises:\n        DutyScheduleParseError: On invalid JSON, missing/invalid meta or schedule,\n            or invalid item fields.\n    \"\"\"\n    try:\n        data = json.loads(raw_bytes.decode(\"utf-8\"))\n    except (json.JSONDecodeError, UnicodeDecodeError) as e:\n        raise DutyScheduleParseError(f\"Invalid JSON or encoding: {e}\") from e\n\n    meta = data.get(\"meta\")\n    if not meta or not isinstance(meta, dict):\n        raise DutyScheduleParseError(\"Missing or invalid 'meta'\")\n\n    start_str = meta.get(\"start_date\")\n    if not start_str or not isinstance(start_str, str):\n        raise DutyScheduleParseError(\"Missing or invalid meta.start_date\")\n    try:\n        start_date = date.fromisoformat(start_str.strip())\n    except ValueError as e:\n        raise DutyScheduleParseError(f\"Invalid meta.start_date: {start_str}\") from e\n\n    schedule = data.get(\"schedule\")\n    if not isinstance(schedule, list):\n        raise DutyScheduleParseError(\"Missing or invalid 'schedule' (must be array)\")\n\n    max_days = 0\n    entries: list[DutyScheduleEntry] = []\n\n    for row in schedule:\n        if not isinstance(row, dict):\n            raise DutyScheduleParseError(\"schedule item must be an object\")\n        name = row.get(\"name\")\n        if name is None or not isinstance(name, str):\n            raise DutyScheduleParseError(\"schedule item must have 'name' (string)\")\n        full_name = name.strip()\n        if not full_name:\n            raise DutyScheduleParseError(\"schedule item 'name' cannot be empty\")\n\n        duty_str = row.get(\"duty\")\n        if duty_str is None:\n            duty_str = \"\"\n        if not isinstance(duty_str, str):\n            raise DutyScheduleParseError(\"schedule item 'duty' must be string\")\n\n        cells = [c.strip() for c in duty_str.split(\";\")]\n        max_days = max(max_days, len(cells))\n\n        duty_dates: list[date] = []\n        unavailable_dates: list[date] = []\n        vacation_dates: list[date] = []\n        for i, cell in enumerate(cells):\n            d = start_date + timedelta(days=i)\n            if cell in DUTY_MARKERS:\n                duty_dates.append(d)\n            elif cell == UNAVAILABLE_MARKER:\n                unavailable_dates.append(d)\n            elif cell == VACATION_MARKER:\n                vacation_dates.append(d)\n        entries.append(\n            DutyScheduleEntry(\n                full_name=full_name,\n                duty_dates=duty_dates,\n                unavailable_dates=unavailable_dates,\n                vacation_dates=vacation_dates,\n            )\n        )\n\n    if max_days == 0:\n        end_date = start_date\n    else:\n        end_date = start_date + timedelta(days=max_days - 1)\n\n    return DutyScheduleResult(start_date=start_date, end_date=end_date, entries=entries)\n
"},{"location":"architecture/","title":"Architecture","text":"

High-level architecture of Duty Teller: components, data flow, and package relationships.

"},{"location":"architecture/#components","title":"Components","text":""},{"location":"architecture/#data-flow","title":"Data flow","text":""},{"location":"architecture/#package-layout","title":"Package layout","text":"
flowchart LR\n    subgraph entry\n        main[main.py / duty-teller]\n    end\n    subgraph duty_teller\n        run[run.py]\n        config[config.py]\n        handlers[handlers]\n        api[api]\n        db[db]\n        services[services]\n        importers[importers]\n        i18n[i18n]\n        utils[utils]\n    end\n    main --> run\n    run --> config\n    run --> handlers\n    run --> api\n    handlers --> services\n    handlers --> db\n    handlers --> i18n\n    api --> db\n    api --> config\n    services --> db\n    services --> importers\n    importers --> .\n

See Project layout in README for file-level details.

"},{"location":"configuration/","title":"Configuration reference","text":"

All configuration is read from the environment (e.g. .env via python-dotenv). Source of truth: duty_teller/config.py and Settings.from_env().

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 hash_mismatch. DATABASE_URL string (SQLAlchemy URL) sqlite:///data/duty_teller.db Database connection URL. Example: sqlite:///data/duty_teller.db. MINI_APP_BASE_URL string (URL, no trailing slash) (empty) Base URL of the miniapp (for documentation and CORS). Trailing slash is stripped. Example: https://your-domain.com/app. HTTP_PORT integer 8080 Port for the HTTP server (FastAPI + static webapp). ALLOWED_USERNAMES comma-separated list (empty) Telegram usernames allowed to open the calendar miniapp (without @; case-insensitive). If both this and ADMIN_USERNAMES are empty, no one can open the calendar. Example: alice,bob. ADMIN_USERNAMES comma-separated list (empty) Telegram usernames with admin role (access to miniapp + /import_duty_schedule and future admin features). Example: admin1,admin2. ALLOWED_PHONES comma-separated list (empty) Phone numbers allowed to access the miniapp (user sets via /set_phone). Comparison uses digits only (spaces, +, parentheses, dashes ignored). Example: +7 999 123-45-67,89001234567. ADMIN_PHONES comma-separated list (empty) Phone numbers with admin role; same format as ALLOWED_PHONES. MINI_APP_SKIP_AUTH 1, true, or yes (unset) If set, /api/duties is allowed without Telegram initData (dev only; insecure). INIT_DATA_MAX_AGE_SECONDS integer 0 Reject Telegram initData older than this many seconds. 0 = disabled. Example: 86400 for 24 hours. CORS_ORIGINS comma-separated list * Allowed origins for CORS. Leave unset or set to * for allow-all. Example: https://your-domain.com. EXTERNAL_CALENDAR_ICS_URL string (URL) (empty) URL of a public ICS calendar (e.g. holidays). If set, those days are highlighted on the duty grid; users can tap \u00abi\u00bb on a cell to see the event summary. Empty = no external calendar. DUTY_DISPLAY_TZ string (timezone name) Europe/Moscow Timezone for the pinned duty message in groups. Example: Europe/Moscow, UTC. DEFAULT_LANGUAGE en or ru (normalized) en Default UI language when the user's Telegram language is unknown. Values starting with ru are normalized to ru, otherwise en."},{"location":"configuration/#quick-setup","title":"Quick setup","text":"
  1. Copy .env.example to .env.
  2. Set BOT_TOKEN to the token from BotFather.
  3. For miniapp access, set ALLOWED_USERNAMES and/or ADMIN_USERNAMES (and optionally ALLOWED_PHONES / ADMIN_PHONES).

For Mini App URL and production deployment notes (reverse proxy, initData), see the README Setup and Docker sections.

"},{"location":"import-format/","title":"Duty-schedule import format","text":"

The duty-schedule format is used by the /import_duty_schedule command. Only users in ADMIN_USERNAMES or ADMIN_PHONES can import.

"},{"location":"import-format/#import-flow","title":"Import flow","text":"
  1. Handover time \u2014 The bot asks for the shift handover time and optional timezone (e.g. 09:00 Europe/Moscow or 06:00 UTC). This is converted to UTC and used as the boundary between duty periods when creating records.
  2. 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.
"},{"location":"import-format/#format-specification","title":"Format specification","text":""},{"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 \u041d \u041e Vacation (\u043e\u0442\u043f\u0443\u0441\u043a) Exactly \u041e (empty/space/other) No event Ignored for import

The number of days in the schedule is the maximum length of any duty string when split by ;. If duty is empty or missing, it is treated as an empty list of cells.

"},{"location":"import-format/#example-json","title":"Example JSON","text":"
{\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
"},{"location":"import-format/#validation","title":"Validation","text":""},{"location":"runbook/","title":"Runbook (operational guide)","text":"

This document covers running the application, checking health, logs, common errors, and database operations.

"},{"location":"runbook/#starting-and-stopping","title":"Starting and stopping","text":""},{"location":"runbook/#local","title":"Local","text":""},{"location":"runbook/#docker","title":"Docker","text":"

On container start, entrypoint.sh runs Alembic migrations then starts the app as user botuser. Ensure .env (or your orchestrator\u2019s env) contains BOT_TOKEN and any required variables; see configuration.md.

"},{"location":"runbook/#health-check","title":"Health check","text":""},{"location":"runbook/#logs","title":"Logs","text":""},{"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 /api/duties or Miniapp)","text":""},{"location":"runbook/#miniapp-open-in-browser-or-direct-link-access-denied","title":"Miniapp \"Open in browser\" or direct link \u2014 access denied","text":""},{"location":"runbook/#403-open-from-telegram-no-initdata","title":"403 \"Open from Telegram\" / no initData","text":""},{"location":"runbook/#mini-app-url-redirect-and-broken-auth","title":"Mini App URL \u2014 redirect and broken auth","text":""},{"location":"runbook/#user-not-in-allowlist-403","title":"User not in allowlist (403)","text":""},{"location":"runbook/#database-and-migrations","title":"Database and migrations","text":"

For full list of env vars (including DATABASE_URL), see configuration.md. For reverse proxy and Mini App URL details, see the main README.

"}]}