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

Отмена брони как переход состояния

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

Пользователь может создать и прочитать свою бронь. Теперь ему нужен способ освободить кабинет, сохранив факт прежнего намерения. В «Окне» отмена является переходом confirmed в cancelled, а не удалением строки. Результат урока — собственная карточка получает cancelled, её время перестаёт участвовать в запрете пересечений, а повтор отмены возвращает то же конечное состояние.

Выбор состояния важен для смысла истории. Если удалить строку физически, исчезнет объект, на который ссылался первоначальный ответ и запись идемпотентности. При cancelled номер остаётся устойчивым, и читатель видит, что операция когда-то существовала. В этом учебном договоре отмена необратима: отдельного подтверждения назад нет. Возврат к confirmed потребовал бы вновь проверить занятость и определить права, а не просто поменять надпись в интерфейсе.

Операция над принадлежащей строкой

Маршрут /bookings/{booking_id}/cancel получает Actor той же зависимостью, что и чтение. SELECT одновременно ограничивает номер и actor_id. Если совпадения нет, возвращается 404. Это сохраняет прежний договор скрытия чужих объектов. Нельзя сначала получить чужую строку, присвоить ей cancelled, а затем проверять владельца. Порядок проверки является частью предметной операции: право должно быть подтверждено до изменения состояния.

with_for_update блокирует выбранную строку до завершения транзакции. Если два запроса одновременно отменяют одну бронь, они должны согласовать изменение одной записи. Второй ожидает завершения первого, затем работает с её актуальным состоянием. Это пример блокировки существующего объекта. Она отличается от защиты пустого участка расписания: до первой вставки там нет строки, которую можно было бы просто заблокировать. Поэтому exclusion constraint из прежней главы остаётся необходимым.

Если строка уже cancelled, код снова назначает cancelled и возвращает карточку. Повтор не создаёт новую запись и не меняет владельца. Мы не добавляем отдельное событие каждой повторной отмены, поэтому результат прост. В более подробной модели могут существовать время и причина отмены; тогда надо решить, сохраняет ли повтор первоначальные значения. Сейчас нельзя приписывать эти сведения имеющимся полям, поскольку в schema их нет.

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

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

main.py

from typing import Annotated

from fastapi import Depends, FastAPI, Header, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session

from auth import get_actor
from db import get_session
from errors import register_errors
from models import Booking, Resource
from schemas import BookingIn, BookingOut, ResourceOut
from service import create_booking
from settings import get_settings

app = FastAPI(title=get_settings().app_name)
register_errors(app)
DB = Annotated[Session, Depends(get_session)]
Actor = Annotated[int, Depends(get_actor)]


@app.get("/resources", response_model=list[ResourceOut])
def list_resources(db: DB):
    return db.scalars(select(Resource).order_by(Resource.id)).all()


@app.get("/resources/{resource_id}", response_model=ResourceOut)
def read_resource(resource_id: int, db: DB):
    item = db.get(Resource, resource_id)
    if item is None:
        raise HTTPException(404, "Ресурс не найден")
    return item


@app.post("/booking-preview", response_model=BookingIn)
def preview_booking(data: BookingIn):
    return data


@app.post("/bookings", response_model=BookingOut, status_code=201)
def add_booking(
    data: BookingIn, db: DB, actor: Actor,
    request_key: Annotated[str, Header(alias="Idempotency-Key")],
):
    return create_booking(db, data, actor_id=actor, request_key=request_key)


@app.get("/bookings/{booking_id}", response_model=BookingOut)
def read_booking(booking_id: int, db: DB, actor: Actor):
    row = db.scalar(select(Booking).where(
        Booking.id == booking_id, Booking.actor_id == actor
    ))
    if row is None:
        raise HTTPException(404, "Бронь не найдена")
    return row


@app.post("/bookings/{booking_id}/cancel", response_model=BookingOut)
def cancel_booking(booking_id: int, db: DB, actor: Actor):
    try:
        row = db.scalar(select(Booking).where(
            Booking.id == booking_id, Booking.actor_id == actor
        ).with_for_update())
        if row is None:
            raise HTTPException(404, "Бронь не найдена")
        row.state = "cancelled"
        db.flush()
        result = BookingOut.model_validate(row)
        db.commit()
        return result
    except Exception:
        db.rollback()
        raise

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

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

