---
description: Rules for working with the Python backend (duty_teller/)
globs:
  - duty_teller/**
  - alembic/**
  - tests/**
---

# Backend — Python

## Package layout

```
duty_teller/
├── main.py / run.py          # Entry point: bot + uvicorn
├── config.py                  # Settings from env vars (python-dotenv)
├── cache.py                   # TTL caches with pattern invalidation
├── api/                       # FastAPI app, routes, auth, ICS endpoints
│   ├── app.py                 # FastAPI app creation, route registration, static mount
│   ├── dependencies.py        # get_db_session, require_miniapp_username, get_validated_dates
│   ├── telegram_auth.py       # initData HMAC validation
│   ├── calendar_ics.py        # External ICS fetch & parse
│   └── personal_calendar_ics.py
├── db/                        # Database layer
│   ├── models.py              # SQLAlchemy ORM models (Base)
│   ├── repository.py          # CRUD functions (receive Session)
│   ├── schemas.py             # Pydantic response schemas
│   └── session.py             # session_scope, get_engine, get_session
├── handlers/                  # Telegram bot command/message handlers
│   ├── commands.py            # /start, /help, /set_phone, /calendar_link, /set_role
│   ├── common.py              # is_admin_async, invalidate_is_admin_cache
│   ├── errors.py              # Global error handler
│   ├── group_duty_pin.py      # Pinned duty message in group chats
│   └── import_duty_schedule.py
├── i18n/                      # Translations
│   ├── core.py                # get_lang, t()
│   ├── lang.py                # normalize_lang
│   └── messages.py            # MESSAGES dict (ru/en)
├── importers/                 # File parsers
│   └── duty_schedule.py       # parse_duty_schedule
├── services/                  # Business logic (receives Session)
│   ├── import_service.py      # run_import
│   └── group_duty_pin_service.py
└── utils/                     # Shared helpers
    ├── dates.py
    ├── handover.py
    ├── http_client.py
    └── user.py
```

## Imports

- Use absolute imports from the `duty_teller` package: `from duty_teller.db.repository import get_or_create_user`.
- Never import handler modules from services or repository — dependency flows
  downward: handlers → services → repository → models.

## DB access pattern

Handlers are async; SQLAlchemy sessions are synchronous. Two patterns:

### In bot handlers — `session_scope` + `run_in_executor`

```python
def do_work():
    with session_scope(config.DATABASE_URL) as session:
        # synchronous DB code here
        ...

await asyncio.get_running_loop().run_in_executor(None, do_work)
```

### In FastAPI endpoints — dependency injection

```python
@router.get("/api/duties")
def get_duties(session: Session = Depends(get_db_session)):
    ...
```

`get_db_session` yields a `session_scope` context — FastAPI closes it after the request.

## Handler patterns

### Admin-only commands

```python
if not await is_admin_async(update.effective_user.id):
    await update.message.reply_text(t(lang, "import.admin_only"))
    return
```

`is_admin_async` is cached for 60 s and invalidated on role changes.

### i18n in handlers

```python
lang = get_lang(update.effective_user)
text = t(lang, "start.greeting")
```

`t(lang, key, **kwargs)` substitutes `{placeholders}` and falls back to English → raw key.

### Error handling

The global `error_handler` (registered via `app.add_error_handler`) logs the
exception and sends a generic localized error reply.

## Service layer

- Services receive a `Session` — they never open their own.
- Services call repository functions, never handler code.
- After mutations that affect cached data, call `invalidate_duty_related_caches()`.

## Cache invalidation

Three TTL caches in `cache.py`:

| Cache | TTL | Invalidation trigger |
|-------|-----|---------------------|
| `ics_calendar_cache` | 600 s | After duty import |
| `duty_pin_cache` | 90 s | After duty import |
| `is_admin_cache` | 60 s | After `set_user_role` |

- `invalidate_duty_related_caches()` clears ICS and pin caches (call after any duty mutation).
- `invalidate_is_admin_cache(telegram_user_id)` clears a single admin entry.
- Pattern invalidation: `cache.invalidate_pattern(key_prefix_tuple)`.

## Alembic migrations

- Config in `pyproject.toml` under `[tool.alembic]`, script location: `alembic/`.
- **Naming convention:** `NNN_description.py` — sequential zero-padded number (`001`, `002`, …).
- Revision IDs are the string number: `revision = "008"`, `down_revision = "007"`.
- Run: `alembic -c pyproject.toml upgrade head` / `downgrade -1`.
- `entrypoint.sh` runs `alembic upgrade head` before starting the app in Docker.

## Configuration

- All config comes from environment variables, loaded with `python-dotenv`.
- `duty_teller/config.py` exposes module-level constants (`BOT_TOKEN`, `DATABASE_URL`, etc.)
  built from `Settings.from_env()`.
- Never hardcode secrets or URLs — always use `config.*` constants.
- Key variables: `BOT_TOKEN`, `DATABASE_URL`, `MINI_APP_BASE_URL`, `HTTP_HOST`, `HTTP_PORT`,
  `ADMIN_USERNAMES`, `ALLOWED_USERNAMES`, `DUTY_DISPLAY_TZ`, `DEFAULT_LANGUAGE`.

## Code style

- Formatter: Black (line-length 120).
- Linter: Ruff (`ruff check duty_teller tests`).
- Follow PEP 8 and Google Python Style Guide for docstrings.
- Max line length: 120 characters.
