Перейти к содержанию

Бэкенд DZ Hub — подробный гид

Состояние на 2026-10-03 (коммит 2e915ee). Если код поменялся, а гид нет, — верить коду. Короткая инструкция по запуску — в README.md.

Этот документ объясняет каждую часть бэкенда: что это, зачем, как устроено и как связано с остальным. Читать можно подряд или по разделам.

Содержание

  1. Место бэкенда в проекте и быстрый старт
  2. Стек: что и зачем
  3. Структура файлов
  4. pyproject.toml — построчно
  5. uv — менеджер пакетов и окружения
  6. Конфигурация: .env и app/core/config.py
  7. Путь одного запроса от начала до конца
  8. app/main.py — сборка приложения
  9. База данных: app/core/db.py
  10. Таблицы
  11. Миграции (Alembic)
  12. Модули и их устройство
  13. API: все эндпоинты
  14. Логирование
  15. Health-эндпоинты (app/api/health.py)
  16. Docker
  17. Makefile
  18. Тесты
  19. Качество кода
  20. Git
  21. Безопасность — сводка
  22. Клиенты API и CORS
  23. Шпаргалка команд
  24. Ограничения и что дальше
  25. Словарь терминов

0. Место бэкенда в проекте и быстрый старт

DZ Hub — портал для парашютных аэродромов (прыжковые дни, взлёты, запись на них). Бэкенд — HTTP API на FastAPI: хранит данные в PostgreSQL и отдаёт их в JSON. Его клиент — фронтенд на React (отдельный репозиторий frontend/), позже — Telegram-бот. Сейчас реализованы регистрация и вход по email, профиль парашютиста с допусками и электронный логбук со статистикой.

 Клиент (фронтенд на http://localhost:5173, Swagger, curl, тесты)
        │  HTTP + JSON, Authorization: Bearer <токен>
        ▼
 ┌────────────────────────┐          ┌──────────────────────────────┐
 │ backend (FastAPI)      │ ───────► │ PostgreSQL 18 (Docker)       │
 │ http://localhost:8000  │   SQL    │ localhost:5433 → контейнер   │
 └────────────────────────┘          └──────────────────────────────┘

Быстрый старт (нужны uv и Docker):

make install   # первый раз: зависимости + .env из .env.example
make up        # база + API в Docker, миграции применяются сами

1. Стек: что и зачем

Библиотека Версия Зачем
Python 3.13 Язык
FastAPI 0.142 Веб-фреймворк: маршруты, валидация, документация API
uvicorn 0.54 Веб-сервер, который запускает FastAPI-приложение (ASGI-сервер)
Pydantic 2.13 Описание и проверка данных (схемы запросов и ответов)
pydantic-settings 2.15 Чтение настроек из переменных окружения и .env
SQLAlchemy 2.1 Работа с базой: модели-классы вместо голого SQL (ORM)
asyncpg 0.31 Асинхронный драйвер PostgreSQL (через него SQLAlchemy общается с базой)
Alembic 1.20 Миграции: версии схемы базы данных
pwdlib[argon2] 0.3 Хеширование паролей алгоритмом argon2id
PyJWT 2.15 Создание и проверка токенов доступа (JWT)
email-validator 2.3 Проверка формата email (через pydantic[email])

Только для разработки (группа dev, в Docker-образ не попадают):

Библиотека Зачем
pytest Запуск тестов
pytest-asyncio Поддержка async-тестов
httpx HTTP-клиент: тесты обращаются к приложению как настоящий клиент
ruff Линтер и форматтер (стиль кода, типичные ошибки, сортировка импортов)
mypy Проверка типов

Почему асинхронность (async). Пока один запрос ждёт ответа базы, сервер может обрабатывать другие. Поэтому вся цепочка асинхронная: FastAPI → SQLAlchemy (async) → asyncpg.

2. Структура файлов

backend/
├── .python-version       версия Python для uv (3.13)
├── pyproject.toml        описание проекта, зависимости, настройки ruff/mypy/pytest
├── uv.lock               точные версии ВСЕХ пакетов (генерируется uv, в git)
├── .venv/                виртуальное окружение с установленными пакетами (не в git)
├── .env.example          шаблон настроек (в git)
├── .env                  твои настройки (НЕ в git: там секреты)
├── .gitignore            что git не отслеживает
├── .dockerignore         что не копировать в Docker-образ
├── Dockerfile            как собрать образ API
├── docker-compose.yml    как запустить базу и API вместе
├── Makefile              короткие команды: make up, make test, ...
├── README.md             короткая инструкция
├── docs/GUIDE.md         этот гид
├── alembic.ini           настройки Alembic
├── alembic/
│   ├── env.py            как Alembic подключается к базе и находит модели
│   ├── script.py.mako    шаблон файла новой миграции
│   └── versions/         сами миграции (по файлу на изменение схемы)
│       ├── 2026_10_02_2152-b81f8147cc9f_create_users.py
│       ├── 2026_10_03_1137-20ecb46f69d4_add_skydiver_profiles_and_user_phone.py
│       ├── 2026_10_03_1217-900f079cf837_add_skydiver_ratings.py
│       ├── 2026_10_03_1233-05a9e28db108_add_logbook_entries.py
│       ├── 2026_10_03_1628-250050dee879_rename_jump_levels_per_uspa.py
│       ├── 2026_10_03_1637-db4f8658ee5a_align_ratings_and_jump_roles_with_uspa.py
│       ├── 2026_10_03_1705-a9c1340a67e4_add_prior_freefall_and_first_jump_to_.py
│       ├── 2026_10_03_2121-fc89aeb253d0_align_jump_types_with_uspa.py
│       ├── 2026_10_03_2128-d4feb274c7b3_add_night_and_water_flags_to_logbook.py
│       ├── 2026_10_03_2132-ad3d2ec606fb_add_landing_distance_to_logbook.py
│       ├── 2026_10_04_1033-e5941e133078_add_dropzones_and_staff.py
│       └── 2026_10_04_1146-f00b1cecbba7_add_aircraft_and_jump_days.py
├── app/                  код приложения
│   ├── main.py           сборка приложения: create_app()
│   ├── models.py         реестр всех моделей (для Alembic)
│   ├── api/
│   │   ├── router.py     общий роутер /api/v1, сюда подключаются модули
│   │   └── health.py     /health и /health/ready
│   ├── core/             инфраструктура, общая для всех модулей
│   │   ├── config.py     настройки
│   │   ├── db.py         база данных
│   │   ├── logging.py    логирование
│   │   ├── middleware.py request_id + лог запросов
│   │   ├── language.py   язык ответа из Accept-Language (ru, en, ro)
│   │   └── schemas.py    CamelModel, общий тип Name
│   └── modules/          бизнес-модули
│       ├── users/        пользователи: модель, схема ответа, поиск
│       ├── auth/         регистрация, вход, токены
│       ├── skydivers/    профиль, допуски, логбук, статистика
│           ├── enums.py      уровни, допуски, типы и роли прыжков
│           ├── models.py     SkydiverProfile, SkydiverRating, LogbookEntry
│           ├── schemas.py    схемы профиля, допусков, логбука и статистики
│           ├── rules.py      правила USPA: уровень для допуска, возврат после перерыва
│           ├── service.py    профиль, допуски, опыт
│           ├── logbook.py    логбук: записи, автонумерация, фильтры, статистика
│           └── router.py     эндпоинты /me/...
│       ├── canopy/       калькулятор купола по SIM 2026
│           ├── uspa.py       таблица минимальной площади и пороги, со ссылками на SIM
│           ├── texts.py      тексты на ru, en, ro: поведение купола, предупреждение
│           ├── calculator.py расчёт: чистые функции
│           ├── schemas.py    схема ответа
│           └── router.py     GET /canopy-calculator
│       ├── dropzones/    дропзоны и их персонал
│           ├── enums.py      статус дропзоны, права в панели, должности
│           ├── models.py     Dropzone, DropzoneStaff, DropzoneStaffPosition
│           ├── rules.py      какой допуск нужен для должности
│           ├── schemas.py    схемы дропзоны и персонала
│           ├── service.py    видимость, права владельца, персонал
│           ├── router.py     эндпоинты /dropzones/..., /me/dropzones
│           └── cli.py        ручная активация: make activate-dz
│       └── operations/   лётная работа: самолёты, прыжковые дни (позже — взлёты)
│           ├── enums.py      категории прыжков дня, режим записи, статус дня
│           ├── models.py     Aircraft, JumpDay
│           ├── schemas.py    схемы самолётов и прыжковых дней
│           ├── service.py    правила дат, уникальность, права
│           └── router.py     /dropzones/{id}/aircraft, /dropzones/{id}/jump-days
└── tests/
    ├── conftest.py       общие фикстуры: тестовая база, клиент, token, auth, register
    ├── test_health.py
    ├── test_auth.py
    ├── test_skydiver_profiles.py   профиль: модель и ограничения базы
    ├── test_profile_api.py         API профиля
    ├── test_rules.py               правила USPA без базы и HTTP
    ├── test_ratings_api.py         API допусков
    ├── test_logbook_model.py       логбук: ограничения базы
    ├── test_logbook_api.py         API логбука
    ├── test_logbook_stats.py       опыт, статистика, фильтры
    ├── test_language.py            разбор Accept-Language
    ├── test_canopy.py              калькулятор купола: правила и API
    ├── test_dropzone_rules.py      должности и допуски без базы и HTTP
    ├── test_dropzones_api.py       API дропзон и ограничения базы
    ├── test_staff_api.py           API персонала
    ├── test_operations_api.py      самолёты и прыжковые дни
    └── test_openapi.py             группы эндпоинтов в OpenAPI

Файлы __init__.py (пустые) превращают папки в Python-пакеты, чтобы работали импорты вида from app.core.db import Base.

3. pyproject.toml — построчно

[project]
name = "dz-hub-backend"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = [ ... ]          # библиотеки, нужные для работы приложения
  • dependencies — то, без чего приложение не запустится (попадает в Docker).
  • >=1.20.0 — «эта версия или новее». Точные версии фиксирует uv.lock.
[tool.uv]
package = false

Говорит uv: это приложение, а не библиотека. Его не нужно упаковывать и публиковать, нужно только установить зависимости.

[dependency-groups]
dev = [ "httpx", "mypy", "pytest", "pytest-asyncio", "ruff" ]

Зависимости только для разработки. uv sync ставит их локально, uv sync --no-dev (в Docker) — нет.

[tool.ruff]
line-length = 100             # максимальная длина строки
target-version = "py313"      # можно использовать синтаксис Python 3.13
extend-exclude = ["docs"]     # примеры кода в документации не форматируем

[tool.ruff.lint]
select = ["E", "W", "F", "I", "B", "UP", "SIM", "ASYNC", "RUF"]
ignore = ["RUF001", "RUF002", "RUF003"]

[tool.ruff.lint.per-file-ignores]
"alembic/versions/*" = ["E501"]   # длинные SQL-выражения в миграциях не переносим
Код Набор правил
E, W Стиль по PEP 8 (ошибки и предупреждения)
F Pyflakes: неиспользуемые импорты и переменные, неопределённые имена
I Сортировка импортов (isort)
B Bugbear: частые ловушки (изменяемые значения по умолчанию и т. п.)
UP pyupgrade: современный синтаксис (list[str] вместо List[str])
SIM Упрощения кода
ASYNC Ошибки в async-коде (блокирующие вызовы и т. п.)
RUF Правила самого ruff

RUF001–003 отключены: они ругаются на кириллицу в строках и комментариях, путая её с похожими латинскими буквами. У нас комментарии на русском, так что это ложные срабатывания.

extend-exclude = ["docs"] — ruff умеет форматировать код внутри Markdown и однажды «исправил» пример в этом гиде так, что тот стал неверным. Документацию он больше не трогает.

[tool.mypy]
strict = true                 # самый строгий режим: у всего должны быть типы
plugins = ["pydantic.mypy"]   # mypy понимает модели Pydantic
exclude = ["^alembic/versions/"]  # файлы миграций генерируются, их не проверяем
[tool.pytest.ini_options]
asyncio_mode = "auto"                         # async-тесты работают без декоратора
asyncio_default_fixture_loop_scope = "session"
asyncio_default_test_loop_scope = "session"   # один event loop на все тесты
testpaths = ["tests"]

Один общий event loop нужен потому, что движок тестовой базы создаётся один раз на всю сессию тестов, а соединения asyncpg привязаны к loop, в котором созданы.

4. uv — менеджер пакетов и окружения

  • .python-version — uv использует Python 3.13 (при необходимости скачает сам).
  • .venv/ — изолированное окружение проекта: пакеты ставятся сюда, а не в систему.
  • uv.lock — «снимок» точных версий всех пакетов, включая зависимости зависимостей. Благодаря ему у всех (у тебя, в Docker, в CI) стоят одинаковые версии. Коммитится в git.
  • uv sync — привести .venv в соответствие с uv.lock.
  • uv add <пакет> — добавить зависимость (обновит pyproject.toml и uv.lock). uv add --dev <пакет> — в группу dev.
  • uv run <команда> — выполнить команду внутри .venv (например, uv run pytest). Активировать окружение вручную не нужно.

5. Конфигурация: .env и app/core/config.py

Откуда берутся настройки

Класс Settings (pydantic-settings) читает значения в таком порядке (верхнее важнее):

  1. Переменные окружения процесса (например, заданные в docker-compose).
  2. Файл .env в папке, откуда запущено приложение.
  3. Значения по умолчанию в коде Settings.

Имена не зависят от регистра: поле postgres_host читается из POSTGRES_HOST. extra="ignore" — лишние переменные в .env не вызывают ошибку.

.env.example лежит в git как шаблон, .env — твоя локальная копия (в .gitignore), потому что в нём могут быть секреты. make install / make env создают .env из шаблона, если его ещё нет.

Все параметры

Переменная По умолчанию Что делает
ENVIRONMENT local local / test / production. В production включаются строгие проверки секретов
LOG_LEVEL INFO Минимальный уровень логов: DEBUG < INFO < WARNING < ERROR
LOG_FORMAT text text — читать глазами, json — для систем сбора логов
POSTGRES_HOST localhost Адрес базы. В Docker переопределяется на db
POSTGRES_PORT 5433 Порт базы. В Docker переопределяется на 5432
POSTGRES_USER dzhub Пользователь базы
POSTGRES_PASSWORD dzhub Пароль базы (SecretStr, см. ниже)
POSTGRES_DB dzhub Имя базы. Тесты используют dzhub_test
DB_ECHO false true — печатать в лог каждый SQL-запрос
CORS_ORIGINS [] JSON-список сайтов, которым браузер разрешит обращаться к API. Сейчас ["http://localhost:5173"] — фронтенд
JWT_SECRET_KEY dev-значение Секрет подписи токенов. В проде обязателен свой (32+ символа)
ACCESS_TOKEN_EXPIRE_MINUTES 10080 Срок жизни токена: 10080 минут = 7 дней

Эти же POSTGRES_* читает контейнер PostgreSQL в docker-compose: из них он создаёт пользователя и базу при первом запуске. Поэтому настройки приложения и базы всегда совпадают.

Внутри Settings также есть app_name = "DZ Hub API" (заголовок в документации) и jwt_algorithm = "HS256" — их можно переопределить через APP_NAME / JWT_ALGORITHM, но обычно не нужно.

Детали кода

  • SecretStr — тип для секретов. При печати показывает **********, а не значение, поэтому пароль не утечёт в лог случайно. Настоящее значение — .get_secret_value().
  • database_url — собирает строку подключения: postgresql+asyncpg://dzhub:dzhub@localhost:5433/dzhub. Часть +asyncpg говорит SQLAlchemy, каким драйвером пользоваться.
  • _check_production_secrets — валидатор: если ENVIRONMENT=production, а секрет JWT стандартный или короче 32 символов, приложение не запустится. Это защита от выкладки в прод с dev-секретом, которым любой мог бы подделать токен.
  • get_settings() с @lru_cache — настройки читаются один раз и кэшируются. Везде в коде используем get_settings(), а не os.environ.

6. Путь одного запроса от начала до конца

Пример: POST /api/v1/auth/register с JSON-телом.

 1. uvicorn принимает HTTP-соединение и передаёт запрос приложению FastAPI
 2. CORSMiddleware: если запрос из браузера с другого сайта — проверяет, разрешён ли он
 3. RequestLoggingMiddleware: назначает request_id, засекает время
 4. Роутер находит обработчик по пути и методу: auth/router.py → register()
 5. FastAPI готовит аргументы обработчика:
      data: RegisterRequest   ← JSON проверяется Pydantic; ошибка → 422 автоматически
      session: SessionDep     ← вызывается get_session(), открывается сессия к базе
 6. Обработчик вызывает сервис: auth/service.py → register(session, data)
 7. Сервис работает с базой через SQLAlchemy → asyncpg → PostgreSQL
 8. Обработчик возвращает TokenResponse → FastAPI превращает его в JSON (camelCase)
 9. get_session() закрывает сессию
10. RequestLoggingMiddleware пишет строку лога и добавляет заголовок X-Request-ID
11. uvicorn отправляет ответ клиенту

7. app/main.py — сборка приложения

app = create_app()

Uvicorn запускается командой uvicorn app.main:app: «в модуле app/main.py возьми переменную app».

create_app() по шагам:

  1. setup_logging(...) — настраивает логи первым делом, чтобы всё дальше логировалось в нашем формате.
  2. FastAPI(...):
  3. title — название в документации;
  4. openapi_url="/api/v1/openapi.json" — машиночитаемое описание API (OpenAPI). Из него в будущем фронтенд будет генерировать TypeScript-типы;
  5. docs_url="/api/docs" — интерактивная документация Swagger UI;
  6. redoc_url=None — альтернативная документация ReDoc отключена (хватает одной);
  7. lifespan — код, который выполняется при старте и остановке.
  8. CORS — подключается, только если CORS_ORIGINS не пустой (подробнее в разделе 21):
  9. allow_origins — список разрешённых сайтов;
  10. allow_credentials=True — разрешить отправку куки/авторизации;
  11. allow_methods=["*"], allow_headers=["*"] — любые методы и заголовки (в том числе Authorization).
  12. RequestLoggingMiddleware — логирование запросов.
  13. include_router(api_router) — подключает все маршруты /api/v1/....

Порядок middleware. В Starlette последний добавленный middleware оказывается внешним. Поэтому запрос сначала проходит логирование, потом CORS. Так в лог попадают даже запросы, отклонённые CORS.

lifespan. Код до yield выполняется при старте (пишем «Запуск DZ Hub API»), после yield — при остановке: engine.dispose() аккуратно закрывает все соединения с базой.

8. База данных: app/core/db.py

Движок и сессии

engine = create_async_engine(database_url, echo=db_echo, pool_pre_ping=True)
SessionFactory = async_sessionmaker(engine, expire_on_commit=False)
  • Engine (движок) — один на всё приложение. Держит пул соединений: открытые соединения с базой переиспользуются, а не открываются заново на каждый запрос (это дорого).
  • pool_pre_ping=True — перед выдачей соединения из пула проверяет, живо ли оно. Если база перезапускалась, «мёртвое» соединение будет заменено, а не вызовет ошибку.
  • echo — печать SQL в лог (DB_ECHO).
  • Session (сессия) — «рабочий сеанс» с базой на время одного запроса: накапливает изменения объектов и отправляет их при commit().
  • expire_on_commit=False — после commit() объекты не «протухают». Без этого обращение к user.email после коммита потребовало бы нового запроса к базе, а в async-коде такие скрытые запросы вызывают ошибки.
async def get_session():
    async with SessionFactory() as session:
        yield session

SessionDep = Annotated[AsyncSession, Depends(get_session)]
  • get_session — зависимость FastAPI (dependency). FastAPI вызывает её на каждый запрос, передаёт сессию в обработчик, а после ответа выполняет код после yield — async with закрывает сессию (и откатывает незакоммиченное, если была ошибка).
  • SessionDep — сокращение. В обработчике достаточно написать session: SessionDep.
  • Правило проекта: коммит делает сервис, а не зависимость. Так сервис сам решает, когда изменения готовы.

Базовый класс моделей

class Base(DeclarativeBase):
    metadata = MetaData(naming_convention=NAMING_CONVENTION)

Все модели наследуются от Base. metadata — это «каталог» всех таблиц; по нему Alembic понимает, какой должна быть схема.

Naming convention — правила имён для ограничений в базе:

Тип Шаблон Пример
Первичный ключ pk_<таблица> pk_users
Уникальность uq_<таблица>_<все столбцы> uq_users_email, uq_skydiver_ratings_user_id_rating
Проверка ck_<таблица>_<имя> ck_users_email_lowercase
Внешний ключ fk_<таблица>_<столбец>_<куда> fk_loads_aircraft_id_aircraft
Индекс ix_<столбец> ix_users_email

Без правил PostgreSQL придумывает имена сам, и потом миграция не может надёжно найти ограничение, чтобы изменить или удалить его. В имени уникального ограничения перечислены все его столбцы (column_0_N_name): иначе составное ограничение называлось бы только по первому столбцу и вводило в заблуждение.

Перечисления в базе: str_enum() и enum_check()

Уровни, допуски, типы и роли прыжков — это StrEnum в Python. В базе они хранятся как обычный varchar, а допустимые значения проверяет CHECK:

class SkydiverRating(TimestampMixin, Base):
    __table_args__ = (enum_check("rating", Rating),)   # CHECK (rating IN ('tandem_trainer', ...))
    rating: Mapped[Rating] = mapped_column(str_enum(Rating, "rating"))
  • str_enum() — столбец-перечисление как varchar (native_enum=False). Тип ENUM в PostgreSQL неудобно менять в миграциях, а varchar + CHECK — легко.
  • enum_check() — ограничение ck_<таблица>_<столбец>_valid со всеми значениями перечисления. Autogenerate не видит изменения CHECK: новое значение в Python требует миграции, написанной руками (alembic revision -m "...", пример — 250050dee879).
  • В Python столбец читается как перечисление: profile.jump_level is JumpLevel.LICENSE_A.

TimestampMixin

created_at = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
  • Mixin — класс-«примесь»: модель наследует от него готовые поля.
  • DateTime(timezone=True) → тип timestamptz в PostgreSQL: время хранится с часовым поясом (фактически в UTC), без путаницы с летним временем.
  • server_default=func.now() — значение ставит сама база при вставке (DEFAULT now()).
  • onupdate=func.now() — при каждом UPDATE через SQLAlchemy updated_at обновляется.

9. Таблицы

users ──1:1──► skydiver_profiles      заявленный уровень и опыт
  │
  ├────1:N──► skydiver_ratings       заявленные допуски
  │
  └────1:N──► logbook_entries        прыжки в логбуке

Все дочерние таблицы удаляются вместе с пользователем (ON DELETE CASCADE).

users (app/modules/users/models.py)

Столбец Тип в PostgreSQL Особенности
id uuid Первичный ключ. DEFAULT uuidv7() — генерирует база
email varchar(320) UNIQUE; CHECK (email = lower(email))
password_hash varchar(255) Хеш argon2id, сам пароль не хранится
first_name varchar(100)
last_name varchar(100)
phone varchar(20) Необязательный. Формат E.164 (+37369123456) проверяет API
is_active boolean DEFAULT true. false — аккаунт заблокирован
created_at timestamptz DEFAULT now()
updated_at timestamptz DEFAULT now(), обновляется при изменении
  • UUIDv7 — уникальный идентификатор вида 01a0fdf6-fb77-7142-bbd2-75a5d7f351d4. Версия 7 начинается с метки времени, поэтому новые id идут по порядку — это хорошо для индексов. По id нельзя угадать количество пользователей (в отличие от 1, 2, 3). Функция uuidv7() встроена в PostgreSQL начиная с версии 18.
  • 320 — максимальная длина email по стандарту.
  • CHECK (email = lower(email)) — база не даст записать email с заглавными буквами. Код приводит email к нижнему регистру перед сохранением, а ограничение — страховка от ошибки в коде. Благодаря этому Ion@Mail.md и ion@mail.md — один и тот же пользователь.
  • full_name — не столбец, а свойство Python: first_name + " " + last_name.
  • В модели Mapped[str] без | None означает NOT NULL — значение обязательно.

Главный принцип: заявлено ≠ подтверждено

skydiver_profiles и skydiver_ratings хранят только то, что человек заявляет сам. Полей «подтверждено» там нет и быть не должно. Уровень, опыт и роли подтверждает каждая дропзона отдельно, при очной встрече, и хранит у себя копию данных на момент проверки (будущий модуль дропзон, идея описана в MVP.md).

skydiver_profiles — профиль (1:1 с users)

Столбец Тип Особенности
user_id uuid PK и FK → users.id: у пользователя ровно один профиль
jump_level varchar(20) Уровень по USPA: student / self_supervised / license_a … license_d; null — не указан
prior_jumps_count int Прыжков до DZ Hub, со слов человека. DEFAULT 0, CHECK >= 0
prior_freefall_seconds int Время свободного падения до DZ Hub, секунды, со слов человека. DEFAULT 0, CHECK >= 0
prior_jumps_as_of date На какую дату указаны прыжки и время падения до DZ Hub
prior_first_jump_on date Самый первый прыжок в жизни. От него считается стаж (тандему USPA нужно 3 года)
prior_last_jump_on date Последний прыжок до DZ Hub (по бумажному логбуку)
created_at, updated_at timestamptz

Профиль создаётся при регистрации, в одной транзакции с пользователем. Для пользователей, зарегистрированных до появления профилей, его создала миграция.

skydiver_ratings — допуски

Столбец Тип Особенности
id uuid uuidv7()
user_id uuid FK → users.id
rating varchar(32) Рейтинги USPA: tandem_trainer / tandem_instructor / aff_instructor / iad_instructor / static_line_instructor / coach; допуск DZ Hub: camera (у USPA это не рейтинг)
valid_until date Необязательный срок действия. Прошедшая дата допустима: допуск помечается истёкшим

UNIQUE (user_id, rating) — один и тот же допуск дважды заявить нельзя.

logbook_entries — логбук

Столбец Тип Особенности
id uuid uuidv7()
user_id uuid FK → users.id
jump_number int >= 1, UNIQUE (user_id, jump_number)
jump_date date Обязательна
jump_type varchar(32) aff, iad, static_line, tandem, belly, freefly, tracking, wingsuit, hop_and_pop, crw (купольные формации), canopy_piloting (купольная подготовка, свуп), accuracy, other; null — не указан
jump_role varchar(32) jumper, student, passenger, tandem_instructor (пилот тандема: Trainer и Instructor не различаем), instructor, coach, camera; null — не указана
dropzone_name varchar(200) Пока текстом; позже добавится ссылка на дропзону в DZ Hub
aircraft, canopy varchar(100) canopy — снаряжение (купол), текстом
landing_distance_m numeric(6,2) От точки касания до центра цели, метры, до сантиметра. CHECK >= 0; null — не целился. numeric, а не float: 2.00 должно остаться 2.00
is_night bool Ночной прыжок. NOT NULL DEFAULT false. Условие, а не тип: ночной freefly — это freefly
is_water bool Намеренное приземление на воду (навык USPA). NOT NULL DEFAULT false. Случайное — не сюда, а в notes
exit_altitude_m, deployment_altitude_m int Метры, > 0
freefall_seconds int >= 0
notes text
source varchar(32) manual (внёс сам) / dz_hub (в будущем — автоматически после взлёта). DEFAULT 'manual'

Индекс (user_id, jump_date) ускоряет статистику: последний прыжок, прыжки за период.

dropzones — дропзоны

Столбец Тип Особенности
id uuid uuidv7()
name varchar(200)
slug varchar(50) Для адреса /dz/skydive-moldova. UNIQUE; CHECK: латиница в нижнем регистре, цифры, дефисы между ними
country varchar(2) ISO 3166-1 alpha-2 (MD). CHECK: две заглавные буквы. Существование кода не проверяем
city varchar(100)
timezone varchar(64) Имя IANA (Europe/Chisinau). Проверяется в схеме через zoneinfo; пакет tzdata в зависимостях, чтобы база часовых поясов была в любом окружении
status varchar(20) draft (видит только персонал) → active (видят все вошедшие) → suspended (скрыта, данные целы). DEFAULT 'draft'. Физически дропзону не удаляем
owner_id uuid FK → users.id, ON DELETE RESTRICT: владельца нельзя удалить, пока он владеет дропзоной. Индекс

Частичный уникальный индекс uq_dropzones_owner_id_draft — UNIQUE (owner_id) WHERE status = 'draft': у владельца не больше одного черновика, чтобы не плодить мусор. Сервис проверяет то же самое заранее ради понятного 409; индекс — страховка от гонки двух запросов.

Почему владелец — столбец, а не access = owner в персонале. Внешний ключ умеет одно поведение на столбец: у dropzone_staff.user_id нужен CASCADE, а владельца удалять нельзя. Столбец owner_id с RESTRICT решает это сам, и «ровно один владелец» получается без дополнительных индексов. Передача владения позже — просто смена owner_id.

dropzone_staff — персонал

Столбец Тип Особенности
id uuid uuidv7()
dropzone_id uuid FK → dropzones.id, CASCADE
user_id uuid FK → users.id, CASCADE. Индекс: «где я работаю»
access varchar(20) Права в панели: organizer (дни, взлёты, допуск) / manifest (записи во взлёты); NULL — прав нет, человек только числится в персонале. У владельца всегда NULL: его права полные и следуют из owner_id

UNIQUE (dropzone_id, user_id). У владельца тоже есть строка в персонале — для его должностей.

dropzone_staff_positions — должности

Столбец Тип Особенности
id uuid uuidv7()
staff_id uuid FK → dropzone_staff.id, CASCADE
position varchar(32) Рейтинги USPA: tandem_instructor, tandem_trainer, aff_instructor, iad_instructor, static_line_instructor, coach; допуск DZ Hub camera; назначение USPA safety_advisor (S&TA); свидетельства FAA rigger, pilot; без бумаг: packer, manifest, chief_instructor

UNIQUE (staff_id, position). Права и должности — разные вещи: права решают, что человек может в панели, должности — кем он официально работает. AFF-инструктор без прав в панели — это access = NULL, positions = [aff_instructor].

aircraft — самолёты

Столбец Тип Особенности
id uuid uuidv7()
dropzone_id uuid FK → dropzones.id, CASCADE
name varchar(100) «An-2», «Cessna 182»
registration varchar(20) Бортовой номер ER-AKB, в верхнем регистре. Необязателен. UNIQUE (dropzone_id, registration): NULL уникальность не нарушает
seats int Мест для парашютистов, без пилота. CHECK BETWEEN 1 AND 50
is_active bool DEFAULT true. Самолёт не удаляется, только выключается: на него будут ссылаться взлёты

jump_days — прыжковые дни

Столбец Тип Особенности
id uuid uuidv7()
dropzone_id uuid FK → dropzones.id, CASCADE. UNIQUE (dropzone_id, date) — один день на дату
date date Местная дата дропзоны
starts_at time Местное время начала, без часового пояса: «9:00 на аэродроме» не должно сдвигаться при переходе на летнее время
jump_types varchar(16)[] Категории прыжков дня: fun (лицензированные и допущенные к самостоятельным), tandem, student (AFF, IAD, принудительное раскрытие). Массив PostgreSQL: CHECK cardinality(jump_types) > 0 и CHECK jump_types <@ ARRAY[...] (только известные значения). Отдельная таблица под три значения не нужна
booking_mode varchar(16) Режим записи во взлёты дня: auto / confirm. По умолчанию в API — confirm
status varchar(16) planned / cancelled. finished нет: что день прошёл, видно по дате
notes text Анонс: «Ветер до обеда, тандемы с 12:00»

День не удаляется, только отменяется: на него будут записываться люди, и отмену они должны увидеть.

10. Миграции (Alembic)

Что это и зачем

Миграция — файл с инструкцией, как изменить схему базы (upgrade) и как отменить это изменение (downgrade). Миграции выстраиваются в цепочку версий. Alembic хранит в базе таблицу alembic_version с номером текущей версии и знает, какие миграции ещё не применены.

Зачем: схема базы меняется вместе с кодом. Миграции позволяют одинаково обновить базу у тебя, в тестах и в продакшене, не теряя данных.

Как создаётся миграция

make makemigration m="add loads"
  1. Alembic подключается к базе и сравнивает текущую схему базы с моделями в коде (Base.metadata). Это называется autogenerate.
  2. Записывает разницу в новый файл alembic/versions/<дата>-<id>_add_loads.py.
  3. Post-write hooks прогоняют файл через ruff check --fix и ruff format.
  4. Файл нужно прочитать глазами. Autogenerate не всё понимает: например, переименование столбца он видит как «удалить старый + создать новый», что уничтожит данные.
  5. make migrate — применить.

make downgrade откатывает последнюю миграцию, make history показывает цепочку.

Цепочка миграций

Ревизия Что делает
b81f8147cc9f Таблица users
20ecb46f69d4 Таблица skydiver_profiles, столбец users.phone; вручную дописано: пустые профили для уже существующих пользователей
900f079cf837 Таблица skydiver_ratings
05a9e28db108 Таблица logbook_entries и индекс
250050dee879 Уровни по USPA: licensed → license_a (новые значения license_a…license_d), aff_student → student, aff_solo → self_supervised. Целиком вручную: замена CHECK и перевод данных
db4f8658ee5a Рейтинги по USPA: tandem_master → tandem_instructor, новые tandem_trainer и iad_instructor; роль в логбуке tandem_master → tandem_instructor. Целиком вручную; откат удаляет дубли, иначе сработает уникальность (user_id, rating)
a9c1340a67e4 Столбцы prior_freefall_seconds и prior_first_jump_on; вручную дописан CHECK (prior_freefall_seconds >= 0) — autogenerate его не увидел
fc89aeb253d0 Типы прыжков по USPA: новый iad; canopy → crw и canopy_piloting. Старый canopy → other (не понять, что из двух это было). Целиком вручную
d4feb274c7b3 Флаги is_night и is_water в логбуке. Autogenerate без правок: server_default false заполняет старые записи, CHECK не нужен
ad3d2ec606fb Столбец landing_distance_m; вручную дописан CHECK (landing_distance_m >= 0)
e5941e133078 Таблицы dropzones, dropzone_staff, dropzone_staff_positions, частичный уникальный индекс «один черновик на владельца». Autogenerate без правок
f00b1cecbba7 Таблицы aircraft и jump_days (массив jump_types с двумя CHECK). Autogenerate без правок

Пример того, что autogenerate не умеет и что дописывается руками, — перенос данных:

op.execute("INSERT INTO skydiver_profiles (user_id) SELECT id FROM users")

alembic.ini

Параметр Значение Смысл
script_location %(here)s/alembic Где лежат env.py и versions/
file_template %%(year)d_%%(month).2d_... Имена файлов начинаются с даты: 2026_10_02_2152-<id>_<описание>.py, поэтому сортируются по времени
prepend_sys_path . Добавить папку проекта в путь импорта, чтобы работал import app
sqlalchemy.url — Не задан: адрес берётся из .env в env.py
[post_write_hooks] ruff_fix, ruff_format Автоформатирование новых миграций
[loggers] и далее Логи самого Alembic при запуске из консоли

alembic/env.py

Сгенерирован официальным async-шаблоном Alembic, изменено три вещи:

config.set_main_option("sqlalchemy.url", get_settings().database_url)  # адрес из .env
target_metadata = Base.metadata          # модели для autogenerate (из app/models.py)
compare_type=True                        # замечать смену типа столбца

Режимы: online (обычный) — подключается к базе и применяет миграции; offline (alembic upgrade head --sql) — только печатает SQL, не подключаясь.

app/models.py — реестр моделей

Alembic видит только те модели, чей модуль был импортирован. Поэтому все модели импортируются в одном файле, а env.py импортирует его. Новая модель → добавь импорт сюда.

Где применяются миграции

  • Локально: make migrate.
  • В Docker: при каждом старте контейнера API выполняется alembic upgrade head (см. command в docker-compose).
  • В тестах миграции не используются: тестовая база создаётся напрямую из моделей (create_all), это быстрее. Поэтому сами миграции проверяются отдельно — make migrate.

11. Модули и их устройство

Каждый бизнес-модуль в app/modules/<имя>/ устроен одинаково:

Файл Что внутри Знает про HTTP?
models.py Таблицы SQLAlchemy нет
schemas.py Pydantic-схемы запросов и ответов нет
service.py Бизнес-логика, работа с базой нет — ошибки как свои исключения
router.py Эндпоинты: принимают запрос, зовут сервис, переводят исключения в HTTP-коды да
dependencies.py Зависимости FastAPI (если нужны) да

Почему сервис не знает про HTTP: ту же логику можно вызвать из теста, из фоновой задачи или из Telegram-бота, где нет HTTP-кодов.

Где живут правила: база или Python

Договорённость проекта:

Где Что Примеры
База Простая целостность данных: типы, NOT NULL, уникальность, внешние ключи, проверки одной строки, понятные с первого взгляда prior_jumps_count >= 0, email в нижнем регистре, допустимые значения перечислений, номер прыжка уникален
Python (сервисы и схемы) Бизнес-правила: всё, что про смысл и может меняться допуск только с достаточным уровнем (rules.py); дата не в будущем; раскрытие не выше отделения; автонумерация

Так правила остаются прозрачными: их можно прочитать в коде в одном месте, а база гарантирует, что в ней не окажется мусора, даже при ошибке в коде.

Правила проверяются до изменения данных. Сервис сначала вычисляет итоговое состояние («что получится после PATCH»), проверяет его и только потом меняет объекты. Если правило нарушено, в сессии не остаётся наполовину применённых правок.

Ошибка у поля. Нарушение бизнес-правила сервис сообщает исключением FieldValidationError(field, message), а роутер превращает его в 422 в том же формате, что и обычные ошибки валидации FastAPI. Фронтенд показывает их одинаково — у нужного поля:

{"detail": [{"loc": ["body", "priorLastJumpOn"], "msg": "Last jump date requires at least one prior jump", "type": "value_error"}]}

app/core/schemas.py — CamelModel

class CamelModel(BaseModel):
    model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)

