Café y Código

6. Django Admin y Vistas CRUD ⚙️

1. El Panel Administrativo de Django (Django Admin)

Uno de los mayores superpoderes de Django frente a otros frameworks backend es su panel de administración automático (django.contrib.admin). Lee los metadatos de tus modelos y genera al instante una interfaz web completa para crear, buscar, filtrar y editar registros.

1.1 Creación del Superusuario

Para ingresar al panel de administración necesitas una cuenta con privilegios de superusuario (is_superuser = True). Ejecuta en tu terminal con el entorno virtual (venv) activo:

Creación de Superusuario en CMD
CMD
1(venv) C:\proyectos\django\biblioteca> python manage.py createsuperuser
2Username: admin_biblioteca
3Email address: admin@biblioteca.local
4Password: **********
5Password (again): **********
6Superuser created successfully.

1.2 Registro Profesional de Modelos en admin.py

Registrar un modelo con admin.site.register(Libro) solo ofrece una vista básica. En proyectos reales configuramos clases ModelAdmin con tablas interactivas, filtros laterales, búsquedas optimizadas e inlines para editar registros relacionados en la misma pantalla:

biblioteca/admin.py
PYTHON
1# biblioteca/admin.py
2from django.contrib import admin
3from django.utils.html import format_html
4from .models import Autor, Categoria, Libro, SolicitudPrestamo, Tag
5
6
7class SolicitudPrestamoInline(admin.TabularInline):
8 """Permite ver y gestionar los préstamos directamente dentro de la ficha del Libro."""
9 model = SolicitudPrestamo
10 extra = 0
11 raw_id_fields = ['usuario']
12 readonly_fields = ['fecha_solicitud', 'multa_acumulada']
13 fields = ['usuario', 'fecha_solicitud', 'fecha_devolucion_limite', 'estado', 'multa_acumulada']
14
15
16@admin.action(description="Marcar libros seleccionados como DISPONIBLES")
17def marcar_disponible(modeladmin, request, queryset):
18 actualizados = queryset.update(disponible_para_prestamo=True)
19 modeladmin.message_user(
20 request,
21 f"Se han actualizado {actualizados} libro(s) a estado disponible."
22 )
23
24
25@admin.register(Libro)
26class LibroAdmin(admin.ModelAdmin):
27 list_display = ['titulo', 'autor', 'categoria', 'precio_reposicion', 'stock_disponible', 'disponible_badge', 'fecha_publicacion']
28 list_filter = ['disponible_para_prestamo', 'categoria', 'idioma', 'fecha_publicacion']
29 search_fields = ['titulo', 'isbn', 'autor__nombre_completo']
30 prepopulated_fields = {'slug': ('titulo',)}
31 filter_horizontal = ['tags'] # Interfaz de doble caja para relaciones ManyToMany
32 inlines = [SolicitudPrestamoInline]
33 actions = [marcar_disponible]
34 list_per_page = 20
35
36 def disponible_badge(self, obj):
37 if obj.disponible_para_prestamo and obj.stock_disponible > 0:
38 return format_html('<span style="color: #10B981; font-weight: bold;">● Disponible ({})</span>', obj.stock_disponible)
39 return format_html('<span style="color: #EF4444; font-weight: bold;">○ Agotado / No disp.</span>')
40 disponible_badge.short_description = "Disponibilidad"

2. Arquitectura de Cada Vista CRUD (Create, Read, Update, Delete)

El acrónimo CRUD define las cuatro operaciones fundamentales sobre cualquier base de datos:

C — Create (Crear):Presenta un formulario vacío mediante GET y procesa la carga de datos en base de datos mediante POST con validaciones de tipo e integridad.
R — Read (Leer / Listar):Consulta registros en base de datos para presentarlos en catálogos paginados (ListView) o en fichas individuales (DetailView).
U — Update (Actualizar / Editar):Carga la instancia existente en el formulario (instance=obj), permite modificar sus atributos y aplica validaciones antes de guardar.
D — Delete (Eliminar):Solicita confirmación mediante POST para prevenir borrados accidentales y ejecuta obj.delete() con chequeo de integridad referencial.

2.1 Formularios Basados en Modelos (ModelForm)

En Django, no escribes formularios HTML a mano. Utilizas forms.ModelForm, que genera los campos, etiquetas y validaciones directamente a partir de la definición de tu modelo:

biblioteca/forms.py
PYTHON
1# biblioteca/forms.py
2from django import forms
3from .models import Libro
4
5
6class LibroForm(forms.ModelForm):
7 class Meta:
8 model = Libro
9 fields = [
10 'isbn', 'titulo', 'autor', 'categoria', 'tags',
11 'resumen', 'precio_reposicion', 'stock_disponible',
12 'disponible_para_prestamo', 'idioma', 'fecha_publicacion'
13 ]
14 widgets = {
15 'fecha_publicacion': forms.DateInput(attrs={'type': 'date', 'class': 'form-input'}),
16 'resumen': forms.Textarea(attrs={'rows': 4, 'placeholder': 'Sinopsis o resumen del libro...'}),
17 'precio_reposicion': forms.NumberInput(attrs={'step': '0.01', 'min': '0.01'}),
18 }
19
20 # Validación de Campo Único (clean_<nombre_campo>)
21 def clean_isbn(self):
22 isbn = self.cleaned_data.get('isbn', '').strip().replace('-', '')
23 if len(isbn) not in [10, 13]:
24 raise forms.ValidationError("El ISBN debe contener exactamente 10 o 13 dígitos numéricos.")
25 return isbn
26
27 # Validación Cruzada Global (clean)
28 def clean(self):
29 cleaned_data = super().clean()
30 stock = cleaned_data.get('stock_disponible')
31 disponible = cleaned_data.get('disponible_para_prestamo')
32
33 if disponible and (stock is None or stock <= 0):
34 raise forms.ValidationError({
35 'stock_disponible': "No puedes marcar el libro como disponible si el stock es cero."
36 })
37 return cleaned_data

2.2 Implementación de Cada Vista CRUD (FBV vs CBV)

A continuación comparamos cómo se implementan las 4 operaciones mediante funciones (FBV) y mediante clases genéricas (CBV):

biblioteca/views_crud.py
PYTHON
1# biblioteca/views_crud.py
2from django.shortcuts import render, get_object_or_404, redirect
3from django.urls import reverse_lazy
4from django.contrib import messages
5from django.views.generic import ListView, DetailView, CreateView, UpdateView, DeleteView
6from .models import Libro
7from .forms import LibroForm
8
9
10# ===============================================================
11# 1. CREATE (Crear Nuevo Libro)
12# ===============================================================
13# A. Como FBV:
14def libro_crear_fbv(request):
15 if request.method == 'POST':
16 form = LibroForm(request.POST, request.FILES)
17 if form.is_valid():
18 nuevo_libro = form.save()
19 messages.success(request, f"¡Libro '{nuevo_libro.titulo}' registrado con éxito!")
20 return redirect('biblioteca:libro_detalle_id', pk=nuevo_libro.id)
21 else:
22 form = LibroForm()
23 return render(request, 'biblioteca/libro_form.html', {'form': form, 'accion': 'Crear'})
24
25# B. Como CBV:
26class LibroCreateView(CreateView):
27 model = Libro
28 form_class = LibroForm
29 template_name = 'biblioteca/libro_form.html'
30 success_url = reverse_lazy('biblioteca:catalogo')
31
32 def form_valid(self, form):
33 messages.success(self.request, "Libro creado exitosamente.")
34 return super().form_valid(form)
35
36
37# ===============================================================
38# 2. UPDATE (Editar Libro Existente)
39# ===============================================================
40# A. Como FBV (Importante: pasar instance=libro):
41def libro_editar_fbv(request, pk):
42 libro = get_object_or_404(Libro, pk=pk)
43 if request.method == 'POST':
44 # Al pasar instance=libro, form.save() actualiza el registro en vez de crear uno nuevo
45 form = LibroForm(request.POST, request.FILES, instance=libro)
46 if form.is_valid():
47 form.save()
48 messages.info(request, f"Libro '{libro.titulo}' actualizado.")
49 return redirect('biblioteca:libro_detalle_id', pk=libro.id)
50 else:
51 form = LibroForm(instance=libro)
52 return render(request, 'biblioteca/libro_form.html', {'form': form, 'libro': libro, 'accion': 'Editar'})
53
54# B. Como CBV:
55class LibroUpdateView(UpdateView):
56 model = Libro
57 form_class = LibroForm
58 template_name = 'biblioteca/libro_form.html'
59 success_url = reverse_lazy('biblioteca:catalogo')
60
61
62# ===============================================================
63# 3. DELETE (Eliminar Libro)
64# ===============================================================
65# A. Como FBV:
66def libro_eliminar_fbv(request, pk):
67 libro = get_object_or_404(Libro, pk=pk)
68 if request.method == 'POST': # Siempre validar que la eliminación sea por POST
69 titulo = libro.titulo
70 libro.delete()
71 messages.warning(request, f"El libro '{titulo}' ha sido eliminado.")
72 return redirect('biblioteca:catalogo')
73 return render(request, 'biblioteca/libro_confirm_delete.html', {'libro': libro})
74
75# B. Como CBV:
76class LibroDeleteView(DeleteView):
77 model = Libro
78 template_name = 'biblioteca/libro_confirm_delete.html'
79 success_url = reverse_lazy('biblioteca:catalogo')

