Saltar al contenido

Capítulo 13 de 14

Proyecto · del formulario a la transacción

Construir pantallas de reservas con permisos comprobados en el servidor, errores conservando entrada y protección frente a carreras.

#Qué vamos a conectar

Continúa en la carpeta y base de la primera parte. Ya existen usuarios, organizaciones, membresías y salas. Ahora añadimos la operación de reserva y sus pantallas. El camino de una petición será explícito: sesión → membresía → formulario → servicio → transacción → redirección.

Dos solicitudes compiten: cada una valida acceso, la primera bloquea la sala y confirma; la segunda espera, vuelve a consultar y responde conflicto. La restricción de exclusión protege también escrituras fuera del servicio.Ampliar diagrama ↗

#Fase 6 · Validar y autorizar en un servicio

El servicio será una función Python normal en reservas/services.py. Lo separamos de la view porque puede llamarlo mañana un comando o una tarea. Su interfaz pide usuario desde la sesión verificada; nunca lo obtiene de un campo editable. organizacion_id es una selección que hay que comprobar, no una credencial.

La parte central es esta:

# Fragmento: la implementación completa aparece debajo.
with transaction.atomic():
    membresia_bloqueada(usuario, organizacion_id)
    sala = get_object_or_404(
        Sala.objects.select_for_update(),
        pk=sala_id, organizacion_id=organizacion_id, activa=True,
    )
    ocupada = Reserva.objects.filter(
        sala=sala, cancelada=False, inicio__lt=fin, fin__gt=inicio,
    ).exists()

Bloqueamos la fila de membresía para que una revocación concurrente se ordene respecto a la operación; bloqueamos después la sala, que existe aunque no haya reservas. Si bloqueásemos solo las reservas encontradas, una sala vacía no proporcionaría ninguna fila que bloquear. Cuando otra petición obtiene el bloqueo tras esperar, su nueva consulta ve las reservas confirmadas antes de decidir.

La transacción es corta: no incluye renderizado, correo ni llamadas de red. Dos usuarios de salas distintas pueden progresar independientemente. El bloqueo de membresía serializa también operaciones de un mismo usuario en esa organización; es un coste sencillo y deliberado de esta política de revocación. Si fuese un cuello de botella, habría que revisar la semántica de revocación y medir, no eliminarlo sin comprenderla.

La restricción de base de datos sigue siendo necesaria: un script que inserte fuera del servicio no toma el bloqueo de sala. Si PostgreSQL detecta el solape, traducimos únicamente la restricción conocida a ConflictoReserva. Otros IntegrityError siguen siendo fallos reales para investigar.

Servicio completo · crear reservas/services.py
from datetime import UTC, timedelta
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import IntegrityError, transaction
from django.shortcuts import get_object_or_404
from django.utils import timezone
from .models import Membresia, Reserva, Sala


class ConflictoReserva(ValidationError):
    pass


def validar_periodo(inicio, fin):
    if timezone.is_naive(inicio) or timezone.is_naive(fin):
        raise ValidationError("Las fechas necesitan zona horaria.")
    if inicio <= timezone.now():
        raise ValidationError("La reserva debe empezar en el futuro.")
    duracion = fin.astimezone(UTC) - inicio.astimezone(UTC)
    if not timedelta(0) < duracion <= timedelta(hours=4):
        raise ValidationError("La duración debe ser mayor que cero y como máximo cuatro horas.")


def membresia_bloqueada(usuario, organizacion_id):
    miembro = get_object_or_404(
        Membresia.objects.select_for_update(),
        usuario=usuario, organizacion_id=organizacion_id,
    )
    if not miembro.activa:
        raise PermissionDenied("La membresía ya no está activa.")
    return miembro