В Python принято first_name, в JavaScript — firstName. CamelModel автоматически создаёт для каждого поля псевдоним в camelCase: в JSON уходит firstName, в Python остаётся first_name. populate_by_name=True позволяет создавать объект и по Python-именам. Все схемы API наследуются от CamelModel.

Там же лежит общий тип Name (имя или фамилия: 1–100 символов, пробелы по краям обрезаются). Его используют и регистрация, и профиль.

Модуль users

  • models.py — модель User (раздел 9).
  • schemas.py — UserPublic: что о пользователе видит клиент — id, email, firstName, lastName, fullName. Пароля и служебных полей там нет. from_attributes=True позволяет построить схему прямо из объекта модели: UserPublic.model_validate(user).
  • service.py:
  • normalize_email — убирает пробелы по краям и приводит к нижнему регистру;
  • get_by_id — найти по id;
  • get_by_email — найти по email (с нормализацией).

Модуль auth

schemas.py — что принимает и отдаёт API:

Схема Поля и правила
RegisterRequest email — валидный email; password — 8–128 символов, пробелы не обрезаются (могут быть частью пароля); firstName, lastName — 1–100 символов, пробелы по краям обрезаются
LoginRequest email — валидный email; password — 1–128 символов
TokenResponse accessToken, tokenType (всегда "bearer"), user (UserPublic)

