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`. diff --git a/django.registry.Dockerfile b/django.registry.Dockerfile new file mode 100644 index 0000000..bc5605c --- /dev/null +++ b/django.registry.Dockerfile @@ -0,0 +1,21 @@ +# 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 \ + DJANGO_ENV=production + +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/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/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 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) 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/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..191d43c 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 @@ -46,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/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/services/tryton/categories.py b/tienda_ilusion/don_confiao/services/tryton/categories.py new file mode 100644 index 0000000..930dafe --- /dev/null +++ b/tienda_ilusion/don_confiao/services/tryton/categories.py @@ -0,0 +1,152 @@ +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_names = self._extract_category_names(tryton_products) + if not category_names: + return self._empty_report() + + 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")} + 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", + "template.categories.name", + ] + method = "model.product.product.read" + params = (product_ids, tryton_fields, 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 _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 6703c0e..344eddc 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: @@ -106,57 +107,28 @@ 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: - 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)} ) @@ -174,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""" @@ -214,8 +192,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""" @@ -254,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) @@ -269,6 +290,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_categories_from_tryton.py b/tienda_ilusion/don_confiao/tests/test_categories_from_tryton.py new file mode 100644 index 0000000..a03c5b1 --- /dev/null +++ b/tienda_ilusion/don_confiao/tests/test_categories_from_tryton.py @@ -0,0 +1,211 @@ +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], + "categories.": [{"id": 6, "name": "Alimentos"}], + }, + }, + { + "id": 2, + "name": "Producto 2", + "template.": { + "id": 999, + "categories": [7], + "categories.": [{"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], + "categories.": [ + {"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], + "categories.": [{"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], + "categories.": [{"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 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..d32d1c1 --- /dev/null +++ b/tienda_ilusion/don_confiao/tests/test_product_categories.py @@ -0,0 +1,102 @@ +from django.test import TestCase + +from ..models.products import Product, 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) + + +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, + } + ], + ) + + 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 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..67db842 --- /dev/null +++ b/tienda_ilusion/don_confiao/tests/test_product_categories_api.py @@ -0,0 +1,226 @@ +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], + "categories.": [{"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/tests/test_products_from_tryton.py b/tienda_ilusion/don_confiao/tests/test_products_from_tryton.py index 4cb594a..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}, ) @@ -661,7 +650,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 @@ -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}" ) @@ -724,12 +709,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 +737,98 @@ 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", + "template.categories.name", + ], + {"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], + "categories.": [{"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")], + ) 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,