POST /bookings/N/cancel → 200, state = cancelled
Повтор cancel → 200, state = cancelled
GET /bookings/N → cancelled
Повтор ключа создания → сохранённый первоначальный ответ

Момент освобождения кабинета

Ограничение bookings_no_overlap имеет предикат только для confirmed. После фиксации изменения cancelled строка больше не занимает интервал. До успешного commit клиенту нельзя обещать освобождённое время. Если транзакция отменится, прежняя подтверждённая бронь сохранит действие. Поэтому, как и при создании, BookingOut собирается в управляемом блоке, commit выполняется перед return, а исключение приводит к rollback и повторному подъёму.

Представьте третий запрос, который пытается создать новую бронь в тот же час одновременно с отменой. Он должен согласоваться с реальным состоянием ограничения базы. Мы не гарантируем мгновенного успеха и не называем время свободным только из-за начавшегося запроса cancel. После успешной фиксации отмены новый допустимый интервал может быть сохранён. При другом порядке завершения создание может столкнуться с занятой строкой и получить конфликт, который клиент обработает по своему договору.

Такой пример показывает, почему интерфейс должен ждать ответа отмены перед последующим обещанием. Оптимистическая надпись допустима как UX-приём, но она не является фактом базы. Если пользователь увидел сетевой сбой, текущее состояние можно узнать GET своей карточки. Не следует трактовать потерянный ответ как доказательство, что cancelled не сохранилось. Граница HTTP и граница транзакции находятся в разных местах, и это особенно заметно при неопределённом результате сети.

История создания и текущее состояние

После отмены GET должен вернуть cancelled. Повтор исходного POST создания с прежним Idempotency-Key возвращает сохранённую первоначальную карточку confirmed. Это не восстановление брони: сервис не выполняет новый INSERT и не меняет строку. Он подтверждает результат прежнего намерения. Чтобы увидеть текущее состояние, клиент использует GET. Такое различие уже определено в прошлой главе; здесь появляется конкретная причина, почему оно важно.

Ожидаемый сценарий начинается со своей подтверждённой брони кабинета 1 на вымышленный час. Первый cancel возвращает cancelled, второй — тоже cancelled. Новый участник после фиксации может создать бронь этого же кабинета на тот же интервал с собственным ключом, если нет иных конфликтов. Старый участник сохраняет доступ к отменённой карточке. Попытка отменить её чужим токеном остаётся 404, независимо от того, занимает ли она кабинет сейчас.

Полный main.py данного состояния включён на этой странице, чтобы можно было увидеть маршруты и общий контекст Actor. Остальные файлы берутся из lesson-12. Код не использует DELETE, потому что выбранный предметный результат связан с состоянием, а не с очисткой хранения. Удаление старых данных, архивирование и сроки хранения были бы другими задачами. Не добавляем их под видом небольшой правки кнопки отмены: они меняют историю и договор повторного запроса.

Для самостоятельного разбора попробуйте словами описать три разных факта: бронь существовала, бронь сейчас отменена, время сейчас может быть свободно. Третий факт не следует только из отмены одной записи, если есть другие ресурсы или дополнительные правила. В нашем минимальном расписании его устанавливает общий инвариант подтверждённых интервалов. Полученная операция сохраняет историю и даёт предсказуемый повтор; дальше построим ограниченный список собственных карточек с устойчивым порядком.

Отмена не является новой бронью

Повтор отмены можно сделать предсказуемым, потому что операция назначает конкретное конечное состояние. Если бы она переключала confirmed и cancelled на противоположное при каждом вызове, сетевой повтор мог бы восстановить отменённую запись. Поэтому используем явное cancel, а не универсальный toggle. Это важный предметный выбор: удобный переключатель в интерфейсе не должен определять неясную семантику HTTP-операции.

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

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

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