Если данные не проходят проверку, FastAPI сам отвечает 422 со списком ошибок по полям.

security.py — пароли и токены:

  • Хеширование паролей (argon2id). Пароль не хранится и не может быть восстановлен из хеша. При входе введённый пароль хешируется тем же способом и сравнивается. Argon2id — современный алгоритм, который специально сделан медленным и требовательным к памяти, чтобы перебор паролей был дорогим. Хеш выглядит так: $argon2id$v=19$m=65536,t=3,p=4$... — в нём записаны алгоритм, параметры и случайная «соль».
  • Защита от подбора email по времени (_DUMMY_HASH). Если пользователя с таким email нет, мы всё равно проверяем пароль против «пустышки». Иначе ответ «email не найден» приходил бы мгновенно, а «неверный пароль» — с задержкой на хеширование, и по времени можно было бы понять, зарегистрирован ли email.
  • JWT (JSON Web Token) — токен доступа. Состоит из трёх частей через точку: заголовок.данные.подпись.
  • Данные (payload): sub — id пользователя, iat — когда выдан, exp — когда истекает.
  • Подпись — HMAC-SHA256 (HS256) от первых двух частей с секретом JWT_SECRET_KEY. Кто не знает секрета, не может создать или изменить токен: подпись не сойдётся.
  • Данные токена не зашифрованы, их может прочитать любой (например, на jwt.io). Поэтому в токен не кладём ничего секретного — только id.
  • decode_access_token возвращает id пользователя или None, если подпись неверна, срок истёк или токен повреждён. Поля sub и exp обязательны.

