🛠 Unidad ISS-09 · Catálogo con ModelViewSet — 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-09 — Catálogo con ModelViewSet
Objetivo
Publicar tipos y productos con el mismo criterio de CRUD homogéneo.
Requisitos
- ISS-08 superado.
Construcción
apps/product/views.py ya existe. Igual que en clientes, el permiso de esta fase es AllowAny.
cat > apps/product/views.py <<'EOF'
from rest_framework import viewsets
from rest_framework.permissions import AllowAny
from apps.common.status import RecordStatus
from apps.product.models import Product, ProductType
from apps.product.serializers import ProductSerializer, ProductTypeSerializer
class CatalogViewSet(viewsets.ModelViewSet):
permission_classes = [AllowAny]
lookup_value_converter = "int"
def get_queryset(self):
queryset = super().get_queryset()
status_value = self.request.query_params.get("status")
if status_value:
queryset = queryset.filter(status=status_value)
return queryset
def perform_destroy(self, instance):
instance.status = RecordStatus.INACTIVE
instance.save(update_fields=["status", "updated_at"])
class ProductTypeViewSet(CatalogViewSet):
queryset = ProductType.objects.all()
serializer_class = ProductTypeSerializer
class ProductViewSet(CatalogViewSet):
queryset = Product.objects.select_related("product_type")
serializer_class = ProductSerializer
EOF
AvailableProductListAPIView no va en este archivo todavía. El ISS-10 la agrega al final.
cat > apps/product/urls.py <<'EOF'
from rest_framework.routers import SimpleRouter
from apps.product.views import ProductTypeViewSet, ProductViewSet
router = SimpleRouter(use_regex_path=False)
router.register("product-types", ProductTypeViewSet, basename="product-type")
router.register("products", ProductViewSet, basename="product")
urlpatterns = router.urls
EOF
ARCHIVO: config/urls.py
DEBAJO DE:
path("api/", include("apps.client.urls")),
AGREGAR:
path("api/", include("apps.product.urls")),
Mismo mecanismo que en clientes: el proyecto no conoce product-types ni products. Solo vuelve a ceder el resto de api/ a otra lista. El router produce cuatro rutas, dos por recurso, con <int:pk> en el detalle. La ruta products/available/ no existe todavía; el ISS-10 la inserta delante de router.urls.
apps/product/tests.py existe desde startapp. Se reescribe en acceso OPEN. El setUp solo crea el tipo en el ORM para poder publicar productos; no autentica. El listado de disponibles se agrega en el ISS-10. El JWT y el grant_crud se agregan en el ISS-22.
cat > apps/product/tests.py <<'EOF'
from decimal import Decimal
from django.db.models.deletion import RestrictedError
from rest_framework.test import APITestCase
from apps.common.status import RecordStatus
from apps.product.models import Product, ProductType
class ProductApiTests(APITestCase):
def setUp(self):
self.product_type = ProductType.objects.create(name="Bebida", status=RecordStatus.ACTIVE)
def test_product_type_crud(self):
created = self.client.post(
"/api/product-types/",
{"name": "Lácteo", "description": "Fríos"},
format="json",
)
self.assertEqual(created.status_code, 201)
self.assertEqual(created.data["status"], RecordStatus.INACTIVE)
pk = created.data["id"]
self.assertEqual(self.client.get("/api/product-types/").status_code, 200)
detail = self.client.get(f"/api/product-types/{pk}/")
self.assertEqual(detail.data["name"], "Lácteo")
updated = self.client.patch(
f"/api/product-types/{pk}/",
{"status": "active"},
format="json",
)
self.assertEqual(updated.status_code, 200)
deleted = self.client.delete(f"/api/product-types/{pk}/")
self.assertEqual(deleted.status_code, 204)
self.assertEqual(ProductType.objects.get(pk=pk).status, RecordStatus.INACTIVE)
def test_product_retrieve_update_and_soft_delete(self):
created = self.client.post(
"/api/products/",
{
"productTypeId": self.product_type.id,
"name": "Leche",
"price": "3.20",
"stock": 2,
"status": "active",
},
format="json",
)
pk = created.data["id"]
detail = self.client.get(f"/api/products/{pk}/")
self.assertEqual(detail.status_code, 200)
updated = self.client.patch(f"/api/products/{pk}/", {"stock": 9}, format="json")
self.assertEqual(updated.status_code, 200)
self.assertEqual(updated.data["stock"], 9)
deleted = self.client.delete(f"/api/products/{pk}/")
self.assertEqual(deleted.status_code, 204)
self.assertEqual(Product.objects.get(pk=pk).status, RecordStatus.INACTIVE)
def test_default_status_and_type_restriction(self):
created = self.client.post(
"/api/products/",
{"productTypeId": self.product_type.id, "name": "Té", "price": "1.00"},
format="json",
)
self.assertEqual(created.status_code, 201)
self.assertEqual(created.data["status"], "inactive")
self.assertEqual(created.data["stock"], 0)
with self.assertRaises(RestrictedError):
self.product_type.delete()
def test_duplicate_product_name(self):
payload = {"productTypeId": self.product_type.id, "name": "Café", "price": "3.00"}
self.assertEqual(self.client.post("/api/products/", payload, format="json").status_code, 201)
self.assertEqual(self.client.post("/api/products/", payload, format="json").status_code, 400)
self.assertEqual(Product.objects.get(name="Café").price, Decimal("3.00"))
EOF
Explicación
No se repite la discusión del ISS-08. El catálogo es el mismo tipo de recurso. Extraer CatalogViewSet evita copiar perform_destroy y no inventa una capa nueva: sigue siendo un ModelViewSet.
ProductSerializer exige productTypeId. Si el tipo no existe, DRF responde 400 antes de tocar la base.
Cómo probarlo
Capa HTTP, acceso OPEN. El setUp crea el tipo "Bebida" en el ORM para poder publicar productos. Ese alta de apoyo no pasa por la API. El CRUD del tipo "Lácteo" sí.
python manage.py test apps.product
│
▼
ProductApiTests sin Bearer
│
├── /api/product-types/ POST GET GET/<id> PATCH DELETE
│ alta inactive → activa → baja lógica
│
└── /api/products/ POST GET/<id> PATCH DELETE
productTypeId, stock, nombre duplicado = 400
borrar el tipo con hijos = RestrictedError
GET /api/products/available/ todavía no se prueba. Entra en el ISS-10.
Swagger de tipos y productos, en OPEN y con el servidor encendido, es el ISS-14. Este ISS solo afirma el CRUD por HTTP. El mismo clic con token y semilla queda para el ISS-24, después del cierre del ISS-22.
Criterios de aceptación
- AC-09-01: crear un producto activo con stock aparece en el catálogo.
- AC-09-02: el nombre duplicado responde 400.
- AC-09-03: el default de status es
inactivey el de stock es 0.
Verificación
ProductApiTests.
Evidencias
- EVI-09-01: tests de producto PASS en los tres motores ejecutados.
GATE
| AC | Verificación | Evidencia | Resultado |
|---|---|---|---|
| AC-09-01 | POST producto | EVI-09-01 | PASS |
| AC-09-02 | duplicado | EVI-09-01 | PASS |
| AC-09-03 | defaults | EVI-09-01 | PASS |
✅ GATE de la unidad ISS-09 — 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-09 · 🧠 Aprender · ↑ Ruta Django · → ISS-10 · 🧠 Aprender