Время брони: смещение и нормализация UTC
«Окно» сохраняет начало и конец в timestamptz, но входная модель ещё позволяла обе даты без смещения. Для расписания это недостаточно: запись «10:00» не говорит, какой момент подразумевал клиент. Сделаем договор строгим. Даты должны содержать смещение, а после проверки приводятся к UTC. Результат — одинаковые моменты получают одинаковое серверное представление независимо от исходного написания.
Часовой пояс и смещение — связанные, но разные понятия. Строка с +03:00 показывает смещение для переданного момента; она не несёт правил региона на будущие годы. Название вроде Europe/Minsk обозначает область с историей и правилами. В этой серии клиент отправляет уже выбранный момент со смещением. Мы не строим календарь повторяющихся встреч и не пытаемся восстановить географический пояс по одному числу +03:00.
AwareDatetime на границе
Pydantic AwareDatetime требует временную осведомлённость значения. Отсутствие смещения перестаёт быть допустимым вариантом запроса. Это означает изменение контракта относительно раннего предварительного просмотра. Теперь пользовательский интерфейс должен явно передать Z или числовое смещение. Сервер не додумывает пояс по своему компьютеру: настройки машины, где работает API, не являются намерением пользователя. Такой отказ лучше тихого сохранения другого времени.
После разбора двух дат model_validator проверяет ends_at > starts_at. Сравнение aware-значений относится к моментам, а не просто к визуальным числам на часах. Поэтому начало 12:00+03:00 и конец 10:00Z образуют часовой интервал: начало соответствует 09:00 UTC. Важно разобрать этот пример словами, поскольку внешне часы конца выглядят меньше часов начала. Правильное правило использует общую временную шкалу.
Затем astimezone(timezone.utc) переводит обе даты в UTC. Этот вызов сохраняет момент, меняя его представление. Он не переносит встречу на три часа раньше; он меняет координаты описания. Наивная замена timezone через replace имела бы другую семантику и могла бы назначить исходным числам новый смысл. В нашем коде сначала требуется известное смещение, поэтому перевод имеет основание. Не исправляем неизвестный момент присоединением UTC задним числом.
Полные файлы шага
Показанные файлы полностью совпадают с выбранным состоянием архива. Остальные согласованные файлы находятся в examples/fastapi-booking-lab/lesson-08/.
schemas.py
from datetime import timezone
from typing import Literal
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, model_validator
class ResourceOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
class BookingIn(BaseModel):
model_config = ConfigDict(extra="forbid")
resource_id: int = Field(gt=0)
starts_at: AwareDatetime
ends_at: AwareDatetime
@model_validator(mode="after")
def valid_interval(self):
if self.ends_at <= self.starts_at:
raise ValueError("End must follow start")
self.starts_at = self.starts_at.astimezone(timezone.utc)
self.ends_at = self.ends_at.astimezone(timezone.utc)
return self
class BookingOut(BookingIn):
model_config = ConfigDict(from_attributes=True, extra="ignore")
id: int
actor_id: int
state: Literal["confirmed", "cancelled"] = "confirmed"
Ожидаемый результат
Это ожидаемый договор для самостоятельного исследования; пример не выполнялся.
2026-11-15T12:00:00+03:00 → 2026-11-15T09:00:00Z
Начало без смещения → 422
Равные моменты начала и конца → 422
Что хранит PostgreSQL
timestamptz удобен для общей шкалы времени, однако не сохраняет исходное имя часового пояса клиента. Представление при чтении зависит от настроек соединения, а содержательный момент остаётся тем же. Мы снова нормализуем DTO к UTC, поэтому внешний API не зависит от случайного отображения сессии базы. Если проекту понадобится помнить выбранный пользователем регион для будущих повторений, потребуется отдельное поле и отдельные правила.
CHECK ends_at > starts_at остаётся в базе. Проверка DTO даёт ранний отказ, а ограничение таблицы защищает записи из других путей. Они согласованы по строгому неравенству. Если изменить только Python, позволив равные моменты, база всё равно отклонит запись. Такой случай не следует маскировать сообщением «время занято»: причина относится к длине интервала. Согласование правил предотвращает странные различия между API и прямыми административными операциями.
Входной словарь после model_dump содержит datetime-объекты. Для JSON нужен mode="json", где даты становятся строками. Эти два вида словаря служат разным потребителям: ORM ожидает Python-значения, HTTP и сохраняемый снимок ответа — сериализуемую форму. Позже идемпотентность использует именно JSON-представление нормализованной модели. Тогда одинаковый момент, написанный со смещением +03:00 или Z, не должен отличаться лишь по исходной строке запроса.
Интервал как две границы
В «Окне» фиксируем начало включительно, конец исключительно. На этой ступени сами два столбца ещё не выражают скобки, но договор нужен для следующего запрета пересечений. Бронь с 09:00 до 10:00 занимает время до границы 10:00, а следующая может начинаться ровно в 10:00. Если бизнесу нужен промежуток уборки между встречами, это отдельное правило расширения занятого интервала. Не подменяем его неясной трактовкой равных границ.
Возьмите вымышленный интервал 15 ноября 2026 года: 12:00+03:00 — 13:00+03:00. Ожидаемая серверная форма соответствует 09:00Z — 10:00Z. Потом отправьте 09:00Z — 10:00Z: смысл одинаков. Если убрать смещение только у начала, ожидается 422. Если оставить смещение, но задать конец равным началу, также ожидается 422, уже по другому правилу. Два отказа полезно различать при объяснении формы пользователю.
Полный schemas.py на этой странице заменяет прежние datetime на AwareDatetime и добавляет нормализацию. Остальные файлы состояния согласованы с этим входом. Приложение и база в ходе подготовки не запускались, поэтому строка результата является ожидаемым договором. При самостоятельном чтении проследите момент через модель, ORM и ответ. Исходная строка не должна оставаться скрытой мерой порядка после того, как клиент уже передал определённое смещение.
Для дополнительного разбора представьте начало 23:30+03:00 и конец 00:30+03:00 следующего дня. Важно передать разные даты, а не только часы. Через полночь интервал не становится отрицательным, если календарная граница указана правильно. Мы получили общую шкалу и ясные края; следующая глава выразит на ней конкурентный запрет в самой базе. Именно там решится ситуация, когда два клиента одновременно выбирают один кабинет.
Длительность и календарное отображение
После нормализации длительность можно обсуждать на общей шкале: для выбранного часа разность конца и начала составляет один час. Но подпись этого интервала на пользовательском экране всё ещё зависит от выбранного региона. Серверный UTC-ответ не обязан отображаться человеку как 09:00, если он привык к локальным 12:00. Клиент может форматировать момент для своего часового пояса, сохраняя исходное значение данных для обратной отправки.
Повторяющееся пожелание «каждый понедельник в десять» имеет иную семантику. Для него нужны календарные правила, регион и политика неоднозначных часов при переходах. Одна aware-дата описывает один момент и не кодирует весь будущий ряд. В «Окне» каждое намерение содержит конкретные начало и конец, поэтому не создаём recurrence из нескольких строк преобразования времени. Такое ограничение помогает читать модель точно: это API отдельных интервалов, и его корректность не следует распространять на календарь повторяющихся событий, которого в коде ещё нет.
Документация и исходники
Механизмы главы сверены с первичными источниками: документация 1, документация 2, документация 3. Полный архив «Окна» содержит все шестнадцать состояний. Пакеты, приложение и миграции не запускались.