service.py — логика:

  • register:
  • нормализует email;
  • если такой уже есть → EmailAlreadyRegisteredError;
  • создаёт User с хешем пароля;
  • flush() — отправляет INSERT и получает id от базы, но транзакция ещё открыта;
  • создаёт пустой профиль парашютиста и делает один commit(): пользователь и профиль сохраняются вместе или не сохраняются вовсе;
  • если два запроса регистрируют один email одновременно, второй упадёт на UNIQUE в базе (IntegrityError) — это тоже превращается в EmailAlreadyRegisteredError;
  • refresh(user) подгружает значения, которые поставила база (created_at и т. п.);
  • пишет в лог «Пользователь зарегистрирован» с user_id.
  • authenticate:
  • ищет пользователя по email;
  • проверяет пароль (или «пустышку», если пользователя нет) → иначе InvalidCredentialsError;
  • если is_active = false → UserInactiveError.

dependencies.py — кто сейчас вошёл:

  • HTTPBearer(auto_error=False) — читает заголовок Authorization: Bearer <токен>. auto_error=False — если заголовка нет, не падать сразу, а дать нам самим вернуть понятный 401.
  • get_current_user — достаёт токен → расшифровывает id → загружает пользователя → проверяет is_active. Любая проблема → 401 с заголовком WWW-Authenticate: Bearer.
  • CurrentUser — сокращение. Чтобы закрыть эндпоинт от анонимов, достаточно добавить параметр user: CurrentUser.

router.py — эндпоинты: см. таблицу в следующем разделе. Ошибки сервиса переводятся в HTTP-коды. from None убирает из логов лишнюю цепочку исключений.

Модуль skydivers

Всё о человеке как о парашютисте: профиль, допуски, логбук, статистика. Все эндпоинты — под /me/...: id пользователя в адресе нет, он берётся из токена, поэтому до чужих данных через эти эндпоинты не добраться в принципе.

enums.py — JumpLevel, Rating, JumpType, JumpRole, EntrySource. Лежат отдельно от моделей, потому что понадобятся и будущему модулю дропзон (подтверждённый уровень и допуски).

Профиль (service.py): - get_profile / build_profile_response — аккаунт, профиль, допуски и опыт одним объектом; - update_profile — частичное обновление: поле не передано — не трогать, null — очистить. Поля firstName, lastName, phone пишутся в users, остальные — в skydiver_profiles. Правила: - нет прыжков до DZ Hub → нет ни даты первого и последнего прыжка, ни времени падения до DZ Hub; - первый прыжок не позже последнего (один и тот же день — можно); - при смене уровня новый уровень должен подходить для всех имеющихся допусков. Иначе 422 со списком мешающих допусков: их нужно сначала удалить. Например, с tandem_instructor нельзя перейти с D на C, а с coach можно перейти с D на B; - эта проверка срабатывает только если jumpLevel есть в запросе. Старое неверное сочетание (например, после миграции db4f8658ee5a) не мешает менять другие поля; - проверки в схемах: телефон нормализуется (+373 (69) 12-34-56 → +37369123456) и проверяется по E.164; прыжков 0–100 000; даты не в будущем (с запасом в сутки: сервер работает в UTC, а у пользователя в Молдове может быть уже завтра); email здесь не меняется.

Правила USPA (rules.py) — единственное место, где они живут. Чистые функции без базы и HTTP: их вызывают и сервис (проверка при записи), и API (GET /me/ratings/eligibility, returnAfterBreak в профиле). Правило не может разойтись между проверкой и тем, что видит фронт.

Допуск Минимальный уровень (USPA IRM 2026)
camera license_a (у USPA только рекомендация)
coach license_b
aff_instructor, iad_instructor, static_line_instructor license_c
tandem_trainer, tandem_instructor license_d
  • Пока проверяется только класс лицензии. Остальные требования USPA (прыжки, возраст, медсправка, прошлый инструкторский рейтинг) добавятся в rules.py, фронтенд менять не придётся.
  • Уровни сравниваются методом JumpLevel.at_least() по порядку объявления. Не >=: у StrEnum он сравнивает строки по алфавиту, и "student" > "license_d" — правда.
  • Тест «у каждого допуска есть требование»: новый допуск без строки в таблице не пройдёт make check.

Возврат после перерыва (rules.check_return_after_break, USPA SIM 4-2): если перерыв без прыжков больше порога, следующий прыжок — под надзором инструктора.