def reservar(*, usuario, organizacion_id, sala_id, inicio, fin, motivo):
    validar_periodo(inicio, fin)
    motivo = motivo.strip()
    if not 1 <= len(motivo) <= 120:
        raise ValidationError("El motivo debe tener entre 1 y 120 caracteres.")
    try:
        with transaction.atomic():
            membresia_bloqueada(usuario, organizacion_id)
            sala = get_object_or_404(
                Sala.objects.select_for_update(),
                pk=sala_id, organizacion_id=organizacion_id, activa=True,
            )
            ocupada = Reserva.objects.filter(
                sala=sala, cancelada=False, inicio__lt=fin, fin__gt=inicio,
            ).exists()
            if ocupada:
                raise ConflictoReserva("La sala ya está reservada en ese intervalo.")
            return Reserva.objects.create(
                sala=sala, creada_por=usuario, inicio=inicio, fin=fin, motivo=motivo,
            )
    except IntegrityError as error:
        diagnostico = getattr(error.__cause__, "diag", None)
        if getattr(diagnostico, "constraint_name", None) == "reservas_no_solape":
            raise ConflictoReserva("La sala ya está reservada en ese intervalo.") from error
        raise


def cancelar(*, usuario, organizacion_id, reserva_id):
    with transaction.atomic():
        miembro = membresia_bloqueada(usuario, organizacion_id)
        reserva = get_object_or_404(
            Reserva.objects.select_for_update(of=("self",)),
            pk=reserva_id, sala__organizacion_id=organizacion_id,
        )
        if reserva.creada_por_id != usuario.pk and not miembro.gestiona:
            raise PermissionDenied("Solo su autor o un gestor puede cancelarla.")
        if not reserva.cancelada:
            reserva.cancelada = True
            reserva.save(update_fields=["cancelada"])
    return reserva

El except IntegrityError está fuera de with transaction.atomic(): primero se revierte la transacción fallida y después se interpreta el error. error.__cause__.diag.constraint_name es el diagnóstico que Psycopg proporciona desde PostgreSQL; no comparamos cadenas traducibles del mensaje. Cancelar marca un estado y repetir la cancelación mantiene ese estado: no alternamos un booleano con cada clic.

select_for_update(of=("self",)) en cancelación bloquea la reserva seleccionada, aunque el filtro atraviese la relación con sala. El servicio comprueba el autor o rol gestor después de resolver la reserva dentro de la organización permitida. El identificador global de una reserva ajena no proporciona un atajo.

#Fase 7 · El formulario convierte texto en valores válidos

Un formulario HTML envía texto. ModelChoiceField convierte el identificador de sala en una instancia solo si aparece en su QuerySet permitido. No hacemos Sala.objects.get(pk=request.POST["sala"]): eso abriría acceso a cualquier sala conocida por identificador.

# Fragmento del constructor del formulario.
self.fields["sala"].queryset = Sala.objects.filter(
    organizacion=organizacion, activa=True,
).order_by("nombre", "pk")

*args y **kwargs recogen argumentos posicionales y con nombre para pasarlos al constructor de forms.Form. organizacion es un argumento adicional obligatorio que proporciona nuestra view tras comprobar la membresía. super().__init__ inicializa primero la maquinaria del formulario antes de configurar sus campos.

El control HTML datetime-local no contiene una zona horaria. Hemos decidido mostrar y recibir horas de Madrid; con USE_TZ=True, Django interpreta esas entradas en la zona activa y devuelve fechas conscientes de zona. Las horas ambiguas o inexistentes durante cambios de horario se rechazan en la validación: no inventamos silenciosamente qué instante quiso reservar la persona. Para el máximo de cuatro horas medimos duración real convirtiendo ambos extremos a UTC antes de restar: durante el cambio de octubre, de 01:00 a 05:00 en Madrid transcurren cinco horas reales, aunque el reloj local avance cuatro.

Formulario completo · crear reservas/forms.py
from django import forms
from .models import Sala
from .services import validar_periodo


