Introducción al sistema de carrito de compras híbrido con Django
En este artículo te mostraré cómo implementar un sistema de carrito de compras híbrido en un proyecto Django. El objetivo es que tanto usuarios anónimos como autenticados puedan añadir productos al carrito de forma fluida y coherente.
Con este enfoque, un visitante de la web puede comenzar a añadir juegos a su carrito sin necesidad de registrarse. Si posteriormente decide iniciar sesión, los productos que había añadido como anónimo se fusionarán automáticamente con su carrito persistente, sin pérdida de información.
Además, los usuarios autenticados dispondrán de un carrito que se guarda permanentemente en la base de datos, de modo que si cierran sesión o acceden desde otro dispositivo, su carrito seguirá disponible.
Este sistema aporta una excelente experiencia de usuario y es el patrón más habitual en aplicaciones de comercio electrónico modernas.
A lo largo del artículo veremos:
- Cómo diseñar un modelo de carrito persistente con Django.
- Cómo adaptar las vistas para funcionar en modo híbrido (sesión o persistente).
- Cómo usar una señal (
user_logged_in
) para fusionar automáticamente el carrito de sesión con el persistente. - Cómo mantener la interfaz consistente con un
context_processor
que muestra el número de productos en todo momento. - Cómo preparar la aplicación para futuras extensiones (pedidos, cupones, gestión de stock…).
¡Vamos a construirlo paso a paso!
Perfecto, vamos a construir un artículo completo, en varias partes, bien explicado y con todo el código que has ido consolidando.
Los modelos Django
El primer paso para tener persistencia es definir los modelos que nos permitirán guardar los carritos e items en la base de datos.
Hemos creado un modelo Carrito
, que es un carrito asociado a un usuario (OneToOne con User
, es decir, un carrito por usuario), y un modelo ItemCarrito
, que es cada línea del carrito (producto + cantidad).
Aquí está el contenido del models.py
de la app carrito
:
from django.db import models from django.contrib.auth.models import User from juegos.models import Juego class Carrito(models.Model): usuario = models.OneToOneField(User, on_delete=models.CASCADE) creado_en = models.DateTimeField(auto_now_add=True) actualizado_en = models.DateTimeField(auto_now=True) def __str__(self): return f"Carrito de {self.usuario.username}" def total(self): return sum(item.subtotal() for item in self.items.all()) class ItemCarrito(models.Model): carrito = models.ForeignKey(Carrito, related_name='items', on_delete=models.CASCADE) juego = models.ForeignKey(Juego, on_delete=models.CASCADE) cantidad = models.PositiveIntegerField(default=1) class Meta: unique_together = ('carrito', 'juego') def subtotal(self): return self.juego.precio * self.cantidad def __str__(self): return f"{self.cantidad} x {self.juego.nombre}"
El modelo Carrito
es muy simple: contiene la relación con el usuario y un par de campos automáticos de fecha. Además, le añadimos un método total()
que nos permite calcular el total del carrito fácilmente.
El modelo ItemCarrito
relaciona un Carrito
con un Juego
y contiene un campo cantidad
que nos indica cuántas unidades de ese juego hay en el carrito.
Hemos definido una restricción unique_together
para que un mismo juego no pueda aparecer más de una vez en un mismo carrito.
El método subtotal()
es útil para calcular el subtotal de cada línea (precio del juego × cantidad).
Migraciones de los modelos Django
Una vez definidos los modelos, debemos crear las migraciones correspondientes y aplicarlas a la base de datos.
Esto se hace con los siguientes comandos de Django (debes ejecutarlos en el terminal, estando en el directorio raíz del proyecto):
python manage.py makemigrations carrito python manage.py migrate carrito
El primer comando genera las migraciones necesarias para crear las tablas Carrito
e ItemCarrito
.
El segundo comando aplica esas migraciones y crea las tablas en tu base de datos.
Con esto ya tenemos la estructura de datos preparada para soportar carritos persistentes.
Preparando el context processor de la app Django
Queremos que en toda nuestra aplicación podamos mostrar cuántos productos hay en el carrito del usuario.
Para ello, utilizamos un context_processor
, que es una función que añade datos al contexto de todas las plantillas.
El contenido de context_processors.py
en la app carrito
es el siguiente:
from .models import Carrito def carrito_total_items(request): if request.user.is_authenticated: carrito = Carrito.objects.filter(usuario=request.user).first() total_items = sum(item.cantidad for item in carrito.items.all()) if carrito else 0 else: carrito_sesion = request.session.get('carrito', {}) total_items = sum(item['cantidad'] for item in carrito_sesion.values()) return {'carrito_total_items': total_items}
La función carrito_total_items
comprueba si el usuario está autenticado.
Si lo está, recuperamos su carrito de base de datos (si existe), y sumamos las cantidades de sus items.
Si no está autenticado, usamos el carrito que tenemos guardado en sesión, exactamente como funcionaba antes.
Esto permite mostrar en la barra superior o en cualquier parte del sitio un indicador con el número total de productos en el carrito, tanto para usuarios logueados como para invitados.
Importante: no olvides registrar este context processor en tu settings.py
, en la sección TEMPLATES
, dentro de OPTIONS['context_processors']
.
Por ejemplo:
'OPTIONS': { 'context_processors': [ ... 'carrito.context_processors.carrito_total_items', ], },
Perfecto, vamos con la siguiente parte del artículo: las vistas.
Te lo voy a explicar bien, paso a paso, para que veas la lógica completa de este nuevo enfoque que combina carrito en sesión + carrito persistente.
Adaptación de las vistas de la app carrito de Django
Las vistas de la app carrito
son el núcleo funcional de nuestro sistema.
Teníamos ya una versión previa que usaba solo sesión. Ahora la hemos mejorado para que funcione así:
- Si el usuario está autenticado → usamos el carrito persistente en base de datos.
- Si el usuario no está autenticado → seguimos usando la sesión, igual que antes.
Así mantenemos compatibilidad con usuarios anónimos (visitantes), y damos una mejor experiencia a los registrados.
Veamos ahora el contenido completo y actualizado del archivo views.py
, explicado por partes.
Importaciones iniciales
Empezamos con las importaciones necesarias:
from django.shortcuts import render, redirect, get_object_or_404 from django.contrib import messages from django.contrib.auth.decorators import login_required from juegos.models import Juego from .models import Carrito, ItemCarrito
Aquí hemos añadido la importación de nuestros nuevos modelos Carrito
e ItemCarrito
, ya que los vamos a usar.
Vista para agregar al carrito
Esta vista permite al usuario añadir un juego al carrito.
Aquí es donde diferenciamos claramente si el usuario está logueado o no:
def agregar_al_carrito(request, juego_id): juego = get_object_or_404(Juego, pk=juego_id) if request.user.is_authenticated: # Carrito persistente carrito, created = Carrito.objects.get_or_create(usuario=request.user) item, created = ItemCarrito.objects.get_or_create(carrito=carrito, juego=juego) if not created: item.cantidad += 1 item.save() else: # Carrito en sesión carrito = request.session.get('carrito', {}) juego_id_str = str(juego.id) if juego_id_str in carrito: carrito[juego_id_str]['cantidad'] += 1 else: carrito[juego_id_str] = { 'nombre': juego.nombre, 'precio': float(juego.precio), 'cantidad': 1 } request.session['carrito'] = carrito request.session.modified = True messages.success(request, f"'{juego.nombre}' añadido al carrito.") return redirect('juegos.index')
Explicación:
- Si el usuario está logueado:
- obtenemos (o creamos) su carrito persistente.
- obtenemos (o creamos) un
ItemCarrito
para ese juego. - si ya existía, aumentamos su cantidad.
- Si el usuario no está logueado:
- usamos el sistema antiguo basado en sesión (
request.session
).
- usamos el sistema antiguo basado en sesión (
Resultado: mismo comportamiento que antes, pero ahora con persistencia para usuarios logueados.
Vista para mostrar el carrito
Cuando el usuario entra al carrito queremos mostrar sus productos.
Aquí también diferenciamos si es carrito persistente o en sesión:
def indice_carrito(request): juegos = [] total = 0 if request.user.is_authenticated: carrito, created = Carrito.objects.get_or_create(usuario=request.user) items = carrito.items.select_related('juego').all() for item in items: subtotal = item.subtotal() total += subtotal juegos.append({ 'id': item.juego.id, 'nombre': item.juego.nombre, 'precio': float(item.juego.precio), 'cantidad': item.cantidad, 'subtotal': subtotal, }) else: carrito = request.session.get('carrito', {}) for juego_id_str, item in carrito.items(): subtotal = item['precio'] * item['cantidad'] total += subtotal juegos.append({ 'id': juego_id_str, 'nombre': item['nombre'], 'precio': item['precio'], 'cantidad': item['cantidad'], 'subtotal': subtotal, }) datos_plantilla = { 'juegos': juegos, 'total': total, 'titulo': 'Carrito de compras' } plantilla_principal = { 'title': 'Carrito de compras' } return render(request, 'carrito/index.html', { 'datos_plantilla': datos_plantilla, 'plantilla_principal': plantilla_principal })
Explicación:
- Si el usuario está autenticado:
- cargamos su carrito persistente.
- recorremos sus items.
- Si no está autenticado:
- mostramos los datos guardados en la sesión.
De nuevo, misma experiencia para el usuario.
Vista para limpiar el carrito
Esta vista permite vaciar el carrito completo:
def limpiar_carrito(request): if request.user.is_authenticated: carrito = Carrito.objects.filter(usuario=request.user).first() if carrito: carrito.items.all().delete() messages.success(request, "El carrito ha sido vaciado.") else: messages.info(request, "El carrito ya estaba vacío.") else: if 'carrito' in request.session: del request.session['carrito'] messages.success(request, "El carrito ha sido vaciado.") else: messages.info(request, "El carrito ya estaba vacío.") return redirect('carrito:indice')
Explicación:
- Si el usuario está autenticado:
- borramos todos los items de su carrito en base de datos.
- Si no está autenticado:
- eliminamos el carrito de la sesión.
Esto unifica el comportamiento de "vaciar carrito".
Vista para eliminar un producto del carrito
Por último, la vista para eliminar un producto concreto del carrito:
def eliminar(request, juego_id): if request.user.is_authenticated: carrito = get_object_or_404(Carrito, usuario=request.user) try: item = ItemCarrito.objects.get(carrito=carrito, juego_id=juego_id) item.delete() messages.success(request, "Juego eliminado del carrito.") except ItemCarrito.DoesNotExist: messages.warning(request, "El juego no estaba en el carrito.") else: carrito = request.session.get('carrito', {}) juego_id_str = str(juego_id) if juego_id_str in carrito: del carrito[juego_id_str] request.session['carrito'] = carrito request.session.modified = True messages.success(request, "Juego eliminado del carrito.") else: messages.warning(request, "El juego no estaba en el carrito.") return redirect('carrito:indice')
Explicación:
- Si el usuario está autenticado:
- buscamos el item en su carrito persistente y lo eliminamos.
- Si no está autenticado:
- eliminamos el juego del carrito en sesión.
Una vez más, todo transparente para el usuario.
Las URLs de la app carrito
No hemos cambiado nada aquí, las rutas siguen igual:
from django.urls import path from . import views app_name = 'carrito' urlpatterns = [ path('', views.indice_carrito, name='indice'), path('agregar/<int:juego_id>/', views.agregar_al_carrito, name='agregar'), path('eliminar/<int:juego_id>/', views.eliminar, name='eliminar'), path('limpiar/', views.limpiar_carrito, name='limpiar'), ]
Es muy importante que la interfaz de usuario no haya cambiado: seguimos usando las mismas URLs, lo cual hace que el resto de tu proyecto no necesite ninguna adaptación.
Fusión de carritos al iniciar sesión en Django
Como te había contado antes, el comportamiento que queremos es el siguiente:
- Un usuario navega anónimamente y añade productos al carrito → estos se guardan en la sesión.
- El usuario decide iniciar sesión.
- Automáticamente, queremos que los productos que estaban en la sesión se añadan al carrito persistente del usuario (si ya tenía un carrito, los productos nuevos se suman, no se reemplazan).
Este es un flujo muy común en e-commerce y mejora mucho la experiencia de usuario.
¿Cómo se logra esto?
La clave está en escuchar la señal user_logged_in
.
Django emite esta señal cada vez que un usuario inicia sesión.
Nosotros podemos "escucharla" y ejecutar código cuando eso ocurre.
Para ello hemos creado un archivo llamado signals.py
dentro de la app carrito
.
Este archivo contiene el siguiente código:
from django.contrib.auth.signals import user_logged_in from django.dispatch import receiver from django.contrib import messages from .models import Carrito, ItemCarrito from juegos.models import Juego
Aquí importamos todo lo necesario:
- la señal
user_logged_in
, - nuestros modelos,
- el sistema de mensajes de Django para mostrar mensajes en la interfaz.
El receptor de la señal
El núcleo es este decorador @receiver
que conecta la señal con nuestra función:
@receiver(user_logged_in) def fusionar_carrito_sesion_con_db(sender, user, request, **kwargs): carrito_sesion = request.session.get('carrito', {}) if not carrito_sesion: return # No hay nada que fusionar carrito_db, created = Carrito.objects.get_or_create(usuario=user) for juego_id_str, item_data in carrito_sesion.items(): juego_id = int(juego_id_str) juego = Juego.objects.get(pk=juego_id) item_db, created = ItemCarrito.objects.get_or_create(carrito=carrito_db, juego=juego) if created: item_db.cantidad = item_data['cantidad'] else: item_db.cantidad += item_data['cantidad'] item_db.save() # Vaciar carrito de sesión después de fusionar del request.session['carrito'] request.session.modified = True messages.info(request, "Se han añadido los juegos del carrito de invitado a tu carrito de usuario.")
Explicación paso a paso
Ahora te explico con calma lo que hace esta función.
Primero, obtenemos el carrito en sesión con la siguiente línea:
carrito_sesion = request.session.get('carrito', {})
Aquí miramos si el usuario tenía un carrito en sesión (anónimo). Si el carrito en sesión está vacío, no hacemos nada.
A continuación, obtenemos o creamos el carrito persistente:
carrito_db, created = Carrito.objects.get_or_create(usuario=user)
Si el usuario ya tenía un carrito persistente, lo usamos. Si no, se crea uno nuevo automáticamente.
Luego, recorremos los ítems de la sesión y los fusionamos con el carrito persistente. El código que realiza esta operación es el siguiente:
for juego_id_str, item_data in carrito_sesion.items(): juego_id = int(juego_id_str) juego = Juego.objects.get(pk=juego_id) item_db, created = ItemCarrito.objects.get_or_create(carrito=carrito_db, juego=juego) if created: item_db.cantidad = item_data['cantidad'] else: item_db.cantidad += item_data['cantidad'] item_db.save()
Aquí está el corazón de la fusión. Para cada item del carrito de la sesión, obtenemos el juego correspondiente. Luego, buscamos si ese juego ya estaba en el carrito persistente. Si no estaba, lo creamos con la cantidad que tenía en la sesión. Si ya estaba, le sumamos la cantidad proveniente de la sesión. Finalmente, guardamos los cambios.
Una vez que hemos fusionado todo, limpiamos el carrito en sesión para evitar duplicados. Esto se hace así:
del request.session['carrito'] request.session.modified = True
Por último, mostramos un mensaje al usuario para que sepa que la fusión se ha realizado:
messages.info(request, "Se han añadido los juegos del carrito de invitado a tu carrito de usuario.")
Conectar la señal en apps.py
Para que este código se ejecute al arrancar Django, hemos añadido esta línea en el apps.py
:
def ready(self): import carrito.signals
Esto asegura que al arrancar Django, se importe el archivo signals.py
y nuestra función quede conectada a la señal user_logged_in
.
Sin esta línea, el sistema de fusión no funcionaría.
Comportamiento que hemos logrado
Con esta lógica ya conseguimos exactamente lo que querías:
- Si un usuario navega como anónimo y añade productos → estos se guardan en sesión.
- Al iniciar sesión, los productos de la sesión se suman a su carrito persistente.
- Si el carrito persistente ya contenía algún producto, los productos de la sesión se suman (sin perder lo que ya había).
- Después de la fusión, el carrito de la sesión se vacía para no duplicar.
Beneficios del sistema
- Experiencia consistente: el usuario nunca pierde lo que tenía en el carrito.
- Compatibilidad completa: todo el código previo (vistas, plantillas, URLs) sigue funcionando igual.
- Fácil de mantener: la lógica de la fusión está completamente separada, es fácil de testear y mejorar.
- Extensible: más adelante podrías añadir reglas más avanzadas (por ejemplo, priorizar precios promocionales, controlar stock, etc.).
Adaptación del context_processor
en Django
Recordemos: un context processor en Django es una función que inyecta variables en todas las plantillas de tu sitio web.
En este caso, se usa para mostrar en la cabecera (por ejemplo en un icono de carrito o en el menú) el total de productos que hay actualmente en el carrito del usuario.
El código es este:
from .models import Carrito def carrito_total_items(request): if request.user.is_authenticated: carrito = Carrito.objects.filter(usuario=request.user).first() total_items = sum(item.cantidad for item in carrito.items.all()) if carrito else 0 else: carrito_sesion = request.session.get('carrito', {}) total_items = sum(item['cantidad'] for item in carrito_sesion.values()) return {'carrito_total_items': total_items}
Vamos a desglosarlo con calma.
Flujo para usuarios autenticados
if request.user.is_authenticated: carrito = Carrito.objects.filter(usuario=request.user).first() total_items = sum(item.cantidad for item in carrito.items.all()) if carrito else 0
Si el usuario ha iniciado sesión:
- Buscamos su carrito persistente en la base de datos.
- Si existe → sumamos las cantidades de todos los
ItemCarrito
que tenga. - Si no existe → el total es
0
.
Este cálculo es 100 % coherente con el carrito persistente que verá en la vista /carrito/
y que se actualiza al agregar/eliminar productos.
Flujo para usuarios anónimos
else: carrito_sesion = request.session.get('carrito', {}) total_items = sum(item['cantidad'] for item in carrito_sesion.values())
Si el usuario no está autenticado (es un visitante anónimo):
- El carrito está guardado en
request.session['carrito']
. - Sumamos las cantidades de los productos allí guardados.
Resultado
return {'carrito_total_items': total_items}
Finalmente, devolvemos un diccionario con una clave carrito_total_items
que estará disponible en todas las plantillas.
Así puedes hacer algo como:
<a href="{% url 'carrito:indice' %}"> Carrito ({{ carrito_total_items }}) </a>
Esto funciona siempre:
- Si el usuario es anónimo → muestra el total del carrito en sesión.
- Si el usuario ha iniciado sesión → muestra el total del carrito persistente.
- Si el usuario inicia sesión y se hace la fusión → el contador se actualiza automáticamente gracias a la lógica que vimos en
signals.py
.
Añadir el contador del carrito en la navegación principal
Para mejorar la experiencia del usuario, es importante que el carrito sea accesible y visible en toda la aplicación. Una forma sencilla de hacerlo es añadir un icono de carrito en la barra de navegación superior con un contador de productos.
Este contador debe funcionar tanto para usuarios anónimos (carrito en sesión) como para usuarios autenticados (carrito persistente en base de datos). Aquí es donde el context_processor
que creamos previamente cobra todo su sentido.
Usar el context_processor
El archivo context_processors.py
de la app carrito
define la función carrito_total_items
, que calcula el número total de productos en el carrito. Esta función:
- Si el usuario está autenticado, cuenta los
ItemCarrito
del carrito asociado en la base de datos. - Si el usuario no ha iniciado sesión, cuenta los items guardados en la variable de sesión
request.session['carrito']
.
Esta función se carga automáticamente en el contexto de las plantillas gracias a la configuración de Django (recordemos que para que funcione hay que registrarla en TEMPLATES
→ OPTIONS
→ context_processors
en settings.py
).
Usar la variable en main.html
En el archivo main.html
, que actúa como plantilla base de todo el proyecto, encontramos este fragmento en el menú de navegación, dentro de la lista de enlaces:
<li> <a href="{% url 'carrito:indice' %}" class="nav-link px-2 position-relative {% if request.path == '/carrito/' %} text-danger fw-bold {% else %} text-body {% endif %}"> <i class="fas fa-shopping-cart"></i> Carrito {% if carrito_total_items > 0 %} <span class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger"> {{ carrito_total_items }} </span> {% endif %} </a> </li>
¿Qué hace este código?
- El enlace muestra el icono de carrito (
fas fa-shopping-cart
). - Si
carrito_total_items
es mayor que 0, muestra un badge rojo con el número de artículos actuales. - Además, el link se resalta en rojo (
text-danger fw-bold
) si el usuario está navegando en la página del carrito.
Resultado
El resultado es una navegación dinámica:
- Si un usuario navega por la tienda e introduce productos en el carrito, el contador se actualiza automáticamente.
- Si el usuario inicia sesión, el contador se sincroniza con su carrito persistente.
- Si cierra sesión, vuelve a mostrarse el carrito de la sesión anónima.
Esto hace que la experiencia de usuario sea fluida y coherente, sin perder contexto sobre el estado del carrito.
Con este paso, ya hemos cerrado todo el flujo del artículo:
- Backend con modelo persistente.
- Signals para la fusión del carrito de sesión.
- Vistas adaptadas (agregar, eliminar, limpiar, mostrar).
- Navegación de usuario con contador visible.
- Context processor que sincroniza sesión y BD en la interfaz.
Beneficio práctico
Con este context_processor
, no tienes que preocuparte de en qué "modo" está el carrito (anónimo o persistente).
La cabecera y otras partes del sitio siempre mostrarán el número real de productos en el carrito.
Notas finales y consejos
Con este sistema hemos convertido nuestra app carrito
en un componente robusto y flexible:
- Usuarios anónimos pueden usar el carrito sin necesidad de registrarse.
- Al iniciar sesión, el contenido del carrito en sesión se fusiona automáticamente con el carrito persistente en la base de datos.
- El número de productos en el carrito se muestra de forma consistente en toda la web, gracias al
context_processor
. - Toda la lógica de persistencia está encapsulada en los modelos
Carrito
eItemCarrito
, facilitando su mantenimiento.
Migraciones
Recuerda siempre crear y aplicar las migraciones cuando introduces nuevos modelos o modificas los existentes:
python manage.py makemigrations carrito python manage.py migrate
Este paso es necesario para que Django cree en la base de datos las tablas correspondientes para Carrito
e ItemCarrito
.
Consideraciones sobre integridad de datos
Gracias al uso de on_delete=models.CASCADE
, no tendrás problemas de integridad:
- Si se elimina un
User
, su carrito y sus items se eliminan automáticamente. - Si se elimina un
Juego
, también se eliminarán losItemCarrito
correspondientes.
Esto mantiene la base de datos limpia sin referencias rotas.
Posibles mejoras futuras
Si deseas evolucionar aún más este sistema, aquí tienes algunas ideas:
- Permitir al usuario modificar la cantidad de cada producto desde la vista del carrito.
- Añadir comprobación de stock disponible antes de permitir añadir más unidades.
- Implementar un sistema de pedidos que copie los items del carrito a un nuevo modelo
Pedido
y vacíe el carrito tras la compra. - Añadir un sistema de descuentos o cupones.
Estas funcionalidades se pueden ir construyendo fácilmente sobre la arquitectura actual.
Rendimiento
Actualmente, este carrito es perfectamente eficiente para un sitio de juegos como el que tienes.
Si en el futuro tu proyecto escala mucho (miles de usuarios concurrentes), podrías considerar:
- Cachear el total de items en el carrito para evitar recalcularlo en cada petición.
- Revisar que las consultas a la base de datos sean lo más ligeras posible (por ejemplo, usando
values_list
en elcontext_processor
).
Conclusión
Has conseguido implementar un sistema de carrito híbrido:
- Sencillo para el usuario final.
- Coherente en todo el flujo de navegación.
- Persistente para usuarios registrados.
- Sin pérdida de información para anónimos que luego se autentican.
Este patrón es muy utilizado en aplicaciones reales de comercio electrónico, ya que ofrece la mejor experiencia posible para el usuario: no se pierde el esfuerzo de llenar el carrito si decide autenticarse después.