Saltar al contenido
🐍 Curso de Python
/

Módulo 5 · Lección 16 de 30

ViewSet y enrutadores

Una clase por recurso, las acciones que no caben en los cinco verbos, y el dinero que vuelve a ser un número en cuanto te saltas el serializador.

⏱ 16 min de lectura

Un conjunto de vistas agrupa las cinco operaciones de un recurso en una clase, y un enrutador genera las rutas a partir de ella. Es lo normal en DRF y es lo que conviene usar.

Lo interesante no es eso, que son dos líneas. Es lo que pasa cuando el recurso tiene acciones que no son ninguna de las cinco, que es la pregunta que la lección 11 dejó abierta.

Lo básico#

python
class LibroViewSet(viewsets.ModelViewSet):
        queryset = Libro.objects.select_related('editorial').prefetch_related('autores')
    serializer_class = LibroSerializer
python
router = DefaultRouter()
router.register('libros', LibroViewSet)
urlpatterns = [path('api/', include(router.urls))]

La diferencia con las genéricas de la lección anterior no es de potencia, es de organización: en vez de dos clases y dos rutas escritas a mano, hay una clase y el enrutador deduce el resto. Con doce recursos, eso son doce líneas en lugar de cuarenta y ocho.

Y hay tres tipos que conviene distinguir:

ClaseQué trae
ViewSetNada. Escribes list, retrieve, etc. a mano
GenericViewSetEl conjunto de datos y el serializador, sin acciones
ModelViewSetLas cinco acciones
ReadOnlyModelViewSetListar y consultar. Nada más

ReadOnlyModelViewSet es la más infravalorada. Para un catálogo público o un listado de países, es una palabra en lugar de configurar permisos que impidan escribir. Y lo que no existe no se puede romper.

Con GenericViewSet y las piezas de la lección anterior se compone lo que quieras:

python
class LibroViewSet(mixins.ListModelMixin,
                   mixins.RetrieveModelMixin,
                   mixins.CreateModelMixin,
                   viewsets.GenericViewSet):
        """Se puede crear y consultar, pero no modificar ni borrar."""

Eso da un recurso donde no existe el borrado, y por tanto no hay permiso que se pueda configurar mal. Es mejor defensa que un permiso bien puesto.

Las acciones propias#

Aquí está la respuesta a la pregunta de la lección 11. Un decorador:

python
from rest_framework.decorators import action


class LibroViewSet(viewsets.ModelViewSet):
        queryset = Libro.objects.select_related('editorial')
    serializer_class = LibroSerializer

    @action(detail=True, methods=['post'])
    def publicar(self, peticion, pk=None):
                libro = self.get_object()
        if libro.estado == Libro.Estado.PUBLICADO:
                        return Response({'detail': 'Ya estaba publicado.'},
                            status=status.HTTP_409_CONFLICT)
        libro.estado = Libro.Estado.PUBLICADO
        libro.publicado_en = timezone.now().date()
        libro.save(update_fields=['estado', 'publicado_en'])
        return Response(self.get_serializer(libro).data)

    @action(detail=False, url_path='mas-caros')
    def mas_caros(self, peticion):
                consulta = self.filter_queryset(self.get_queryset()).order_by('-precio')[:5]
        return Response(self.get_serializer(consulta, many=True).data)

Funcionando:

Salida en pantalla
estado antes: borrador
POST /api/libros/2/publicar/   -> 200, estado: publicado
POST /api/libros/2/publicar/   -> 409  {"detail": "Ya estaba publicado."}
GET  /api/libros/mas-caros/    -> 200  ['44.79', '44.63', '44.33', '44.12', '44.10']
GET  /api/libros/2/publicar/   -> 405  {"detail": "Método \"GET\" no permitido."}

El 409 es la decisión de diseño que merece la pena: publicar dos veces no es un error del cliente ni un éxito, es un conflicto con el estado actual, y es el código que la lección 2 reservaba para eso.

detail=True o detail=False es lo único que hay que decidir:

RutaQué recibe
detail=True/libros/42/publicar/Un objeto, con self.get_object()
detail=False/libros/mas-caros/La colección, con self.get_queryset()

Y self.get_object() no es Libro.objects.get(pk=pk): pasa por get_queryset y por los permisos de objeto de la lección 18. Usar el atajo se salta las dos cosas, que es un agujero de seguridad con forma de comodidad.

Lo que genera el enrutador#

Salida en pantalla
^libros/$                              name=libro-list
^libros/mas-caros/$                    name=libro-top
^libros/novedades/$                    name=libro-novedades
^libros/(?P<pk>[^/.]+)/$               name=libro-detail
^libros/(?P<pk>[^/.]+)/publicacion/$   name=libro-publicacion
^libros/(?P<pk>[^/.]+)/publicar/$      name=libro-publicar

Fíjate en el orden: las acciones de colección van antes que la ruta de detalle. Es deliberado y necesario, porque /libros/mas-caros/ encajaría con /libros/<pk>/ y mas-caros acabaría interpretado como identificador. El enrutador lo resuelve, y es la clase de detalle que se agradece cuando se entiende por qué está.

Los nombres se personalizan:

python
@action(detail=False, url_path='mas-caros', url_name='top')
def mas_caros(self, peticion):
        ...

url_path es lo que se ve en la dirección y url_name lo que usas con reverse. Por defecto los dos salen del nombre del método con los guiones bajos convertidos, que produce /libros/mas_caros/ con guion bajo: feo en una dirección, y por eso conviene poner url_path a mano en cuanto el nombre lleva más de una palabra.

Serializador y permisos por acción#

Es lo que hace que un conjunto de vistas aguante un recurso real:

python
class LibroViewSet(viewsets.ModelViewSet):
        queryset = Libro.objects.select_related('editorial').prefetch_related('autores')

    def get_serializer_class(self):
                if self.action == 'list':
                        return LibroListaSerializer
        if self.action in ('create', 'update', 'partial_update'):
                        return LibroEscrituraSerializer
        if self.action == 'estadisticas':
                        return EstadisticasSerializer
        return LibroDetalleSerializer

    def get_permissions(self):
                if self.action in ('list', 'retrieve'):
                        return [permissions.AllowAny()]
        if self.action == 'publicar':
                        return [EsEditor()]
        return [permissions.IsAdminUser()]

self.action vale 'list', 'retrieve', 'create', 'update', 'partial_update', 'destroy' o el nombre de tu acción propia. Eso último es lo que permite que publicar tenga sus propios permisos sin tocar nada más.

Dos cuidados:

get_permissions devuelve instancias, no clases. Con los paréntesis. Olvidarlos da un error confuso a la hora de comprobar, no al arrancar.

El return final es el que protege. Si la cadena de condicionales acaba en algo permisivo, cualquier acción nueva que añadas quedará abierta. Que acabe en lo restrictivo es la misma idea de "cerrado por defecto" de la lección 12, dentro de la clase.