Saltar al contenido
🐍 Curso de Python
/

Módulo 4 · Lección 31 de 45

Personalizar el panel

Veinte atributos que convierten una tabla de filas en una herramienta usable, y el N+1 que el panel resuelve solo y el que no.

⏱ 16 min de lectura

El panel sin configurar enseña una lista de objetos con su __str__ y poco más. Con veinte líneas en la clase ModelAdmin pasa a tener columnas, buscador, filtros, acciones en lote y formularios anidados, y deja de parecer una tabla para parecer una herramienta.

Y hay una cosa que no se suele contar: el panel no es gratis en rendimiento. Una lista con una columna mal elegida hace una consulta por fila, igual que cualquier plantilla, y como no hay ninguna vista escrita nadie lo mira. Eso también está medido aquí.

La lista#

contactos/admin.py
@admin.register(Contacto)
class ContactoAdmin(admin.ModelAdmin):
        list_display = ['nombre', 'correo', 'grupo', 'tiene_telefono', 'creado']
    list_display_links = ['nombre']
    list_filter = ['grupo', 'creado', 'etiquetas']
    search_fields = ['nombre', 'correo']
    ordering = ['nombre']
    list_per_page = 25
    date_hierarchy = 'creado'
    list_editable = ['correo']

Cada uno hace una cosa concreta:

AtributoQué hace
list_displayLas columnas. Campos, métodos o propiedades
list_display_linksCuáles llevan a la ficha. Por defecto, la primera
list_filterLa barra lateral de filtros
search_fieldsEl buscador de arriba
orderingEl orden por defecto de esta lista
list_per_pageCuántas filas por página. Son cien de serie
date_hierarchyLa navegación por año, mes y día de arriba
list_editableCampos que se cambian sin abrir la ficha

list_filter merece un apunte: los filtros por fecha traen rangos hechos (hoy, esta semana, este mes), y sobre un ForeignKey o un ManyToMany listan las opciones. Ahí hay una trampa de escala: un filtro sobre una relación con diez mil filas pinta diez mil opciones en la barra lateral.

Y search_fields admite los mismos sufijos que un filter, con una particularidad: sin sufijo usa icontains, y con = delante del nombre ('=correo') busca exacto, que es mucho más rápido cuando hay un índice.

Columnas calculadas#

Una columna puede ser un método:

python
        @admin.display(description='¿Tiene teléfono?', boolean=True)
    def tiene_telefono(self, obj):
                return bool(obj.telefono)

El decorador configura la columna: description es el título, y boolean=True hace que se pinte un icono de sí o no en vez de la palabra.

Por defecto una columna calculada no se puede ordenar, porque Django no sabe traducirla a SQL. Ese es el mismo límite de la lección 15: lo que se calcula en Python no existe para la base de datos. La salida es la misma, calcularlo allí:

python
@admin.register(Grupo)
class GrupoAdmin(admin.ModelAdmin):
        list_display = ['nombre', 'cuantos']

    def get_queryset(self, peticion):
                return super().get_queryset(peticion).annotate(_cuantos=Count('contactos'))

    @admin.display(description='Contactos', ordering='_cuantos')
    def cuantos(self, obj):
                return obj._cuantos

Con annotate el dato viene de la consulta, y el ordering del decorador le dice al panel por qué columna de SQL ordenar. Es el patrón para cualquier columna de recuento o de total.

Y para pintar HTML, la regla de la lección 19 sin cambios:

python
from django.utils.html import format_html

@admin.display(description='Foto')
def miniatura(self, obj):
        if not obj.foto:
                return '—'
    return format_html('<img src="{}" height="40">', obj.foto.url)

format_html y no una f-string, porque el panel también escapa.

La ficha#

python
        fieldsets = [
        (None, {'fields': ['nombre', 'correo']}),
        ('Clasificación', {'fields': ['grupo', 'etiquetas'], 'classes': ['collapse']}),
        ('Otros', {'fields': ['telefono', 'notas', 'creado']}),
    ]
    readonly_fields = ['creado']
    filter_horizontal = ['etiquetas']
    autocomplete_fields = ['grupo']

fieldsets agrupa los campos en secciones con título, y 'classes': ['collapse'] las deja plegadas. Un formulario de veinte campos sin secciones es ilegible, y con tres secciones se entiende.

Cuando no hace falta agrupar, fields sirve para elegir y ordenar, y también acepta tuplas para poner dos campos en la misma línea:

python
        fields = [('nombre', 'correo'), 'telefono', 'notas']

readonly_fields enseña el valor sin dejar cambiarlo, y es obligatorio para los campos con auto_now_add, que no son editables. También admite métodos, lo que permite mostrar datos calculados dentro de la ficha.

Los dos últimos son los que arreglan los desplegables:

filter_horizontal sustituye el selector múltiple por dos cajas con búsqueda, que es lo único usable con más de diez opciones.

autocomplete_fields convierte un desplegable en un campo que busca según escribes, cargando por peticiones en lugar de volcar la tabla entera. Es la respuesta a lo que quedó pendiente en la lección 26, y tiene un requisito: el modelo al que apunta necesita search_fields en su propio ModelAdmin, porque es lo que usa la búsqueda. Sin eso, el panel da un error de comprobación diciéndolo.

Modelos anidados#

Para editar lo relacionado desde la misma página:

python
class FichaInline(admin.StackedInline):
        model = Ficha
    extra = 0


@admin.register(Contacto)
class ContactoAdmin(admin.ModelAdmin):
        inlines = [FichaInline]

StackedInline pone cada uno en bloque y TabularInline en filas de tabla; la segunda va mejor con pocos campos. El extra son los formularios vacíos que aparecen para añadir, y cero es casi siempre lo que se quiere, porque el valor por defecto llena la pantalla.

Los anidados son la mejor parte del panel y también la más cara: cada uno añade consultas y formularios. Tres anidados con veinte filas cada uno hacen una página lenta.

Acciones en lote#

Una función que recibe la selección y hace algo con ella:

python
@admin.action(description='Vaciar el teléfono de los seleccionados')
def vaciar_telefono(modeladmin, peticion, consulta):
        actualizados = consulta.update(telefono='')
    modeladmin.message_user(peticion, f'{actualizados} contactos actualizados.')


@admin.register(Contacto)
class ContactoAdmin(admin.ModelAdmin):
        actions = [vaciar_telefono]
Salida en pantalla
acciones: [('delete_selected', 'Eliminar contactos seleccionado/s'),
           ('vaciar_telefono', 'Vaciar el teléfono de los seleccionados')]

El tercer argumento es un QuerySet con lo seleccionado, así que se puede usar update(), recorrerlo o exportarlo. message_user deja el aviso verde de arriba, que es el sistema de mensajes de la lección 35.

El aspecto#

Tres líneas que quitan el "Django administration" de todas las páginas:

contactos/admin.py
admin.site.site_header = 'Agenda de contactos'
admin.site.site_title = 'Agenda'
admin.site.index_title = 'Gestión'
Salida en pantalla
<title>Seleccione contacto a modificar | Agenda</title>

Y para lo demás, el mecanismo es el de la lección 16: las plantillas de DIRS se miran antes que las de las aplicaciones, así que un fichero en plantillas/admin/base_site.html sustituye al del panel. Lo razonable es heredar del original y cambiar solo un bloque:

plantillas/admin/base_site.html
{% extends "admin/base_site.html" %}
{% block extrastyle %}<link rel="stylesheet" href="{% static 'admin_propio.css' %}">{% endblock %}