#El producto y sus límites explícitos
Vamos a construir una agenda de salas. Una persona puede pertenecer a varias organizaciones; ve sus salas y reservas, crea una reserva y cancela las propias. Un gestor también puede cancelar las de otras personas de su organización. Una reserva activa ocupa una sala durante un intervalo futuro de hasta cuatro horas. Dos intervalos pueden tocarse en el límite, pero no solaparse.
Todo el proyecto se explica aquí y en las dos páginas siguientes: datos y garantías → interacción web → pruebas y operación. No necesitas descargar nada. Los ficheros mencionados son lugares donde copiar el código si quieres ejecutarlo; la explicación y los resultados permanecen completos en la web. Los bloques finales reúnen fragmentos que se han explicado antes.
No implementaremos pagos, invitaciones por correo ni un calendario arrastrable. Sí implementaremos las garantías que esas funciones deberían respetar después: aislamiento entre organizaciones, validación de entrada y exclusión concurrente. La interfaz sencilla permite ver mejor esos mecanismos.
#Fase 1 · Decidir qué debe ser siempre verdad
| Regla | Dónde se comprueba | Por qué ahí |
|---|---|---|
| La persona pertenece a la organización | View y servicio, a partir de request.user |
Un identificador en una URL o formulario no otorga autoridad |
| La sala pertenece a esa organización | QuerySet del formulario y consulta del servicio | Ocultar una opción no impide enviar su ID manualmente |
| Fin posterior a inicio | Formulario/servicio y restricción SQL | Mensaje útil antes y garantía al guardar |
| Dos reservas activas no se solapan | Consulta bajo bloqueo y restricción PostgreSQL | Dos comprobaciones simultáneas por sí solas no garantizan nada |
| Solo autor o gestor cancela | Servicio, antes del cambio | Un botón oculto no es autorización |
| El histórico no desaparece al cancelar | Campo cancelada |
Se conserva quién reservó y qué ocurrió |
No duplicamos organizacion_id en Reserva: se obtiene mediante reserva.sala.organizacion_id. Así no puede existir una reserva que diga pertenecer a A y apunte a una sala de B. Esto no evita por sí mismo leer datos de B: las consultas siguen necesitando el filtro de acceso.
#Por qué un intervalo semiabierto
Representamos una reserva como [inicio, fin): incluye el comienzo y excluye el final. De 10:00 a 11:00 y de 11:00 a 12:00 son compatibles. Dos intervalos se solapan cuando inicio_existente < fin_nuevo y fin_existente > inicio_nuevo. Usar <= en ambas condiciones bloquearía también reservas contiguas.
Una restricción única de (sala, inicio) no basta: 10:00–11:00 y 10:30–11:30 tienen comienzos distintos y se pisan. PostgreSQL tiene una restricción de exclusión que puede prohibir combinar «misma sala» con «intervalos que se solapan». Es una herramienta específica para esta necesidad; no hace falta aprender todas las extensiones de PostgreSQL.
#Fase 2 · Preparar un proyecto independiente
La práctica requiere Python, Django y PostgreSQL. PostgreSQL es un servidor: Psycopg es la biblioteca que Django utiliza para hablar con él. Docker solo nos permite arrancar una base local sin instalarla en el sistema; puedes usar una instalación equivalente ajustando host, puerto y credenciales. Si ese comando aún no te resulta claro, consulta datos persistentes en Docker.
mkdir agenda-reservas
cd agenda-reservas
python3 -m venv .venv
source .venv/bin/activate
python -m pip install 'Django>=5.2,<5.3' 'psycopg[binary]>=3.2,<4' 'gunicorn>=23,<27'
python -m django startproject config .
python manage.py startapp reservas
mkdir -p secrets
chmod 700 secrets
openssl rand -hex 24 > secrets/db_password
chmod 600 secrets/db_password
El extra [binary] instala Psycopg con sus bibliotecas precompiladas. Gunicorn será el servidor de aplicación de la última fase; por ahora no se ejecuta. OpenSSL genera un secreto aleatorio; si no lo tienes, usa un gestor de contraseñas y guarda una cadena aleatoria en ese fichero. No subas .venv, secrets, archivos de entorno ni dumps a Git.
docker run -d --name reservas-db-lab \
-e POSTGRES_USER=reservas -e POSTGRES_DB=reservas \
-e POSTGRES_PASSWORD_FILE=/run/secrets/db_password \
--mount type=bind,src="$(pwd)/secrets/db_password",dst=/run/secrets/db_password,readonly \
--mount type=volume,src=reservas-datos-lab,dst=/var/lib/postgresql/data \
-p 127.0.0.1:55439:5432 postgres:17-alpine
docker exec reservas-db-lab pg_isready -h 127.0.0.1 -U reservas -d reservas
Repite la comprobación hasta ver accepting connections. El puerto está publicado solo en la interfaz local. El usuario creado por POSTGRES_USER es administrador de esta base de laboratorio; permite instalar la extensión y crear la base temporal de pruebas. No es el rol limitado con el que operaremos una aplicación pública.
En la terminal del proyecto, define las credenciales para los comandos posteriores:
export DB_NAME=reservas
export DB_USER=reservas
export DB_PASSWORD="$(cat secrets/db_password)"
export DB_HOST=127.0.0.1
export DB_PORT=55439
Estos valores pertenecen a esta terminal; abrir otra exige volver a activarlos. La aplicación falla si falta un secreto, en vez de conectarse silenciosamente a otra base. Añade lo siguiente al final de config/settings.py, manteniendo el contenido generado:
import os
INSTALLED_APPS += ["django.contrib.postgres", "reservas"]
LANGUAGE_CODE = "es"
TIME_ZONE = "Europe/Madrid"
USE_TZ = True
ALLOWED_HOSTS = ["127.0.0.1", "localhost", "testserver"]
LOGIN_URL = "login"
LOGIN_REDIRECT_URL = "reservas:organizaciones"
LOGOUT_REDIRECT_URL = "login"
DATABASES = {"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ["DB_NAME"],
"USER": os.environ["DB_USER"],
"PASSWORD": os.environ["DB_PASSWORD"],
"HOST": os.environ.get("DB_HOST", "127.0.0.1"),
"PORT": os.environ.get("DB_PORT", "5432"),
"CONN_MAX_AGE": 0,
"OPTIONS": {"connect_timeout": 5, "options": "-c lock_timeout=5000 -c statement_timeout=15000"},
}}
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
INSTALLED_APPS += conserva sesiones, autenticación, administrador y estáticos. CONN_MAX_AGE=0 cierra la conexión al final de cada petición; es una decisión sencilla para este tamaño. Los límites de 5 segundos para conectar/bloquear y 15 para una sentencia evitan esperas indefinidas; un sistema con cargas distintas necesitará medirlos y traducir sus fallos al usuario.
#Fase 3 · Relaciones y garantía de solape
Empezamos por la relación de acceso:
# Fragmento del modelo completo que aparece debajo.
usuario = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
organizacion = models.ForeignKey(Organizacion, on_delete=models.CASCADE)
activa = models.BooleanField(default=True)
gestiona = models.BooleanField(default=False)
Membresia es una fila que afirma qué usuario pertenece a qué organización. Su restricción única evita dos políticas contradictorias para la misma pareja. settings.AUTH_USER_MODEL evita acoplar la relación a una clase de usuario concreta; usamos el usuario incorporado, pero esta referencia respeta el mecanismo de sustitución de Django.
Ahora la parte específica de PostgreSQL:
# Fragmento de Reserva.Meta.constraints.
ExclusionConstraint(
name="reservas_no_solape",
expressions=[
("sala", RangeOperators.EQUAL),
(Func(F("inicio"), F("fin"), Value("[)"),
function="TSTZRANGE", output_field=DateTimeRangeField()),
RangeOperators.OVERLAPS),
],
condition=Q(cancelada=False),
)
F referencia columnas, Value representa un valor literal y Func describe una función SQL; no construyen fechas en Python. TSTZRANGE forma un intervalo de instantes con zona a partir de las columnas. RangeOperators da nombres legibles a los operadores SQL de igualdad y solape. La condición limita la prohibición a reservas activas. Esta restricción impide conflictos incluso si un script evita nuestro servicio.
No hay que memorizar esa sintaxis para comprender el diseño: estamos trasladando una regla sobre dos filas al motor que coordina las escrituras. Una comprobación CHECK ordinaria es adecuada para fin > inicio dentro de la misma fila, pero no para comparar todas las reservas entre sí.
Modelos completos · sustituir reservas/models.py
from django.conf import settings
from django.contrib.postgres.constraints import ExclusionConstraint
from django.contrib.postgres.fields import DateTimeRangeField, RangeOperators
from django.db import models
from django.db.models import F, Func, Q, Value
class Organizacion(models.Model):
nombre = models.CharField(max_length=100)
def __str__(self):
return self.nombre
class Membresia(models.Model):
usuario = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
organizacion = models.ForeignKey(Organizacion, on_delete=models.CASCADE)
activa = models.BooleanField(default=True)
gestiona = models.BooleanField(default=False)
class Meta:
constraints = [models.UniqueConstraint(
fields=["usuario", "organizacion"], name="membresia_unica"
)]
class Sala(models.Model):
organizacion = models.ForeignKey(Organizacion, on_delete=models.PROTECT)
nombre = models.CharField(max_length=80)
activa = models.BooleanField(default=True)
class Meta:
constraints = [models.UniqueConstraint(
fields=["organizacion", "nombre"], name="sala_nombre_por_org"
)]
def __str__(self):
return self.nombre
class Reserva(models.Model):
sala = models.ForeignKey(Sala, on_delete=models.PROTECT)
creada_por = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
inicio = models.DateTimeField()
fin = models.DateTimeField()
motivo = models.CharField(max_length=120)
cancelada = models.BooleanField(default=False)
creada = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["inicio", "pk"]
constraints = [
models.CheckConstraint(condition=Q(fin__gt=F("inicio")), name="reserva_intervalo_valido"),
ExclusionConstraint(
name="reservas_no_solape",
expressions=[
("sala", RangeOperators.EQUAL),
(Func(F("inicio"), F("fin"), Value("[)"),
function="TSTZRANGE", output_field=DateTimeRangeField()),
RangeOperators.OVERLAPS),
],
condition=Q(cancelada=False),
),
]
indexes = [models.Index(fields=["sala", "inicio"])]
PROTECT conserva referencias históricas: no se puede borrar alegremente una sala o un usuario con reservas. La cuenta se puede desactivar. La política de retención y anonimización de un producto real dependerá de sus requisitos; no la resolvemos borrando en cascada todo el historial.
#Fase 4 · Preparar la extensión antes de crear la restricción
La extensión btree_gist proporciona a PostgreSQL los operadores de índice necesarios para combinar igualdad de sala y solape de intervalos. No es un servicio nuevo ni una dependencia Python. Genera la migración:
python manage.py makemigrations reservas
Abre el fichero generado reservas/migrations/0001_initial.py. Añade este import al principio:
from django.contrib.postgres.operations import BtreeGistExtension
Y añade BtreeGistExtension(), como primera operación de la lista operations, conservando todas las operaciones generadas después. No reemplaces la migración por esta muestra parcial:
operations = [
BtreeGistExtension(),
# Aquí siguen todas las operaciones CreateModel/AddConstraint generadas.
]
Después:
python manage.py migrate
python manage.py check
python manage.py makemigrations --check --dry-run
Esperas todas las migraciones aplicadas, ninguna incidencia de sistema y ningún cambio de modelo pendiente. Crear la extensión requiere permisos suficientes; el rol de migración de producción puede prepararla antes, sin conceder esos permisos al rol que atiende peticiones.
#Fase 5 · Sembrar datos que hagan visible el aislamiento
Registra solo organizaciones, membresías y salas en el administrador:
from django.contrib import admin
from .models import Membresia, Organizacion, Sala
admin.site.register([Organizacion, Membresia, Sala])
No registramos reservas editables en el administrador: las operaciones de reserva deben pasar por el servicio que añadiremos. La restricción SQL protege solapes, pero por sí sola no aplica duración máxima ni permisos de negocio. El administrador es global y se reserva a operadores de confianza; un «gestor» de organización no recibe is_staff.
Ejecuta python manage.py createsuperuser para la cuenta operadora. Abre python manage.py shell para crear dos personas, dos organizaciones y una sala en cada una:
from django.contrib.auth import get_user_model
from reservas.models import Membresia, Organizacion, Sala
User = get_user_model()
ana = User.objects.create_user("ana")
bruno = User.objects.create_user("bruno")
norte = Organizacion.objects.create(nombre="Estudio Norte")
sur = Organizacion.objects.create(nombre="Estudio Sur")
Membresia.objects.create(usuario=ana, organizacion=norte, gestiona=True)
Membresia.objects.create(usuario=bruno, organizacion=sur)
Sala.objects.create(organizacion=norte, nombre="Taller")
Sala.objects.create(organizacion=sur, nombre="Reuniones")
print(norte.pk, sur.pk)
Sin contraseña proporcionada, esas cuentas no pueden iniciar sesión todavía. Sal de la shell y asigna claves con los comandos interactivos python manage.py changepassword ana y python manage.py changepassword bruno, para no dejarlas escritas en el historial. No repitas la creación a ciegas: si ya existen datos, consulta o utiliza el administrador.
Ahora existe una estructura con garantías comprobables, pero aún no hay una web de reservas. La siguiente página explica cada decisión desde el formulario hasta la transacción, y reúne la interfaz completa.