Saltar a contenido

Sistema de películas de usuario en DRF

En esta última unidad vamos a programar la interacción entre los usuarios y las películas. Esto nos permitirá que puedan gestionar diferentes opciones en las películas:

  • Marcarlas como favoritas
  • Configurar estados (vista, por ver...)
  • Añadir una puntuación
  • Escribir reseñas

Además esto implicará que las películas deberán mostrar el número de favoritos y la nota media en su endpoint, por lo que deberemos configurar estos campos.

Modelo de películas de usuario

Para poder gestionar todo el tinglado necesitamos dar de alta un nuevo modelo para manejar la relación entre usuario y película, lo llamaremos FilmUser:

server/films/models.py

from django.conf import settings  # new
from django.core.validators import MaxValueValidator  # new

class FilmUser(models.Model):

    STATUS_CHOICES = (
        (0, "Sin estado"),
        (1, "Vista"),
        (2, "Quiero verla"))

    user = models.ForeignKey(settings.AUTH_USER_MODEL,
                             on_delete=models.CASCADE)
    film = models.ForeignKey(Film, on_delete=models.CASCADE)

    # Se podría hacer en tres modelos separados para que sea más eficiente
    # pero a nivel de desarrollo habría que hacer lo mismo tres veces

    state = models.PositiveSmallIntegerField(
        choices=STATUS_CHOICES, default=0)  # Al crearse sin estado se borra
    favorite = models.BooleanField(
        default=False)
    note = models.PositiveSmallIntegerField(
        null=True, validators=[MaxValueValidator(10)])
    review = models.TextField(null=True)

    class Meta:
        unique_together = ['film', 'user']
        ordering = ['film__title']

Migramos:

pipenv run makemigrations
pipenv run migrate

Ahora vamos a crear una vista de tipo APIView para manejar estas interacciones, pero antes necesitamos crear un serializador para las películas de usuario:

server/films/serializers.py

from .models import Film, FilmGenre, FilmUser  #updated

class FilmUserSerializer(serializers.ModelSerializer):

    film = FilmSerializer(read_only=True)

    class Meta:
        model = FilmUser
        fields = ['film', 'favorite', 'note', 'state', 'review']

Y ahora la viewset para nuestro modelo FilmUser:

server/films/views.py

from rest_framework import viewsets, filters, status, views  # updated
from .models import Film, FilmGenre, FilmUser  # updated
from .serializers import (FilmSerializer, FilmGenreSerializer, 
                            FilmUserSerializer)  # updated

class FilmUserViewSet(views.APIView):

    # El método GET devolverá las peliculas del usuario
    def get(self, request, *args, **kwargs):
        queryset = FilmUser.objects.filter(user=self.request.user)
        serializer = FilmUserSerializer(queryset, many=True)
        return Response(serializer.data, status=status.HTTP_200_OK)

    # El método POST permitirá gestionar su información de la película
    def post(self, request, *args, **kwargs):
        try:
            film = Film.objects.get(id=request.data['uuid'])
        except Film.DoesNotExist:
            return Response(
                {'status': 'Film not found'},
                status=status.HTTP_404_NOT_FOUND)

        # Una vez recuperada la película creamos o recuperamos su FilmUser
        film_user, created = FilmUser.objects.get_or_create(
            user=request.user, film=film)

        # Configuramos cada campo
        film_user.state = request.data.get('state', 0)
        film_user.favorite = request.data.get('favorite', False)
        film_user.note = request.data.get('note', -1)
        film_user.review = request.data.get('review', None)

        # Si se marca la pelicula como NO VISTA la borramos automáticamente
        if int(film_user.state) == 0:
            film_user.delete()
            return Response(
                {'status': 'Deleted'}, status=status.HTTP_200_OK)

        # En otro caso guardamos los campos de la película de usuario
        else:
            film_user.save()

        return Response(
            {'status': 'Saved'}, status=status.HTTP_200_OK)

Damos de alta la URL en un nuevo endpoint:

server/server/urls.py

urlpatterns = [
    # ...
    path('api/userfilms/', film_views.FilmUserViewSet.as_view())
]

En este punto deberíamos ser capaces de acceder a http://127.0.0.1:8000/api/userfilms/ y ver las películas del usuario identificado en el administrador.

Como no hemos creado ninguna esa respuesta estará vacía, pero podemos crear una película de usuario de prueba haciendo una petición POST.

Por ejemplo para añadir la información de la película de Apocalypse Now, cuyo uuid es d1d5acd7-5f76-4faa-aca7-fdb9cc88eb02 escribiremos lo siguiente:

{   "uuid": "d1d5acd7-5f76-4faa-aca7-fdb9cc88eb02", 
    "favorite": 1, 
    "note": 10, 
    "state": 1, 
    "review": "Me encanta esta película..."
}

Esto nos devolverá:

{
    "status": "Saved"
}

Indicando que el registro de la película de usuario se ha guardado correctamente.

Podemos consultar de nuevo la lista recargando el endpoint y ahora sí veremos la lista con las películas favoritas del usuario:

[
    {
        "film": {
            "id": "d1d5acd7-5f76-4faa-aca7-fdb9cc88eb02",
            "genres": [
                {
                    "id": 15,
                    "name": "Bélica",
                    "slug": "belica"
                },
                {
                    "id": 3,
                    "name": "Drama",
                    "slug": "drama"
                },
                {
                    "id": 10,
                    "name": "Misterio",
                    "slug": "misterio"
                }
            ],
            "title": "Apocalypse Now",
            "year": 1979,
            "review_short": "En 1969, durante la guerra de Vietnam, el coronel Kurtz (Marlon Brando), de las Fuerzas Especiales del Ejército de Estados Unidos, se ha vuelto loco y ahora manda a sus propias tropas de montañeses, dentro de la neutral Camboya, como un semidiós.",
            "review_large": "En 1969, durante la guerra de Vietnam, el coronel Kurtz (Marlon Brando), de las Fuerzas Especiales del Ejército de Estados Unidos, se ha vuelto loco y ahora manda a sus propias tropas de montañeses, dentro de la neutral Camboya, como un semidiós. \r\n\r\nEl coronel Lucas (Harrison Ford) y el general Corman (G. D. Spradlin), cada vez más preocupados por las operaciones de vigilancia de Kurtz, asignan al capitán de MACV-SOG Benjamin L. Willard (Martin Sheen) para que «ponga fin» a Kurtz «con extremo perjuicio» (asesinarlo).",
            "trailer_url": "https://www.youtube.com/watch?v=9l-ViOOFH-s",
            "image_thumbnail": "http://localhost:8000/media/films/d1d5acd7-5f76-4faa-aca7-fdb9cc88eb02/17.jpg",
            "image_wallpaper": "http://localhost:8000/media/films/d1d5acd7-5f76-4faa-aca7-fdb9cc88eb02/17-1.jpg"
        },
        "favorite": true,
        "note": 10,
        "state": 1,
        "review": "Me encanta esta película..."
    }
]

Si queremos modificar la película de usuario basta con enviar de nuevo los campos deseados a la petición:

{   "uuid": "d1d5acd7-5f76-4faa-aca7-fdb9cc88eb02", 
    "favorite": 0, 
    "note": 0, 
    "state": 1,
    "review": "Ya no me gusta esta película"
}

Pero si enviamos una petición sin estado, o con el estado en cero (que es de película no vista), la película de usuario se borrará de la base de datos:

{ 
    "uuid": "d1d5acd7-5f76-4faa-aca7-fdb9cc88eb02"
}

Devolviendo:

{
    "status": "Deleted"
}

Evidentemente esto es solo mi forma de manejar toda la lógica, cada uno puede decidir como y cuando borrar los campos.

Reseñas y películas favoritas

Ahora que manejamos favoritos y notas de película, podemos almacenarlas dinámicamente en las películas. Para ello vamos a crear dos nuevos campos en el modelo de película:

server/films/models.py

from django.db.models import Sum  # new
from django.db.models.signals import post_save  # new

class Film(models.Model):

    # ...

    # Estadisticas actualizadas con señales
    favorites = models.IntegerField(
        default=0, verbose_name="favoritos")
    average_note = models.FloatField(
        default=0.0, verbose_name="nota media", 
        validators=[MaxValueValidator(10.0)])

Para actualizar estos campos lo haremos usando una señal post_save a nivel de FilmUser:

server/films/models.py

def update_film_stats(sender, instance, **kwargs):
    # Actualizamos los favoritos contando los favoritos de esa película
    count_favorites = FilmUser.objects.filter(
        film=instance.film, favorite=True).count()
    instance.film.favorites = count_favorites
    # Actualizamos la nota recuperando el número de notas y haciendo la media
    notes = FilmUser.objects.filter(
        film=instance.film).exclude(note__isnull=True)
    count_notes = notes.count()
    sum_notes = notes.aggregate(Sum('note')).get('note__sum')
    # Intentamos hacer la media con dos decimales usando un try
    # Fallará si sum_notes es None como count_notes es 0
    # Esto sucede las primeras veces porque aún no hay notas establecidas
    try:
        instance.film.average_note = round(sum_notes/count_notes, 2)
    except:
        pass
    # Guardamos la película
    instance.film.save()

# en el post delete se pasa la copia de la instance que ya no existe
post_save.connect(update_film_stats, sender=FilmUser)

Migramos:

pipenv run makemigrations
pipenv run migrate

Ahora podemos añadir los campos favorites y average_note como campos de ordenación en el viewset:

server/films/views.py

class FilmViewSet(viewsets.ReadOnlyModelViewSet):
    # ...
    ordering_fields = ['title', 'year',
                    'genres__name', 'favorites', 'average_note']

Y si creamos una petición de película de usuario en http://127.0.0.1:8000/api/userfilms/, por ejemplo la de antes:

{   "uuid": "d1d5acd7-5f76-4faa-aca7-fdb9cc88eb02", 
    "favorite": 1, 
    "note": 10, 
    "state": 1, 
    "review": "Me encanta esta película..."
}

Deberíamos ser capaces de ver la información de esta película en su viewset con la nota media y el número de favoritos http://127.0.0.1:8000/api/films/d1d5acd7-5f76-4faa-aca7-fdb9cc88eb02/:

{
    "id": "d1d5acd7-5f76-4faa-aca7-fdb9cc88eb02",
    // ...
    "favorites": 1,
    "average_note": 10.0
}

¡Perfecto! Ya tenemos el viewset de UserFilm casi listo y el proyecto acabado, solo falta un pequeñísimo detalle.

Proteger las urls con interacción de usuario

Si intentamos acceder al endpoint de UserFilm sin estar autenticados http://127.0.0.1:8000/api/userfilms/ aparece un error TypeError at /api/userfilms/.

Podemos mejorar la seguridad estableciendo un requisito en la APIView para requerir autenticación en esta vista, es muy sencillo:

server/films/views.py

from rest_framework import (viewsets, filters, status, views, 
                            authentication, permissions) # updated

class FilmUserViewSet(views.APIView):
    authentication_classes = [authentication.SessionAuthentication]  # new
    permission_classes = [permissions.IsAuthenticated] # new

Ahora al acceder sin estar autenticado a http://127.0.0.1:8000/api/userfilms/ nos devolverá el siguiente error, pero en esta ocasión a nivel de API, evitando el fallo de código:

{
    "detail": "Las credenciales de autenticación no se proveyeron."
}

Y con esto acabamos este curso fallido de Django Rest Framework.

Si os ha gustado este curso y aún no sois alumnos de mi curso de Django en Udemy considerad adquirirlo en este enlace, en él explico todas las claves de este framework web.


Última edición: 19 de Marzo de 2021