Añadiendo un sistema de reseñas de usuario en Django

Añadiendo un sistema de reseñas de usuario en Django

¿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 la calificació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:

  1. Una para la página principal del catálogo.
  2. Otra para mostrar los detalles de un juego individual.
  3. Una para permitir al usuario crear una reseña.
  4. Otra para permitirle editarla si ya existe.
  5. 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 modelo Juego. Esto permite saber a qué juego pertenece la reseña.
    Usamos on_delete=models.CASCADE para que, si se elimina el juego, también se eliminen todas sus reseñas automáticamente.
    El related_name='resenas' nos permitirá hacer cosas como juego.resenas.all() desde el modelo Juego.
  • usuario: otro ForeignKey, esta vez al modelo User de Django. Nos dice quién escribió la reseña.
    También usamos on_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 argumento choices. Esto facilita la integración de botones de estrella en la interfaz, además de prevenir calificaciones inválidas.
  • creado_en y actualizado_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ñado
    • usuario: el usuario que hizo la reseña
    • calificacion: puntuación dada
    • creado_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.

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

curso django Entrada anterior Cómo implementar registro, login y logout en Django
Entrada siguiente Cómo resaltar el enlace activo en un menú de navegación con Django + Bootstrap