Уровень Порог
student, self_supervised 30 дней
license_a 60 дней
license_b 90 дней
license_c, license_d 180 дней
  • В профиле — объект returnAfterBreak: limitDays, daysSinceLastJump, required.
  • Только подсказка, ничего не блокирует: решает организатор.
  • required: null — «не знаем» (нет уровня или ни одной даты прыжка). Не false, чтобы отсутствие данных не выглядело как «всё в порядке».
  • Считаются все прыжки, в том числе без свободного падения (тандем-пассажир, hop & pop).
  • Подробная таблица SIM (какую категорию повторить студенту, check dive) пока не учитывается.

Допуски (service.py): - заявить можно только с уровнем не ниже требуемого (rules.check_rating), иначе 422 "coach requires license_b or higher"; - upsert_rating идемпотентен: повторный PUT обновляет срок, а не создаёт дубль; - isExpired = срок указан и уже прошёл.

Логбук (logbook.py): - автонумерация: следующий номер = max(самый большой номер, прыжки до DZ Hub) + 1. Номер можно указать вручную (перенос старых прыжков с бумаги); занятый номер → 409, в том числе если его одновременно занял параллельный запрос; - раскрытие не выше отделения — по итоговому состоянию, в том числе после PATCH; - текстовые поля обрезают пробелы, пустая строка → null; source клиент не задаёт; - чужая и несуществующая запись неразличимы — обе 404; - фильтры списка: jumpType, jumpRole, dateFrom, dateTo. - флаги isNight и isWater: при создании по умолчанию false, в PATCH null запрещён; - landingDistanceM: 0–1000 м, округляется до сантиметра в схеме, а не в базе: иначе ответ на POST (1.234) расходился бы с тем, что потом вернёт GET (1.23); - счётчики landingsWithin10M и landingsWithin2M (для лицензий B, C, D): границы включительно, приземление в 1,5 м попадает в оба; - подсказки к полям — Field(description=...) в схеме (IS_NIGHT_HINT, IS_WATER_HINT, LANDING_DISTANCE_HINT). Они попадают в OpenAPI, и фронтенд показывает их рядом с полем. Текст на английском, как и сообщения об ошибках: перевод — на стороне фронтенда.

Опыт и статистика: - get_experience (service.py) — итоги опыта за всё время, одна функция для профиля и статистики: - totalJumps = max(прыжки до DZ Hub, самый большой номер в логбуке). Старые прыжки, перенесённые с бумаги, не считаются дважды: 120 до DZ Hub + перенесённый № 50 + № 121–123 дают 123, а не 124; - lastJumpOn — самая поздняя дата из логбука и «последнего прыжка до DZ Hub»; - daysSinceLastJump; - firstJumpOn — самая ранняя дата из «первого прыжка до DZ Hub» и логбука; - totalFreefallSeconds = время до DZ Hub + время записей логбука с номером больше prior_jumps_count. Записи с меньшими номерами — бумажные прыжки, их время уже входит в «до DZ Hub». max, как для прыжков, здесь не работает, поэтому граница по номеру; - get_stats (logbook.py) — агрегатные запросы (COUNT, SUM, GROUP BY), ничего не хранится. Период dateFrom..dateTo влияет на loggedJumps, loggedFreefallSeconds (только записи логбука, без времени до DZ Hub), byType, byRole, nightJumps, waterJumps, landingsWithin10M, landingsWithin2M, но не на итоги опыта и не на окна «последние 30 / 365 дней».

Модуль canopy

Калькулятор купола: по взлётному весу и числу прыжков — минимальная площадь купола по таблице USPA, а для введённого купола — нагрузка на крыло и высокопроизводительный ли он. Все числа — только из SIM 2026, у каждого результата есть источник (документ, редакция, раздел). Базы нет: это чистый расчёт, эндпоинт публичный.

uspa.py — данные: - LB_PER_KG = 2.20462; - таблица Minimum-Canopy-Size Recommendations (SIM 4-3.B) — текстом, строка в строку как в SIM, чтобы сверять глазами; _parse_chart превращает её в ChartRow; - допуск 3 % и граница 1000 прыжков; - пороги высокой производительности (SIM 5-9.C) и «150 sq ft и меньше — всегда».

Правила на случай неясностей в SIM (решение 2026-10-03: всегда в сторону большего купола):

Ситуация Что делаем
Вес между столбцами (165 lb) Следующий, более тяжёлый столбец (170)
Вес < 100 или > 250 lb out_of_chart: минимум не выдаём, нагрузку считаем
750 прыжков есть в двух строках Строка «501-750», там купол больше
> 1000 прыжков own_discretion
Ровно 230 sq ft (нет ни в «above 230», ни в «190 to 229») Полоса 190–230, порог 1.0
Ровно 150 sq ft («smaller than 150» и «150 or less» в одной главе) Всегда высокопроизводительный
Ровно на 3 % меньше минимума («less than 3% smaller») Уже below

calculator.py — чистые функции, как rules.py: - вес переводится в lb и округляется до десятых один раз, дальше всё считается от этого числа: формула на экране всегда сходится с результатом; - нагрузка на крыло округляется до сотых, с порогом сравнивается округлённая — та, что видит человек; - у каждого результата есть expression — формула с подставленными числами. Слова в ней («столбец», «строка») — на языке запроса, числа — с точкой.

texts.py — поведение купола словами, без цифр скоростей и качества: USPA их не публикует, данные производителей не используем. Пересказ SIM 1-C, 4-3.B, 5-9.C.

Язык — app/core/language.py: тот же файл, что в ветке глоссария, байт в байт, чтобы при слиянии не было конфликта.

Модуль dropzones

Дропзона и её персонал — первый блок «организации». Подписки и оплаты пока нет: дропзону активирует вручную администратор командой make activate-dz SLUG=... (cli.py).

Кто что видит и может (service.get_access → DropzoneAccess):

Кто Видит дропзону Права и контакты персонала Меняет дропзону и персонал
Владелец всегда да да
Персонал с правами (organizer, manifest) всегда да нет → 403
Персонал без прав всегда нет: только имена и должности нет → 403
Остальные вошедшие только active, иначе 404 нет нет → 403 (активная) / 404 (черновик)

Черновик чужим отвечает 404, а не 403, — как и любой чужой ресурс: снаружи не видно, что он вообще есть. Права проверяет сервис (NotOwnerError), роутер только переводит исключения в HTTP одной функцией _raise_http.

Правила: - у владельца не больше одного черновика → 409 (плюс частичный уникальный индекс); - slug уникален → 409; - сотрудника ищем среди зарегистрированных по email или телефону; не найден → 422 у поля; телефон в users не уникален, поэтому если он у нескольких — 422 с просьбой указать email; - уже в персонале → 409; - владельцу нельзя выставить access (его права полные) → 422 у access; убрать владельца из персонала нельзя → 422 у staffId; - positions в PATCH заменяется целиком, дубли убираются, порядок в ответе — порядок объявления StaffPosition.

Должность без допуска (rules.py) — подсказка, не запрет: запись сохраняется, в ответе warnings. Учитываются только действующие заявленные допуски. Старший рейтинг покрывает младшего: Tandem Instructor годится в tandem_trainer, любой инструктор — в coach; chief_instructor — любой инструкторский рейтинг. Для S&TA, риггера, пилота, укладчика и манифеста допуск не проверяем.

Два вида ответа в GET /staff. Тип ответа — list[StaffMemberDetails] | list[StaffMemberResponse]: Pydantic выбирает по типу объектов, так что посторонний физически не получит поля access, email, phone.

Модуль operations

Лётная работа дропзоны: самолёты и прыжковые дни, следующими карточками — взлёты и запись. Свой модуль, потому что это отдельная предметная область, а дропзона для неё — только «контейнер» с правами.

Права берутся из модуля dropzones: get_access (видеть) и get_managed (менять: владелец или organizer, иначе NotManagerError → 403). manifest дни и самолёты не трогает. Ошибки доступа роутер переводит в HTTP той же функцией raise_http из dropzones/router.py, поэтому коды и тексты одинаковые во всех модулях.

«Сегодня» — по часовому поясу дропзоны (dropzones.service.local_today), а не сервера: в 00:30 по Кишинёву на сервере в UTC ещё вчера. От этой даты считаются все правила и isPast.

Правила прыжкового дня: - объявить можно на сегодня и вперёд, максимум на 365 дней → иначе 422 у date; - один день на дату → 409 (плюс UNIQUE в базе); - прошедший день менять нельзя → 409; перенести день в прошлое → 422; - отмена и возврат — status через PATCH; - jumpTypes: хотя бы одна категория, дубли убираются, порядок — порядок объявления JumpCategory.

Самолёты: список по названию; выключенные видит только персонал с правами и только с includeInactive=true (посторонним параметр молча ничего не даёт). Бортовой номер приводится к верхнему регистру и уникален в пределах дропзоны → 409.

Поля с именем date. В схемах и модели используется import datetime as dt и тип dt.date: если написать date: date | None = None, Pydantic разбирает аннотации в пространстве имён класса и может принять тип за значение поля по умолчанию.

12. API: все эндпоинты

Базовый адрес: http://localhost:8000/api/v1. Интерактивно — http://localhost:8000/api/docs.

Метод и путь Нужен токен Ответы
GET /health нет 200 {"status":"ok"} — сервер жив
GET /health/ready нет 200 — база доступна; 503 — нет
POST /auth/register нет 201 токен; 409 email занят; 422 неверные данные
POST /auth/login нет 200 токен; 401 неверный email или пароль; 403 аккаунт заблокирован; 422
GET /auth/me да 200 пользователь; 401
POST /auth/logout да 204; 401
GET /me/profile да 200 профиль с допусками, опытом и returnAfterBreak
PATCH /me/profile да 200; 422
GET /me/ratings да 200 список допусков
GET /me/ratings/eligibility да 200 все допуски: требуемый уровень и можно ли заявить с моим
PUT /me/ratings/{rating} да 200; 422 (уровень ниже требуемого или неизвестный допуск)
DELETE /me/ratings/{rating} да 204; 404
GET /me/logbook да 200 страница; фильтры jumpType, jumpRole, dateFrom, dateTo; limit 1–100, offset
POST /me/logbook да 201; 409 номер занят; 422
GET /me/logbook/stats да 200 статистика; период dateFrom, dateTo
GET /me/logbook/{id} да 200; 404
PATCH /me/logbook/{id} да 200; 404; 409; 422
DELETE /me/logbook/{id} да 204; 404
GET /canopy-calculator нет 200 расчёт; weight (0–600], unit kg/lb, jumps ≥ 0, canopyArea необязательный; 422
POST /dropzones да 201 дропзона в draft, создатель — владелец; 409 уже есть черновик или slug занят; 422
GET /dropzones да 200 активные дропзоны по алфавиту
GET /dropzones/{id} да 200; 404 нет или не видна (черновик чужой дропзоны)
PATCH /dropzones/{id} да 200; 403 не владелец; 404; 409 slug занят; 422
GET /me/dropzones да 200 где я в персонале, включая черновики: isOwner, access, positions
GET /dropzones/{id}/staff да 200 команда, владелец первым; права и контакты — только владельцу и персоналу с правами; 404
POST /dropzones/{id}/staff да 201 с warnings; 403; 404; 409 уже в персонале; 422 не найден или нет email/телефона
PATCH /dropzones/{id}/staff/{staffId} да 200 с warnings; 403; 404; 422 (access владельцу, positions: null)
DELETE /dropzones/{id}/staff/{staffId} да 204; 403; 404; 422 владельца убрать нельзя
GET /dropzones/{id}/aircraft да 200 активные по названию; includeInactive=true — и выключенные (только персоналу с правами); 404
POST /dropzones/{id}/aircraft да 201; 403 не владелец и не организатор; 404; 409 номер занят; 422
PATCH /dropzones/{id}/aircraft/{aircraftId} да 200; 403; 404; 409; 422
GET /dropzones/{id}/jump-days да 200 дни по дате, включая отменённые; from (по умолчанию — сегодня дропзоны), to; 422 если from > to; 404
GET /dropzones/{id}/jump-days/{dayId} да 200; 404
POST /dropzones/{id}/jump-days да 201; 403; 404; 409 на дату уже есть день; 422 дата в прошлом или дальше года
PATCH /dropzones/{id}/jump-days/{dayId} да 200; 403; 404; 409 день прошёл или дата занята; 422

