#51 feat: API de categorias de productos
Issue #51: discriminacion y administracion de categorias de productos. - ProductCategoryView en /api/product_categories (list/retrieve IsNotPublico, mutaciones admin): filtro ?active, PATCH de description/alternative_name/active - POST deactivate/activate con body {ids} -> {deactivated|activated, failed} (ignora ids inexistentes) - POST import_from_external admin via CategoryTrytonService - create/destroy deshabilitados (la fuente de categorias es Tryton)
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
from .catalogue_images import CatalogueImageViewSet
|
from .catalogue_images import CatalogueImageViewSet
|
||||||
from .products import ProductView, ProductsFromTrytonView
|
from .products import ProductView, ProductsFromTrytonView
|
||||||
|
from .categories import ProductCategoryView
|
||||||
from .customers import CustomerView, CustomersFromTrytonView
|
from .customers import CustomerView, CustomersFromTrytonView
|
||||||
from .sales import (
|
from .sales import (
|
||||||
SaleView,
|
SaleView,
|
||||||
@@ -35,6 +36,8 @@ __all__ = [
|
|||||||
# Products
|
# Products
|
||||||
"ProductView",
|
"ProductView",
|
||||||
"ProductsFromTrytonView",
|
"ProductsFromTrytonView",
|
||||||
|
# Product Categories
|
||||||
|
"ProductCategoryView",
|
||||||
# Customers
|
# Customers
|
||||||
"CustomerView",
|
"CustomerView",
|
||||||
"CustomersFromTrytonView",
|
"CustomersFromTrytonView",
|
||||||
|
|||||||
85
tienda_ilusion/don_confiao/api/categories.py
Normal file
85
tienda_ilusion/don_confiao/api/categories.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
from rest_framework import viewsets
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
|
||||||
|
from ..models.products import ProductCategory
|
||||||
|
from ..serializers import ProductCategorySerializer
|
||||||
|
from ..permissions import IsAdministrator, IsNotPublico
|
||||||
|
from ..services.tryton.categories import CategoryTrytonService
|
||||||
|
from ..services.tryton.client import get_tryton_client
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCategoryView(viewsets.ModelViewSet):
|
||||||
|
queryset = ProductCategory.objects.all()
|
||||||
|
serializer_class = ProductCategorySerializer
|
||||||
|
|
||||||
|
def create(self, request):
|
||||||
|
return Response(status=405)
|
||||||
|
|
||||||
|
def destroy(self, request, pk=None):
|
||||||
|
return Response(status=405)
|
||||||
|
|
||||||
|
def get_permissions(self):
|
||||||
|
if self.action in ("list", "retrieve"):
|
||||||
|
return [IsNotPublico()]
|
||||||
|
return [IsAuthenticated(), IsAdministrator()]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""
|
||||||
|
Filters categories by active status for list operations.
|
||||||
|
Query params for list:
|
||||||
|
- active=true (default): Only active categories
|
||||||
|
- active=false: Only inactive categories
|
||||||
|
- active=all: All categories regardless of status
|
||||||
|
"""
|
||||||
|
queryset = ProductCategory.objects.all()
|
||||||
|
|
||||||
|
if self.action != "list":
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
active_param = self.request.query_params.get("active", "true")
|
||||||
|
|
||||||
|
if active_param.lower() == "all":
|
||||||
|
return queryset
|
||||||
|
elif active_param.lower() in ["true", "1", "yes"]:
|
||||||
|
return queryset.filter(active=True)
|
||||||
|
elif active_param.lower() in ["false", "0", "no"]:
|
||||||
|
return queryset.filter(active=False)
|
||||||
|
else:
|
||||||
|
return queryset.filter(active=True)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["post"])
|
||||||
|
def import_from_external(self, request):
|
||||||
|
tryton_client = get_tryton_client()
|
||||||
|
service = CategoryTrytonService(tryton_client)
|
||||||
|
return Response(service.import_from_external(), status=200)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["post"])
|
||||||
|
def deactivate(self, request):
|
||||||
|
return self._change_active(request, active=False)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["post"])
|
||||||
|
def activate(self, request):
|
||||||
|
return self._change_active(request, active=True)
|
||||||
|
|
||||||
|
def _change_active(self, request, active):
|
||||||
|
"""Activa o desactiva categorías por ids. Ignora ids inexistentes."""
|
||||||
|
key = "activated" if active else "deactivated"
|
||||||
|
affected = []
|
||||||
|
failed = []
|
||||||
|
for category_id in request.data.get("ids", []):
|
||||||
|
try:
|
||||||
|
category = ProductCategory.objects.get(pk=category_id)
|
||||||
|
except ProductCategory.DoesNotExist:
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
failed.append({"id": category_id, "error": str(e)})
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
category.active = active
|
||||||
|
category.save()
|
||||||
|
affected.append({"id": category.id, "name": category.name})
|
||||||
|
except Exception as e:
|
||||||
|
failed.append({"id": category_id, "error": str(e)})
|
||||||
|
return Response({key: affected, "failed": failed})
|
||||||
224
tienda_ilusion/don_confiao/tests/test_product_categories_api.py
Normal file
224
tienda_ilusion/don_confiao/tests/test_product_categories_api.py
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import json
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.test import TestCase
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
|
|
||||||
|
from ..models.products import ProductCategory
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCategoryApiTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_superuser(
|
||||||
|
username="admin",
|
||||||
|
email="admin@example.com",
|
||||||
|
password="adminpass",
|
||||||
|
)
|
||||||
|
refresh = RefreshToken.for_user(self.user)
|
||||||
|
self.client = APIClient()
|
||||||
|
self.client.credentials(
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {refresh.access_token}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def login_as_regular_user(self):
|
||||||
|
user = User.objects.create_user(
|
||||||
|
username="vendedor", password="vendedorpass"
|
||||||
|
)
|
||||||
|
refresh = RefreshToken.for_user(user)
|
||||||
|
self.client.credentials(
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {refresh.access_token}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_list_categories_filters_by_active(self):
|
||||||
|
ProductCategory.objects.create(name="Granos", external_id="6")
|
||||||
|
ProductCategory.objects.create(
|
||||||
|
name="Verduras", external_id="7", active=False
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
"/don_confiao/api/product_categories/?active=all"
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(len(response.data), 2)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
"/don_confiao/api/product_categories/?active=true"
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(len(response.data), 1)
|
||||||
|
self.assertEqual(response.data[0]["name"], "Granos")
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
"/don_confiao/api/product_categories/?active=false"
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(len(response.data), 1)
|
||||||
|
self.assertEqual(response.data[0]["name"], "Verduras")
|
||||||
|
|
||||||
|
def test_patch_updates_don_confiao_fields_only(self):
|
||||||
|
category = ProductCategory.objects.create(
|
||||||
|
name="Granos", external_id="6"
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.patch(
|
||||||
|
f"/don_confiao/api/product_categories/{category.pk}/",
|
||||||
|
{
|
||||||
|
"description": "Granos enteros",
|
||||||
|
"alternative_name": "Cereales",
|
||||||
|
"active": False,
|
||||||
|
"name": "Nombre intentado",
|
||||||
|
"external_id": "999",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
refreshed = ProductCategory.objects.get(pk=category.pk)
|
||||||
|
self.assertEqual(refreshed.description, "Granos enteros")
|
||||||
|
self.assertEqual(refreshed.alternative_name, "Cereales")
|
||||||
|
self.assertFalse(refreshed.active)
|
||||||
|
self.assertEqual(refreshed.name, "Granos")
|
||||||
|
self.assertEqual(refreshed.external_id, "6")
|
||||||
|
|
||||||
|
def test_deactivate_categories(self):
|
||||||
|
category = ProductCategory.objects.create(
|
||||||
|
name="Granos", external_id="6"
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/don_confiao/api/product_categories/deactivate/",
|
||||||
|
{"ids": [category.pk]},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(
|
||||||
|
response.data,
|
||||||
|
{
|
||||||
|
"deactivated": [{"id": category.pk, "name": "Granos"}],
|
||||||
|
"failed": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
ProductCategory.objects.get(pk=category.pk).active
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_activate_categories(self):
|
||||||
|
category = ProductCategory.objects.create(
|
||||||
|
name="Granos", external_id="6", active=False
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/don_confiao/api/product_categories/activate/",
|
||||||
|
{"ids": [category.pk]},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(
|
||||||
|
response.data,
|
||||||
|
{
|
||||||
|
"activated": [{"id": category.pk, "name": "Granos"}],
|
||||||
|
"failed": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertTrue(ProductCategory.objects.get(pk=category.pk).active)
|
||||||
|
|
||||||
|
def test_deactivate_ignores_nonexistent_ids(self):
|
||||||
|
category = ProductCategory.objects.create(
|
||||||
|
name="Granos", external_id="6"
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/don_confiao/api/product_categories/deactivate/",
|
||||||
|
{"ids": [category.pk, 99999]},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(
|
||||||
|
response.data["deactivated"],
|
||||||
|
[{"id": category.pk, "name": "Granos"}],
|
||||||
|
)
|
||||||
|
self.assertEqual(response.data["failed"], [])
|
||||||
|
self.assertFalse(
|
||||||
|
ProductCategory.objects.get(pk=category.pk).active
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("sabatron_tryton_rpc_client.client.Client.call")
|
||||||
|
@patch("sabatron_tryton_rpc_client.client.Client.connect")
|
||||||
|
def test_import_from_external(self, mock_connect, mock_call):
|
||||||
|
mock_connect.return_value = None
|
||||||
|
|
||||||
|
def fake_call(*args, **kwargs):
|
||||||
|
if args[0] == "model.product.product.search":
|
||||||
|
return [1]
|
||||||
|
if args[0] == "model.product.product.read":
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "Producto 1",
|
||||||
|
"template.": {"id": 999, "categories": [6]},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
if args[0] == "model.product.category.read":
|
||||||
|
return [{"id": 6, "name": "Alimentos"}]
|
||||||
|
raise Exception(f"args no esperados: {args}")
|
||||||
|
|
||||||
|
mock_call.side_effect = fake_call
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/don_confiao/api/product_categories/import_from_external/"
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
content = json.loads(response.content.decode("utf-8"))
|
||||||
|
self.assertTrue(
|
||||||
|
set(
|
||||||
|
[
|
||||||
|
"checked_tryton_categories",
|
||||||
|
"created_categories",
|
||||||
|
"updated_categories",
|
||||||
|
"untouched_categories",
|
||||||
|
"failed_categories",
|
||||||
|
]
|
||||||
|
).issubset(content.keys())
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
content["created_categories"],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": ProductCategory.objects.get(external_id="6").pk,
|
||||||
|
"name": "Alimentos",
|
||||||
|
"external_id": "6",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("sabatron_tryton_rpc_client.client.Client.call")
|
||||||
|
@patch("sabatron_tryton_rpc_client.client.Client.connect")
|
||||||
|
def test_mutations_require_administrator(
|
||||||
|
self, mock_connect, mock_call
|
||||||
|
):
|
||||||
|
mock_connect.return_value = None
|
||||||
|
self.login_as_regular_user()
|
||||||
|
category = ProductCategory.objects.create(
|
||||||
|
name="Granos", external_id="6"
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/don_confiao/api/product_categories/deactivate/",
|
||||||
|
{"ids": [category.pk]},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/don_confiao/api/product_categories/import_from_external/"
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
response = self.client.patch(
|
||||||
|
f"/don_confiao/api/product_categories/{category.pk}/",
|
||||||
|
{"description": "x"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
@@ -8,6 +8,8 @@ from .api import (
|
|||||||
# Products
|
# Products
|
||||||
ProductView,
|
ProductView,
|
||||||
ProductsFromTrytonView,
|
ProductsFromTrytonView,
|
||||||
|
# Product Categories
|
||||||
|
ProductCategoryView,
|
||||||
# Customers
|
# Customers
|
||||||
CustomerView,
|
CustomerView,
|
||||||
CustomersFromTrytonView,
|
CustomersFromTrytonView,
|
||||||
@@ -45,6 +47,11 @@ router.register(r"sales", SaleView, basename="sale")
|
|||||||
router.register(r"catalog_sales", CatalogSaleView, basename="catalog_sale")
|
router.register(r"catalog_sales", CatalogSaleView, basename="catalog_sale")
|
||||||
router.register(r"customers", CustomerView, basename="customer")
|
router.register(r"customers", CustomerView, basename="customer")
|
||||||
router.register(r"products", ProductView, basename="product")
|
router.register(r"products", ProductView, basename="product")
|
||||||
|
router.register(
|
||||||
|
r"product_categories",
|
||||||
|
ProductCategoryView,
|
||||||
|
basename="product_category",
|
||||||
|
)
|
||||||
router.register(
|
router.register(
|
||||||
r"catalogue_images",
|
r"catalogue_images",
|
||||||
CatalogueImageViewSet,
|
CatalogueImageViewSet,
|
||||||
|
|||||||
Reference in New Issue
Block a user