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

Создание брони и граница транзакции

Оглавление серии

В базе существуют справочники кабинетов и владельцев, а API читает каталог через сессию одного запроса. Добавим создание брони. Результат этого урока узкий: валидное намерение становится строкой bookings, получает серверный номер и возвращается как BookingOut. Конкурентное расписание ещё не защищено от пересечений, поэтому это промежуточное локальное состояние для изучения транзакции, а не готовая служба бронирования.

POST /bookings принимает только BookingIn. Временным владельцем служит вымышленный actor_id=1, назначенный сервером. Клиенту не предлагается передать actor_id в теле. Это упрощение нужно до отдельной главы о входе, где заменим фиксированное значение зависимостью. Не считайте его разрешением разместить API в публичной среде: сейчас любой локальный запрос действует от одного учебного владельца и чтение карточки ещё не ограничено правами.

Намерение и сохранённый объект

Сервисная функция сначала ищет Resource по resource_id. Если кабинет отсутствует, возникает DomainError resource_missing со статусом 404. Эта проверка даёт человеку понятную причину. Внешний ключ базы остаётся последним структурным правилом, поскольку ресурс мог измениться конкурентно или запись мог отправить другой инструмент. Сочетание предварительной проверки и ограничения полезно, но каждая часть выполняет свою обязанность.

Booking создаётся из data.model_dump и server-side actor_id. Словарь берётся из уже проверенной модели, а не из произвольного тела HTTP. Затем объект добавляется в Session. Добавление само по себе ещё не означает успешной записи. SQLAlchemy отслеживает новое состояние и отправляет SQL при flush. После flush становится доступен номер, созданный базой. Клиентский ответ нельзя объявлять завершённым только потому, что объект существует в памяти Python.

BookingOut.model_validate(row) собирает внешнюю карточку. from_attributes читает поля ORM-объекта, включая выданный id. Важно сделать это до выхода из контролируемой единицы работы и до возврата результата. В этой конфигурации expire_on_commit=False сохраняет загруженные значения после фиксации, но мы всё равно возвращаем DTO, а не сессию или лениво загружаемый граф связанных объектов. Маршрут имеет ясный выходной договор без случайного дополнительного SQL.

Полные файлы шага

Показанные файлы полностью совпадают с выбранным состоянием архива. Остальные согласованные файлы находятся в examples/fastapi-booking-lab/lesson-07/.

service.py

from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session

from errors import DomainError
from models import Booking, Resource
from schemas import BookingIn, BookingOut


def create_booking(db: Session, data: BookingIn, actor_id: int) -> BookingOut:
    try:
        if db.get(Resource, data.resource_id) is None:
            raise DomainError("resource_missing", "Ресурс не найден", 404)
        row = Booking(**data.model_dump(), actor_id=actor_id)
        db.add(row)
        db.flush()
        result = BookingOut.model_validate(row)
        db.commit()
        return result
    except IntegrityError as error:
        db.rollback()
        constraint = getattr(getattr(error.orig, "diag", None), "constraint_name", None)
        if constraint == "bookings_no_overlap":
            raise DomainError("slot_busy", "Интервал уже занят") from error
        raise
    except Exception:
        db.rollback()
        raise

Ожидаемый результат

Это ожидаемый договор для самостоятельного исследования; пример не выполнялся.

POST /bookings → 201
Получен серверный id = N и state = confirmed.
GET /bookings/N → та же сохранённая карточка.

Flush ещё не равен commit

Представьте, что INSERT уже отправлен, но транзакция ещё не завершилась. В этом состоянии база может знать строку внутри текущей операции, однако клиент не получил гарантированного успешного результата. Commit является границей фиксации. Если он завершится ошибкой, функция не должна возвращать ранее подготовленную карточку как созданную бронь. Поэтому return находится после db.commit. HTTP-статус 201 относится к завершённому сервисному вызову, а не к промежуточному ORM-объекту.

При IntegrityError вызываем rollback. Сессия, в которой flush завершился ошибкой, требует завершения логической транзакции. Простое подавление исключения оставило бы её в неудобном состоянии для следующей операции. В показанном коде после rollback распознаётся только именованное ограничение bookings_no_overlap, которое появится в дальнейшей главе. Остальные нарушения не объявляются автоматически занятым временем: у них могут быть иные причины, которые надо исследовать.

Общий except также выполняет rollback и повторно поднимает исключение. Этот блок отвечает за завершение неуспешной единицы работы, а не за публикацию её текста клиенту. Ранее зарегистрированные обработчики переводят DomainError в согласованный ответ. Неожиданный сбой остаётся неожиданным. Если бы функция всегда возвращала BookingOut из finally, она могла бы сообщить об успехе после отмены записи; такой порядок принципиально неверен.

Чтение созданной карточки

GET /bookings/{booking_id} ищет строку по первичному ключу и возвращает её через BookingOut. До урока о владении этот маршрут учебно открыт в localhost. Успешная карточка должна содержать id, resource_id, actor_id, времена и confirmed. Номер не следует заранее фиксировать в статье как гарантированную единицу: он зависит от предыдущих вставок и откатов в собственной базе. Пример результата использует условный номер N, подчёркивая его серверное происхождение.

Ожидаемый положительный сценарий состоит из двух операций. Сначала клиент создаёт часовой интервал для кабинета 1, затем читает полученный номер. Данные должны совпасть, поскольку ответ и GET описывают ту же строку. Ошибочный сценарий использует ресурс 99: создание должно отказать, а новый номер брони не возвращается. Замена только времени не делает неизвестный кабинет существующим; различайте предметные причины отказа.

В этом состоянии схема разрешает два пересекающихся интервала одного кабинета. Это существенное ограничение, а не скрытая мелочь. Даже если добавить перед INSERT поиск свободного времени, два одновременных запроса могли бы оба увидеть отсутствие конфликта. Пока изучаем сохранение одной операции, а полноценный инвариант установим в PostgreSQL. На этой ступени не выполняйте коллективный сценарий настоящего расписания и не называйте предварительную проверку защитой конкуренции.

Полный service.py показывает всю границу создания. Маршруты и остальные согласованные файлы находятся в lesson-07 архива. Для самостоятельного разбора проследите, где появляются разные факты: DTO подтверждает форму, поиск подтверждает известный ресурс в текущем чтении, flush выдаёт номер, commit завершает запись. Следующая глава уточнит семантику времени, чтобы один момент не превращался в разные интервалы из-за часового пояса читателя.

Успешный ответ и неизвестный результат сети

После commit у процесса ещё остаётся отправка HTTP-ответа. Если соединение оборвётся на этом промежутке, клиент не увидит карточку, хотя строка уже сохранена. Это не противоречие транзакции: PostgreSQL отвечает за фиксацию данных, а сеть — за перенос ответа. Поэтому факт отсутствия ответа не равен отсутствию брони. Сейчас повтор POST ещё не имеет особого договора; позже ключ идемпотентности даст способ безопасно спросить о том же намерении.

Разберите этот промежуток отдельно от ошибки flush. При ошибке flush сервис выполняет rollback, и операция не считается успешной. При потере ответа после успешного commit откатывать уже нечего на стороне завершённой функции. Клиентская стратегия должна учитывать неопределённость, а не автоматически создавать новый номер. Так мы заранее связываем изученную границу транзакции с будущим сетевым поведением. Номер N полезен для чтения, но без сохранённого ключа клиент может вообще не знать его после такого разрыва.

Документация и исходники

Механизмы главы сверены с первичными источниками: документация 1, документация 2. Полный архив «Окна» содержит все шестнадцать состояний. Пакеты, приложение и миграции не запускались.