dateFrom позже dateTo → 422 у поля dateFrom. Маршрут /me/logbook/stats объявлен раньше /me/logbook/{id}, иначе FastAPI принял бы слово stats за id записи.

Пример регистрации:

POST /api/v1/auth/register
Content-Type: application/json

{"email": "Ion@Mail.md", "password": "parasutist123", "firstName": "Ion", "lastName": "Popescu"}
HTTP/1.1 201 Created
X-Request-ID: b1bdb36ae291

{
  "accessToken": "eyJhbGciOiJIUzI1NiIs...",
  "tokenType": "bearer",
  "user": {
    "id": "01a0fdf6-fb77-7142-bbd2-75a5d7f351d4",
    "email": "ion@mail.md",
    "firstName": "Ion",
    "lastName": "Popescu",
    "fullName": "Ion Popescu"
  }
}

Формат ошибок — стандартный для FastAPI:

{"detail": "Email already registered"}

Ошибки валидации (422) — списком, loc указывает на поле:

{"detail": [{"loc": ["body", "password"], "msg": "String should have at least 8 characters", "type": "string_too_short"}]}

Профиль (GET /me/profile):

{
  "id": "01a0fdf6-fb77-7142-bbd2-75a5d7f351d4",
  "email": "ion@mail.md",
  "firstName": "Ion", "lastName": "Popescu", "fullName": "Ion Popescu",
  "phone": "+37369123456",
  "jumpLevel": "license_d",
  "priorJumpsCount": 120, "priorFreefallSeconds": 7200, "priorJumpsAsOf": "2026-10-01",
  "priorFirstJumpOn": "2019-06-01", "priorLastJumpOn": "2026-09-01",
  "ratings": [{"rating": "tandem_instructor", "validUntil": "2027-05-01", "isExpired": false}],
  "experience": {
    "totalJumps": 123, "lastJumpOn": "2026-10-02", "daysSinceLastJump": 1,
    "firstJumpOn": "2019-06-01", "totalFreefallSeconds": 7315
  },
  "returnAfterBreak": {"limitDays": 180, "daysSinceLastJump": 1, "required": false}
}

Что можно заявить (GET /me/ratings/eligibility, уровень license_c):

[
  {"rating": "tandem_trainer", "minJumpLevel": "license_d", "eligible": false},
  {"rating": "aff_instructor", "minJumpLevel": "license_c", "eligible": true},
  {"rating": "coach", "minJumpLevel": "license_b", "eligible": true}
]

Ошибка при недостаточном уровне (PUT /me/ratings/tandem_instructor с license_c):

{"detail": [{"loc": ["path", "rating"], "msg": "tandem_instructor requires license_d or higher", "type": "value_error"}]}

Запись логбука (POST /me/logbook, без jumpNumber — номер автоматически):

{
  "id": "01a0...", "jumpNumber": 121, "jumpDate": "2026-09-20",
  "jumpType": "freefly", "jumpRole": "coach",
  "dropzoneName": "Vadul lui Vodă", "aircraft": "An-2",
  "exitAltitudeM": 4000, "deploymentAltitudeM": 1200, "freefallSeconds": 55,
  "canopy": "Sabre3 170", "notes": "Хороший прыжок", "source": "manual"
}

Список — {"items": [...], "total": 37, "limit": 20, "offset": 0}, новые сверху по номеру.

Статистика (GET /me/logbook/stats):

{
  "totalJumps": 123, "lastJumpOn": "2026-10-02", "daysSinceLastJump": 1,
  "firstJumpOn": "2019-06-01", "totalFreefallSeconds": 7315,
  "dateFrom": null, "dateTo": null,
  "loggedJumps": 4, "jumpsLast30Days": 3, "jumpsLast12Months": 3,
  "loggedFreefallSeconds": 165, "nightJumps": 0, "waterJumps": 0,
  "landingsWithin10M": 0, "landingsWithin2M": 0,
  "byType": {"belly": 1, "freefly": 2, "tandem": 1},
  "byRole": {"jumper": 2, "coach": 1, "unspecified": 1}
}

Добавление сотрудника (POST /dropzones/{id}/staff):

{"email": "maria@example.md", "access": "manifest", "positions": ["aff_instructor", "packer"]}
{
  "id": "01a105d7-e308-...", "userId": "01a105d7-e2c9-...",
  "firstName": "Maria", "lastName": "Rusu", "fullName": "Maria Rusu",
  "isOwner": false, "positions": ["aff_instructor", "packer"],
  "access": "manifest", "email": "maria@example.md", "phone": null,
  "warnings": [{
    "position": "aff_instructor", "acceptedRatings": ["aff_instructor"],
    "message": "aff_instructor usually requires one of these ratings: aff_instructor"
  }]
}

Прыжковый день (POST /dropzones/{id}/jump-days):

{"date": "2026-10-05", "startsAt": "09:00", "jumpTypes": ["student", "fun"], "notes": "Ветер до обеда"}
{
  "id": "01a106...", "date": "2026-10-05", "startsAt": "09:00:00",
  "jumpTypes": ["fun", "student"], "bookingMode": "confirm", "status": "planned",
  "notes": "Ветер до обеда", "isPast": false
}

Калькулятор купола (GET /canopy-calculator?weight=80&unit=kg&jumps=120&canopyArea=170, Accept-Language: en), сокращённо:

{
  "exitWeight": {"lb": 176.4, "expression": "80 kg × 2.20462 = 176.4 lb"},
  "minimumArea": {
    "status": "ok", "weightColumn": 180, "jumpsRow": "101-200",
    "area": 170, "areaWithTolerance": 164.9,
    "expression": "176.4 lb → column 180 lb; 120 jumps → row 101-200 → minimum 170 sq ft; 170 × 0.97 = 164.9 sq ft",
    "source": {"document": "SIM", "edition": "2026", "section": "4-3.B", "url": "https://www.uspa.org/sim"}
  },
  "canopy": {
    "area": 170.0, "wingLoading": 1.04, "expression": "176.4 lb / 170 sq ft = 1.04",
    "source": {"section": "1-C.B", ...},
    "vsMinimum": "ok",
    "highPerformance": {
      "value": true, "rule": "150 < S < 190 sq ft: ≥ 0.9",
      "expression": "170 sq ft: 1.04 ≥ 0.9 → high performance", "source": {"section": "5-9.C", ...}
    }
  },
  "behaviour": [{"text": "The higher the wing loading, ...", "source": {"section": "1-C.B", ...}}],
  "disclaimer": {"text": "This calculator is guidance only. ...", "source": {"section": "4-3.B", ...}}
}

minimumArea.status: ok, out_of_chart, own_discretion. canopy.vsMinimum: ok, within_tolerance, below, unknown. Без canopyArea — "canopy": null. Число прыжков фронтенд подставляет из experience.totalJumps профиля.

Группы в OpenAPI. В /api/docs эндпоинты разбиты по тегам: health, auth, profile, ratings, logbook, canopy, dropzones, staff, aircraft, jump-days. Порядок и описания групп — OPENAPI_TAGS в app/main.py. В skydivers/router.py три роутера (profile_router, ratings_router, logbook_router) без префикса, общий префикс /me — у router внизу файла. Новый тег нужно добавить в OPENAPI_TAGS, иначе упадёт test_openapi.py.

operation_id у каждого эндпоинта (auth_register, auth_login, ...) — стабильное имя операции в OpenAPI. Из него будут генерироваться имена функций в TypeScript-клиенте.

13. Логирование

Основные понятия модуля logging

Понятие Что это
Logger Объект, через который пишут: logger = logging.getLogger(__name__). Имена образуют дерево: app.modules.auth.service → app.modules.auth → app → корень
Уровень Важность: DEBUG < INFO < WARNING < ERROR. Записи ниже LOG_LEVEL отбрасываются
Handler Куда писать. У нас один — stdout (стандартный вывод). Docker собирает его в docker compose logs
Formatter Как превратить запись в строку (text или json)
Filter Может изменить или отбросить запись. Наш RequestIdFilter добавляет в каждую запись request_id
propagate Передавать ли запись родительскому логгеру. Все записи «всплывают» к корню, где стоит наш handler

setup_logging(level, fmt)

  1. Создаёт один handler в stdout с нужным formatter и фильтром request_id.
  2. Ставит его на корневой логгер — туда приходят записи всех логгеров: приложения, SQLAlchemy, uvicorn.
  3. Логгеры uvicorn (uvicorn, uvicorn.error) uvicorn настраивает ещё до импорта приложения, со своим форматом. Мы убираем их handlers и включаем propagate, чтобы их записи шли через наш формат.
  4. uvicorn.access (стандартный лог запросов uvicorn) выключаем: запросы логирует наш middleware, с request_id и длительностью.

Форматы

text (LOG_FORMAT=text) — для чтения глазами:

18:53:37 INFO  app.modules.auth.service [b1bdb36ae291] Пользователь зарегистрирован user_id=01a0fdf6-...
18:53:37 INFO  app.request [b1bdb36ae291] POST /api/v1/auth/register 201 duration_ms=118.6

время уровень логгер [request_id] сообщение дополнительные_поля

json (LOG_FORMAT=json) — одна JSON-строка на запись, для систем сбора логов (Loki, ELK и т. п.), где по полям можно искать и строить графики:

{"time": "2026-10-02T17:01:29+00:00", "level": "INFO", "logger": "app.demo", "request_id": "abc123", "message": "Взлёт создан", "load_id": 7}

Если в записи есть исключение, добавляется поле exception с traceback.

Дополнительные поля (extra)

logger.info("Взлёт создан", extra={"load_id": load.id})

Всё, что передано в extra, попадает в лог отдельными полями (load_id=7 в text, "load_id": 7 в json). Так лучше, чем вклеивать значения в текст сообщения: по полям удобно искать.

request_id

  • Зачем. Когда пользователь пишет «у меня ошибка», по request_id можно найти все строки лога именно этого запроса среди тысяч других.
  • Как работает:
  • Middleware берёт заголовок X-Request-ID из запроса (если клиент прислал) или генерирует новый — 12 символов.
  • Кладёт его в contextvar — переменную, у которой своё значение в каждом асинхронном запросе. Параллельные запросы не перепутают свои id.
  • RequestIdFilter подставляет его в каждую запись лога.
  • Возвращает тот же id в заголовке ответа X-Request-ID.
  • Вне запроса (запуск, остановка) request_id равен -.

Middleware логирования (app/core/middleware.py)