⚡ Laboratorio Interactivo: Simulador de Django Admin y CRUD

Experimenta el ciclo de vida completo de un CRUD en tiempo real: crea registros pasando por las validaciones de clean(), busca y filtra como en Django Admin, edita instancias existentes y elimina elementos con confirmación.

⚙️

Django Admin & CRUD Simulator

Experimenta el panel administrativo, formularios y ciclo Create-Read-Update-Delete

🔍
Filtro:
IDTítulo del LibroCategoríaPrecio VentaCostoStockEstadoAcciones CRUD
#1Clean Code: Manual de desarrollo ágilIngeniería$45.00$28.004 u.● Activo
#2Python Fluente 2da EdiciónBackend$65.00$42.002 u.● Activo
#3Django 5 ProfesionalBackend$55.00$34.006 u.● Activo
#4Patrones de Diseño (GoF)Arquitectura$50.00$30.000 u.○ Inactivo

3. Ejercicios Didácticos: Proyecto 1 (Biblioteca)

Aplica las técnicas de administración y formularios sobre el dominio de la biblioteca.

Ejercicio 3.1: Formulario con Validación de Préstamos

Crea un ModelForm para SolicitudPrestamo que impida fijar una fecha de devolución superior a 30 días en el futuro.

Ver Solución Explicada
solucion_prestamo_form.py
PYTHON
1from django import forms
2from django.utils import timezone
3from datetime import timedelta
4from .models import SolicitudPrestamo
5
6class SolicitudPrestamoForm(forms.ModelForm):
7 class Meta:
8 model = SolicitudPrestamo
9 fields = ['libro', 'fecha_devolucion_limite', 'observaciones']
10
11 def clean_fecha_devolucion_limite(self):
12 fecha_limite = self.cleaned_data.get('fecha_devolucion_limite')
13 hoy = timezone.now().date()
14 maximo_permitido = hoy + timedelta(days=30)
15
16 if fecha_limite < hoy:
17 raise forms.ValidationError("La fecha de devolución no puede ser una fecha pasada.")
18 if fecha_limite > maximo_permitido:
19 raise forms.ValidationError("El período máximo de préstamo de la biblioteca es de 30 días.")
20 return fecha_limite

Ejercicio 3.2: Acción Personalizada en Django Admin con Mensajes

Escribe una acción en SolicitudPrestamoAdmin que permita seleccionar múltiples solicitudes atrasadas y condonar sus multas acumuladas poniéndolas en $0.00.

Ver Solución Explicada
solucion_condonar_admin.py
PYTHON
1@admin.action(description="Condonar multas acumuladas a los préstamos seleccionados")
2def condonar_multas(modeladmin, request, queryset):
3 afectados = queryset.filter(multa_acumulada__gt=0).update(multa_acumulada=0.00)
4 modeladmin.message_user(
5 request,
6 f"Se han condonado las multas de {afectados} préstamo(s) con éxito.",
7 messages.SUCCESS
8 )
9
10@admin.register(SolicitudPrestamo)
11class SolicitudPrestamoAdmin(admin.ModelAdmin):
12 list_display = ['id', 'usuario', 'libro', 'estado', 'multa_acumulada']
13 actions = [condonar_multas]

4. Desafío Práctico: Segundo Proyecto — Tienda Online (`tienda`) 🛒

