From 570fec05322a296de6fc60de66312bed1bcfae31 Mon Sep 17 00:00:00 2001 From: monomono Date: Sat, 10 Oct 2026 16:27:49 -0500 Subject: [PATCH 01/13] #51 feat: agregar descripcion, nombre alternativo y activo a categorias de productos Issue #51: discriminacion y administracion de categorias de productos. - ProductCategory.description (TextField blank) - ProductCategory.alternative_name (CharField 100 nullable) - ProductCategory.active (Boolean default True) - Migracion 0056_productcategory_active_and_more - Tests de modelo (campo don confiao y active por defecto) --- .../0056_productcategory_active_and_more.py | 28 +++++++++++++++++++ tienda_ilusion/don_confiao/models/products.py | 3 ++ .../tests/test_product_categories.py | 25 +++++++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 tienda_ilusion/don_confiao/migrations/0056_productcategory_active_and_more.py create mode 100644 tienda_ilusion/don_confiao/tests/test_product_categories.py diff --git a/tienda_ilusion/don_confiao/migrations/0056_productcategory_active_and_more.py b/tienda_ilusion/don_confiao/migrations/0056_productcategory_active_and_more.py new file mode 100644 index 0000000..45fd798 --- /dev/null +++ b/tienda_ilusion/don_confiao/migrations/0056_productcategory_active_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.0.6 on 2026-10-10 21:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('don_confiao', '0055_productcategory_external_id'), + ] + + operations = [ + migrations.AddField( + model_name='productcategory', + name='active', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='productcategory', + name='alternative_name', + field=models.CharField(blank=True, max_length=100, null=True), + ), + migrations.AddField( + model_name='productcategory', + name='description', + field=models.TextField(blank=True, default=''), + ), + ] diff --git a/tienda_ilusion/don_confiao/models/products.py b/tienda_ilusion/don_confiao/models/products.py index 3783337..495c8f2 100644 --- a/tienda_ilusion/don_confiao/models/products.py +++ b/tienda_ilusion/don_confiao/models/products.py @@ -9,6 +9,9 @@ class MeasuringUnits(models.TextChoices): class ProductCategory(models.Model): name = models.CharField(max_length=100, unique=True) external_id = models.CharField(max_length=100, null=True, blank=True) + description = models.TextField(blank=True, default="") + alternative_name = models.CharField(max_length=100, null=True, blank=True) + active = models.BooleanField(default=True) def __str__(self): return self.name diff --git a/tienda_ilusion/don_confiao/tests/test_product_categories.py b/tienda_ilusion/don_confiao/tests/test_product_categories.py new file mode 100644 index 0000000..90420f9 --- /dev/null +++ b/tienda_ilusion/don_confiao/tests/test_product_categories.py @@ -0,0 +1,25 @@ +from django.test import TestCase + +from ..models.products import ProductCategory + + +class TestProductCategoryModel(TestCase): + def test_create_category_with_don_confiao_fields(self): + category = ProductCategory.objects.create( + name="Bebidas", + external_id="8", + description="Bebidas y jugos naturales", + alternative_name="Jugos", + active=False, + ) + self.assertEqual(category.name, "Bebidas") + self.assertEqual(category.external_id, "8") + self.assertEqual(category.description, "Bebidas y jugos naturales") + self.assertEqual(category.alternative_name, "Jugos") + self.assertFalse(category.active) + + def test_category_active_por_defecto(self): + category = ProductCategory.objects.create(name="Granos") + self.assertTrue(category.active) + self.assertEqual(category.description, "") + self.assertIsNone(category.alternative_name) \ No newline at end of file -- 2.49.1 From c0dcb7901a593d11f29a7293649c63817c10381e Mon Sep 17 00:00:00 2001 From: monomono Date: Sat, 10 Oct 2026 16:33:18 -0500 Subject: [PATCH 02/13] #51 fix: detectar cambios de categorias al sincronizar productos Issue #51: discriminacion y administracion de categorias de productos. - _need_update compara las categorias (external_id) local vs Tryton - _describe_changes reporta categorias agregadas (+Nombre) y removidas (-Nombre) - Actualiza test que asumia asignacion de categorias sin marcar update --- .../don_confiao/services/tryton/products.py | 53 ++++++++ .../tests/test_products_from_tryton.py | 117 +++++++++++++++++- 2 files changed, 166 insertions(+), 4 deletions(-) diff --git a/tienda_ilusion/don_confiao/services/tryton/products.py b/tienda_ilusion/don_confiao/services/tryton/products.py index 6703c0e..f49c1f4 100644 --- a/tienda_ilusion/don_confiao/services/tryton/products.py +++ b/tienda_ilusion/don_confiao/services/tryton/products.py @@ -214,8 +214,50 @@ class ProductTrytonService: changes.append( f"Unidad: {product.measuring_unit} → {unit_name}" ) + category_changes = self._describe_category_changes( + product, tryton_product + ) + if category_changes: + changes.append(category_changes) return ", ".join(changes) + def _describe_category_changes(self, product, tryton_product): + """Describe los cambios de categorías entre el producto local y Tryton""" + tryton_ids = set( + str(category_id) + for category_id in self._extract_category_ids(tryton_product) + ) + local = { + external_id: name + for external_id, name in product.categories.values_list( + "external_id", "name" + ) + if external_id + } + local_ids = set(local) + + added = sorted(tryton_ids - local_ids) + removed = sorted(local_ids - tryton_ids) + if not added and not removed: + return "" + + parts = [] + if added: + names = {} + for external_id in added: + category = ( + ProductCategory.objects.filter( + external_id=external_id + ) + .values_list("name", flat=True) + .first() + ) + names[external_id] = category or external_id + parts.append(", ".join(f"+{names[e]}" for e in added)) + if removed: + parts.append(", ".join(f"-{local[e]}" for e in removed)) + return f"Categoría: {', '.join(parts)}" + @staticmethod def _format_price(value): """Formatea un precio sin ceros decimales innecesarios""" @@ -269,6 +311,17 @@ class ProductTrytonService: unit = tryton_product.get("default_uom.") if not product.measuring_unit == unit.get("rec_name"): return True + tryton_category_ids = set( + str(category_id) + for category_id in self._extract_category_ids(tryton_product) + ) + local_category_ids = set( + product.categories.exclude( + external_id__isnull=True + ).values_list("external_id", flat=True) + ) + if tryton_category_ids != local_category_ids: + return True return False def _create_product(self, tryton_product): diff --git a/tienda_ilusion/don_confiao/tests/test_products_from_tryton.py b/tienda_ilusion/don_confiao/tests/test_products_from_tryton.py index 4cb594a..70db33a 100644 --- a/tienda_ilusion/don_confiao/tests/test_products_from_tryton.py +++ b/tienda_ilusion/don_confiao/tests/test_products_from_tryton.py @@ -661,7 +661,7 @@ class TestProductsFromTryton(TestCase, LoginMixin): @patch("sabatron_tryton_rpc_client.client.Client.call") @patch("sabatron_tryton_rpc_client.client.Client.connect") - def test_import_assigns_categories_to_untouched_products( + def test_import_assigns_categories_marks_product_as_updated( self, mock_connect, mock_call ): mock_connect.return_value = None @@ -724,12 +724,20 @@ class TestProductsFromTryton(TestCase, LoginMixin): self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["untouched_products"], []) self.assertEqual( - content["untouched_products"], [{"id": 2, "name": "Papa"}] + content["updated_products"], + [ + { + "id": 2, + "name": "Papa", + "detail": "Categoría: +Tubérculos", + } + ], ) - untouched_product = Product.objects.get(id=2) - categories = untouched_product.categories.all() + updated_product = Product.objects.get(id=2) + categories = updated_product.categories.all() self.assertEqual(len(categories), 1) self.assertEqual(categories[0].name, "Tubérculos") self.assertEqual(categories[0].external_id, "9") @@ -744,3 +752,104 @@ class TestProductsFromTryton(TestCase, LoginMixin): } ], ) + + @patch("sabatron_tryton_rpc_client.client.Client.call") + @patch("sabatron_tryton_rpc_client.client.Client.connect") + def test_category_change_marks_product_as_updated( + self, mock_connect, mock_call + ): + mock_connect.return_value = None + local_abarrotes = ProductCategory.objects.create( + name="Abarrotes", external_id="5" + ) + self.product2.categories.add(local_abarrotes) + + def fake_call(*args, **kwargs): + product_search = "model.product.product.search" + search_args = [ + [["salable", "=", True]], + 0, + 1000, + [["rec_name", "ASC"], ["id", None]], + {"company": 1}, + ] + if args == (product_search, search_args): + return [192] + + product_read = "model.product.product.read" + product_args = ( + [192], + [ + "id", + "name", + "default_uom.id", + "default_uom.rec_name", + "list_price", + "template.categories", + ], + {"company": 1}, + ) + if args == (product_read, product_args): + return [ + { + "id": 192, + "list_price": Decimal("4500"), + "name": "Papa", + "default_uom.": {"id": 2, "rec_name": "Kilogram"}, + "template.": {"id": 999, "categories": [8]}, + }, + ] + + category_read = "model.product.category.read" + category_args = ( + [8], + ["id", "name"], + {"company": 1}, + ) + if args == (category_read, category_args): + return [ + {"id": 8, "name": "Bebidas"}, + ] + + raise Exception( + f"Sorry, args non expected on this test: {args}" + ) + + mock_call.side_effect = fake_call + + url = "/don_confiao/api/importar_productos_de_tryton" + response = self.client.post(url) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["untouched_products"], []) + self.assertEqual(content["failed_products"], []) + self.assertEqual( + content["updated_products"], + [ + { + "id": self.product2.pk, + "name": "Papa", + "detail": "Categoría: +Bebidas, -Abarrotes", + } + ], + ) + self.assertEqual( + content["created_categories"], + [ + { + "id": ProductCategory.objects.get( + external_id="8" + ).pk, + "name": "Bebidas", + "external_id": "8", + } + ], + ) + + refreshed_product = Product.objects.get(pk=self.product2.pk) + categories = list(refreshed_product.categories.all()) + self.assertEqual( + [(c.external_id, c.name) for c in categories], + [("8", "Bebidas")], + ) -- 2.49.1 From 73fff016a32086d33b22c896f10d08d6f21326fb Mon Sep 17 00:00:00 2001 From: monomono Date: Sat, 10 Oct 2026 16:35:43 -0500 Subject: [PATCH 03/13] #51 feat: serializadores de categorias y categorias anidadas en productos Issue #51: discriminacion y administracion de categorias de productos. - ProductCategorySerializer (name/external_id read_only) - ProductCategoryBriefSerializer para el JSON de productos - ProductSerializer.categories ahora devuelve objetos anidados --- .../don_confiao/serializers/__init__.py | 9 ++- .../don_confiao/serializers/products.py | 21 +++++++ .../tests/test_product_categories.py | 56 ++++++++++++++++++- 3 files changed, 83 insertions(+), 3 deletions(-) diff --git a/tienda_ilusion/don_confiao/serializers/__init__.py b/tienda_ilusion/don_confiao/serializers/__init__.py index 46480a9..33780a3 100644 --- a/tienda_ilusion/don_confiao/serializers/__init__.py +++ b/tienda_ilusion/don_confiao/serializers/__init__.py @@ -1,5 +1,10 @@ from .catalogue_images import CatalogueImageSerializer -from .products import ProductSerializer, ListProductSerializer +from .products import ( + ProductSerializer, + ListProductSerializer, + ProductCategorySerializer, + ProductCategoryBriefSerializer, +) from .customers import CustomerSerializer, ListCustomerSerializer from .sales import ( SaleSerializer, @@ -26,6 +31,8 @@ __all__ = [ # Products "ProductSerializer", "ListProductSerializer", + "ProductCategorySerializer", + "ProductCategoryBriefSerializer", # Customers "CustomerSerializer", "ListCustomerSerializer", diff --git a/tienda_ilusion/don_confiao/serializers/products.py b/tienda_ilusion/don_confiao/serializers/products.py index d43c07c..0baa835 100644 --- a/tienda_ilusion/don_confiao/serializers/products.py +++ b/tienda_ilusion/don_confiao/serializers/products.py @@ -4,11 +4,32 @@ from ..models.products import Product, ProductCategory from ..models.provenance import Supplier +class ProductCategorySerializer(serializers.ModelSerializer): + class Meta: + model = ProductCategory + fields = [ + "id", + "name", + "external_id", + "description", + "alternative_name", + "active", + ] + read_only_fields = ["name", "external_id"] + + +class ProductCategoryBriefSerializer(serializers.ModelSerializer): + class Meta: + model = ProductCategory + fields = ["id", "name", "alternative_name", "active"] + + class ProductSerializer(serializers.ModelSerializer): catalogue_images = serializers.SerializerMethodField() suppliers = serializers.PrimaryKeyRelatedField( many=True, queryset=Supplier.objects.all(), required=False ) + categories = ProductCategoryBriefSerializer(many=True, read_only=True) class Meta: model = Product diff --git a/tienda_ilusion/don_confiao/tests/test_product_categories.py b/tienda_ilusion/don_confiao/tests/test_product_categories.py index 90420f9..d20b42c 100644 --- a/tienda_ilusion/don_confiao/tests/test_product_categories.py +++ b/tienda_ilusion/don_confiao/tests/test_product_categories.py @@ -1,6 +1,6 @@ from django.test import TestCase -from ..models.products import ProductCategory +from ..models.products import Product, ProductCategory class TestProductCategoryModel(TestCase): @@ -22,4 +22,56 @@ class TestProductCategoryModel(TestCase): category = ProductCategory.objects.create(name="Granos") self.assertTrue(category.active) self.assertEqual(category.description, "") - self.assertIsNone(category.alternative_name) \ No newline at end of file + self.assertIsNone(category.alternative_name) + + +class TestProductCategorySerializers(TestCase): + def test_product_category_serializer_devuelve_campos_don_confiao(self): + from ..serializers import ProductCategorySerializer + + category = ProductCategory.objects.create( + name="Bebidas", + external_id="8", + description="Bebidas naturales", + alternative_name="Jugos", + active=False, + ) + data = ProductCategorySerializer(category).data + self.assertEqual( + data, + { + "id": category.pk, + "name": "Bebidas", + "external_id": "8", + "description": "Bebidas naturales", + "alternative_name": "Jugos", + "active": False, + }, + ) + + def test_product_serializer_incluye_categorias_anidadas(self): + from ..serializers import ProductSerializer + + category = ProductCategory.objects.create( + name="Bebidas", external_id="8", alternative_name="Jugos" + ) + product = Product.objects.create( + name="Jugo de naranja", + price=2500, + measuring_unit="UNIT", + external_id="10", + ) + product.categories.add(category) + + data = ProductSerializer(product).data + self.assertEqual( + data["categories"], + [ + { + "id": category.pk, + "name": "Bebidas", + "alternative_name": "Jugos", + "active": True, + } + ], + ) \ No newline at end of file -- 2.49.1 From 74cd9738b4c8e71f3a4d10f2e43b064d76ed2222 Mon Sep 17 00:00:00 2001 From: monomono Date: Sat, 10 Oct 2026 16:38:47 -0500 Subject: [PATCH 04/13] #51 feat: servicio de importacion externa de categorias de productos Issue #51: discriminacion y administracion de categorias de productos. - CategoryTrytonService.import_from_external: lee categorias asociadas a productos salables, hace upsert por external_id (fallback nombre), preserva description/alternative_name/active, reporta checked/created/updated/untouched/failed - upsert_category compartido y reutilizado por ProductTrytonService --- .../don_confiao/services/tryton/categories.py | 147 +++++++++++++ .../don_confiao/services/tryton/products.py | 33 +-- .../tests/test_categories_from_tryton.py | 200 ++++++++++++++++++ 3 files changed, 351 insertions(+), 29 deletions(-) create mode 100644 tienda_ilusion/don_confiao/services/tryton/categories.py create mode 100644 tienda_ilusion/don_confiao/tests/test_categories_from_tryton.py diff --git a/tienda_ilusion/don_confiao/services/tryton/categories.py b/tienda_ilusion/don_confiao/services/tryton/categories.py new file mode 100644 index 0000000..89b1420 --- /dev/null +++ b/tienda_ilusion/don_confiao/services/tryton/categories.py @@ -0,0 +1,147 @@ +from ...models.products import ProductCategory + + +def upsert_category(category_data): + """Crea o actualiza una ProductCategory desde datos de Tryton. + + Solo administra name y external_id (fuente Tryton). Preserva los + campos administrados por Don Confiao (description, alternative_name + y active). Devuelve (category, status, changes) donde status es + 'created', 'updated' o 'untouched' y changes es la lista de cambios. + """ + external_id = str(category_data.get("id")) + name = category_data.get("name") + category = ProductCategory.objects.filter(external_id=external_id).first() + if category is None and name: + category = ProductCategory.objects.filter(name=name).first() + is_new = category is None + if is_new: + category = ProductCategory() + + changes = [] + if category.name != name: + changes.append(f"Nombre: {category.name} → {name}") + if category.external_id != external_id: + changes.append( + f"External ID: {category.external_id} → {external_id}" + ) + + category.name = name + category.external_id = external_id + category.save() + + status = "created" if is_new else ("updated" if changes else "untouched") + return category, status, changes + + +class CategoryTrytonService: + """Servicio para sincronización de categorías con Tryton""" + + def __init__(self, tryton_client): + self.client = tryton_client + + def import_from_external(self): + """Importa desde Tryton las categorías asociadas a productos. + + Lee las categorías referenciadas por los productos salables + (sin suponer eliminación de categorías locales). Reporta las + categorías revisadas, creadas, actualizadas, intactas y fallidas. + """ + context = {"company": 1} + product_ids = self._search_salable_products(context) + if not product_ids: + return self._empty_report() + tryton_products = self._get_product_details(product_ids, context) + + category_ids = set() + for tryton_product in tryton_products: + category_ids.update(self._extract_category_ids(tryton_product)) + if not category_ids: + return self._empty_report() + + tryton_categories = self._get_category_details( + sorted(category_ids), context + ) + + checked = [ + {"id": c.get("id"), "name": c.get("name")} + for c in tryton_categories + ] + created_categories = [] + updated_categories = [] + untouched_categories = [] + failed_categories = [] + + for tryton_category in tryton_categories: + try: + category, status, changes = upsert_category(tryton_category) + item = { + "id": category.id, + "name": category.name, + "external_id": category.external_id, + } + if status == "created": + created_categories.append(item) + elif status == "updated": + updated_categories.append( + {**item, "detail": ", ".join(changes)} + ) + else: + untouched_categories.append(item) + except Exception as e: + print( + f"Error al importar categoría: {e}" + f"La categoría: {tryton_category}" + ) + failed_categories.append( + { + "external_id": tryton_category.get("id"), + "name": tryton_category.get("name"), + "error": f"Error al importar la categoría: {e}", + } + ) + + return { + "checked_tryton_categories": checked, + "created_categories": created_categories, + "updated_categories": updated_categories, + "untouched_categories": untouched_categories, + "failed_categories": failed_categories, + } + + def _search_salable_products(self, context): + method = "model.product.product.search" + params = [ + [["salable", "=", True]], + 0, + 1000, + [["rec_name", "ASC"], ["id", None]], + context, + ] + return self.client.call(method, params) or [] + + def _get_product_details(self, product_ids, context): + tryton_fields = ["id", "name", "template.categories"] + method = "model.product.product.read" + params = (product_ids, tryton_fields, context) + return self.client.call(method, params) + + @staticmethod + def _extract_category_ids(tryton_product): + """Extrae los IDs de categorías de la respuesta RPC de Tryton""" + template = tryton_product.get("template.") or {} + return template.get("categories") or [] + + def _get_category_details(self, category_ids, context): + method = "model.product.category.read" + params = (category_ids, ["id", "name"], context) + return self.client.call(method, params) + + def _empty_report(self): + return { + "checked_tryton_categories": [], + "created_categories": [], + "updated_categories": [], + "untouched_categories": [], + "failed_categories": [], + } \ No newline at end of file diff --git a/tienda_ilusion/don_confiao/services/tryton/products.py b/tienda_ilusion/don_confiao/services/tryton/products.py index f49c1f4..d5a2f9d 100644 --- a/tienda_ilusion/don_confiao/services/tryton/products.py +++ b/tienda_ilusion/don_confiao/services/tryton/products.py @@ -1,4 +1,5 @@ from ...models.products import Product, ProductCategory +from .categories import upsert_category class ProductTrytonService: @@ -121,42 +122,16 @@ class ProductTrytonService: ) for tryton_category in tryton_categories: try: - external_id = str(tryton_category.get("id")) - name = tryton_category.get("name") - category = ProductCategory.objects.filter( - external_id=external_id - ).first() - if category is None and name: - category = ProductCategory.objects.filter( - name=name - ).first() - is_new = category is None - if is_new: - category = ProductCategory() - old_name = category.name - old_external_id = category.external_id - category.name = name - category.external_id = external_id - category.save() + category, status, changes = upsert_category(tryton_category) categories[tryton_category.get("id")] = category - item = { "id": category.id, "name": category.name, "external_id": category.external_id, } - if is_new: + if status == "created": report["created"].append(item) - continue - changes = [] - if old_name != category.name: - changes.append(f"Nombre: {old_name} → {category.name}") - if old_external_id != category.external_id: - changes.append( - "External ID: " - f"{old_external_id} → {category.external_id}" - ) - if changes: + elif status == "updated": report["updated"].append( {**item, "detail": ", ".join(changes)} ) diff --git a/tienda_ilusion/don_confiao/tests/test_categories_from_tryton.py b/tienda_ilusion/don_confiao/tests/test_categories_from_tryton.py new file mode 100644 index 0000000..ca57ed3 --- /dev/null +++ b/tienda_ilusion/don_confiao/tests/test_categories_from_tryton.py @@ -0,0 +1,200 @@ +from unittest.mock import patch + +from django.test import TestCase + +from ..models.products import ProductCategory +from ..services.tryton.categories import CategoryTrytonService + + +class TestCategoryTrytonImport(TestCase): + def _service(self, mock_call): + fake_client = type("FakeClient", (), {"call": mock_call})() + return CategoryTrytonService(fake_client) + + def _empty_report(self): + return { + "checked_tryton_categories": [], + "created_categories": [], + "updated_categories": [], + "untouched_categories": [], + "failed_categories": [], + } + + @patch("sabatron_tryton_rpc_client.client.Client.call") + def test_import_creates_only_referenced_categories(self, mock_call): + def fake_call(*args, **kwargs): + if args[0] == "model.product.product.search": + return [1, 2] + if args[0] == "model.product.product.read": + return [ + { + "id": 1, + "name": "Producto 1", + "template.": {"id": 999, "categories": [6]}, + }, + { + "id": 2, + "name": "Producto 2", + "template.": {"id": 999, "categories": [7]}, + }, + ] + if args[0] == "model.product.category.read": + return [ + {"id": 6, "name": "Alimentos"}, + {"id": 7, "name": "Verduras"}, + ] + raise Exception(f"args no esperados: {args}") + + mock_call.side_effect = fake_call + result = self._service(mock_call).import_from_external() + + self.assertEqual( + result["checked_tryton_categories"], + [ + {"id": 6, "name": "Alimentos"}, + {"id": 7, "name": "Verduras"}, + ], + ) + self.assertEqual( + result["created_categories"], + [ + { + "id": ProductCategory.objects.get(external_id="6").pk, + "name": "Alimentos", + "external_id": "6", + }, + { + "id": ProductCategory.objects.get(external_id="7").pk, + "name": "Verduras", + "external_id": "7", + }, + ], + ) + self.assertEqual(result["updated_categories"], []) + self.assertEqual(result["untouched_categories"], []) + self.assertEqual(result["failed_categories"], []) + self.assertEqual(ProductCategory.objects.count(), 2) + + @patch("sabatron_tryton_rpc_client.client.Client.call") + def test_import_preserves_don_confiao_fields_on_update(self, mock_call): + local_category = ProductCategory.objects.create( + name="Granos", + external_id="7", + description="Granos enteros", + alternative_name="Cereales", + active=False, + ) + + 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": [7]}, + }, + ] + if args[0] == "model.product.category.read": + return [{"id": 7, "name": "Cereales y Granos"}] + raise Exception(f"args no esperados: {args}") + + mock_call.side_effect = fake_call + result = self._service(mock_call).import_from_external() + + self.assertEqual(result["created_categories"], []) + self.assertEqual( + result["updated_categories"], + [ + { + "id": local_category.pk, + "name": "Cereales y Granos", + "external_id": "7", + "detail": "Nombre: Granos → Cereales y Granos", + } + ], + ) + refreshed = ProductCategory.objects.get(pk=local_category.pk) + self.assertEqual(refreshed.name, "Cereales y Granos") + self.assertEqual(refreshed.description, "Granos enteros") + self.assertEqual(refreshed.alternative_name, "Cereales") + self.assertFalse(refreshed.active) + + @patch("sabatron_tryton_rpc_client.client.Client.call") + def test_import_counts_untouched_categories(self, mock_call): + local_category = ProductCategory.objects.create( + name="Alimentos", external_id="6" + ) + + 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 + result = self._service(mock_call).import_from_external() + + self.assertEqual(result["created_categories"], []) + self.assertEqual(result["updated_categories"], []) + self.assertEqual( + result["untouched_categories"], + [ + { + "id": local_category.pk, + "name": "Alimentos", + "external_id": "6", + } + ], + ) + self.assertEqual(result["failed_categories"], []) + self.assertEqual(ProductCategory.objects.count(), 1) + + @patch("sabatron_tryton_rpc_client.client.Client.call") + def test_import_without_products_returns_empty_report(self, mock_call): + def fake_call(*args, **kwargs): + if args[0] == "model.product.product.search": + return [] + raise Exception(f"args no esperados: {args}") + + mock_call.side_effect = fake_call + result = self._service(mock_call).import_from_external() + self.assertEqual(result, self._empty_report()) + self.assertEqual(ProductCategory.objects.count(), 0) + + @patch("sabatron_tryton_rpc_client.client.Client.call") + def test_import_failed_category_reported(self, mock_call): + 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": None}] + raise Exception(f"args no esperados: {args}") + + mock_call.side_effect = fake_call + result = self._service(mock_call).import_from_external() + + self.assertEqual(result["created_categories"], []) + self.assertEqual(len(result["failed_categories"]), 1) + failure = result["failed_categories"][0] + self.assertEqual(failure["external_id"], 6) + self.assertEqual(failure["name"], None) + self.assertIn("Error", failure["error"]) \ No newline at end of file -- 2.49.1 From 3b53703c4d18953cc7c0fe223fb107ec3437b228 Mon Sep 17 00:00:00 2001 From: monomono Date: Sat, 10 Oct 2026 16:43:37 -0500 Subject: [PATCH 05/13] #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, -- 2.49.1 From 436f5300fa3efc1839a14953b9d9b982e46f6c44 Mon Sep 17 00:00:00 2001 From: monomono Date: Sat, 10 Oct 2026 16:45:32 -0500 Subject: [PATCH 06/13] #51 feat: categorias anidadas en el listado legacy de productos Issue #51: discriminacion y administracion de categorias de productos. - Product.to_list() devuelve categorias como objetos (id, name, alternative_name, active) en vez de solo nombres --- tienda_ilusion/don_confiao/models/products.py | 10 +++++++- .../tests/test_product_categories.py | 25 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/tienda_ilusion/don_confiao/models/products.py b/tienda_ilusion/don_confiao/models/products.py index 495c8f2..191d43c 100644 --- a/tienda_ilusion/don_confiao/models/products.py +++ b/tienda_ilusion/don_confiao/models/products.py @@ -49,7 +49,15 @@ class Product(models.Model): "price_list": product.price, "uom": product.measuring_unit, "external_id": product.external_id, - "categories": [c.name for c in product.categories.all()], + "categories": [ + { + "id": c.id, + "name": c.name, + "alternative_name": c.alternative_name, + "active": c.active, + } + for c in product.categories.all() + ], } products_list.append(rproduct) return products_list diff --git a/tienda_ilusion/don_confiao/tests/test_product_categories.py b/tienda_ilusion/don_confiao/tests/test_product_categories.py index d20b42c..d32d1c1 100644 --- a/tienda_ilusion/don_confiao/tests/test_product_categories.py +++ b/tienda_ilusion/don_confiao/tests/test_product_categories.py @@ -74,4 +74,29 @@ class TestProductCategorySerializers(TestCase): "active": True, } ], + ) + + def test_product_to_list_incluye_categorias_anidadas(self): + category = ProductCategory.objects.create( + name="Bebidas", external_id="8", alternative_name="Jugos" + ) + product = Product.objects.create( + name="Jugo de naranja", + price=2500, + measuring_unit="UNIT", + external_id="10", + ) + product.categories.add(category) + + data = Product.to_list() + self.assertEqual( + data[0]["categories"], + [ + { + "id": category.pk, + "name": "Bebidas", + "alternative_name": "Jugos", + "active": True, + } + ], ) \ No newline at end of file -- 2.49.1 From 128ba215dd1823066f442111bb2f08e85fd6ee61 Mon Sep 17 00:00:00 2001 From: monomono Date: Sat, 10 Oct 2026 16:47:12 -0500 Subject: [PATCH 07/13] #51 feat: registrar categorias en admin y documentar endpoints Issue #51: discriminacion y administracion de categorias de productos. - ProductCategoryAdmin con list_display/filtro por active - doc/requests.org: GET/PATCH product_categories, import_from_external, deactivate, activate --- doc/requests.org | 46 +++++++++++++++++++++++++++++ tienda_ilusion/don_confiao/admin.py | 8 ++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/doc/requests.org b/doc/requests.org index f4de0bc..89f529f 100644 --- a/doc/requests.org +++ b/doc/requests.org @@ -137,3 +137,49 @@ post importar_productos_de_tryton #+begin_src json [] #+end_src + +*** Categorías de productos +La fuente de categorías es Tryton. name y external_id son solo lectura; Don +Confiao administra description, alternative_name y active. + +**** Listar categorías +get product_categories/?active=all +get product_categories/?active=true +get product_categories/?active=false + +**** Actualizar campos administrados por Don Confiao +patch product_categories/1/ + +#+begin_src json +{ + "description": "Granos enteros", + "alternative_name": "Cereales", + "active": false +} +#+end_src + +**** Importar categorías desde Tryton +Solo importa las categorías asociadas a productos salables; no borra +categorías locales. Requiere administrador. + +post product_categories/import_from_external + +{} + +**** Desactivar categorías +post product_categories/deactivate/ + +#+begin_src json +{ + "ids": [1, 2] +} +#+end_src + +**** Activación de categorías +post product_categories/activate/ + +#+begin_src json +{ + "ids": [1, 2] +} +#+end_src diff --git a/tienda_ilusion/don_confiao/admin.py b/tienda_ilusion/don_confiao/admin.py index 6ca0f2a..a9a937a 100644 --- a/tienda_ilusion/don_confiao/admin.py +++ b/tienda_ilusion/don_confiao/admin.py @@ -77,6 +77,12 @@ class ProductAdmin(admin.ModelAdmin): search_fields = ("id", "name",) list_filter = ("name", "id") -admin.site.register(ProductCategory) + +@admin.register(ProductCategory) +class ProductCategoryAdmin(admin.ModelAdmin): + list_display = ("id", "name", "alternative_name", "active", "external_id") + search_fields = ("name", "alternative_name", "external_id") + list_filter = ("active",) + admin.site.register(Payment) admin.site.register(ReconciliationJar) -- 2.49.1 From 6e14dd9f366a516d2e9a3f8aeca0a15f39c260b2 Mon Sep 17 00:00:00 2001 From: monomono Date: Sat, 10 Oct 2026 17:52:21 -0500 Subject: [PATCH 08/13] #51 refactor: traer categorias anidadas en un solo request de productos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #51: discriminacion y administracion de categorias de productos. - product.read ahora solicita template.categories.name; se elimina la llamada separada model.product.category.read - ProductTrytonService y CategoryTrytonService construyen el mapa {id_tryton: nombre} desde 'template.categories.' de los productos leídos - import_from_external pasa de 3 llamadas RPC a 2 --- .../don_confiao/services/tryton/categories.py | 39 ++++--- .../don_confiao/services/tryton/products.py | 32 +++--- .../tests/test_categories_from_tryton.py | 43 +++++--- .../tests/test_product_categories_api.py | 8 +- .../tests/test_products_from_tryton.py | 101 +++++++----------- 5 files changed, 112 insertions(+), 111 deletions(-) diff --git a/tienda_ilusion/don_confiao/services/tryton/categories.py b/tienda_ilusion/don_confiao/services/tryton/categories.py index 89b1420..930dafe 100644 --- a/tienda_ilusion/don_confiao/services/tryton/categories.py +++ b/tienda_ilusion/don_confiao/services/tryton/categories.py @@ -53,15 +53,14 @@ class CategoryTrytonService: return self._empty_report() tryton_products = self._get_product_details(product_ids, context) - category_ids = set() - for tryton_product in tryton_products: - category_ids.update(self._extract_category_ids(tryton_product)) - if not category_ids: + category_names = self._extract_category_names(tryton_products) + if not category_names: return self._empty_report() - tryton_categories = self._get_category_details( - sorted(category_ids), context - ) + tryton_categories = [ + {"id": category_id, "name": category_names[category_id]} + for category_id in sorted(category_names) + ] checked = [ {"id": c.get("id"), "name": c.get("name")} @@ -121,21 +120,27 @@ class CategoryTrytonService: return self.client.call(method, params) or [] def _get_product_details(self, product_ids, context): - tryton_fields = ["id", "name", "template.categories"] + tryton_fields = [ + "id", + "name", + "template.categories", + "template.categories.name", + ] method = "model.product.product.read" params = (product_ids, tryton_fields, context) return self.client.call(method, params) @staticmethod - def _extract_category_ids(tryton_product): - """Extrae los IDs de categorías de la respuesta RPC de Tryton""" - template = tryton_product.get("template.") or {} - return template.get("categories") or [] - - def _get_category_details(self, category_ids, context): - method = "model.product.category.read" - params = (category_ids, ["id", "name"], context) - return self.client.call(method, params) + def _extract_category_names(tryton_products): + """Extrae el mapa {id_tryton: nombre} de las categorías anidadas en + la respuesta de product.read (clave 'template.categories.name', + agrupada bajo 'template.' como 'categories.')""" + names = {} + for tryton_product in tryton_products: + template = tryton_product.get("template.") or {} + for category in template.get("categories.") or []: + names[category.get("id")] = category.get("name") + return names def _empty_report(self): return { diff --git a/tienda_ilusion/don_confiao/services/tryton/products.py b/tienda_ilusion/don_confiao/services/tryton/products.py index d5a2f9d..344eddc 100644 --- a/tienda_ilusion/don_confiao/services/tryton/products.py +++ b/tienda_ilusion/don_confiao/services/tryton/products.py @@ -107,19 +107,16 @@ class ProductTrytonService: reporte {"created": [...], "updated": [...]} """ empty_report = {"created": [], "updated": []} - category_ids = { - category_id - for tryton_product in tryton_products - for category_id in self._extract_category_ids(tryton_product) - } - if not category_ids: + category_names = self._extract_category_names(tryton_products) + if not category_names: return {}, empty_report categories = {} report = {"created": [], "updated": []} - tryton_categories = self._get_category_details( - sorted(category_ids), context - ) + tryton_categories = [ + {"id": category_id, "name": category_names[category_id]} + for category_id in sorted(category_names) + ] for tryton_category in tryton_categories: try: category, status, changes = upsert_category(tryton_category) @@ -149,11 +146,17 @@ class ProductTrytonService: template = tryton_product.get("template.") or {} return template.get("categories") or [] - def _get_category_details(self, category_ids, context): - """Obtiene los nombres de categorías desde Tryton""" - method = "model.product.category.read" - params = (category_ids, ["id", "name"], context) - return self.client.call(method, params) + @staticmethod + def _extract_category_names(tryton_products): + """Extrae el mapa {id_tryton: nombre} de las categorías anidadas en + la respuesta de product.read (clave 'template.categories.name', + agrupada bajo 'template.' como 'categories.')""" + names = {} + for tryton_product in tryton_products: + template = tryton_product.get("template.") or {} + for category in template.get("categories.") or []: + names[category.get("id")] = category.get("name") + return names def _sync_product_categories(self, product, tryton_product, categories): """Vincula al producto las categorías importadas desde Tryton""" @@ -271,6 +274,7 @@ class ProductTrytonService: "default_uom.rec_name", "list_price", "template.categories", + "template.categories.name", ] method = "model.product.product.read" params = (product_ids, tryton_fields, context) diff --git a/tienda_ilusion/don_confiao/tests/test_categories_from_tryton.py b/tienda_ilusion/don_confiao/tests/test_categories_from_tryton.py index ca57ed3..a03c5b1 100644 --- a/tienda_ilusion/don_confiao/tests/test_categories_from_tryton.py +++ b/tienda_ilusion/don_confiao/tests/test_categories_from_tryton.py @@ -30,19 +30,22 @@ class TestCategoryTrytonImport(TestCase): { "id": 1, "name": "Producto 1", - "template.": {"id": 999, "categories": [6]}, + "template.": { + "id": 999, + "categories": [6], + "categories.": [{"id": 6, "name": "Alimentos"}], + }, }, { "id": 2, "name": "Producto 2", - "template.": {"id": 999, "categories": [7]}, + "template.": { + "id": 999, + "categories": [7], + "categories.": [{"id": 7, "name": "Verduras"}], + }, }, ] - if args[0] == "model.product.category.read": - return [ - {"id": 6, "name": "Alimentos"}, - {"id": 7, "name": "Verduras"}, - ] raise Exception(f"args no esperados: {args}") mock_call.side_effect = fake_call @@ -93,11 +96,15 @@ class TestCategoryTrytonImport(TestCase): { "id": 1, "name": "Producto 1", - "template.": {"id": 999, "categories": [7]}, + "template.": { + "id": 999, + "categories": [7], + "categories.": [ + {"id": 7, "name": "Cereales y Granos"} + ], + }, }, ] - if args[0] == "model.product.category.read": - return [{"id": 7, "name": "Cereales y Granos"}] raise Exception(f"args no esperados: {args}") mock_call.side_effect = fake_call @@ -135,11 +142,13 @@ class TestCategoryTrytonImport(TestCase): { "id": 1, "name": "Producto 1", - "template.": {"id": 999, "categories": [6]}, + "template.": { + "id": 999, + "categories": [6], + "categories.": [{"id": 6, "name": "Alimentos"}], + }, }, ] - 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 @@ -182,11 +191,13 @@ class TestCategoryTrytonImport(TestCase): { "id": 1, "name": "Producto 1", - "template.": {"id": 999, "categories": [6]}, + "template.": { + "id": 999, + "categories": [6], + "categories.": [{"id": 6, "name": None}], + }, }, ] - if args[0] == "model.product.category.read": - return [{"id": 6, "name": None}] raise Exception(f"args no esperados: {args}") mock_call.side_effect = fake_call diff --git a/tienda_ilusion/don_confiao/tests/test_product_categories_api.py b/tienda_ilusion/don_confiao/tests/test_product_categories_api.py index 1e200e0..67db842 100644 --- a/tienda_ilusion/don_confiao/tests/test_product_categories_api.py +++ b/tienda_ilusion/don_confiao/tests/test_product_categories_api.py @@ -157,11 +157,13 @@ class ProductCategoryApiTest(TestCase): { "id": 1, "name": "Producto 1", - "template.": {"id": 999, "categories": [6]}, + "template.": { + "id": 999, + "categories": [6], + "categories.": [{"id": 6, "name": "Alimentos"}], + }, }, ] - 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 diff --git a/tienda_ilusion/don_confiao/tests/test_products_from_tryton.py b/tienda_ilusion/don_confiao/tests/test_products_from_tryton.py index 70db33a..c931ded 100644 --- a/tienda_ilusion/don_confiao/tests/test_products_from_tryton.py +++ b/tienda_ilusion/don_confiao/tests/test_products_from_tryton.py @@ -56,6 +56,7 @@ class TestProductsFromTryton(TestCase, LoginMixin): "default_uom.rec_name", "list_price", "template.categories", + "template.categories.name", ], {"company": 1}, ) @@ -157,6 +158,7 @@ class TestProductsFromTryton(TestCase, LoginMixin): "default_uom.rec_name", "list_price", "template.categories", + "template.categories.name", ], {"company": 1}, ) @@ -226,6 +228,7 @@ class TestProductsFromTryton(TestCase, LoginMixin): "default_uom.rec_name", "list_price", "template.categories", + "template.categories.name", ], {"company": 1}, ) @@ -288,6 +291,7 @@ class TestProductsFromTryton(TestCase, LoginMixin): "default_uom.rec_name", "list_price", "template.categories", + "template.categories.name", ], {"company": 1}, ) @@ -354,6 +358,7 @@ class TestProductsFromTryton(TestCase, LoginMixin): "default_uom.rec_name", "list_price", "template.categories", + "template.categories.name", ], {"company": 1}, ) @@ -364,22 +369,17 @@ class TestProductsFromTryton(TestCase, LoginMixin): "list_price": Decimal("25000"), "name": "Producto 1", "default_uom.": {"id": 1, "rec_name": "Unit"}, - "template.": {"id": 999, "categories": [5, 8]}, + "template.": { + "id": 999, + "categories": [5, 8], + "categories.": [ + {"id": 5, "name": "Abarrotes"}, + {"id": 8, "name": "Bebidas"}, + ], + }, }, ] - category_read = "model.product.category.read" - category_args = ( - [5, 8], - ["id", "name"], - {"company": 1}, - ) - if args == (category_read, category_args): - return [ - {"id": 5, "name": "Abarrotes"}, - {"id": 8, "name": "Bebidas"}, - ] - raise Exception( f"Sorry, args non expected on this test: {args}" ) @@ -445,6 +445,7 @@ class TestProductsFromTryton(TestCase, LoginMixin): "default_uom.rec_name", "list_price", "template.categories", + "template.categories.name", ], {"company": 1}, ) @@ -455,21 +456,14 @@ class TestProductsFromTryton(TestCase, LoginMixin): "list_price": Decimal("25000"), "name": "Producto 1", "default_uom.": {"id": 1, "rec_name": "Unit"}, - "template.": {"id": 999, "categories": [7]}, + "template.": { + "id": 999, + "categories": [7], + "categories.": [{"id": 7, "name": "Cereales"}], + }, }, ] - category_read = "model.product.category.read" - category_args = ( - [7], - ["id", "name"], - {"company": 1}, - ) - if args == (category_read, category_args): - return [ - {"id": 7, "name": "Cereales"}, - ] - raise Exception( f"Sorry, args non expected on this test: {args}" ) @@ -536,6 +530,7 @@ class TestProductsFromTryton(TestCase, LoginMixin): "default_uom.rec_name", "list_price", "template.categories", + "template.categories.name", ], {"company": 1}, ) @@ -546,21 +541,14 @@ class TestProductsFromTryton(TestCase, LoginMixin): "list_price": Decimal("25000"), "name": "Producto 1", "default_uom.": {"id": 1, "rec_name": "Unit"}, - "template.": {"id": 999, "categories": [5]}, + "template.": { + "id": 999, + "categories": [5], + "categories.": [{"id": 5, "name": "Abarrotes"}], + }, }, ] - category_read = "model.product.category.read" - category_args = ( - [5], - ["id", "name"], - {"company": 1}, - ) - if args == (category_read, category_args): - return [ - {"id": 5, "name": "Abarrotes"}, - ] - raise Exception( f"Sorry, args non expected on this test: {args}" ) @@ -627,6 +615,7 @@ class TestProductsFromTryton(TestCase, LoginMixin): "default_uom.rec_name", "list_price", "template.categories", + "template.categories.name", ], {"company": 1}, ) @@ -688,6 +677,7 @@ class TestProductsFromTryton(TestCase, LoginMixin): "default_uom.rec_name", "list_price", "template.categories", + "template.categories.name", ], {"company": 1}, ) @@ -698,21 +688,16 @@ class TestProductsFromTryton(TestCase, LoginMixin): "list_price": Decimal("4500"), "name": "Papa", "default_uom.": {"id": 2, "rec_name": "Kilogram"}, - "template.": {"id": 999, "categories": [9]}, + "template.": { + "id": 999, + "categories": [9], + "categories.": [ + {"id": 9, "name": "Tubérculos"} + ], + }, }, ] - category_read = "model.product.category.read" - category_args = ( - [9], - ["id", "name"], - {"company": 1}, - ) - if args == (category_read, category_args): - return [ - {"id": 9, "name": "Tubérculos"}, - ] - raise Exception( f"Sorry, args non expected on this test: {args}" ) @@ -786,6 +771,7 @@ class TestProductsFromTryton(TestCase, LoginMixin): "default_uom.rec_name", "list_price", "template.categories", + "template.categories.name", ], {"company": 1}, ) @@ -796,21 +782,14 @@ class TestProductsFromTryton(TestCase, LoginMixin): "list_price": Decimal("4500"), "name": "Papa", "default_uom.": {"id": 2, "rec_name": "Kilogram"}, - "template.": {"id": 999, "categories": [8]}, + "template.": { + "id": 999, + "categories": [8], + "categories.": [{"id": 8, "name": "Bebidas"}], + }, }, ] - category_read = "model.product.category.read" - category_args = ( - [8], - ["id", "name"], - {"company": 1}, - ) - if args == (category_read, category_args): - return [ - {"id": 8, "name": "Bebidas"}, - ] - raise Exception( f"Sorry, args non expected on this test: {args}" ) -- 2.49.1 From 86a67834d165e91352ee7718af5b488c20f96362 Mon Sep 17 00:00:00 2001 From: monomono Date: Mon, 12 Oct 2026 16:20:05 -0500 Subject: [PATCH 09/13] #51 docs(opencode): skill tryton-rpc-probe y agente de proyecto Documenta como hacer requests RPC de prueba contra Tryton con sabatron-tryton-rpc-client: - .opencode/skills/tryton-rpc-probe/SKILL.md: comando base con docker compose dev (--no-deps), credenciales desde .env, como revelar estados HTTP (401/403/429) que la libreria enmascara, convencion de agrupacion de campos con punto ('template.' -> 'categories.') y sondas de ejemplo - .opencode/agents/tryton-rpc-probe.md: subagente que obliga a cargar el skill - AGENTS.md: referencia al skill para todos los agents del proyecto --- .opencode/agents/tryton-rpc-probe.md | 23 +++ .opencode/skills/tryton-rpc-probe/SKILL.md | 154 +++++++++++++++++++++ AGENTS.md | 18 +++ 3 files changed, 195 insertions(+) create mode 100644 .opencode/agents/tryton-rpc-probe.md create mode 100644 .opencode/skills/tryton-rpc-probe/SKILL.md diff --git a/.opencode/agents/tryton-rpc-probe.md b/.opencode/agents/tryton-rpc-probe.md new file mode 100644 index 0000000..1db48b2 --- /dev/null +++ b/.opencode/agents/tryton-rpc-probe.md @@ -0,0 +1,23 @@ +--- +description: Ejecuta requests RPC de prueba contra Tryton usando sabatron-tryton-rpc-client, siguiendo el skill tryton-rpc-probe. Úsalo para probes aislados (model.product.*, model.product.category.*, fields_get, credenciales staging). +mode: subagent +permission: + bash: + "docker compose *": allow + "*": ask +--- + +Eres un agente especializado en ejecutar requests RPC de prueba (probes) +contra Tryton dentro de este proyecto. + +Antes de ejecutar cualquier probe, **carga el skill `tryton-rpc-probe`** y sigue +exactamente sus instrucciones: +- Usa el paquete `sabatron-tryton-rpc-client` (nunca curl). +- Lee las credenciales `TRYTON_*` desde `.env` y pásalas con `-e` al contenedor. +- Usa `docker compose -f docker-compose.dev.yml run --rm --no-deps ...` para no + levantar dependencias. +- Ante `HTTP 401` (credenciales), `403` (denegado) o `429` (rate-limit) usa la + técnica de revelar el estado HTTP real descripta en el skill y reporta el + código al usuario en lugar de asumir el motivo. +- Reporta los resultados crudos (campos, agrupaciones `field.`, ids) tal cual + los devuelve Tryton, y su implicación para la implementación. \ No newline at end of file diff --git a/.opencode/skills/tryton-rpc-probe/SKILL.md b/.opencode/skills/tryton-rpc-probe/SKILL.md new file mode 100644 index 0000000..3cd3661 --- /dev/null +++ b/.opencode/skills/tryton-rpc-probe/SKILL.md @@ -0,0 +1,154 @@ +--- +name: tryton-rpc-probe +description: Use cuando se necesite hacer requests RPC de prueba contra Tryton (probes) con el paquete sabatron-tryton-rpc-client. Dispara ante tryton, sabatron, probe rpc, model.product.search/read, model.product.category.read, fields_get, credenciales Tryton staging o verificaciones empíricas de respuestas RPC. NO usar curl: solo el paquete de python. +--- + +# Probes RPC a Tryton con sabatron-tryton-rpc-client + +Guía para ejecutar requests RPC de prueba (probes) contra Tryton dentro de este +proyecto, usando el paquete `sabatron-tryton-rpc-client` y el contenedor de +desarrollo. Sirve para verificar campos, modelos, credenciales y respuestas +antes de planear o implementar cambios de integración (TDD: confirmar empíricamente +antes de asumir). + +## Reglas obligatorias + +- **Nunca usar curl.** Solo el paquete python `sabatron-tryton-rpc-client` + (el RPC de Tryton enmascara/muestra estados HTTP distintos que confunden a curl). +- **No hardcodear credenciales**: leer los valores `TRYTON_HOST`, + `TRYTON_DATABASE`, `TRYTON_USERNAME`, `TRYTON_PASSWORD` de `.env` (raíz del + proyecto) y pasarlos al contenedor con `-e`. No imprimir la contraseña (a lo + sumo imprimir el nombre de la base de datos). +- **Respetar rate-limit**: ante `HTTP 429` esperar (p. ej. ~120s) antes de + reintentar; no hacer ráfagas de logins fallidos. +- Verificar primero el login/credenciales con una sonda mínima antes de asumir + que falla por campos o datos. + +## Comando base (plantilla) + +Todos los probes se ejecutan con `docker compose` dev sin levantar dependencias +(`--no-deps`): + +```bash +docker compose -f docker-compose.dev.yml run --rm --no-deps \ + -e TRYTON_HOST= \ + -e TRYTON_DATABASE= \ + -e TRYTON_USERNAME= \ + -e TRYTON_PASSWORD= \ + django python -c ' +import os +from sabatron_tryton_rpc_client.client import Client +c = Client(hostname=os.environ["TRYTON_HOST"], database=os.environ["TRYTON_DATABASE"], username=os.environ["TRYTON_USERNAME"], password=os.environ["TRYTON_PASSWORD"]) +c.connect() +print("CONNECTED OK", os.environ["TRYTON_DATABASE"]) +ctx = {"company": 1} +print(c.call("model.product.product.search", [[["salable", "=", True]], 0, 5, [["rec_name", "ASC"], ["id", None]], ctx])) +' +``` + +Patrón de la librería: + +```python +from sabatron_tryton_rpc_client.client import Client +c = Client(hostname=..., database=..., username=..., password=...) +c.connect() # login +resultado = c.call("modelo.metodo", (args..., contexto)) # contexto {"company": 1} +``` + +## Ver estados HTTP reales (401/403/429 enmascarados) + +La librería **enmascara** los errores HTTP del login como +`UnboundLocalError: cannot access local variable 'response'...` (bug en +`jsonrpc.py:__request`). Para ver el código real, monkeypatchear el transporte +XML-RPC y capturar `ProtocolError`: + +```python +import xmlrpc.client +from sabatron_tryton_rpc_client import jsonrpc +orig = xmlrpc.client.Transport.request +def wrapped(self, host, handler, body, verbose=False): + try: + return orig(self, host, handler, body, verbose=verbose) + except xmlrpc.client.ProtocolError as e: + print("HTTP_STATUS", e.errcode, repr(e.errmsg)) + raise +jsonrpc.Transport.request = wrapped +# luego Client(...).connect() / ServerProxy(...) +``` + +Guía rápida de códigos vistos en el proyecto: + +- `HTTP 200` + fault JSON (p. ej. `FATAL: database "..." does not exist`): la DB + no existe o el endpoint responde con error de aplicacion. +- `HTTP 401`: credenciales rechazadas (usuario/password) en esa base de datos. +- `HTTP 403`: ruta/método denegado (p. ej. `common.db.list`). +- `HTTP 429`: rate-limit, esperar antes de reintentar. + +## Convención de campos RPC de Tryton (agrupación con punto) + +Los campos con punto (ej. `template.categories.name`) NO llegan como una clave +plana: Tryton los agrupa bajo la clave del modelo padre con puntos. + +En `model.product.product.read`, con los fields +`["id", "name", "template.categories", "template.categories.name"]`, la +respuesta llega así: + +```json +{ + "id": 488, + "name": "Cloruro de magnesio", + "template.": { + "id": 1248, + "categories": [6], + "categories.": [{"id": 6, "name": "Alimentos Transformados Y Granos"}] + } +} +``` + +Combinaciones verificadas en staging (Tryton 8): + +| fields solicitados | claves resultantes | +|---|---| +| `template.categories` | `categories: [ids]` (sin `categories.`) | +| `template.categories.id` | `categories.: [{"id": ...}]` | +| `template.categories.name` | `categories.: [{"id", "name"}]` (id viene incluido) | +| ambos `.id` + `.name` | igual que solo `.name` | + +Conclusión operativa: pedir `template.categories.name` basta para traer ids y +nombres; `template.categories.id` es redundante. No hay `active` en +`product.category` (fields_get lo confirma) → activar/desactivar categorías es +solo local. + +## Ejemplos de sondas útiles + +Login + datos de productos con categorias anidadas: + +```python +ctx = {"company": 1} +pids = c.call("model.product.product.search", [[["salable", "=", True]], 0, 1000, [["rec_name", "ASC"], ["id", None]], ctx]) +detalles = c.call("model.product.product.read", (pids, ["id", "name", "default_uom.id", "default_uom.rec_name", "list_price", "template.categories", "template.categories.name"], ctx)) +``` + +Listar los campos reales de un modelo (evita asumir campos): + +```python +campos = c.call("model.product.category.fields_get", ([], ctx)) +print(sorted(campos)) +``` + +Listar bases de datos (suele estar bloqueado con 403): + +```python +from sabatron_tryton_rpc_client.jsonrpc import ServerProxy +p = ServerProxy(hostname, 8000, "") +print(p.common.db.list()) +``` + +## Integración con tests + +- Los tests de integración NO llaman Tryton real: mockean + `sabatron_tryton_rpc_client.client.Client.call` y `.connect` + (ver `don_confiao/tests/test_products_from_tryton.py`, + `test_categories_from_tryton.py`). +- Los probes de esta skill son solo para verificación empírica/planeación + contra staging, no para la suite de tests. \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 47146f3..cf68152 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -468,3 +468,21 @@ Antes de ir a producción, verificar: ## Integraciones - **Tryton ERP**: Integración mediante sabatron-tryton-rpc-client para sincronización de clientes, productos y ventas + +## Probes RPC a Tryton + +Cuando se necesiten requests RPC de prueba contra Tryton (verificación empírica +de campos/modelos/credenciales, planeación de integraciones, etc.) **cargar el +skill `tryton-rpc-probe`** (`.opencode/skills/tryton-rpc-probe/SKILL.md`) y +seguir sus instrucciones: + +- Usar `sabatron-tryton-rpc-client` en el contenedor dev (`run --rm --no-deps`), + nunca curl. +- Credenciales desde `.env` (TRYTON_HOST/DATABASE/USERNAME/PASSWORD), sin + hardcodear ni imprimir secretos. +- Los errores HTTP (401/403/429) llegan enmascarados como `UnboundLocalError`: + revelar el código real monkeypatcheando `xmlrpc.client.Transport.request`. +- Los campos con punto de Tryton se agrupan en `field.` (ej. `template.` → + `categories.`); pedir `template.categories.name` ya trae id+nombre. +- Para trabajo de integración/código se usan mocks de `Client.call` (no Tryton + real) en `don_confiao/tests/test_*_from_tryton.py`. -- 2.49.1 From 13e14e5d2e83d3809a585edc73172c96f1780f87 Mon Sep 17 00:00:00 2001 From: monomono Date: Mon, 12 Oct 2026 16:46:18 -0500 Subject: [PATCH 10/13] #51 chore(coolify): docker-compose y Dockerfile dedicados para deploy en Coolify - docker-compose.coolify.yaml: stack postgres+django sin env_file (variables via UI de Coolify), sin puertos publicos de BD, sin nginx, expose 8000 - django.coolify.Dockerfile: copia el codigo dentro de la imagen (no toca django.Dockerfile usado por dev/staging/prod) --- django.coolify.Dockerfile | 15 ++++++++ docker-compose.coolify.yaml | 71 +++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 django.coolify.Dockerfile create mode 100644 docker-compose.coolify.yaml diff --git a/django.coolify.Dockerfile b/django.coolify.Dockerfile new file mode 100644 index 0000000..6f1d4e7 --- /dev/null +++ b/django.coolify.Dockerfile @@ -0,0 +1,15 @@ +# Dockerfile dedicado para el despliegue en Coolify. +# No toca django.Dockerfile (que usan los compose dev/staging/prod locales). +# Diferencias: copia el código de tienda_ilusion/ dentro de la imagen (Coolify +# no monta el directorio local) y expone PYTHONUNBUFFERED para logs en tiempo real. +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app/ + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY tienda_ilusion/ /app/ \ No newline at end of file diff --git a/docker-compose.coolify.yaml b/docker-compose.coolify.yaml new file mode 100644 index 0000000..2900daf --- /dev/null +++ b/docker-compose.coolify.yaml @@ -0,0 +1,71 @@ +# Docker Compose para despliegue en Coolify. +# Copia este stack tal cual: Coolify lo clona y parsea (Build Pack: Docker Compose). +# En Coolify indica: +# - Docker Compose Location: docker-compose.coolify.yaml +# - Service 'django' -> Domain: https://api.tudominio.com (puerto interno 8000) +# - Environment Variables se crean solas a partir de los ${VAR} de abajo. +# NO usa env_file (.env.production): las variables se definen en la UI de Coolify. + +services: + postgres: + image: postgres:15-alpine + restart: unless-stopped + environment: + - POSTGRES_USER=${DB_USER:?Define DB_USER en Coolify} + - POSTGRES_PASSWORD=${DB_PASSWORD:?Define DB_PASSWORD en Coolify} + - POSTGRES_DB=${DB_NAME:?Define DB_NAME en Coolify} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"] + interval: 10s + timeout: 5s + retries: 5 + + django: + build: + context: ./ + dockerfile: django.coolify.Dockerfile + restart: unless-stopped + environment: + - DJANGO_ENV=production + - DEBUG=${DEBUG:-False} + - SECRET_KEY=${SECRET_KEY:?Define SECRET_KEY en Coolify} + - ALLOWED_HOSTS=${ALLOWED_HOSTS:?Define ALLOWED_HOSTS en Coolify} + - CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:?Define CORS_ALLOWED_ORIGINS en Coolify} + - CSRF_TRUSTED_ORIGINS=${CSRF_TRUSTED_ORIGINS:?Define CSRF_TRUSTED_ORIGINS en Coolify} + - DB_HOST=postgres + - DB_PORT=5432 + - DB_NAME=${DB_NAME:?Define DB_NAME en Coolify} + - DB_USER=${DB_USER:?Define DB_USER en Coolify} + - DB_PASSWORD=${DB_PASSWORD:?Define DB_PASSWORD en Coolify} + - EMAIL_HOST=${EMAIL_HOST:-smtp.gmail.com} + - EMAIL_PORT=${EMAIL_PORT:-587} + - EMAIL_USE_TLS=${EMAIL_USE_TLS:-True} + - EMAIL_HOST_USER=${EMAIL_HOST_USER:-} + - EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD:-} + - DEFAULT_FROM_EMAIL=${DEFAULT_FROM_EMAIL:-noreply@tiendailusion.com} + - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@tiendailusion.com} + - TRYTON_HOST=${TRYTON_HOST:?Define TRYTON_HOST en Coolify} + - TRYTON_DATABASE=${TRYTON_DATABASE:?Define TRYTON_DATABASE en Coolify} + - TRYTON_USERNAME=${TRYTON_USERNAME:?Define TRYTON_USERNAME en Coolify} + - TRYTON_PASSWORD=${TRYTON_PASSWORD:?Define TRYTON_PASSWORD en Coolify} + expose: + - "8000" + volumes: + - static_volume:/app/staticfiles + - media_volume:/app/media + - logs_volume:/app/logs + depends_on: + postgres: + condition: service_healthy + command: > + sh -c "python manage.py migrate && + python manage.py collectstatic --noinput && + gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 4 --timeout 120" + +volumes: + postgres_data: + static_volume: + media_volume: + logs_volume: \ No newline at end of file -- 2.49.1 From 786359c10263662ee278946df6d2b0c45f8106c2 Mon Sep 17 00:00:00 2001 From: monomono Date: Mon, 12 Oct 2026 21:57:28 -0500 Subject: [PATCH 11/13] #51 chore(registry): imagen del backend para publicar en el registry de Gitea - django.registry.Dockerfile: imagen autocontenida (codigo + entrypoint) - docker/entrypoint.sh: migrate + collectstatic + gunicorn; con args ejecuta ese comando - scripts/build-push-image.sh: build y push a gitea.onecluster.org/oneteam/don_confiao_backend (latest + git sha) --- django.registry.Dockerfile | 20 ++++++++++++++++++++ docker/entrypoint.sh | 23 +++++++++++++++++++++++ scripts/build-push-image.sh | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+) create mode 100644 django.registry.Dockerfile create mode 100755 docker/entrypoint.sh create mode 100755 scripts/build-push-image.sh diff --git a/django.registry.Dockerfile b/django.registry.Dockerfile new file mode 100644 index 0000000..f4bd233 --- /dev/null +++ b/django.registry.Dockerfile @@ -0,0 +1,20 @@ +# Imagen de produccion del backend para publicar en el registry de Gitea. +# Autocontenida: incluye el codigo y un entrypoint que aplica migraciones, +# colecta estaticos y arranca gunicorn. Coolify solo hace pull y define env vars. +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app/ + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY tienda_ilusion/ /app/ +COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +EXPOSE 8000 + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] \ No newline at end of file diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..f4cb406 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,23 @@ +#!/bin/sh +# Entrypoint de la imagen de produccion del backend. +# +# Sin argumentos: aplica migraciones, colecta estaticos y arranca gunicorn. +# Con argumentos: ejecuta ese comando tal cual (util para operaciones, p. ej. +# docker run --rm python manage.py createsuperuser). +set -e + +if [ "$#" -gt 0 ]; then + exec "$@" +fi + +echo "[entrypoint] Aplicando migraciones..." +python manage.py migrate --noinput + +echo "[entrypoint] Colectando estaticos..." +python manage.py collectstatic --noinput + +echo "[entrypoint] Arrancando gunicorn en 0.0.0.0:8000..." +exec gunicorn config.wsgi:application \ + --bind 0.0.0.0:8000 \ + --workers "${GUNICORN_WORKERS:-4}" \ + --timeout 120 \ No newline at end of file diff --git a/scripts/build-push-image.sh b/scripts/build-push-image.sh new file mode 100755 index 0000000..cc40953 --- /dev/null +++ b/scripts/build-push-image.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Construye y publica la imagen del backend en el container registry de Gitea. +# +# Uso: +# scripts/build-push-image.sh +# +# Requiere haber hecho login previo en el registry (hazlo tu, con tu token): +# docker login gitea.onecluster.org -u +# (password = tu token con scope write:package) +# +# Variables opcionales: +# REGISTRY (default: gitea.onecluster.org) +# OWNER (default: oneteam) +# IMAGE (default: don_confiao_backend) +# TAG (default: git short sha) +set -euo pipefail + +REGISTRY="${REGISTRY:-gitea.onecluster.org}" +OWNER="${OWNER:-oneteam}" +IMAGE="${IMAGE:-don_confiao_backend}" +DOCKERFILE="${DOCKERFILE:-django.registry.Dockerfile}" + +TAG="${TAG:-$(git rev-parse --short HEAD)}" +FULL="${REGISTRY}/${OWNER}/${IMAGE}" + +echo "==> Imagen: ${FULL}:latest y ${FULL}:${TAG}" +echo "==> Dockerfile: ${DOCKERFILE}" + +docker build -f "${DOCKERFILE}" \ + -t "${FULL}:latest" \ + -t "${FULL}:${TAG}" \ + . + +docker push "${FULL}:latest" +docker push "${FULL}:${TAG}" + +echo "==> Publicado: ${FULL}:latest , ${FULL}:${TAG}" \ No newline at end of file -- 2.49.1 From df3549353a9314b0243af774bc8bea1bb0193f5e Mon Sep 17 00:00:00 2001 From: monomono Date: Mon, 12 Oct 2026 22:28:44 -0500 Subject: [PATCH 12/13] #51 chore(registry): fijar DJANGO_ENV=production por defecto en la imagen --- django.registry.Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/django.registry.Dockerfile b/django.registry.Dockerfile index f4bd233..bc5605c 100644 --- a/django.registry.Dockerfile +++ b/django.registry.Dockerfile @@ -4,7 +4,8 @@ FROM python:3.12-slim ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 + PYTHONUNBUFFERED=1 \ + DJANGO_ENV=production WORKDIR /app/ -- 2.49.1 From 2dbe55a660a66fe6a616efefaa8d57831710ed61 Mon Sep 17 00:00:00 2001 From: monomono Date: Sat, 17 Oct 2026 17:54:16 -0500 Subject: [PATCH 13/13] #51 chore(coolify): eliminar variante docker-compose, deploy por imagen del registry --- django.coolify.Dockerfile | 15 -------- docker-compose.coolify.yaml | 71 ------------------------------------- 2 files changed, 86 deletions(-) delete mode 100644 django.coolify.Dockerfile delete mode 100644 docker-compose.coolify.yaml diff --git a/django.coolify.Dockerfile b/django.coolify.Dockerfile deleted file mode 100644 index 6f1d4e7..0000000 --- a/django.coolify.Dockerfile +++ /dev/null @@ -1,15 +0,0 @@ -# Dockerfile dedicado para el despliegue en Coolify. -# No toca django.Dockerfile (que usan los compose dev/staging/prod locales). -# Diferencias: copia el código de tienda_ilusion/ dentro de la imagen (Coolify -# no monta el directorio local) y expone PYTHONUNBUFFERED para logs en tiempo real. -FROM python:3.12-slim - -ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 - -WORKDIR /app/ - -COPY requirements.txt ./ -RUN pip install --no-cache-dir -r requirements.txt - -COPY tienda_ilusion/ /app/ \ No newline at end of file diff --git a/docker-compose.coolify.yaml b/docker-compose.coolify.yaml deleted file mode 100644 index 2900daf..0000000 --- a/docker-compose.coolify.yaml +++ /dev/null @@ -1,71 +0,0 @@ -# Docker Compose para despliegue en Coolify. -# Copia este stack tal cual: Coolify lo clona y parsea (Build Pack: Docker Compose). -# En Coolify indica: -# - Docker Compose Location: docker-compose.coolify.yaml -# - Service 'django' -> Domain: https://api.tudominio.com (puerto interno 8000) -# - Environment Variables se crean solas a partir de los ${VAR} de abajo. -# NO usa env_file (.env.production): las variables se definen en la UI de Coolify. - -services: - postgres: - image: postgres:15-alpine - restart: unless-stopped - environment: - - POSTGRES_USER=${DB_USER:?Define DB_USER en Coolify} - - POSTGRES_PASSWORD=${DB_PASSWORD:?Define DB_PASSWORD en Coolify} - - POSTGRES_DB=${DB_NAME:?Define DB_NAME en Coolify} - volumes: - - postgres_data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"] - interval: 10s - timeout: 5s - retries: 5 - - django: - build: - context: ./ - dockerfile: django.coolify.Dockerfile - restart: unless-stopped - environment: - - DJANGO_ENV=production - - DEBUG=${DEBUG:-False} - - SECRET_KEY=${SECRET_KEY:?Define SECRET_KEY en Coolify} - - ALLOWED_HOSTS=${ALLOWED_HOSTS:?Define ALLOWED_HOSTS en Coolify} - - CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:?Define CORS_ALLOWED_ORIGINS en Coolify} - - CSRF_TRUSTED_ORIGINS=${CSRF_TRUSTED_ORIGINS:?Define CSRF_TRUSTED_ORIGINS en Coolify} - - DB_HOST=postgres - - DB_PORT=5432 - - DB_NAME=${DB_NAME:?Define DB_NAME en Coolify} - - DB_USER=${DB_USER:?Define DB_USER en Coolify} - - DB_PASSWORD=${DB_PASSWORD:?Define DB_PASSWORD en Coolify} - - EMAIL_HOST=${EMAIL_HOST:-smtp.gmail.com} - - EMAIL_PORT=${EMAIL_PORT:-587} - - EMAIL_USE_TLS=${EMAIL_USE_TLS:-True} - - EMAIL_HOST_USER=${EMAIL_HOST_USER:-} - - EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD:-} - - DEFAULT_FROM_EMAIL=${DEFAULT_FROM_EMAIL:-noreply@tiendailusion.com} - - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@tiendailusion.com} - - TRYTON_HOST=${TRYTON_HOST:?Define TRYTON_HOST en Coolify} - - TRYTON_DATABASE=${TRYTON_DATABASE:?Define TRYTON_DATABASE en Coolify} - - TRYTON_USERNAME=${TRYTON_USERNAME:?Define TRYTON_USERNAME en Coolify} - - TRYTON_PASSWORD=${TRYTON_PASSWORD:?Define TRYTON_PASSWORD en Coolify} - expose: - - "8000" - volumes: - - static_volume:/app/staticfiles - - media_volume:/app/media - - logs_volume:/app/logs - depends_on: - postgres: - condition: service_healthy - command: > - sh -c "python manage.py migrate && - python manage.py collectstatic --noinput && - gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 4 --timeout 120" - -volumes: - postgres_data: - static_volume: - media_volume: - logs_volume: \ No newline at end of file -- 2.49.1