Для каждого запроса пишет одну строку: метод, путь, статус, duration_ms.

Ситуация Уровень
Ответ 5xx WARNING
Успешный /health, /health/ready DEBUG — Docker дёргает их каждые 10 секунд, иначе засорили бы лог
Всё остальное INFO
Необработанное исключение ERROR с traceback, затем исключение пробрасывается дальше (клиент получит 500)

Что намеренно не логируется: пароли, токены и email при неудачном входе (это персональные данные). При регистрации логируется только user_id.

14. Health-эндпоинты (app/api/health.py)

  • /health (liveness) — «процесс жив и отвечает». Базу не трогает. Его проверяет Docker healthcheck: если он перестанет отвечать, контейнер помечается unhealthy.
  • /health/ready (readiness) — «готов обслуживать запросы»: выполняет SELECT 1 в базе. Если база недоступна → 503. Пригодится для мониторинга и балансировщиков.

Почему два: если база временно недоступна, перезапускать сам API бессмысленно — он жив, просто не готов.

15. Docker

Понятия

Понятие Что это
Образ (image) Готовый «слепок» системы с приложением. Собирается по Dockerfile
Контейнер Запущенный экземпляр образа: изолированный процесс со своей файловой системой и сетью
Том (volume) Хранилище данных, которое переживает удаление контейнера. Там лежат данные PostgreSQL
Порт A:B Порт A на твоём компьютере ведёт в порт B внутри контейнера
Сеть compose Контейнеры одного compose видят друг друга по имени сервиса (db, api)

Dockerfile — построчно

Сборка в два этапа (multi-stage): на первом ставим зависимости, во второй (итоговый) образ копируем только результат. Так итоговый образ меньше и в нём нет инструментов сборки.

# syntax=docker/dockerfile:1

Включает современный синтаксис Dockerfile (нужен для --mount).

Этап 1 — builder:

FROM python:3.13-slim AS builder

Базовый образ: минимальный Debian с Python 3.13.

COPY --from=ghcr.io/astral-sh/uv:0.12 /uv /bin/uv

Берём бинарник uv из официального образа uv.

ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=0
  • UV_COMPILE_BYTECODE=1 — заранее скомпилировать .py в байткод .pyc: контейнер стартует быстрее;
  • UV_LINK_MODE=copy — копировать файлы пакетов, а не делать ссылки на кэш (кэш на следующем этапе недоступен);
  • UV_PYTHON_DOWNLOADS=0 — не скачивать Python, использовать тот, что в образе.
WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \
    --mount=type=bind,source=uv.lock,target=uv.lock \
    --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
    uv sync --locked --no-dev --no-install-project

Устанавливаем только зависимости, ещё без нашего кода: - --mount=type=cache — кэш скачанных пакетов между сборками (быстрее пересборка); - --mount=type=bind — временно подкладываем uv.lock и pyproject.toml; - --locked — версии строго из uv.lock; если он не совпадает с pyproject.toml — ошибка; - --no-dev — без pytest, ruff и т. п.

Зачем отдельный шаг. Docker кэширует каждый шаг. Пока uv.lock не менялся, этот медленный шаг берётся из кэша, даже если код поменялся.

COPY . .
RUN --mount=type=cache,target=/root/.cache/uv uv sync --locked --no-dev

Копируем весь код (кроме исключённого в .dockerignore) и завершаем установку.

Этап 2 — итоговый образ:

FROM python:3.13-slim
RUN groupadd --system app && useradd --system --gid app --no-create-home app

Чистый образ и системный пользователь app без прав администратора.

WORKDIR /app
COPY --from=builder --chown=app:app /app /app

Копируем из builder готовую папку (код + .venv), владелец — app.

ENV PATH="/app/.venv/bin:$PATH" PYTHONUNBUFFERED=1
  • PATH — команды uvicorn, alembic берутся из .venv;
  • PYTHONUNBUFFERED=1 — вывод сразу уходит в лог, без буферизации (иначе логи могут появляться с задержкой).
USER app

Дальше всё выполняется не от root. Если приложение взломают, у злоумышленника не будет прав администратора внутри контейнера.

EXPOSE 8000
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=3 \
    CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/v1/health')"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
  • EXPOSE — документирует, что приложение слушает 8000;
  • HEALTHCHECK — каждые 10 секунд дёргать /health; 3 неудачи подряд → unhealthy. Используется стандартная библиотека Python, потому что curl в slim-образе нет;
  • CMD — команда запуска по умолчанию. --host 0.0.0.0 — слушать на всех интерфейсах, иначе снаружи контейнера сервер недоступен.

.dockerignore

Не копировать в образ: .venv (собирается внутри), .env (секреты передаются при запуске), .git, кэши, tests. Образ меньше, секреты в него не попадают.

docker-compose.yml — построчно

name: dzhub

Имя проекта: контейнеры называются dzhub-db-1, dzhub-api-1, том — dzhub_pgdata.

Сервис db — PostgreSQL:

  db:
    image: postgres:18-alpine          # официальный образ, Alpine — маленький
    restart: unless-stopped            # перезапускать при сбое, пока не остановили вручную
    environment:
      POSTGRES_USER: ${POSTGRES_USER:-dzhub}       # из .env, иначе dzhub
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-dzhub}
      POSTGRES_DB: ${POSTGRES_DB:-dzhub}
    ports:
      - "${POSTGRES_PORT:-5433}:5432"  # localhost:5433 → 5432 в контейнере
    volumes:
      - pgdata:/var/lib/postgresql     # данные в томе, переживают перезапуск
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 5s
      timeout: 3s
      retries: 10
  • ${VAR:-default} — compose подставляет значение из .env (или из окружения), иначе значение после :-.
  • Почему 5433. На твоём Mac уже установлен свой PostgreSQL, и он занимает 5432. Если бы контейнер тоже был на 5432, запросы на localhost:5432 уходили бы в локальную базу.
  • Путь тома. В PostgreSQL 18 образ хранит данные в /var/lib/postgresql/18/docker, поэтому монтируется родительская папка /var/lib/postgresql.
  • pg_isready — утилита PostgreSQL: «готова ли база принимать подключения». $$ — экранирование $ для compose: переменная раскрывается внутри контейнера.

Сервис api — наше приложение:

  api:
    build: .                     # собрать образ по Dockerfile из текущей папки
    env_file: .env               # передать в контейнер все переменные из .env
    environment:
      POSTGRES_HOST: db          # внутри сети compose база доступна по имени сервиса
      POSTGRES_PORT: 5432        # и на своём внутреннем порту
      WATCHFILES_FORCE_POLLING: "true"   # надёжный --reload на macOS (см. ниже)
    ports:
      - "8000:8000"
    depends_on:
      db:
        condition: service_healthy   # ждать, пока база станет healthy
    command: sh -c "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"
    volumes:
      - ./app:/app/app           # код с твоего диска «подменяет» код в образе
      - ./alembic:/app/alembic
  • environment важнее env_file, поэтому localhost:5433 из .env заменяется на db:5432.
  • WATCHFILES_FORCE_POLLING — Docker на macOS иногда теряет события об изменении файлов в примонтированных папках. Однажды из-за этого контейнер продолжал работать со старым кодом без новых эндпоинтов. С опросом файлов --reload срабатывает надёжно.
  • command заменяет CMD из Dockerfile: сначала миграции, потом сервер. --reload — перезапуск при изменении файлов (это режим разработки).
  • Bind mounts (./app:/app/app) — правишь код на компьютере, контейнер сразу видит изменения и перезапускается. Но: новые зависимости (uv add) в образ так не попадут — нужна пересборка make up (в ней есть --build).
volumes:
  pgdata:

Объявление именованного тома.

16. Makefile

make <команда> — сокращения для длинных команд. Список: make help.