Implementa el panel administrativo y los formularios CRUD del segundo proyecto ubicado en C:\proyectos\django\tienda.

Reto 4.1: PedidoAdmin con TabularInline de Ítems y Acción de Despacho

En pedidos/admin.py, registra Pedido con una relación tabular de ItemPedidoInline para ver los productos comprados, cantidades y subtotales en la misma vista de administración. Añade una acción para marcar los pedidos seleccionados como 'ENVIADO'.

Ver Solución Explicada
solucion_pedido_admin.py
PYTHON
1# pedidos/admin.py
2from django.contrib import admin
3from .models import Pedido, ItemPedido
4
5class ItemPedidoInline(admin.TabularInline):
6 model = ItemPedido
7 extra = 0
8 raw_id_fields = ['producto']
9 readonly_fields = ['precio_unitario', 'subtotal']
10
11@admin.action(description="Marcar pedidos seleccionados como ENVIADOS")
12def marcar_como_enviado(modeladmin, request, queryset):
13 actualizados = queryset.filter(estado='PAGADO').update(estado='ENVIADO')
14 modeladmin.message_user(request, f"{actualizados} pedidos marcados como despachados.")
15
16@admin.register(Pedido)
17class PedidoAdmin(admin.ModelAdmin):
18 list_display = ['id', 'numero_seguimiento', 'cliente', 'estado', 'total', 'fecha_creacion']
19 list_filter = ['estado', 'fecha_creacion']
20 search_fields = ['id', 'cliente__user__username', 'numero_seguimiento']
21 inlines = [ItemPedidoInline]
22 actions = [marcar_como_enviado]

Reto 4.2: ModelForm con Validación Cruzada (Precio vs Costo)

En catalogo/forms.py, crea ProductoForm garantizando que: 1. El SKU se guarde siempre en mayúsculas y tenga al menos 4 caracteres. 2. El precio de venta nunca sea inferior al costo de adquisición.

Ver Solución Explicada
solucion_producto_form.py
PYTHON
1# catalogo/forms.py
2from django import forms
3from .models import Producto
4
5class ProductoForm(forms.ModelForm):
6 class Meta:
7 model = Producto
8 fields = ['sku', 'nombre', 'categoria', 'descripcion', 'precio', 'costo', 'stock', 'destacado']
9
10 def clean_sku(self):
11 sku = self.cleaned_data.get('sku', '').strip().upper()
12 if len(sku) < 4:
13 raise forms.ValidationError("El código SKU debe contener al menos 4 caracteres.")
14 return sku
15
16 def clean(self):
17 cleaned_data = super().clean()
18 precio = cleaned_data.get('precio')
19 costo = cleaned_data.get('costo')
20
21 if precio is not None and costo is not None:
22 if precio < costo:
23 raise forms.ValidationError({
24 'precio': "El precio de venta no puede ser menor al costo comercial del producto."
25 })
26 return cleaned_data

Reto 4.3: Vista de Eliminación Suave (Soft Delete)

En comercio electrónico nunca se borra físicamente un producto si ya forma parte del historial de compras de clientes pasados (provocaría error de integridad referencial). Escribe una vista FBV desactivar_producto(request, pk) que al recibir un POST cambie disponible = False en vez de ejecutar delete().

Ver Solución Explicada
soft_delete_view.py
PYTHON
1# catalogo/views.py
2from django.shortcuts import get_object_or_404, redirect, render
3from django.contrib import messages
4from .models import Producto
5
6def desactivar_producto_view(request, pk):
7 producto = get_object_or_404(Producto, pk=pk)
8
9 if request.method == 'POST':
10 # Soft delete: se desactiva sin borrarlo de la base de datos
11 producto.disponible = False
12 producto.save(update_fields=['disponible'])
13 messages.warning(request, f"El producto '{producto.nombre}' ha sido desactivado del catálogo.")
14 return redirect('catalogo:lista')
15
16 return render(request, 'catalogo/producto_confirm_deactivate.html', {'producto': producto})

Ponte a prueba

Comprueba que dominas el panel de administración, la arquitectura CRUD y la validación de formularios en Django.

Ko-fi
Donaciones
Apoyá cafeycodigo con un café en Ko-fi. Colaboradores: insignia, muro y zona exclusiva.