From 3b53703c4d18953cc7c0fe223fb107ec3437b228 Mon Sep 17 00:00:00 2001 From: monomono Date: Sat, 10 Oct 2026 16:43:37 -0500 Subject: [PATCH] #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) --- tienda_ilusion/don_confiao/api/__init__.py | 3 + tienda_ilusion/don_confiao/api/categories.py | 85 +++++++ .../tests/test_product_categories_api.py | 224 ++++++++++++++++++ tienda_ilusion/don_confiao/urls.py | 7 + 4 files changed, 319 insertions(+) create mode 100644 tienda_ilusion/don_confiao/api/categories.py create mode 100644 tienda_ilusion/don_confiao/tests/test_product_categories_api.py diff --git a/tienda_ilusion/don_confiao/api/__init__.py b/tienda_ilusion/don_confiao/api/__init__.py index 3c00bdb..dcd5907 100644 --- a/tienda_ilusion/don_confiao/api/__init__.py +++ b/tienda_ilusion/don_confiao/api/__init__.py @@ -1,5 +1,6 @@ from .catalogue_images import CatalogueImageViewSet from .products import ProductView, ProductsFromTrytonView +from .categories import ProductCategoryView from .customers import CustomerView, CustomersFromTrytonView from .sales import ( SaleView, @@ -35,6 +36,8 @@ __all__ = [ # Products "ProductView", "ProductsFromTrytonView", + # Product Categories + "ProductCategoryView", # Customers "CustomerView", "CustomersFromTrytonView", diff --git a/tienda_ilusion/don_confiao/api/categories.py b/tienda_ilusion/don_confiao/api/categories.py new file mode 100644 index 0000000..cc75807 --- /dev/null +++ b/tienda_ilusion/don_confiao/api/categories.py @@ -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}) \ No newline at end of file diff --git a/tienda_ilusion/don_confiao/tests/test_product_categories_api.py b/tienda_ilusion/don_confiao/tests/test_product_categories_api.py new file mode 100644 index 0000000..1e200e0 --- /dev/null +++ b/tienda_ilusion/don_confiao/tests/test_product_categories_api.py @@ -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) \ No newline at end of file diff --git a/tienda_ilusion/don_confiao/urls.py b/tienda_ilusion/don_confiao/urls.py index 3547445..32aea0e 100644 --- a/tienda_ilusion/don_confiao/urls.py +++ b/tienda_ilusion/don_confiao/urls.py @@ -8,6 +8,8 @@ from .api import ( # Products ProductView, ProductsFromTrytonView, + # Product Categories + ProductCategoryView, # Customers CustomerView, CustomersFromTrytonView, @@ -45,6 +47,11 @@ router.register(r"sales", SaleView, basename="sale") router.register(r"catalog_sales", CatalogSaleView, basename="catalog_sale") router.register(r"customers", CustomerView, basename="customer") router.register(r"products", ProductView, basename="product") +router.register( + r"product_categories", + ProductCategoryView, + basename="product_category", +) router.register( r"catalogue_images", CatalogueImageViewSet,