Команда Что выполняет Когда использовать
make help Печатает список команд (по комментариям ##) Забыл команду
make install make env + uv sync Первый запуск, после git pull
make env Создаёт .env из .env.example, если его нет Обычно вызывается автоматически
make dev uv run uvicorn app.main:app --reload --port 8000 Разработка: API локально, база в Docker
make db docker compose up -d --wait db Поднять только базу
make up docker compose up -d --build --wait Всё в Docker; пересобирает образ
make down docker compose down Остановить (данные сохраняются)
make restart down + up Перезапуск
make logs docker compose logs -f Смотреть логи (Ctrl+C — выход)
make ps docker compose ps Статус контейнеров
make psql Консоль PostgreSQL в контейнере Посмотреть данные
make migrate alembic upgrade head Применить миграции
make makemigration m="..." alembic revision --autogenerate -m "..." Изменил модели
make downgrade alembic downgrade -1 Откатить последнюю миграцию
make history alembic history --verbose Посмотреть цепочку миграций
make activate-dz SLUG=... python -m app.modules.dropzones.cli activate ... Сделать дропзону активной, пока нет оплаты
make test pytest Тесты (нужна make db)
make lint ruff check + ruff format --check Проверить стиль
make format ruff check --fix + ruff format Исправить стиль
make typecheck mypy . Проверить типы
make check lint + typecheck + test Перед коммитом
make clean docker compose down -v Удаляет данные базы!
  • -d — запустить в фоне; --wait — дождаться, пока контейнеры станут healthy.
  • .PHONY — эти имена — команды, а не файлы.
  • $$ в Makefile — экранирование $.

Два режима работы:

make up (всё в Docker) make db + make dev
Где API в контейнере на твоём компьютере
Миграции автоматически при старте вручную make migrate
Отладчик VS Code неудобно работает
Новые зависимости нужна пересборка (make up) сразу после uv add

Одновременно оба режима не запустить: оба используют порт 8000.

17. Тесты

make db     # база должна работать
make test

Отдельная тестовая база

Тесты не трогают твою базу dzhub: они работают в dzhub_test.

tests/conftest.py содержит фикстуры — функции, которые pytest вызывает, чтобы подготовить окружение для теста:

Фикстура Область Что делает
engine вся сессия тестов Создаёт базу dzhub_test, если её нет (через AUTOCOMMIT: CREATE DATABASE нельзя выполнить в транзакции). Удаляет и заново создаёт все таблицы из моделей
session каждый тест Открывает соединение и внешнюю транзакцию. Сессия работает в режиме create_savepoint: commit() в коде приложения становится точкой сохранения (SAVEPOINT) внутри этой транзакции. В конце теста — rollback(): всё, что тест записал, исчезает
client каждый тест HTTP-клиент, который ходит прямо в приложение (без сети, через ASGITransport). Подменяет зависимость get_session на тестовую сессию
token каждый тест Регистрирует пользователя ion@example.md и возвращает его токен
auth каждый тест Готовые заголовки Authorization: Bearer <token> для этого пользователя
register каждый тест Функция: зарегистрировать ещё одного пользователя и получить его заголовки. Для тестов, где нужны владелец, сотрудник и посторонний

В итоге каждый тест начинает с пустой базы и не влияет на другие.

app.dependency_overrides — механизм FastAPI: на время теста подменить зависимость другой функцией. Это главный инструмент тестирования в FastAPI.

Что покрыто (384 теста)

  • test_health.py: /health; генерация X-Request-ID; передача своего X-Request-ID; /health/ready с базой.
  • test_auth.py:
  • регистрация возвращает токен и пользователя; email приведён к нижнему регистру; имя без лишних пробелов; хеша пароля нет в ответе;
  • повторный email (в другом регистре) → 409;
  • ошибки валидации по всем четырём полям → 422;
  • пароль в базе — хеш argon2id;
  • вход (email в другом регистре работает), неверный пароль → 401, неизвестный email → такой же 401, заблокированный → 403;
  • /me с токеном, без токена (401 + WWW-Authenticate), с поддельным токеном → 401;
  • выход → 204.
  • test_skydiver_profiles.py: профиль создаётся при регистрации; ограничения базы; удаление вместе с пользователем.
  • test_profile_api.py: чтение; частичное обновление; null очищает; телефон; границы; даты; время падения (границы, null запрещён); правило «нет прыжков → нет дат и времени до DZ Hub»; первый прыжок не позже последнего; returnAfterBreak.
  • test_rules.py: таблицы требований и порогов полные; сравнение уровней по порядку, а не по алфавиту; check_rating, ratings_blocking_level; порог перерыва строгий (60 дней — можно, 61 — нужен инструктор); без данных — null.
  • test_ratings_api.py: добавление, идемпотентный PUT, удаление; минимальный уровень для каждого допуска; eligibility; запрет понижать уровень ниже допуска; старое неверное сочетание не мешает менять другие поля; чужие допуски недоступны.
  • test_logbook_model.py: ограничения базы для логбука.
  • test_logbook_api.py: CRUD, автонумерация, занятый номер, правило высот, пагинация, чужие записи → 404; каждый тип прыжка принимается, старый canopy — нет; флаги ночи и воды (по умолчанию false, null запрещён); расстояние приземления (округление, границы, очистка); подсказки в OpenAPI.
  • test_logbook_stats.py: формула «всего прыжков», последняя и первая дата из двух источников, время падения бумажных прыжков не считается дважды, окна 30 / 365 дней, разбивки, период, фильтры списка.
  • test_language.py: Accept-Language — регион, веса q, q=0, неподдерживаемые языки.
  • test_canopy.py: таблица USPA (форма, строки без пропусков, площадь не растёт с опытом, клетки сверены с SIM); выбор столбца и строки на границах; перевод кг → lb; допуск 3 %; полосы высокой производительности на границах; пример из SIM 1-C; формулы целиком; вне таблицы и после 1000 прыжков; у каждого текста три языка, у каждого результата источник SIM 2026; API: публичный, Content-Language, без купола, валидация.
  • test_dropzone_rules.py: должности без допуска ничего не требуют; у каждой должности с одноимённым рейтингом есть правило; старший рейтинг покрывает младшую должность, младший старшую — нет.
  • test_dropzones_api.py: создание в draft, создатель — владелец и в персонале; нормализация ввода; валидация slug, country, timezone; один черновик на владельца (и в API, и индексом в базе); новый черновик после активации; уникальный slug; владельца нельзя удалить (RESTRICT); список только активных; черновик и suspended скрыты от чужих; изменение только владельцем; status через API не меняется; activate.
  • test_staff_api.py: владелец в персонале и первым в списке; посторонний и персонал без прав не видят права и контакты; добавление по email и по телефону; телефон у двоих → 422; ровно одно из email и телефона; повторное добавление → 409; owner не значение access; предупреждения о допусках (нет, есть, истёк); частичный PATCH; права владельца не меняются; владельца не удалить; сотрудник чужой дропзоны → 404; GET /me/dropzones.
  • test_operations_api.py: самолёт — нормализация, номер необязателен, уникален в пределах дропзоны, границы мест, выключение и includeInactive (посторонним не работает), частичный PATCH; права — организатор может, manifest и посторонний нет, черновик чужим не виден; прыжковый день — создание, сегодня можно, вчера и через 366 дней нельзя, категории (пустые, неизвестные, дубли), один день на дату, CHECK массива в базе, список и период, прошедшие скрыты по умолчанию, отмена и возврат, перенос, прошедший день только для чтения, null в PATCH, дни разных дропзон не пересекаются.
  • test_openapi.py: у каждого эндпоинта ровно один тег из OPENAPI_TAGS; /me/... разделены на profile, ratings, logbook; дропзоны — на dropzones, staff, aircraft, jump-days.

18. Качество кода

  • ruff — make lint проверяет, make format исправляет. Папку docs/ не трогает, в сгенерированных миграциях не проверяет длину строк.
  • mypy strict — make typecheck. Каждая функция с типами аргументов и результата.
  • make check — всё сразу. Запускай перед коммитом.

CI (автоматических проверок на сервере) для бэкенда пока нет.

19. Git

  • Ветка main. Один шаг задачи — один коммит (git log --oneline).
  • .gitignore исключает .env (секреты), .venv, __pycache__, кэши ruff/mypy/pytest.
  • alembic/versions/.gitkeep — пустой файл, чтобы git сохранил папку versions/ (git не хранит пустые папки, а без неё Alembic не работает).
  • Удалённого репозитория (GitHub) пока нет.

20. Безопасность — сводка

Мера Где
Пароли — argon2id, не восстанавливаются auth/security.py
Одинаковый ответ и время для «нет email» и «неверный пароль» auth/service.py, security.py
Подписанные JWT со сроком жизни auth/security.py
Прод не запустится с dev-секретом core/config.py
Секреты не в git и не в образе .gitignore, .dockerignore
SecretStr — пароли не попадают в логи core/config.py
Контейнер работает не от root Dockerfile
Email уникален без учёта регистра на уровне базы users/models.py
Пароли и токены не логируются middleware.py, auth/service.py
CORS разрешает только фронтенд .env → CORS_ORIGINS
Свои данные — только через /me/..., id пользователя берётся из токена skydivers/router.py
Чужая запись логбука неотличима от несуществующей (404) skydivers/logbook.py

21. Клиенты API и CORS

CORS

Браузер считает «источником» (origin) комбинацию протокол + домен + порт. http://localhost:5173 (фронтенд) и http://localhost:8000 (API) — разные источники. По умолчанию браузер не даёт JavaScript читать ответы с чужого источника — это защита от сайтов, которые пытаются от твоего имени обращаться к другим сервисам.

CORS — способ серверу сказать браузеру «этому источнику можно». Перед запросом с Authorization или JSON-телом браузер отправляет предварительный запрос OPTIONS (preflight). Бэкенд отвечает заголовками Access-Control-Allow-Origin и т. п., и только тогда браузер отправляет настоящий запрос.

Поэтому: - в backend/.env: CORS_ORIGINS=["http://localhost:5173"]; - в vite.config.ts: strictPort: true — если порт 5173 занят, Vite не перейдёт молча на 5174 (с которого CORS бы не пустил), а выдаст ошибку.

Контракт данных

  • JSON в camelCase (на бэкенде CamelModel).
  • Ошибки — { "detail": "..." } или список для 422 (фронтенд разбирает их в ApiError).
  • Типы на фронтенде (clients/auth.ts) пока написаны вручную по схемам бэкенда. Цель — генерировать их из http://localhost:8000/api/v1/openapi.json.

Сценарии

Регистрация:

Форма → zod-проверка → POST /auth/register
  → бэкенд: проверка → хеш пароля → INSERT users → JWT
  ← 201 { accessToken, user }
Фронтенд: токен → localStorage, user → кэш сессии → переход в приложение

Открытие приложения, когда токен уже есть:

useSession → GET /auth/me (Authorization: Bearer ...)
  → бэкенд: проверка подписи и срока → SELECT user → is_active?
  ← 200 user           → показываем приложение
  ← 401 (токен истёк)  → удаляем токен → страница входа

Выход:

POST /auth/logout ← 204 → удаляем токен и очищаем кэш

22. Шпаргалка команд

make up                                   # запустить всё в Docker
make down                                 # остановить
make logs                                 # логи
make psql                                 # консоль базы
make db && make dev                       # база в Docker, API локально
make makemigration m="описание"           # изменил модели → создать миграцию
make migrate                              # применить миграции
make check                                # линтер + типы + тесты, перед коммитом

23. Ограничения и что дальше

Сознательные упрощения:

  1. Выход не отзывает токен на сервере. Токен удаляет только клиент; украденный токен действует до истечения срока (7 дней). Решение в будущем — короткий access-токен + refresh-токен с возможностью отзыва.
  2. Нет подтверждения email и сброса пароля.
  3. Нет CI (автоматических проверок на сервере) и удалённого репозитория.
  4. Тесты не прогоняют миграции — схема в тестах строится из моделей, поэтому миграции проверяются отдельно (make migrate, make downgrade).
  5. Типы фронтенда пишутся вручную по схемам бэкенда; цель — генерировать их из /api/v1/openapi.json.
  6. Email нельзя сменить — понадобится подтверждение нового адреса письмом.
  7. Тип прыжка и роль в логбуке не связаны — можно записать тандем с ролью «коуч». Черновик таблицы допустимых ролей — в MVP.md, раздел «На будущее».
  8. Только парашютные допуски. Укладчик, риггер, пилот отложены: им не нужна лицензия, и правило станет зависеть от роли.

  9. Дропзону активирует администратор вручную (make activate-dz): подписки и оплаты пока нет. Передачи владения тоже нет.

  10. Страница дропзоны только для вошедших. Режим «для всех, только чтение» — позже.

  11. О прыжковом дне никто не узнаёт сам: уведомлений об объявлении и отмене пока нет.

Что дальше по MVP: «я приеду» (запись на день), взлёты и запись во взлёт, члены дропзоны с очной проверкой, подписки и платежи.

24. Словарь терминов

Термин Объяснение
API Набор адресов, по которым программы обмениваются данными
Эндпоинт Один адрес API с методом: POST /auth/login
HTTP-метод GET — получить, POST — создать/выполнить, PATCH — изменить, DELETE — удалить
HTTP-статус Код результата: 2xx успех, 4xx ошибка клиента, 5xx ошибка сервера
JSON Текстовый формат данных: {"email": "a@b.md"}
ORM Работа с таблицами как с классами Python (SQLAlchemy)
Модель Класс, описывающий таблицу (User → users)
Схема (Pydantic) Описание формы данных запроса или ответа с проверками
Миграция Версионированное изменение схемы базы
Зависимость (FastAPI) Функция, результат которой FastAPI подставляет в обработчик (SessionDep, CurrentUser)
Middleware Код, через который проходит каждый запрос до и после обработчика
ASGI Стандарт связи асинхронного Python-приложения с веб-сервером (uvicorn ↔ FastAPI)
Пул соединений Набор открытых соединений с базой, которые переиспользуются
Транзакция Группа изменений, которая применяется целиком или не применяется вовсе
SAVEPOINT Точка сохранения внутри транзакции, к которой можно откатиться
Хеш Необратимый «отпечаток» данных фиксированной длины
Соль Случайная добавка к паролю перед хешированием: одинаковые пароли дают разные хеши
JWT Подписанный токен с данными (id пользователя, срок действия)
Bearer-токен Токен в заголовке Authorization: Bearer <токен>: «кто предъявил, тот и владелец»
CORS Механизм, разрешающий браузеру запросы между разными источниками
Origin (источник) Протокол + домен + порт: http://localhost:5173
Docker-образ / контейнер / том Слепок системы / запущенный экземпляр / постоянное хранилище данных
Healthcheck Регулярная проверка «жив ли сервис»
Liveness / readiness «Процесс жив» / «готов обслуживать запросы»
Фикстура (pytest) Функция, готовящая окружение для теста
Линтер Программа, которая ищет ошибки и проблемы стиля в коде
Мок Подделка внешней системы (бэкенда) для тестов и разработки
contextvar Переменная со своим значением в каждой асинхронной задаче
request_id Уникальный номер запроса для поиска его строк в логах