🛠 Unidad ISS-13 · Django Admin — capa 🛠 CONSTRUIR
🧠 Comprender este bloque → · ✅ GATE de la unidad
Capa Página Para qué 🧠 Aprender Guía de estudio comprender, explicar y relacionar 🛠 Construir esta página ejecutar, programar y verificar ✅ GATE Condiciones de cierre condición para pasar al bloque siguiente Esta es la guía ejecutable. El cuerpo de abajo es el ISS técnico verbatim: comandos, rutas, versiones, verificaciones y criterios, sin simplificar.
ISS-13 — Django Admin
Objetivo
Dejar las cinco tablas de negocio explorables en el admin: listas que se filtran, se buscan y se ordenan, y formularios agrupados para clientes, tipos y productos. Las ventas se inspeccionan; no se rearman desde aquí.
Requisitos
- ISS-12 superado.
django.contrib.admindentro deINSTALLED_APPS. En StoreLab ese archivo esconfig/settings/__init__.py, nositealmacen/settings.py.startprojectya lo incluye.- La ruta
admin/ya está enconfig/urls.pydesde el ISS-00.
Construcción
Superusuario
Con el entorno activo, en la raíz del proyecto:
Django pregunta usuario, correo y contraseña dos veces. Los validadores rechazan una clave común o solo numérica. La contraseña del superusuario tiene que cumplirlos.
StoreUserManager.create_superuser fuerza status=active. Sin eso el admin no dejaría entrar, porque is_active se copia del status.
apps/client/admin.py
startapp ya creó apps/client/admin.py. Se reescribe con cat, porque el comentario inicial no se conserva:
Al terminar la clase, cierre el bloque con EOF. Lo mismo para apps/product/admin.py y apps/sale/admin.py: archivo existente, contenido nuevo vía cat >.
from django.contrib import admin
from apps.client.models import Client
@admin.register(Client)
class ClientAdmin(admin.ModelAdmin):
list_display = ("name", "email", "phone", "address", "status", "created_at")
list_filter = ("status",)
search_fields = ("name", "email", "phone", "address")
list_editable = ("status",)
ordering = ("name", "email")
readonly_fields = ("created_at", "updated_at")
fieldsets = (
("Información del cliente", {"fields": ("name", "email", "phone", "address")}),
("Estado", {"fields": ("status",)}),
(
"Auditoría",
{"fields": ("created_at", "updated_at"), "classes": ("collapse",)},
),
)
@admin.register sustituye a admin.site.register(Client, ClientAdmin). Hace lo mismo junto a la clase.
list_displayson las columnas. Estánname,email,phone,addressy, además,statusycreated_at, que este modelo sí tiene.list_filterqueda enstatus. Esa barra parte la lista en active e inactive. Nombre y correo se consultan consearch_fields.search_fieldscubre nombre, correo, teléfono y dirección: es el cuadro de búsqueda, no la barra lateral.list_editablepermite cambiarstatusen la lista sin abrir el formulario. El primer campo delist_displayno puede ser editable: es el enlace a la ficha. Por esonameva primero.orderingordena la lista por nombre y, en empate, por correo.fieldsetsagrupa el formulario. No hay grupo de credenciales:Clientno tienepassword. La contraseña vive en el usuario desecurity, y su admin llega en la Fase II.created_atyupdated_atson de solo lectura y van plegados. Django los llena solo.
apps/product/admin.py
from django.contrib import admin
from apps.product.models import Product, ProductType
@admin.register(ProductType)
class ProductTypeAdmin(admin.ModelAdmin):
list_display = ("name", "status", "created_at")
list_filter = ("status",)
search_fields = ("name", "description")
list_editable = ("status",)
ordering = ("name",)
readonly_fields = ("created_at", "updated_at")
fieldsets = (
(None, {"fields": ("name", "description", "status")}),
(
"Auditoría",
{"fields": ("created_at", "updated_at"), "classes": ("collapse",)},
),
)
@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
list_display = ("name", "product_type", "price", "stock", "status")
list_filter = ("status", "product_type")
search_fields = ("name", "description")
list_editable = ("price", "stock", "status")
ordering = ("name",)
autocomplete_fields = ("product_type",)
readonly_fields = ("created_at", "updated_at")
fieldsets = (
("Información", {"fields": ("name", "description", "product_type")}),
("Precio y stock", {"fields": ("price", "stock")}),
("Estado", {"fields": ("status",)}),
(
"Auditoría",
{"fields": ("created_at", "updated_at"), "classes": ("collapse",)},
),
)
def get_queryset(self, request):
return super().get_queryset(request).select_related("product_type")
El tipo se registra con su propia clase y la misma lista filtrable: product_types tiene name, description y status.
En el producto no existen brand, quantity ni min_stock. Las columnas equivalentes de este modelo son product_type, price y stock. list_editable aplica a precio, stock y estado, que son los datos de catálogo que el admin sí puede corregir. get_queryset hace select_related("product_type") para no consultar el tipo una vez por fila.
autocomplete_fields = ("product_type",) cambia el desplegable por un buscador. Django solo lo acepta si ProductTypeAdmin define search_fields. Eso lo cumple name y description.
apps/sale/admin.py
from django.contrib import admin
from apps.sale.models import ProductSale, Sale
class ProductSaleInline(admin.TabularInline):
model = ProductSale
extra = 0
can_delete = False
show_change_link = True
fields = ("product", "quantity", "unit_price", "line_total", "status")
readonly_fields = ("product", "quantity", "unit_price", "line_total", "status")
def has_add_permission(self, request, obj=None):
return False
@admin.register(Sale)
class SaleAdmin(admin.ModelAdmin):
list_display = (
"id",
"client",
"sale_date",
"subtotal",
"tax",
"discounts",
"total",
"status",
)
list_filter = ("status", "sale_date")
search_fields = ("client__name", "client__email")
ordering = ("-sale_date",)
date_hierarchy = "sale_date"
readonly_fields = (
"client",
"sale_date",
"subtotal",
"tax",
"discounts",
"total",
"status",
"created_at",
"updated_at",
)
fieldsets = (
("Información de la venta", {"fields": ("client", "status")}),
(
"Totales",
{
"fields": ("subtotal", "tax", "discounts", "total"),
"classes": ("collapse",),
},
),
("Fecha", {"fields": ("sale_date",), "classes": ("collapse",)}),
(
"Auditoría",
{"fields": ("created_at", "updated_at"), "classes": ("collapse",)},
),
)
inlines = (ProductSaleInline,)
def has_add_permission(self, request):
return False
def has_delete_permission(self, request, obj=None):
return False
def get_queryset(self, request):
return super().get_queryset(request).select_related("client")
@admin.register(ProductSale)
class ProductSaleAdmin(admin.ModelAdmin):
list_display = ("sale", "product", "quantity", "unit_price", "line_total", "status")
list_filter = ("status", "sale__sale_date", "product__status")
search_fields = ("sale__client__name", "product__name")
ordering = ("-sale__sale_date", "id")
readonly_fields = (
"sale",
"product",
"quantity",
"unit_price",
"line_total",
"status",
"created_at",
"updated_at",
)
fieldsets = (
("Línea", {"fields": ("sale", "product", "quantity", "unit_price", "line_total")}),
("Estado", {"fields": ("status",)}),
(
"Auditoría",
{"fields": ("created_at", "updated_at"), "classes": ("collapse",)},
),
)
def has_add_permission(self, request):
return False
def has_delete_permission(self, request, obj=None):
return False
def get_queryset(self, request):
return super().get_queryset(request).select_related(
"sale", "sale__client", "product"
)
La lista de ventas muestra los importes reales: subtotal, tax, discounts y total. date_hierarchy parte esa lista por año, mes y día de sale_date. ordering = ("-sale_date",) pone la más reciente arriba. La búsqueda usa client__name y client__email: el doble guion bajo cruza la FK. get_queryset trae el cliente en la misma consulta.
El inline muestra la línea con sus campos: quantity, unit_price y line_total. El importe de la línea es line_total. extra = 0 y has_add_permission en False dejan el formulario sin filas vacías. Una línea creada en el admin no pasaría por la transacción que copia el precio y descuenta el stock. La venta se crea con POST /api/sales/ y se anula con DELETE, que llama a void(). Por eso Sale y ProductSale no se agregan ni se borran en el admin, y su status no es list_editable: cambiarlo en la lista no devolvería el stock.
show_change_link abre la ficha de la línea desde la venta. Esa ficha también es de solo lectura.
Explicación
/admin/
│
├── Client lista + formulario (alta y edición)
├── ProductType lista + formulario
├── Product lista + formulario, tipo por autocompletado
├── Sale lista + ficha de solo lectura, líneas en inline
└── ProductSale lista + ficha de solo lectura
El admin usa sesión y CSRF. No es OPEN, ni JWT con token, ni JWT con token + RBAC. Sirve para mirar y, en catálogo y clientes, para corregir datos. No reemplaza la API.
Un DELETE del admin sobre un cliente o un producto sería físico y chocaría con RESTRICT si ya hay ventas. En ventas y detalles ese botón se apaga. La baja de una venta sigue siendo la de la API.
Criterios de aceptación
- AC-13-01:
Client,ProductType,Product,SaleyProductSaleestán registrados, conlist_display,list_filterysearch_fields. - AC-13-02: el formulario de cliente agrupa sus campos y no pide contraseña.
- AC-13-03: el producto lista
priceystock, no marca niquantity. - AC-13-04: la venta no se agrega ni se borra desde el admin, y el inline no crea líneas.
- AC-13-05:
python manage.py checkno se queja del autocompletado.
Verificación
Entrar a /admin/ con el superusuario y abrir cada modelo de negocio. En clientes y productos debe haber alta. En ventas, no.
Evidencias
- EVI-13-01:
System check identified no issues.
GATE
| AC | Verificación | Evidencia | Resultado |
|---|---|---|---|
| AC-13-01 | admin.py de client, product y sale |
revisión | PASS |
| AC-13-02 | fieldsets de Client |
client/admin.py |
PASS |
| AC-13-03 | columnas de Product |
product/admin.py |
PASS |
| AC-13-04 | has_add_permission y has_delete_permission |
sale/admin.py |
PASS |
| AC-13-05 | check | EVI-13-01 | PASS |
✅ GATE de la unidad ISS-13 — este bloque no añade ningún criterio nuevo.
Las condiciones de cierre son las de esta misma página:
- 📋 Criterios de aceptación → Criterios de aceptación
- ✅ GATE → GATE
- 🔎 Verificación → Verificación
- 🧠 Autoevaluación → Evaluación del cuaderno
Con el GATE en verde queda habilitado el bloque siguiente de la ruta.
Navegación de la ruta: ← ISS-13 · 🧠 Aprender · ↑ Ruta Django · → ISS-14 · 🛠 Construir