Бэкенд DZ Hub — подробный гид¶
Состояние на 2026-10-03 (коммит
2e915ee). Если код поменялся, а гид нет, — верить коду. Короткая инструкция по запуску — в README.md.
Этот документ объясняет каждую часть бэкенда: что это, зачем, как устроено и как связано с остальным. Читать можно подряд или по разделам.
Содержание¶
- Место бэкенда в проекте и быстрый старт
- Стек: что и зачем
- Структура файлов
- pyproject.toml — построчно
- uv — менеджер пакетов и окружения
- Конфигурация:
.envиapp/core/config.py - Путь одного запроса от начала до конца
app/main.py— сборка приложения- База данных:
app/core/db.py - Таблицы
- Миграции (Alembic)
- Модули и их устройство
- API: все эндпоинты
- Логирование
- Health-эндпоинты (
app/api/health.py) - Docker
- Makefile
- Тесты
- Качество кода
- Git
- Безопасность — сводка
- Клиенты API и CORS
- Шпаргалка команд
- Ограничения и что дальше
- Словарь терминов
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, миграции применяются сами
- API: http://localhost:8000
- Интерактивная документация: http://localhost:8000/api/docs
- Данные в базе:
make psql, затемselect * from users;, выход —\q - Остановить:
make down
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) читает значения в таком порядке (верхнее важнее):
- Переменные окружения процесса (например, заданные в docker-compose).
- Файл
.envв папке, откуда запущено приложение. - Значения по умолчанию в коде
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() по шагам:
setup_logging(...)— настраивает логи первым делом, чтобы всё дальше логировалось в нашем формате.FastAPI(...):title— название в документации;openapi_url="/api/v1/openapi.json"— машиночитаемое описание API (OpenAPI). Из него в будущем фронтенд будет генерировать TypeScript-типы;docs_url="/api/docs"— интерактивная документация Swagger UI;redoc_url=None— альтернативная документация ReDoc отключена (хватает одной);lifespan— код, который выполняется при старте и остановке.- CORS — подключается, только если
CORS_ORIGINSне пустой (подробнее в разделе 21): allow_origins— список разрешённых сайтов;allow_credentials=True— разрешить отправку куки/авторизации;allow_methods=["*"],allow_headers=["*"]— любые методы и заголовки (в том числеAuthorization).RequestLoggingMiddleware— логирование запросов.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через SQLAlchemyupdated_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"
- Alembic подключается к базе и сравнивает текущую схему базы с моделями в коде
(
Base.metadata). Это называется autogenerate. - Записывает разницу в новый файл
alembic/versions/<дата>-<id>_add_loads.py. - Post-write hooks прогоняют файл через
ruff check --fixиruff format. - Файл нужно прочитать глазами. Autogenerate не всё понимает: например, переименование столбца он видит как «удалить старый + создать новый», что уничтожит данные.
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)¶
- Создаёт один handler в
stdoutс нужным formatter и фильтромrequest_id. - Ставит его на корневой логгер — туда приходят записи всех логгеров: приложения, SQLAlchemy, uvicorn.
- Логгеры uvicorn (
uvicorn,uvicorn.error) uvicorn настраивает ещё до импорта приложения, со своим форматом. Мы убираем их handlers и включаемpropagate, чтобы их записи шли через наш формат. 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. Ограничения и что дальше¶
Сознательные упрощения:
- Выход не отзывает токен на сервере. Токен удаляет только клиент; украденный токен действует до истечения срока (7 дней). Решение в будущем — короткий access-токен + refresh-токен с возможностью отзыва.
- Нет подтверждения email и сброса пароля.
- Нет CI (автоматических проверок на сервере) и удалённого репозитория.
- Тесты не прогоняют миграции — схема в тестах строится из моделей, поэтому миграции
проверяются отдельно (
make migrate,make downgrade). - Типы фронтенда пишутся вручную по схемам бэкенда; цель — генерировать их из
/api/v1/openapi.json. - Email нельзя сменить — понадобится подтверждение нового адреса письмом.
- Тип прыжка и роль в логбуке не связаны — можно записать тандем с ролью «коуч». Черновик таблицы допустимых ролей — в MVP.md, раздел «На будущее».
-
Только парашютные допуски. Укладчик, риггер, пилот отложены: им не нужна лицензия, и правило станет зависеть от роли.
-
Дропзону активирует администратор вручную (
make activate-dz): подписки и оплаты пока нет. Передачи владения тоже нет. -
Страница дропзоны только для вошедших. Режим «для всех, только чтение» — позже.
-
О прыжковом дне никто не узнаёт сам: уведомлений об объявлении и отмене пока нет.
Что дальше по 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 | Уникальный номер запроса для поиска его строк в логах |