Contexto
ulfblk-scheduling maneja citas con horarios disponibles. El sistema necesita soportar usuarios en diferentes zonas horarias, prevenir double-booking cuando dos personas intentan reservar el mismo slot al mismo tiempo, y detectar conflictos de horario. Almacenar timestamps sin timezone awareness es una receta para bugs silenciosos.
Lo que aprendi
La regla fundamental: almacenar en UTC, mostrar en local. Y para concurrency, delegar el locking a PostgreSQL con SELECT FOR UPDATE.
Modelo de slot
from datetime import datetime
from sqlalchemy import DateTime, String, Boolean, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from ulfblk_db import Base
class Slot(Base):
__tablename__ = "slots"
id: Mapped[str] = mapped_column(String, primary_key=True)
provider_id: Mapped[str] = mapped_column(String, ForeignKey("providers.id"))
start_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True))
end_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True))
is_available: Mapped[bool] = mapped_column(Boolean, default=True)
booked_by: Mapped[str | None] = mapped_column(String, nullable=True)
timezone: Mapped[str] = mapped_column(String, default="America/Mexico_City")
Consulta de disponibilidad con locking
SELECT FOR UPDATE bloquea las filas seleccionadas hasta que la transaccion termine. Si dos requests intentan reservar el mismo slot, la segunda espera hasta que la primera haga commit o rollback.
from datetime import datetime
from zoneinfo import ZoneInfo
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession
async def get_available_slots(
session: AsyncSession,
provider_id: str,
date_local: datetime,
user_timezone: str,
) -> list[Slot]:
"""Obtiene slots disponibles para un dia en la timezone del usuario."""
tz = ZoneInfo(user_timezone)
# Calcular inicio y fin del dia en UTC
day_start_local = date_local.replace(hour=0, minute=0, second=0, tzinfo=tz)
day_end_local = date_local.replace(hour=23, minute=59, second=59, tzinfo=tz)
day_start_utc = day_start_local.astimezone(ZoneInfo("UTC"))
day_end_utc = day_end_local.astimezone(ZoneInfo("UTC"))
result = await session.execute(
select(Slot)
.where(
and_(
Slot.provider_id == provider_id,
Slot.is_available == True,
Slot.start_utc >= day_start_utc,
Slot.start_utc <= day_end_utc,
)
)
.order_by(Slot.start_utc)
)
return list(result.scalars().all())
async def book_slot(
session: AsyncSession,
slot_id: str,
user_id: str,
) -> Slot | None:
"""Reserva un slot con locking para evitar double-booking."""
# SELECT FOR UPDATE bloquea la fila hasta el commit
result = await session.execute(
select(Slot)
.where(and_(Slot.id == slot_id, Slot.is_available == True))
.with_for_update()
)
slot = result.scalar_one_or_none()
if slot is None:
return None # Ya fue reservado por alguien mas
slot.is_available = False
slot.booked_by = user_id
await session.commit()
return slot
Deteccion de conflictos
Dos rangos de tiempo se superponen si el inicio de uno es anterior al fin del otro y viceversa. Esta formula cubre todos los casos de overlap.
async def has_conflict(
session: AsyncSession,
provider_id: str,
start_utc: datetime,
end_utc: datetime,
exclude_slot_id: str | None = None,
) -> bool:
"""Detecta si un rango de tiempo tiene conflicto con slots existentes."""
query = select(Slot).where(
and_(
Slot.provider_id == provider_id,
Slot.is_available == False, # Solo slots reservados
Slot.start_utc < end_utc, # Overlap formula
Slot.end_utc > start_utc, # start1 < end2 AND start2 < end1
)
)
if exclude_slot_id:
query = query.where(Slot.id != exclude_slot_id)
result = await session.execute(query)
return result.scalar_one_or_none() is not None
Conversion de timezone para display
from zoneinfo import ZoneInfo
def format_slot_for_user(slot: Slot, user_timezone: str) -> dict:
"""Convierte un slot de UTC a la timezone del usuario para display."""
tz = ZoneInfo(user_timezone)
start_local = slot.start_utc.astimezone(tz)
end_local = slot.end_utc.astimezone(tz)
return {
"id": slot.id,
"date": start_local.strftime("%Y-%m-%d"),
"start_time": start_local.strftime("%H:%M"),
"end_time": end_local.strftime("%H:%M"),
"timezone": user_timezone,
"available": slot.is_available,
}
Leccion clave
Almacenar UTC y mostrar local suena simple, pero el detalle esta en las queries: un "lunes" en CDMX es un rango de horas diferente en UTC que un "lunes" en Buenos Aires. Siempre convierte el rango del dia del usuario a UTC antes de consultar. Y para concurrency, no inventes tu propio lock -- PostgreSQL SELECT FOR UPDATE existe exactamente para esto.