¿Qué vamos a construir?
Vamos a crear un sistema de reseñas en el que los usuarios registrados podrán:
- Escribir una opinión (texto) sobre un juego.
- Asignar una calificación numérica (por ejemplo, de 1 a 5 estrellas).
- Ver su reseña (si ya la hicieron) y editarla o eliminarla.
- Ver todas las reseñas de otros usuarios para ese juego.
- Ver la calificación promedio del juego.
Esto no solo enriquece la funcionalidad del catálogo de juegos, sino que te enseña conceptos fundamentales de Django como: relaciones entre modelos, vistas protegidas, formularios personalizados y plantillas dinámicas.
En el proyecto estoy trabajando con Bootstrap y Font Awesome para los iconos.
Organización del código
Modelo implicado (Reseña)
Aunque no se ha incluido el modelo Reseña
en el fragmento que compartiste, asumimos que existe un modelo similar a este:
# modelos.py from django.db import models from django.contrib.auth.models import User class Reseña(models.Model): juego = models.ForeignKey('Juego', related_name='reseñas', on_delete=models.CASCADE) usuario = models.ForeignKey(User, on_delete=models.CASCADE) texto = models.TextField() calificacion = models.PositiveSmallIntegerField() # Entre 1 y 5 fecha = models.DateTimeField(auto_now_add=True) def __str__(self): return f"{self.usuario.username} - {self.juego.nombre} ({self.calificacion})"
Esto establece una relación muchos-a-uno entre Reseña
y los modelos Juego
y User
. Cada reseña pertenece a un juego y a un usuario.
Vista para mostrar el detalle del juego con reseñas
En la función mostrar(request, id)
dentro de views.py
, se realiza lo siguiente relacionado con reseñas:
reseña_usuario = None if request.user.is_authenticated: reseña_usuario = juego.reseñas.filter(usuario=request.user).first()
Con esto, buscamos si el usuario ya hizo una reseña. Así evitamos que una persona reseñe el mismo juego varias veces.
reseñas = juego.reseñas.all() total_reseñas = reseñas.count() if total_reseñas > 0: suma_calificaciones = sum(r.calificacion for r in reseñas) promedio_calificacion = suma_calificaciones / total_reseñas else: promedio_calificacion = 0
Aquí calculamos la calificación promedio del juego, sumando todas las calificaciones de las reseñas y dividiendo por el total.
Toda esta información se manda a la plantilla:
datos_plantilla = { 'juego': juego, 'reseña_usuario': reseña_usuario, 'titulo': juego.nombre, 'estrellas': list(range(1, 6)), 'total_reseñas': total_reseñas, 'promedio_calificacion': promedio_calificacion, }
Crear una nueva reseña
Solo usuarios autenticados pueden dejar una reseña:
@login_required def crear_reseña(request, juego_id): juego = get_object_or_404(Juego, pk=juego_id) if request.method == 'POST': texto = request.POST.get('texto', '').strip() calificacion = request.POST.get('calificacion') if texto and calificacion: Reseña.objects.create( juego=juego, usuario=request.user, texto=texto, calificacion=calificacion ) return redirect('juegos.mostrar', id=juego.id)
Claves didácticas:
- Validamos que el
texto
y lacalificación
existan. - Usamos
@login_required
para proteger la vista. - Creamos la reseña directamente con
Reseña.objects.create(...)
.
Editar reseñas
@login_required def editar_reseña(request, juego_id, reseña_id): reseña = get_object_or_404(Reseña, pk=reseña_id, juego_id=juego_id) if reseña.usuario != request.user: return HttpResponseForbidden("No puedes editar esta reseña.")
Aquí garantizamos que solo el autor pueda modificar su reseña. Esta es una medida básica de seguridad, pero muy importante.
Si el método es POST
, actualizamos los campos y redirigimos al detalle del juego.
Eliminar reseñas
@login_required def eliminar_reseña(request, juego_id, reseña_id): reseña = get_object_or_404(Reseña, pk=reseña_id, juego_id=juego_id) if reseña.usuario != request.user: return HttpResponseForbidden("No puedes eliminar esta reseña.") reseña.delete() return redirect('juegos.mostrar', id=juego_id)
De nuevo, la seguridad se basa en comparar reseña.usuario
con request.user
.
Puntos de aprendizaje
- Cómo trabajar con relaciones entre modelos (
ForeignKey
). - Cómo proteger vistas con
@login_required
. - Cómo usar
get_object_or_404
para recuperar datos seguros. - Cómo validar el dueño de un recurso (
reseña.usuario == request.user
). - Cómo calcular promedios dinámicamente desde el backend.
- Cómo organizar el flujo de comentarios por juego y por usuario.
Configurando las URLs para las reseñas
Ahora que ya tenemos las vistas creadas para mostrar, crear, editar y eliminar reseñas, necesitamos asegurarnos de que Django sepa a qué función debe dirigir cada una de esas acciones. Esto se logra desde el archivo urls.py
dentro de la app juegos
.
Empecemos por definir las rutas principales que ya existían: una para mostrar el catálogo completo y otra para mostrar el detalle de un juego individual. Luego añadiremos las rutas nuevas específicamente diseñadas para gestionar reseñas.
En total, vamos a registrar cinco rutas:
- Una para la página principal del catálogo.
- Otra para mostrar los detalles de un juego individual.
- Una para permitir al usuario crear una reseña.
- Otra para permitirle editarla si ya existe.
- Y finalmente, una para eliminarla si así lo desea.
El bloque de rutas quedaría así:
from django.urls import path from . import views urlpatterns = [ path('', views.index, name='juegos.index'), path('<int:id>/', views.mostrar, name='juegos.mostrar'), # Rutas para las reseñas path('<int:juego_id>/resenas/crear/', views.crear_resena, name='resenas.crear'), path('<int:juego_id>/resenas/<int:resena_id>/editar/', views.editar_resena, name='resenas.editar'), path('<int:juego_id>/resenas/<int:resena_id>/eliminar/', views.eliminar_resena, name='resenas.eliminar'), ]
Las tres rutas adicionales relacionadas con las reseñas están organizadas siguiendo una convención lógica: todas parten desde el ID del juego (<int:juego_id>
), seguido por el nombre de la acción (crear
, editar
, eliminar
) y, en los casos de edición o eliminación, el ID específico de la reseña (<int:resena_id>
).
Esta estructura no solo es clara y fácil de mantener, sino que también nos ayuda a mantener el control sobre qué reseña pertenece a qué juego, lo cual es muy útil a la hora de hacer validaciones en las vistas (como asegurar que el usuario no modifique una reseña ajena).
Además, cada ruta tiene un nombre (name='...'
) que nos permitirá referenciarla fácilmente desde plantillas o redirecciones, sin necesidad de escribir las URLs manualmente. Por ejemplo, podemos usar {% url 'resenas.editar' juego.id reseña.id %}
directamente en el HTML, lo que mejora la mantenibilidad del código.
Con estas rutas definidas, Django ahora tiene todo lo necesario para vincular las acciones del usuario con las vistas correspondientes. En la siguiente parte, trabajaremos en cómo representar visualmente este sistema: los formularios, los botones de estrellas y el diseño general en las plantillas HTML.
El modelo de reseñas (models.py
)
Ya están definidos los modelos para Juego
, Género
y Plataforma
de capítulos anteriores. Estos estructuran el catálogo de videojuegos. Ahora vamos a enfocarnos exclusivamente en el modelo Resena
, que es donde vive la lógica de los comentarios y puntuaciones de los usuarios.
La clase Resena
class Resena(models.Model): juego = models.ForeignKey(Juego, on_delete=models.CASCADE, related_name='resenas') usuario = models.ForeignKey(User, on_delete=models.CASCADE) texto = models.TextField() calificacion = models.IntegerField(choices=[(i, str(i)) for i in range(1, 6)]) creado_en = models.DateTimeField(auto_now_add=True) actualizado_en = models.DateTimeField(auto_now=True)
Esta clase representa una reseña que un usuario deja sobre un juego. Vamos a desglosar cada campo:
juego
: relación de tipo ForeignKey con el modeloJuego
. Esto permite saber a qué juego pertenece la reseña.
Usamoson_delete=models.CASCADE
para que, si se elimina el juego, también se eliminen todas sus reseñas automáticamente.
Elrelated_name='resenas'
nos permitirá hacer cosas comojuego.resenas.all()
desde el modeloJuego
.usuario
: otro ForeignKey, esta vez al modeloUser
de Django. Nos dice quién escribió la reseña.
También usamoson_delete=models.CASCADE
, para que si el usuario se elimina, desaparezcan sus reseñas.texto
: el contenido libre que escribe el usuario.calificacion
: es un campo entero limitado a opciones entre 1 y 5 gracias al argumentochoices
. Esto facilita la integración de botones de estrella en la interfaz, además de prevenir calificaciones inválidas.creado_en
yactualizado_en
: campos automáticos que guardan la fecha de creación y la última edición de la reseña, respectivamente. Son útiles para mostrar metainformación, como “reseñado el 2 de mayo” o “actualizado hace 3 días”.
La clase interna Meta
class Meta: constraints = [ models.UniqueConstraint(fields=['juego', 'usuario'], name='unique_review_per_user_per_game') ]
Este bloque Meta
sirve para agregar configuraciones extra al modelo. En este caso, definimos una restricción de unicidad:
Un mismo usuario no puede dejar más de una reseña para el mismo juego.
Esto es crucial para el diseño del sistema, ya que evita que alguien deje múltiples reseñas (positivas o negativas) sobre el mismo título, lo que podría distorsionar el promedio de calificaciones.
El nombre que le damos a la restricción (unique_review_per_user_per_game
) es opcional, pero buena práctica, ya que facilita identificar el problema si esta regla se rompe (por ejemplo, en un error de formulario).
Método __str__
Por último, se define un método especial que indica cómo se verá la reseña al imprimirla o visualizarla en el panel de administración:
def __str__(self): return f"{self.usuario.username} - {self.juego.nombre} ({self.calificacion}⭐)"
Esto mejora la legibilidad cuando se trabaja desde la consola de Django o el admin, mostrando algo como:
maria89 - Hollow Knight (5⭐)
Plantillas para mostrar y editar reseñas
mostrar.html
: Detalles del juego y gestión de reseñas
Esta plantilla se encarga de presentar la información detallada de un juego, incluyendo su descripción, plataformas, géneros y, en la parte inferior, todas las reseñas de los usuarios.
{% extends 'main.html' %} {% load static %} {% block content %} <div class="container py-5"> <div class="row g-4 align-items-start"> <!-- Imagen del juego --> <div class="col-md-5 text-center"> <img src="{% static 'img/portadas/' %}{{ datos_plantilla.juego.id }}.webp" class="img-fluid rounded shadow-sm" alt="Imagen del juego {{ datos_plantilla.juego.nombre }}" /> </div> <!-- Detalles del juego --> <div class="col-md-7"> <h1 class="display-5 mb-3">{{ datos_plantilla.juego.nombre }}</h1> <p class="lead">{{ datos_plantilla.juego.descripcion }}</p> <hr> <!-- Precio destacado --> <div class="mb-4"> <h3 class="text-success fw-bold">${{ datos_plantilla.juego.precio }}</h3> <small class="text-muted">Impuestos incluidos</small> </div> <!-- Géneros --> <p><strong>Género:</strong></p> <div class="mb-3"> {% for genero in datos_plantilla.juego.generos.all %} <span class="badge bg-primary me-1">{{ genero.nombre }}</span> {% endfor %} </div> <!-- Plataformas --> <p><strong>Disponible en:</strong></p> <div class="mb-4"> {% for plataforma in datos_plantilla.juego.plataformas.all %} <span class="badge bg-dark me-1">{{ plataforma.nombre }}</span> {% endfor %} </div> <!-- Botones de acción --> <div class="d-flex flex-column flex-sm-row gap-2"> <button class="btn btn-success btn-md d-flex align-items-center"> <i class="fas fa-shopping-cart me-2"></i>Comprar ahora </button> <button class="btn btn-outline-danger btn-md d-flex align-items-center"> <i class="fas fa-heart me-2"></i>Agregar a favoritos </button> <a href="{% url 'juegos.index' %}" class="btn btn-outline-primary btn-md d-flex align-items-center"> <i class="fas fa-arrow-left me-2"></i>Volver al catálogo </a> </div> <hr class="my-4"> <h3>Reseñas</h3> <div class="mb-3"> <strong>Puntuación global:</strong> <div style="position: relative; display: inline-block; font-size: 1.5rem; color: #ffc107;"> {% for i in datos_plantilla.estrellas %} {% if i <= datos_plantilla.promedio_calificacion %} <i class="fas fa-star" style="color: #ffc107;"></i> <!-- amarillo --> {% else %} <i class="far fa-star"></i> {% endif %} {% endfor %} {% if datos_plantilla.total_resenas > 0 %} <small class="ms-2 text-muted"> ({{ datos_plantilla.total_resenas }} reseña/s) </small> {% else %} <small class="ms-2 text-muted">Aún no hay puntuaciones.</small> {% endif %} </div> </div> <!-- Lista de reseñas --> <div class="mb-4"> {% for resena in datos_plantilla.juego.resenas.all %} <div class="border rounded p-3 mb-3"> <strong>{{ resena.usuario.username }}</strong> <span class="text-warning"> {% for i in datos_plantilla.estrellas %} {% if i <= resena.calificacion %} <i class="fas fa-star"></i> {# estrella llena #} {% else %} <i class="far fa-star"></i> {# estrella vacía #} {% endif %} {% endfor %} </span> <p class="mb-1">{{ resena.texto }}</p> <small class="text-muted">{{ resena.creado_en|date:"d M Y H:i" }}</small> {% if resena.usuario == user %} <div class="mt-2"> <a href="{% url 'resenas.editar' juego_id=datos_plantilla.juego.id resena_id=resena.id %}" class="btn btn-sm btn-outline-primary">Editar</a> <a href="{% url 'resenas.eliminar' juego_id=datos_plantilla.juego.id resena_id=resena.id %}" class="btn btn-sm btn-outline-danger">Eliminar</a> </div> {% endif %} </div> {% empty %} <p>No hay reseñas todavía.</p> {% endfor %} </div> {% if user.is_authenticated %} {% if not datos_plantilla.resena_usuario %} <form method="post" action="{% url 'resenas.crear' juego_id=datos_plantilla.juego.id %}" class="mb-5"> {% csrf_token %} <div class="mb-3"> <label class="form-label">Tu reseña</label> <textarea class="form-control" name="texto" rows="3" required></textarea> </div> <div class="mb-3"> <label class="form-label d-block">Calificación</label> <div class="star-rating"> {% for i in "54321"|make_list %} <input type="radio" id="estrella{{ i }}" name="calificacion" value="{{ i }}" {% if resena.calificacion|stringformat:"s" == i %}checked{% endif %}> <label for="estrella{{ i }}"><i class="fas fa-star"></i></label> {% endfor %} </div> </div> <button type="submit" class="btn btn-primary">Publicar reseña</button> </form> {% else %} <div class="alert alert-info"> Ya has escrito una reseña. Puedes <a href="{% url 'resenas.editar' juego_id=datos_plantilla.juego.id resena_id=datos_plantilla.resena_usuario.id %}">editarla</a> o <a href="{% url 'resenas.eliminar' juego_id=datos_plantilla.juego.id resena_id=datos_plantilla.resena_usuario.id %}">eliminarla</a> para escribir una nueva. </div> {% endif %} {% else %} <p><a href="{% url 'login' %}">Inicia sesión</a> para escribir una reseña.</p> {% endif %} </div> </div> {% endblock content %}
¿Qué hace esta plantilla?
- Muestra un resumen visual de la calificación promedio del juego en forma de estrellas.
- Lista todas las reseñas existentes con su texto, autor y fecha.
- Si el usuario está autenticado, le permite escribir una nueva reseña solo si no tiene ya una registrada.
- Si ya dejó una reseña, le sugiere editarla o eliminarla.
review_edit.html
: Editar una reseña existente
Esta plantilla es simple pero funcional. Se reutiliza el formulario de creación, pero cargando los valores previos.
{% extends 'main.html' %} {% block content %} <div class="container py-5"> <h2>Editar reseña para "{{ juego.nombre }}"</h2> <form method="post"> {% csrf_token %} <div class="mb-3"> <label class="form-label">Texto</label> <textarea name="texto" class="form-control" rows="4" required>{{ resena.texto }}</textarea> </div> <div class="mb-3"> <label class="form-label d-block">Calificación</label> <div class="star-rating"> {% for i in "54321"|make_list %} <input type="radio" id="estrella{{ i }}" name="calificacion" value="{{ i }}" {% if resena.calificacion|stringformat:"s" == i %}checked{% endif %}> <label for="estrella{{ i }}"><i class="fas fa-star"></i></label> {% endfor %} </div> </div> <button type="submit" class="btn btn-primary">Guardar cambios</button> <a href="{% url 'juegos.mostrar' id=juego.id %}" class="btn btn-secondary">Cancelar</a> </form> </div> {% endblock %}
¿Qué hace esta plantilla?
- Prellena el formulario con la reseña actual del usuario.
- Permite editar el texto y la calificación.
- Si se cancela, redirige a la vista del juego.
Mejoras de estilos para las estrellas
En la hoja de estilos que tenemos enlazada, la general u otra si prefieres hacer una mayor separación de las apps, vamos a poner esto, que le dará un aspecto genial a nuestras estrellas cargadas con font awesome.
/* Star styles */ .star-rating { font-size: 2rem; direction: rtl; display: inline-flex; } .star-rating input[type="radio"] { display: none; } .star-rating label { cursor: pointer; color: #ccc; transition: color 0.2s; } .star-rating input[type="radio"]:checked~label, .star-rating label:hover, .star-rating label:hover~label { color: #ffc107; }
¿Qué es admin.py
en Django?
El archivo admin.py
es parte del sistema de administración integrado de Django. Su propósito es registrar modelos para que puedan ser gestionados a través de la interfaz administrativa de Django, sin necesidad de crear una interfaz personalizada desde cero.
Registro básico de modelos
Estos modelos Juego
, Plataforma
y Genero
se registran de forma básica:
admin.site.register(Juego) admin.site.register(Plataforma) admin.site.register(Genero)
Esto le dice a Django que queremos que estos modelos aparezcan en el panel de administración. Sin embargo, no se especifica ninguna configuración adicional sobre cómo se deben mostrar. Es decir, Django usará una representación por defecto en el panel administrativo.
Registro personalizado de Resena
Vamos a editar el archivo admin.py de la app, de esta forma, personalizaremos como se ven las reseñas desde nuestro panel de administración.
from django.contrib import admin from .models import Juego, Plataforma, Genero, Resena # Registra los modelos para que aparezcan en el panel de administración admin.site.register(Juego) admin.site.register(Plataforma) admin.site.register(Genero) @admin.register(Resena) class ResenaAdmin(admin.ModelAdmin): list_display = ('juego', 'usuario', 'calificacion', 'creado_en') list_filter = ('calificacion', 'creado_en') search_fields = ('texto', 'usuario__username', 'juego__nombre')
El modelo Resena
se registra de una manera diferente y más personalizada:
@admin.register(Resena) class ResenaAdmin(admin.ModelAdmin): list_display = ('juego', 'usuario', 'calificacion', 'creado_en') list_filter = ('calificacion', 'creado_en') search_fields = ('texto', 'usuario__username', 'juego__nombre')
Esta configuración usa un decorador @admin.register(Resena)
que es funcionalmente equivalente a admin.site.register(Resena, ResenaAdmin)
, pero más limpio y compacto. Se asocia directamente la clase personalizada ResenaAdmin
al modelo Resena
.
¿Qué hace esta configuración?
list_display
: Define qué campos se mostrarán como columnas en la lista de reseñas. En este caso:juego
: el juego reseñadousuario
: el usuario que hizo la reseñacalificacion
: puntuación dadacreado_en
: fecha de creación
list_filter
: Añade filtros en la barra lateral para refinar la lista por:calificacion
creado_en
search_fields
: Permite realizar búsquedas dentro del panel de admin. Se pueden buscar reseñas por:- El texto de la reseña
- El nombre de usuario del autor (
usuario__username
) - El nombre del juego (
juego__nombre
)
Ventajas del registro personalizado
Personalizar el modelo Resena
mejora significativamente la usabilidad del panel administrativo:
- Mejora la productividad del administrador al permitir búsquedas y filtros.
- Hace la interfaz más informativa al mostrar campos relevantes.
- Ofrece una mejor experiencia al gestionar grandes volúmenes de datos.