class ReservaForm(forms.Form):
    sala = forms.ModelChoiceField(queryset=Sala.objects.none())
    inicio = forms.DateTimeField(
        input_formats=["%Y-%m-%dT%H:%M"],
        widget=forms.DateTimeInput(format="%Y-%m-%dT%H:%M", attrs={"type": "datetime-local"}),
    )
    fin = forms.DateTimeField(
        input_formats=["%Y-%m-%dT%H:%M"],
        widget=forms.DateTimeInput(format="%Y-%m-%dT%H:%M", attrs={"type": "datetime-local"}),
    )
    motivo = forms.CharField(max_length=120)

    def __init__(self, *args, organizacion, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields["sala"].queryset = Sala.objects.filter(
            organizacion=organizacion, activa=True,
        ).order_by("nombre", "pk")

    def clean(self):
        datos = super().clean()
        if datos.get("inicio") and datos.get("fin"):
            validar_periodo(datos["inicio"], datos["fin"])
        return datos

clean() solo compara las dos fechas cuando ambas superaron su validación individual. Reutiliza una regla pura del servicio; el servicio vuelve a comprobarla porque no todas sus llamadas vendrán de este formulario. La comprobación de solape permanece dentro de la transacción, no en clean(), donde una consulta podría quedar obsoleta antes de guardar.

#Fase 8 · Traducir resultados a navegación

La view obtiene la membresía con usuario=request.user. Una organización fuera del conjunto visible responde 404; no revela su nombre ni sus salas. El formulario recibe datos solo cuando el método es POST:

form = ReservaForm(
    request.POST if request.method == "POST" else None,
    organizacion=organizacion,
)

Evita request.POST or None cuando importa distinguir un POST vacío de un formulario nuevo: ambos son falsos en Python, pero representan situaciones distintas. Un formulario inválido se vuelve a dibujar con la misma entrada y errores; uno válido que confirma la reserva redirige al panel. Recargar entonces hace GET, no repite el POST.

Views completas · sustituir reservas/views.py
from django.contrib.auth.decorators import login_required
from django.core.exceptions import ValidationError
from django.core.paginator import Paginator
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from django.views.decorators.http import require_GET, require_http_methods, require_POST
from .forms import ReservaForm
from .models import Membresia, Reserva
from .services import cancelar, reservar


@login_required
@require_GET
def organizaciones(request):
    miembros = Membresia.objects.filter(usuario=request.user, activa=True).select_related("organizacion")
    return render(request, "reservas/organizaciones.html", {"miembros": miembros})


@login_required
@require_http_methods(["GET", "POST"])
def panel(request, organizacion_id):
    miembro = get_object_or_404(
        Membresia.objects.select_related("organizacion"),
        usuario=request.user, organizacion_id=organizacion_id, activa=True,
    )
    organizacion = miembro.organizacion
    form = ReservaForm(
        request.POST if request.method == "POST" else None,
        organizacion=organizacion,
    )
    if request.method == "POST" and form.is_valid():
        datos = form.cleaned_data
        try:
            reservar(
                usuario=request.user, organizacion_id=organizacion.pk,
                sala_id=datos["sala"].pk, inicio=datos["inicio"], fin=datos["fin"],
                motivo=datos["motivo"],
            )
        except ValidationError as error:
            form.add_error(None, error)
        else:
            return redirect("reservas:panel", organizacion_id=organizacion.pk)
    consulta = Reserva.objects.filter(
        sala__organizacion=organizacion, cancelada=False, fin__gte=timezone.now(),
    ).select_related("sala", "creada_por").order_by("inicio", "pk")
    pagina = Paginator(consulta, 20).get_page(request.GET.get("pagina"))
    return render(request, "reservas/panel.html", {
        "organizacion": organizacion, "miembro": miembro, "form": form, "pagina": pagina,
    }, status=400 if request.method == "POST" else 200)


@login_required
@require_POST
def cancelar_reserva(request, organizacion_id, reserva_id):
    cancelar(usuario=request.user, organizacion_id=organizacion_id, reserva_id=reserva_id)
    return redirect("reservas:panel", organizacion_id=organizacion_id)

La lista solo consulta reservas de esa organización y carga sala/autor mediante select_related. La paginación limita los resultados a 20. En el formulario HTML devolvemos 400 junto a los errores; un cliente JSON podría utilizar un contrato con 409 para conflicto de disponibilidad. La distinción de formato se decide en la view, mientras el servicio conserva su excepción de negocio.

Crea las rutas de aplicación:

from django.urls import path
from . import views

app_name = "reservas"
urlpatterns = [
    path("", views.organizaciones, name="organizaciones"),
    path("organizaciones/<int:organizacion_id>/", views.panel, name="panel"),
    path("organizaciones/<int:organizacion_id>/reservas/<int:reserva_id>/cancelar/",
         views.cancelar_reserva, name="cancelar"),
]

Y sustituye config/urls.py para conectar login, logout y aplicación:

from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.urls import include, path

urlpatterns = [
    path("admin/", admin.site.urls),
    path("cuentas/login/", auth_views.LoginView.as_view(), name="login"),
    path("cuentas/logout/", auth_views.LogoutView.as_view(), name="logout"),
    path("", include("reservas.urls")),
]

LoginView comprueba credenciales y crea la sesión usando las piezas de Django. LogoutView requiere POST; el botón de salir tendrá token CSRF. No habilitamos registro libre ni administración por organización en esta versión: el operador crea cuentas y membresías desde el administrador.

#Fase 9 · Una interfaz que explica el estado

Las plantillas muestran contenido, enlaces y errores; no deciden qué consultas están autorizadas. El template puede ocultar un botón de cancelación para que la interfaz sea clara, pero la protección real ya está en el servicio.

El patrón que queremos repetir es pequeño:

<form method="post">
  {% csrf_token %}
  {{ form.as_p }}
  <button type="submit">Reservar</button>
</form>

form.as_p produce etiquetas, campos y mensajes; conserva los valores recibidos si hubo un error. Las cadenas del usuario se escapan al mostrarlas. No uses safe para que un motivo con HTML «se vea bonito»: un motivo es texto, no código autorizado.

Crea las carpetas reservas/templates/registration, reservas/templates/reservas y reservas/static/reservas. Los siguientes bloques son los ficheros completos. La plantilla base concentra cabecera y estilos, el login utiliza el formulario de Django y el panel conserva juntas la acción y su resultado.

Estructura compartida · reservas/templates/base.html
{% load static %}
<!doctype html>
<html lang="es">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{% block titulo %}Reservas{% endblock %}</title>
  <link rel="stylesheet" href="{% static 'reservas/app.css' %}">
</head>
<body>
  <header>
    <a href="{% url 'reservas:organizaciones' %}">Mis organizaciones</a>
    {% if user.is_authenticated %}
      <span>{{ user.username }}</span>
      <form method="post" action="{% url 'logout' %}">
        {% csrf_token %}<button type="submit">Salir</button>
      </form>
    {% endif %}
  </header>
  <main>{% block contenido %}{% endblock %}</main>
</body>
</html>
Acceso · reservas/templates/registration/login.html
{% extends 'base.html' %}
{% block titulo %}Entrar · Reservas{% endblock %}
{% block contenido %}
<h1>Entrar</h1>
<form method="post">
  {% csrf_token %}
  {{ form.as_p }}
  <input type="hidden" name="next" value="{{ next }}">
  <button type="submit">Entrar</button>
</form>
{% endblock %}
Organizaciones permitidas · reservas/templates/reservas/organizaciones.html
{% extends 'base.html' %}
{% block contenido %}
<h1>Mis organizaciones</h1>
<ul>
  {% for miembro in miembros %}
    <li><a href="{% url 'reservas:panel' miembro.organizacion_id %}">{{ miembro.organizacion.nombre }}</a></li>
  {% empty %}
    <li>No tienes ninguna membresía activa. Contacta con quien administra tu organización.</li>
  {% endfor %}
</ul>
{% endblock %}
Formulario y agenda · reservas/templates/reservas/panel.html
{% extends 'base.html' %}
{% block titulo %}{{ organizacion.nombre }} · Reservas{% endblock %}
{% block contenido %}
<h1>{{ organizacion.nombre }}</h1>
<section aria-labelledby="nueva">
  <h2 id="nueva">Nueva reserva</h2>
  <p>Introduce las horas de Madrid. Duración máxima: cuatro horas.</p>
  <form method="post" action="{% url 'reservas:panel' organizacion.pk %}">
    {% csrf_token %}
    {{ form.as_p }}
    <button type="submit">Reservar</button>
  </form>
</section>
<section aria-labelledby="proximas">
  <h2 id="proximas">Próximas reservas</h2>
  <ul>
    {% for reserva in pagina %}
      <li>
        <strong>{{ reserva.sala.nombre }}</strong> · {{ reserva.inicio|date:'d/m/Y H:i' }}
        → {{ reserva.fin|date:'H:i' }} · {{ reserva.motivo }} · {{ reserva.creada_por.username }}
        {% if reserva.creada_por_id == user.pk or miembro.gestiona %}
          <form method="post" action="{% url 'reservas:cancelar' organizacion.pk reserva.pk %}">
            {% csrf_token %}<button type="submit">Cancelar</button>
          </form>
        {% endif %}
      </li>
    {% empty %}
      <li>No hay reservas activas próximas.</li>
    {% endfor %}
  </ul>
  <nav aria-label="Páginas de reservas">
    {% if pagina.has_previous %}<a href="?pagina={{ pagina.previous_page_number }}">Anterior</a>{% endif %}
    <span>Página {{ pagina.number }} de {{ pagina.paginator.num_pages }}</span>
    {% if pagina.has_next %}<a href="?pagina={{ pagina.next_page_number }}">Siguiente</a>{% endif %}
  </nav>
</section>
{% endblock %}
Estilo mínimo · reservas/static/reservas/app.css
body { max-width: 64rem; margin: auto; padding: 1.5rem; font: 1rem/1.6 system-ui, sans-serif; color: #183128; background: #fafcfb; }
header { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; border-bottom: 1px solid #b9cec3; padding-bottom: 1rem; }
main { max-width: 48rem; }
section { margin: 2rem 0; }
label { display: block; font-weight: 600; }
input, select, button { font: inherit; max-width: 100%; padding: .45rem; }
button { cursor: pointer; }
li { margin: 1rem 0; }
a { color: #125e45; }
.errorlist { color: #8f1c21; }

El action del formulario de reserva apunta a la ruta limpia del panel. Así, si estabas leyendo la página 4 de resultados, crear no conserva accidentalmente esa paginación y ocultando la nueva reserva. El servidor sigue siendo la fuente del estado: no hay JavaScript que deba reconciliar una copia de disponibilidad.

#Fase 10 · Recorrer una historia completa

Ejecuta python manage.py runserver 127.0.0.1:8000 y abre http://127.0.0.1:8000/. Debes llegar al login. Entra como Ana: solo aparece Estudio Norte. Selecciona una sala y reserva para mañana de 10:00 a 11:00, usando las horas que indica el formulario. Tras guardar vuelves al panel y aparece una sola fila.

Intenta crear 10:30–11:30: verás el conflicto y conservarás los datos escritos. Prueba 11:00–12:00: es compatible. Cancela la primera y vuelve a reservar 10:00–11:00: ahora debe aceptarse. Cerrar sesión utiliza POST; la sesión deja de autorizar las páginas privadas.

Entra como Bruno y observa Estudio Sur. Cambia manualmente el identificador de organización de la URL al de Norte: debe devolver 404. Eso comprueba el camino de lectura; aún falta demostrar POST manipulado y concurrencia con conexiones independientes. La última página los convierte en pruebas reproducibles y prepara la operación fuera de runserver.

    ↑ ↓ para recorrer · Intro para abrir · Ctrl K para buscar