categorias-productos #51 #52
23
.opencode/agents/tryton-rpc-probe.md
Normal file
23
.opencode/agents/tryton-rpc-probe.md
Normal file
@@ -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.
|
||||||
154
.opencode/skills/tryton-rpc-probe/SKILL.md
Normal file
154
.opencode/skills/tryton-rpc-probe/SKILL.md
Normal file
@@ -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=<host de .env> \
|
||||||
|
-e TRYTON_DATABASE=<db de .env> \
|
||||||
|
-e TRYTON_USERNAME=<usuario de .env> \
|
||||||
|
-e TRYTON_PASSWORD=<password de .env> \
|
||||||
|
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.
|
||||||
18
AGENTS.md
18
AGENTS.md
@@ -468,3 +468,21 @@ Antes de ir a producción, verificar:
|
|||||||
|
|
||||||
## Integraciones
|
## Integraciones
|
||||||
- **Tryton ERP**: Integración mediante sabatron-tryton-rpc-client para sincronización de clientes, productos y ventas
|
- **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`.
|
||||||
|
|||||||
21
django.registry.Dockerfile
Normal file
21
django.registry.Dockerfile
Normal file
@@ -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"]
|
||||||
@@ -137,3 +137,49 @@ post importar_productos_de_tryton
|
|||||||
#+begin_src json
|
#+begin_src json
|
||||||
[]
|
[]
|
||||||
#+end_src
|
#+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
|
||||||
|
|||||||
23
docker/entrypoint.sh
Executable file
23
docker/entrypoint.sh
Executable file
@@ -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 <imagen> 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
|
||||||
37
scripts/build-push-image.sh
Executable file
37
scripts/build-push-image.sh
Executable file
@@ -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 <usuario-gitea>
|
||||||
|
# (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}"
|
||||||
@@ -77,6 +77,12 @@ class ProductAdmin(admin.ModelAdmin):
|
|||||||
search_fields = ("id", "name",)
|
search_fields = ("id", "name",)
|
||||||
list_filter = ("name", "id")
|
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(Payment)
|
||||||
admin.site.register(ReconciliationJar)
|
admin.site.register(ReconciliationJar)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from .catalogue_images import CatalogueImageViewSet
|
from .catalogue_images import CatalogueImageViewSet
|
||||||
from .products import ProductView, ProductsFromTrytonView
|
from .products import ProductView, ProductsFromTrytonView
|
||||||
|
from .categories import ProductCategoryView
|
||||||
from .customers import CustomerView, CustomersFromTrytonView
|
from .customers import CustomerView, CustomersFromTrytonView
|
||||||
from .sales import (
|
from .sales import (
|
||||||
SaleView,
|
SaleView,
|
||||||
@@ -35,6 +36,8 @@ __all__ = [
|
|||||||
# Products
|
# Products
|
||||||
"ProductView",
|
"ProductView",
|
||||||
"ProductsFromTrytonView",
|
"ProductsFromTrytonView",
|
||||||
|
# Product Categories
|
||||||
|
"ProductCategoryView",
|
||||||
# Customers
|
# Customers
|
||||||
"CustomerView",
|
"CustomerView",
|
||||||
"CustomersFromTrytonView",
|
"CustomersFromTrytonView",
|
||||||
|
|||||||
85
tienda_ilusion/don_confiao/api/categories.py
Normal file
85
tienda_ilusion/don_confiao/api/categories.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
from rest_framework import viewsets
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
|
||||||
|
from ..models.products import ProductCategory
|
||||||
|
from ..serializers import ProductCategorySerializer
|
||||||
|
from ..permissions import IsAdministrator, IsNotPublico
|
||||||
|
from ..services.tryton.categories import CategoryTrytonService
|
||||||
|
from ..services.tryton.client import get_tryton_client
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCategoryView(viewsets.ModelViewSet):
|
||||||
|
queryset = ProductCategory.objects.all()
|
||||||
|
serializer_class = ProductCategorySerializer
|
||||||
|
|
||||||
|
def create(self, request):
|
||||||
|
return Response(status=405)
|
||||||
|
|
||||||
|
def destroy(self, request, pk=None):
|
||||||
|
return Response(status=405)
|
||||||
|
|
||||||
|
def get_permissions(self):
|
||||||
|
if self.action in ("list", "retrieve"):
|
||||||
|
return [IsNotPublico()]
|
||||||
|
return [IsAuthenticated(), IsAdministrator()]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""
|
||||||
|
Filters categories by active status for list operations.
|
||||||
|
Query params for list:
|
||||||
|
- active=true (default): Only active categories
|
||||||
|
- active=false: Only inactive categories
|
||||||
|
- active=all: All categories regardless of status
|
||||||
|
"""
|
||||||
|
queryset = ProductCategory.objects.all()
|
||||||
|
|
||||||
|
if self.action != "list":
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
active_param = self.request.query_params.get("active", "true")
|
||||||
|
|
||||||
|
if active_param.lower() == "all":
|
||||||
|
return queryset
|
||||||
|
elif active_param.lower() in ["true", "1", "yes"]:
|
||||||
|
return queryset.filter(active=True)
|
||||||
|
elif active_param.lower() in ["false", "0", "no"]:
|
||||||
|
return queryset.filter(active=False)
|
||||||
|
else:
|
||||||
|
return queryset.filter(active=True)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["post"])
|
||||||
|
def import_from_external(self, request):
|
||||||
|
tryton_client = get_tryton_client()
|
||||||
|
service = CategoryTrytonService(tryton_client)
|
||||||
|
return Response(service.import_from_external(), status=200)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["post"])
|
||||||
|
def deactivate(self, request):
|
||||||
|
return self._change_active(request, active=False)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["post"])
|
||||||
|
def activate(self, request):
|
||||||
|
return self._change_active(request, active=True)
|
||||||
|
|
||||||
|
def _change_active(self, request, active):
|
||||||
|
"""Activa o desactiva categorías por ids. Ignora ids inexistentes."""
|
||||||
|
key = "activated" if active else "deactivated"
|
||||||
|
affected = []
|
||||||
|
failed = []
|
||||||
|
for category_id in request.data.get("ids", []):
|
||||||
|
try:
|
||||||
|
category = ProductCategory.objects.get(pk=category_id)
|
||||||
|
except ProductCategory.DoesNotExist:
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
failed.append({"id": category_id, "error": str(e)})
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
category.active = active
|
||||||
|
category.save()
|
||||||
|
affected.append({"id": category.id, "name": category.name})
|
||||||
|
except Exception as e:
|
||||||
|
failed.append({"id": category_id, "error": str(e)})
|
||||||
|
return Response({key: affected, "failed": failed})
|
||||||
@@ -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=''),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -9,6 +9,9 @@ class MeasuringUnits(models.TextChoices):
|
|||||||
class ProductCategory(models.Model):
|
class ProductCategory(models.Model):
|
||||||
name = models.CharField(max_length=100, unique=True)
|
name = models.CharField(max_length=100, unique=True)
|
||||||
external_id = models.CharField(max_length=100, null=True, blank=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):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
@@ -46,7 +49,15 @@ class Product(models.Model):
|
|||||||
"price_list": product.price,
|
"price_list": product.price,
|
||||||
"uom": product.measuring_unit,
|
"uom": product.measuring_unit,
|
||||||
"external_id": product.external_id,
|
"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)
|
products_list.append(rproduct)
|
||||||
return products_list
|
return products_list
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
from .catalogue_images import CatalogueImageSerializer
|
from .catalogue_images import CatalogueImageSerializer
|
||||||
from .products import ProductSerializer, ListProductSerializer
|
from .products import (
|
||||||
|
ProductSerializer,
|
||||||
|
ListProductSerializer,
|
||||||
|
ProductCategorySerializer,
|
||||||
|
ProductCategoryBriefSerializer,
|
||||||
|
)
|
||||||
from .customers import CustomerSerializer, ListCustomerSerializer
|
from .customers import CustomerSerializer, ListCustomerSerializer
|
||||||
from .sales import (
|
from .sales import (
|
||||||
SaleSerializer,
|
SaleSerializer,
|
||||||
@@ -26,6 +31,8 @@ __all__ = [
|
|||||||
# Products
|
# Products
|
||||||
"ProductSerializer",
|
"ProductSerializer",
|
||||||
"ListProductSerializer",
|
"ListProductSerializer",
|
||||||
|
"ProductCategorySerializer",
|
||||||
|
"ProductCategoryBriefSerializer",
|
||||||
# Customers
|
# Customers
|
||||||
"CustomerSerializer",
|
"CustomerSerializer",
|
||||||
"ListCustomerSerializer",
|
"ListCustomerSerializer",
|
||||||
|
|||||||
@@ -4,11 +4,32 @@ from ..models.products import Product, ProductCategory
|
|||||||
from ..models.provenance import Supplier
|
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):
|
class ProductSerializer(serializers.ModelSerializer):
|
||||||
catalogue_images = serializers.SerializerMethodField()
|
catalogue_images = serializers.SerializerMethodField()
|
||||||
suppliers = serializers.PrimaryKeyRelatedField(
|
suppliers = serializers.PrimaryKeyRelatedField(
|
||||||
many=True, queryset=Supplier.objects.all(), required=False
|
many=True, queryset=Supplier.objects.all(), required=False
|
||||||
)
|
)
|
||||||
|
categories = ProductCategoryBriefSerializer(many=True, read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Product
|
model = Product
|
||||||
|
|||||||
152
tienda_ilusion/don_confiao/services/tryton/categories.py
Normal file
152
tienda_ilusion/don_confiao/services/tryton/categories.py
Normal file
@@ -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": [],
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
from ...models.products import Product, ProductCategory
|
from ...models.products import Product, ProductCategory
|
||||||
|
from .categories import upsert_category
|
||||||
|
|
||||||
|
|
||||||
class ProductTrytonService:
|
class ProductTrytonService:
|
||||||
@@ -106,57 +107,28 @@ class ProductTrytonService:
|
|||||||
reporte {"created": [...], "updated": [...]}
|
reporte {"created": [...], "updated": [...]}
|
||||||
"""
|
"""
|
||||||
empty_report = {"created": [], "updated": []}
|
empty_report = {"created": [], "updated": []}
|
||||||
category_ids = {
|
category_names = self._extract_category_names(tryton_products)
|
||||||
category_id
|
if not category_names:
|
||||||
for tryton_product in tryton_products
|
|
||||||
for category_id in self._extract_category_ids(tryton_product)
|
|
||||||
}
|
|
||||||
if not category_ids:
|
|
||||||
return {}, empty_report
|
return {}, empty_report
|
||||||
|
|
||||||
categories = {}
|
categories = {}
|
||||||
report = {"created": [], "updated": []}
|
report = {"created": [], "updated": []}
|
||||||
tryton_categories = self._get_category_details(
|
tryton_categories = [
|
||||||
sorted(category_ids), context
|
{"id": category_id, "name": category_names[category_id]}
|
||||||
)
|
for category_id in sorted(category_names)
|
||||||
|
]
|
||||||
for tryton_category in tryton_categories:
|
for tryton_category in tryton_categories:
|
||||||
try:
|
try:
|
||||||
external_id = str(tryton_category.get("id"))
|
category, status, changes = upsert_category(tryton_category)
|
||||||
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()
|
|
||||||
categories[tryton_category.get("id")] = category
|
categories[tryton_category.get("id")] = category
|
||||||
|
|
||||||
item = {
|
item = {
|
||||||
"id": category.id,
|
"id": category.id,
|
||||||
"name": category.name,
|
"name": category.name,
|
||||||
"external_id": category.external_id,
|
"external_id": category.external_id,
|
||||||
}
|
}
|
||||||
if is_new:
|
if status == "created":
|
||||||
report["created"].append(item)
|
report["created"].append(item)
|
||||||
continue
|
elif status == "updated":
|
||||||
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:
|
|
||||||
report["updated"].append(
|
report["updated"].append(
|
||||||
{**item, "detail": ", ".join(changes)}
|
{**item, "detail": ", ".join(changes)}
|
||||||
)
|
)
|
||||||
@@ -174,11 +146,17 @@ class ProductTrytonService:
|
|||||||
template = tryton_product.get("template.") or {}
|
template = tryton_product.get("template.") or {}
|
||||||
return template.get("categories") or []
|
return template.get("categories") or []
|
||||||
|
|
||||||
def _get_category_details(self, category_ids, context):
|
@staticmethod
|
||||||
"""Obtiene los nombres de categorías desde Tryton"""
|
def _extract_category_names(tryton_products):
|
||||||
method = "model.product.category.read"
|
"""Extrae el mapa {id_tryton: nombre} de las categorías anidadas en
|
||||||
params = (category_ids, ["id", "name"], context)
|
la respuesta de product.read (clave 'template.categories.name',
|
||||||
return self.client.call(method, params)
|
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):
|
def _sync_product_categories(self, product, tryton_product, categories):
|
||||||
"""Vincula al producto las categorías importadas desde Tryton"""
|
"""Vincula al producto las categorías importadas desde Tryton"""
|
||||||
@@ -214,8 +192,50 @@ class ProductTrytonService:
|
|||||||
changes.append(
|
changes.append(
|
||||||
f"Unidad: {product.measuring_unit} → {unit_name}"
|
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)
|
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
|
@staticmethod
|
||||||
def _format_price(value):
|
def _format_price(value):
|
||||||
"""Formatea un precio sin ceros decimales innecesarios"""
|
"""Formatea un precio sin ceros decimales innecesarios"""
|
||||||
@@ -254,6 +274,7 @@ class ProductTrytonService:
|
|||||||
"default_uom.rec_name",
|
"default_uom.rec_name",
|
||||||
"list_price",
|
"list_price",
|
||||||
"template.categories",
|
"template.categories",
|
||||||
|
"template.categories.name",
|
||||||
]
|
]
|
||||||
method = "model.product.product.read"
|
method = "model.product.product.read"
|
||||||
params = (product_ids, tryton_fields, context)
|
params = (product_ids, tryton_fields, context)
|
||||||
@@ -269,6 +290,17 @@ class ProductTrytonService:
|
|||||||
unit = tryton_product.get("default_uom.")
|
unit = tryton_product.get("default_uom.")
|
||||||
if not product.measuring_unit == unit.get("rec_name"):
|
if not product.measuring_unit == unit.get("rec_name"):
|
||||||
return True
|
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
|
return False
|
||||||
|
|
||||||
def _create_product(self, tryton_product):
|
def _create_product(self, tryton_product):
|
||||||
|
|||||||
211
tienda_ilusion/don_confiao/tests/test_categories_from_tryton.py
Normal file
211
tienda_ilusion/don_confiao/tests/test_categories_from_tryton.py
Normal file
@@ -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"])
|
||||||
102
tienda_ilusion/don_confiao/tests/test_product_categories.py
Normal file
102
tienda_ilusion/don_confiao/tests/test_product_categories.py
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
226
tienda_ilusion/don_confiao/tests/test_product_categories_api.py
Normal file
226
tienda_ilusion/don_confiao/tests/test_product_categories_api.py
Normal file
@@ -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)
|
||||||
@@ -56,6 +56,7 @@ class TestProductsFromTryton(TestCase, LoginMixin):
|
|||||||
"default_uom.rec_name",
|
"default_uom.rec_name",
|
||||||
"list_price",
|
"list_price",
|
||||||
"template.categories",
|
"template.categories",
|
||||||
|
"template.categories.name",
|
||||||
],
|
],
|
||||||
{"company": 1},
|
{"company": 1},
|
||||||
)
|
)
|
||||||
@@ -157,6 +158,7 @@ class TestProductsFromTryton(TestCase, LoginMixin):
|
|||||||
"default_uom.rec_name",
|
"default_uom.rec_name",
|
||||||
"list_price",
|
"list_price",
|
||||||
"template.categories",
|
"template.categories",
|
||||||
|
"template.categories.name",
|
||||||
],
|
],
|
||||||
{"company": 1},
|
{"company": 1},
|
||||||
)
|
)
|
||||||
@@ -226,6 +228,7 @@ class TestProductsFromTryton(TestCase, LoginMixin):
|
|||||||
"default_uom.rec_name",
|
"default_uom.rec_name",
|
||||||
"list_price",
|
"list_price",
|
||||||
"template.categories",
|
"template.categories",
|
||||||
|
"template.categories.name",
|
||||||
],
|
],
|
||||||
{"company": 1},
|
{"company": 1},
|
||||||
)
|
)
|
||||||
@@ -288,6 +291,7 @@ class TestProductsFromTryton(TestCase, LoginMixin):
|
|||||||
"default_uom.rec_name",
|
"default_uom.rec_name",
|
||||||
"list_price",
|
"list_price",
|
||||||
"template.categories",
|
"template.categories",
|
||||||
|
"template.categories.name",
|
||||||
],
|
],
|
||||||
{"company": 1},
|
{"company": 1},
|
||||||
)
|
)
|
||||||
@@ -354,6 +358,7 @@ class TestProductsFromTryton(TestCase, LoginMixin):
|
|||||||
"default_uom.rec_name",
|
"default_uom.rec_name",
|
||||||
"list_price",
|
"list_price",
|
||||||
"template.categories",
|
"template.categories",
|
||||||
|
"template.categories.name",
|
||||||
],
|
],
|
||||||
{"company": 1},
|
{"company": 1},
|
||||||
)
|
)
|
||||||
@@ -364,20 +369,15 @@ class TestProductsFromTryton(TestCase, LoginMixin):
|
|||||||
"list_price": Decimal("25000"),
|
"list_price": Decimal("25000"),
|
||||||
"name": "Producto 1",
|
"name": "Producto 1",
|
||||||
"default_uom.": {"id": 1, "rec_name": "Unit"},
|
"default_uom.": {"id": 1, "rec_name": "Unit"},
|
||||||
"template.": {"id": 999, "categories": [5, 8]},
|
"template.": {
|
||||||
},
|
"id": 999,
|
||||||
]
|
"categories": [5, 8],
|
||||||
|
"categories.": [
|
||||||
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": 5, "name": "Abarrotes"},
|
||||||
{"id": 8, "name": "Bebidas"},
|
{"id": 8, "name": "Bebidas"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
raise Exception(
|
raise Exception(
|
||||||
@@ -445,6 +445,7 @@ class TestProductsFromTryton(TestCase, LoginMixin):
|
|||||||
"default_uom.rec_name",
|
"default_uom.rec_name",
|
||||||
"list_price",
|
"list_price",
|
||||||
"template.categories",
|
"template.categories",
|
||||||
|
"template.categories.name",
|
||||||
],
|
],
|
||||||
{"company": 1},
|
{"company": 1},
|
||||||
)
|
)
|
||||||
@@ -455,19 +456,12 @@ class TestProductsFromTryton(TestCase, LoginMixin):
|
|||||||
"list_price": Decimal("25000"),
|
"list_price": Decimal("25000"),
|
||||||
"name": "Producto 1",
|
"name": "Producto 1",
|
||||||
"default_uom.": {"id": 1, "rec_name": "Unit"},
|
"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(
|
raise Exception(
|
||||||
@@ -536,6 +530,7 @@ class TestProductsFromTryton(TestCase, LoginMixin):
|
|||||||
"default_uom.rec_name",
|
"default_uom.rec_name",
|
||||||
"list_price",
|
"list_price",
|
||||||
"template.categories",
|
"template.categories",
|
||||||
|
"template.categories.name",
|
||||||
],
|
],
|
||||||
{"company": 1},
|
{"company": 1},
|
||||||
)
|
)
|
||||||
@@ -546,19 +541,12 @@ class TestProductsFromTryton(TestCase, LoginMixin):
|
|||||||
"list_price": Decimal("25000"),
|
"list_price": Decimal("25000"),
|
||||||
"name": "Producto 1",
|
"name": "Producto 1",
|
||||||
"default_uom.": {"id": 1, "rec_name": "Unit"},
|
"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(
|
raise Exception(
|
||||||
@@ -627,6 +615,7 @@ class TestProductsFromTryton(TestCase, LoginMixin):
|
|||||||
"default_uom.rec_name",
|
"default_uom.rec_name",
|
||||||
"list_price",
|
"list_price",
|
||||||
"template.categories",
|
"template.categories",
|
||||||
|
"template.categories.name",
|
||||||
],
|
],
|
||||||
{"company": 1},
|
{"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.call")
|
||||||
@patch("sabatron_tryton_rpc_client.client.Client.connect")
|
@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
|
self, mock_connect, mock_call
|
||||||
):
|
):
|
||||||
mock_connect.return_value = None
|
mock_connect.return_value = None
|
||||||
@@ -688,6 +677,7 @@ class TestProductsFromTryton(TestCase, LoginMixin):
|
|||||||
"default_uom.rec_name",
|
"default_uom.rec_name",
|
||||||
"list_price",
|
"list_price",
|
||||||
"template.categories",
|
"template.categories",
|
||||||
|
"template.categories.name",
|
||||||
],
|
],
|
||||||
{"company": 1},
|
{"company": 1},
|
||||||
)
|
)
|
||||||
@@ -698,19 +688,14 @@ class TestProductsFromTryton(TestCase, LoginMixin):
|
|||||||
"list_price": Decimal("4500"),
|
"list_price": Decimal("4500"),
|
||||||
"name": "Papa",
|
"name": "Papa",
|
||||||
"default_uom.": {"id": 2, "rec_name": "Kilogram"},
|
"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(
|
raise Exception(
|
||||||
@@ -724,12 +709,20 @@ class TestProductsFromTryton(TestCase, LoginMixin):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
content = json.loads(response.content.decode("utf-8"))
|
content = json.loads(response.content.decode("utf-8"))
|
||||||
|
self.assertEqual(content["untouched_products"], [])
|
||||||
self.assertEqual(
|
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)
|
updated_product = Product.objects.get(id=2)
|
||||||
categories = untouched_product.categories.all()
|
categories = updated_product.categories.all()
|
||||||
self.assertEqual(len(categories), 1)
|
self.assertEqual(len(categories), 1)
|
||||||
self.assertEqual(categories[0].name, "Tubérculos")
|
self.assertEqual(categories[0].name, "Tubérculos")
|
||||||
self.assertEqual(categories[0].external_id, "9")
|
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")],
|
||||||
|
)
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ from .api import (
|
|||||||
# Products
|
# Products
|
||||||
ProductView,
|
ProductView,
|
||||||
ProductsFromTrytonView,
|
ProductsFromTrytonView,
|
||||||
|
# Product Categories
|
||||||
|
ProductCategoryView,
|
||||||
# Customers
|
# Customers
|
||||||
CustomerView,
|
CustomerView,
|
||||||
CustomersFromTrytonView,
|
CustomersFromTrytonView,
|
||||||
@@ -45,6 +47,11 @@ router.register(r"sales", SaleView, basename="sale")
|
|||||||
router.register(r"catalog_sales", CatalogSaleView, basename="catalog_sale")
|
router.register(r"catalog_sales", CatalogSaleView, basename="catalog_sale")
|
||||||
router.register(r"customers", CustomerView, basename="customer")
|
router.register(r"customers", CustomerView, basename="customer")
|
||||||
router.register(r"products", ProductView, basename="product")
|
router.register(r"products", ProductView, basename="product")
|
||||||
|
router.register(
|
||||||
|
r"product_categories",
|
||||||
|
ProductCategoryView,
|
||||||
|
basename="product_category",
|
||||||
|
)
|
||||||
router.register(
|
router.register(
|
||||||
r"catalogue_images",
|
r"catalogue_images",
|
||||||
CatalogueImageViewSet,
|
CatalogueImageViewSet,
|
||||||
|
|||||||
Reference in New Issue
Block a user