13 Commits

Author SHA1 Message Date
b088794716 docs: agregar workflow TDD rojo/verde con confirmacion de fallos de tests 2026-08-15 14:56:09 -05:00
177b4c01ca feat: incluir code en SaleSerializer y CatalogSaleSerializer 2026-08-10 00:32:59 -05:00
ea3727363b Merge pull request 'feat/46-order-summary-public' (#47) from feat/46-order-summary-public into main
Reviewed-on: #47
2026-08-08 14:25:25 -05:00
bee35ac5cb #51 feat: add editable store logo to StoreSettings 2026-08-08 14:20:07 -05:00
85168e24f0 Merge branch 'main' of ssh://gitea.onecluster.org:6666/OneTeam/don_confiao_backend 2026-08-08 13:57:56 -05:00
56bbc8f9b4 #50 feat: add StoreSettings model and API for store address and map coordinates 2026-08-08 13:50:43 -05:00
mono
805565aa8e feat(#46): show order code in admin 2026-08-08 13:29:14 -05:00
mono
4f41156056 feat(#46): add fill_codes client script 2026-08-08 13:29:14 -05:00
mono
2438cf2576 feat(#46): add public order summary and fill_codes endpoints 2026-08-08 13:28:53 -05:00
mono
332884907c feat(#46): add code to Sale and CatalogSale models 2026-08-08 13:28:49 -05:00
mono
b2312edff5 test(#46): add order summary public and fill_codes tests 2026-08-08 13:28:44 -05:00
4410cc919a Merge pull request 'test(Tryton): add catalog sale test and validate build_tryton_comment' (#44) from fix/EnviarComentarioConDetalleDelPedido into main
Reviewed-on: #44
2026-07-11 18:56:05 -05:00
d1c2b4f1bb test(Tryton): add catalog sale test and validate build_tryton_comment
- Added CatalogSale/CatalogSaleLine to setUp
- Added explicit assertion linking TrytonSale.build_tryton_comment()
  to sale.description
- Rewrote test_send_catalog_sales_to_tryton with proper CatalogSale
  data, validating comment format, description, reference and lines
- Added direct assertion of TrytonCatalogSale.build_tryton_comment()
2026-07-11 14:55:44 -05:00
21 changed files with 1013 additions and 13 deletions

View File

@@ -159,7 +159,44 @@ Nota: El volumen monta `tienda_ilusion/` en `/app/`, por lo que el path correcto
## Tests ## Tests
- Framework: Django unittest - Framework: Django unittest
- Directorio: don_confiao/tests/ - Directorio: don_confiao/tests/
- Ejecutar: `docker-compose -f docker-compose.dev.yml run --rm django python manage.py test` - Ejecutar: `docker compose -f docker-compose.dev.yml run --rm django python manage.py test`
## Workflow TDD (Semáforo Rojo/Verde)
Todo cambio o nueva funcionalidad debe seguir TDD. **Está prohibido asumir fallos de tests: todo fallo debe ser confirmado ejecutando los tests.**
### Regla de oro: confirmar antes de modificar
Antes de modificar tests (por cambios en comportamiento o por nuevas funcionalidades), **SIEMPRE ejecutar los tests primero** y confirmar que el fallo esperado realmente ocurre. Nunca asumir que un test falla: verificar el fallo con la ejecución real.
### Ciclo Rojo → Verde → Refactor
1. **ROJO (escribir el test y confirmar que falla)**:
- Escribir o modificar el test que describe el comportamiento esperado.
- Ejecutar los tests **antes de tocar el código de producción**.
- Confirmar que el test falla por el motivo esperado (assertion fallida por el comportamiento nuevo, no por error de setup o de importación).
- Copiar el output del fallo como evidencia del semáforo rojo.
2. **VERDE (implementar lo mínimo para que pase)**:
- Implementar el código de producción mínimo necesario para que el test pase.
- Volver a ejecutar los tests y confirmar que el semáforo queda en verde.
3. **REFACTOR (limpiar con los tests en verde)**:
- Refactorizar y limpiar el código sin cambiar comportamiento.
- Re-ejecutar los tests para confirmar que siguen en verde.
### Ejecutar tests
Siempre ejecutar dentro del contenedor de desarrollo:
```bash
# Test específico (durante el ciclo, preferible sobre el suite completo para ser rápido)
docker compose -f docker-compose.dev.yml run --rm django python manage.py test don_confiao.tests.<archivo>
# Suite completa (al finalizar, para confirmar que nada se rompió)
docker compose -f docker-compose.dev.yml run --rm django python manage.py test
```
### Confirmar el fallo, no asumirlo
- Si un test falla o se espera que falle, **ejecutarlo y mostrar el resultado** antes de cambiar cualquier código o test.
- Verificar que el fallo corresponde a la razón esperada (leer el traceback), no a un problema de entorno, migraciones, importaciones o fixtures.
- No modificar tests para "hacerlos pasar" si el fallo no fue previamente confirmado.
## Comandos Útiles (dentro del contenedor) ## Comandos Útiles (dentro del contenedor)
- Migraciones: `docker-compose -f docker-compose.dev.yml run --rm django python manage.py makemigrations && docker-compose -f docker-compose.dev.yml run --rm django python manage.py migrate` - Migraciones: `docker-compose -f docker-compose.dev.yml run --rm django python manage.py makemigrations && docker-compose -f docker-compose.dev.yml run --rm django python manage.py migrate`

View File

@@ -0,0 +1,80 @@
#!/usr/bin/env python3
import argparse
import getpass
import sys
import requests
TOKEN_URL = "/api/token/"
FILL_SALES_CODES_URL = "/don_confiao/api/sales/fill_codes/"
FILL_CATALOG_SALES_CODES_URL = "/don_confiao/api/catalog_sales/fill_codes/"
def get_credentials():
username = input("Usuario: ")
password = getpass.getpass("Contraseña: ")
return username, password
def get_token(domain, username, password):
url = domain.rstrip("/") + TOKEN_URL
response = requests.post(
url, json={"username": username, "password": password}
)
if response.status_code != 200:
print(
f"Error al obtener token: {response.status_code} {response.text}",
file=sys.stderr,
)
sys.exit(1)
return response.json()["access"]
def fill_codes(domain, token, url):
headers = {"Authorization": f"Bearer {token}"}
full_url = domain.rstrip("/") + url
response = requests.post(full_url, headers=headers)
if response.status_code != 200:
print(
f"Error al llenar códigos ({url}): {response.status_code} {response.text}",
file=sys.stderr,
)
sys.exit(1)
return response.json()
def main():
parser = argparse.ArgumentParser(
description="Asigna un código único a todas las ventas y pedidos que lo requieran."
)
parser.add_argument(
"domain", help="Dominio del backend (ej: http://localhost:8000)"
)
args = parser.parse_args()
username, password = get_credentials()
token = get_token(args.domain, username, password)
print("Token obtenido correctamente.")
print("Asignando códigos a ventas...")
sales_result = fill_codes(args.domain, token, FILL_SALES_CODES_URL)
print(f" [OK] Ventas actualizadas: {sales_result['sales_updated']}")
print("Asignando códigos a pedidos de catálogo...")
catalog_result = fill_codes(
args.domain, token, FILL_CATALOG_SALES_CODES_URL
)
print(
f" [OK] Pedidos de catálogo actualizados: "
f"{catalog_result['catalog_sales_updated']}"
)
print(
f"\nResumen: {sales_result['sales_updated']} ventas, "
f"{catalog_result['catalog_sales_updated']} pedidos de catálogo actualizados"
)
if __name__ == "__main__":
main()

View File

@@ -153,6 +153,7 @@ CATALOGUE_BACKGROUND_IMAGES_RGBA = os.environ.get(
_rgba_parts = CATALOGUE_BACKGROUND_IMAGES_RGBA.split(",") _rgba_parts = CATALOGUE_BACKGROUND_IMAGES_RGBA.split(",")
CATALOGUE_BACKGROUND_IMAGES_RGBA = tuple(int(p.strip()) for p in _rgba_parts) CATALOGUE_BACKGROUND_IMAGES_RGBA = tuple(int(p.strip()) for p in _rgba_parts)
CATALOGUE_MAX_UPLOAD_SIZE = int(os.environ.get("CATALOGUE_MAX_UPLOAD_SIZE", str(5 * 1024 * 1024))) CATALOGUE_MAX_UPLOAD_SIZE = int(os.environ.get("CATALOGUE_MAX_UPLOAD_SIZE", str(5 * 1024 * 1024)))
LOGO_MAX_UPLOAD_SIZE = int(os.environ.get("LOGO_MAX_UPLOAD_SIZE", str(5 * 1024 * 1024)))
# Default primary key field type # Default primary key field type
# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field

View File

@@ -10,6 +10,12 @@ from .models.sales import (
) )
from .models.products import Product, ProductCategory from .models.products import Product, ProductCategory
from .models.payments import ReconciliationJar from .models.payments import ReconciliationJar
from .models.store_settings import StoreSettings
@admin.register(StoreSettings)
class StoreSettingsAdmin(admin.ModelAdmin):
list_display = ("id", "address", "latitude", "longitude", "updated_at")
@admin.register(Customer) @admin.register(Customer)
@@ -31,8 +37,9 @@ class SaleAdmin(admin.ModelAdmin):
"customer", "customer",
"payment_method", "payment_method",
"external_id", "external_id",
"code",
) )
search_fields = ("date", "customer__name", "payment_method", "external_id") search_fields = ("date", "customer__name", "payment_method", "external_id", "code")
list_filter = ("date", "customer__name", "payment_method") list_filter = ("date", "customer__name", "payment_method")
@admin.register(SaleLine) @admin.register(SaleLine)
@@ -51,8 +58,9 @@ class CatalogSaleAdmin(admin.ModelAdmin):
"customer_address", "customer_address",
"pickup_method", "pickup_method",
"external_id", "external_id",
"code",
) )
search_fields = ("date","customer_name", "customer_phone", "pickup_method","external_id") search_fields = ("date","customer_name", "customer_phone", "pickup_method","external_id", "code")
list_filter = ("date", "customer_name", "pickup_method") list_filter = ("date", "customer_name", "pickup_method")

View File

@@ -6,6 +6,7 @@ from .sales import (
CatalogSaleView, CatalogSaleView,
SaleSummary, SaleSummary,
CatalogSaleSummary, CatalogSaleSummary,
PublicSaleSummaryView,
SalesForTrytonView, SalesForTrytonView,
SalesToTrytonView, SalesToTrytonView,
CatalogSalesToTrytonView, CatalogSalesToTrytonView,
@@ -18,6 +19,7 @@ from .payments import (
Pagination, Pagination,
) )
from .admin import AdminCodeValidateView from .admin import AdminCodeValidateView
from .store_settings import StoreSettingsView
__all__ = [ __all__ = [
# Catalogue Images # Catalogue Images
@@ -33,6 +35,7 @@ __all__ = [
"CatalogSaleView", "CatalogSaleView",
"SaleSummary", "SaleSummary",
"CatalogSaleSummary", "CatalogSaleSummary",
"PublicSaleSummaryView",
"SalesForTrytonView", "SalesForTrytonView",
"SalesToTrytonView", "SalesToTrytonView",
"CatalogSalesToTrytonView", "CatalogSalesToTrytonView",
@@ -44,4 +47,6 @@ __all__ = [
"Pagination", "Pagination",
# Admin # Admin
"AdminCodeValidateView", "AdminCodeValidateView",
# Store Settings
"StoreSettingsView",
] ]

View File

@@ -1,11 +1,17 @@
from rest_framework import viewsets from rest_framework import viewsets
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.decorators import action
import io import io
import csv import csv
from ..models.sales import Sale, SaleLine, CatalogSale from ..models.sales import (
Sale,
SaleLine,
CatalogSale,
generate_order_code,
)
from ..models.customers import Customer from ..models.customers import Customer
from ..models.products import Product from ..models.products import Product
from ..serializers import ( from ..serializers import (
@@ -20,11 +26,27 @@ from ..services.tryton.client import get_tryton_client
from ..views import sales_to_tryton_csv from ..views import sales_to_tryton_csv
def _fill_codes(Model):
rows = Model.objects.all()
for row in rows:
row.code = generate_order_code()
Model.objects.bulk_update(rows, ["code"])
return len(rows)
class SaleView(viewsets.ModelViewSet): class SaleView(viewsets.ModelViewSet):
queryset = Sale.objects.all() queryset = Sale.objects.all()
serializer_class = SaleSerializer serializer_class = SaleSerializer
permission_classes = [IsNotPublico] permission_classes = [IsNotPublico]
@action(
detail=False,
methods=["post"],
permission_classes=[IsAuthenticated, IsAdministrator],
)
def fill_codes(self, request):
return Response({"sales_updated": _fill_codes(Sale)})
def create(self, request): def create(self, request):
data = request.data data = request.data
customer = Customer.objects.get(pk=data["customer"]) customer = Customer.objects.get(pk=data["customer"])
@@ -60,7 +82,17 @@ class CatalogSaleView(viewsets.ModelViewSet):
queryset = CatalogSale.objects.all() queryset = CatalogSale.objects.all()
serializer_class = CatalogSaleSerializer serializer_class = CatalogSaleSerializer
@action(
detail=False,
methods=["post"],
permission_classes=[IsAuthenticated, IsAdministrator],
)
def fill_codes(self, request):
return Response({"catalog_sales_updated": _fill_codes(CatalogSale)})
def get_permissions(self): def get_permissions(self):
if self.action == "fill_codes":
return [IsAuthenticated(), IsAdministrator()]
if self.action == "create": if self.action == "create":
return [IsAuthenticated()] return [IsAuthenticated()]
return [IsNotPublico()] return [IsNotPublico()]
@@ -71,7 +103,9 @@ class SaleSummary(APIView):
def get(self, request, id): def get(self, request, id):
sale = Sale.objects.get(pk=id) sale = Sale.objects.get(pk=id)
serializer = SaleSummarySerializer(sale) serializer = SaleSummarySerializer(
sale, context={"request": request}
)
return Response(serializer.data) return Response(serializer.data)
@@ -80,10 +114,45 @@ class CatalogSaleSummary(APIView):
def get(self, request, id): def get(self, request, id):
catalog_sale = CatalogSale.objects.get(pk=id) catalog_sale = CatalogSale.objects.get(pk=id)
serializer = CatalogSaleSummarySerializer(catalog_sale) serializer = CatalogSaleSummarySerializer(
catalog_sale, context={"request": request}
)
return Response(serializer.data) return Response(serializer.data)
class PublicSaleSummaryView(APIView):
permission_classes = [AllowAny]
def get(self, request, code=None):
code = code or request.query_params.get("code")
if not code:
return Response(
{"detail": "code is required"}, status=400
)
sale = Sale.objects.filter(code=code).first()
if sale:
serializer = SaleSummarySerializer(
sale, context={"request": request}
)
data = serializer.data
data["type"] = "sale"
return Response(data)
catalog_sale = CatalogSale.objects.filter(code=code).first()
if catalog_sale:
serializer = CatalogSaleSummarySerializer(
catalog_sale, context={"request": request}
)
data = serializer.data
data["type"] = "catalog"
return Response(data)
return Response(
{"detail": "order not found"}, status=404
)
class SalesForTrytonView(APIView): class SalesForTrytonView(APIView):
permission_classes = [IsAuthenticated, IsAdministrator] permission_classes = [IsAuthenticated, IsAdministrator]

View File

@@ -0,0 +1,32 @@
from rest_framework import status
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from ..models.store_settings import StoreSettings
from ..permissions import IsAdministrator
from ..serializers.store_settings import StoreSettingsSerializer
class StoreSettingsView(APIView):
def get_permissions(self):
if self.request.method == "GET":
return [AllowAny()]
return [IsAuthenticated(), IsAdministrator()]
def get(self, request):
settings = StoreSettings.get_singleton()
return Response(
StoreSettingsSerializer(
settings, context={"request": request}
).data
)
def patch(self, request):
settings = StoreSettings.get_singleton()
serializer = StoreSettingsSerializer(
settings, data=request.data, partial=True, context={"request": request}
)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)

View File

@@ -0,0 +1,24 @@
# Generated by Django 5.0.6 on 2026-08-08 18:24
import don_confiao.models.sales
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('don_confiao', '0049_catalogueimage'),
]
operations = [
migrations.AddField(
model_name='catalogsale',
name='code',
field=models.CharField(default=don_confiao.models.sales.generate_order_code, editable=False, max_length=32),
),
migrations.AddField(
model_name='sale',
name='code',
field=models.CharField(default=don_confiao.models.sales.generate_order_code, editable=False, max_length=32),
),
]

View File

@@ -0,0 +1,27 @@
# Generated by Django 5.0.6 on 2026-08-08 17:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('don_confiao', '0049_catalogueimage'),
]
operations = [
migrations.CreateModel(
name='StoreSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('address', models.TextField()),
('latitude', models.FloatField(blank=True, null=True)),
('longitude', models.FloatField(blank=True, null=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Configuración de la tienda',
'verbose_name_plural': 'Configuración de la tienda',
},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.0.6 on 2026-08-08 19:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('don_confiao', '0050_storesettings'),
]
operations = [
migrations.AddField(
model_name='storesettings',
name='logo',
field=models.ImageField(blank=True, null=True, upload_to='logos/'),
),
]

View File

@@ -0,0 +1,14 @@
# Generated by Django 5.0.6 on 2026-08-10 03:51
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('don_confiao', '0050_catalogsale_code_sale_code'),
('don_confiao', '0051_storesettings_logo'),
]
operations = [
]

View File

@@ -0,0 +1,3 @@
from .store_settings import StoreSettings
__all__ = ["StoreSettings"]

View File

@@ -4,6 +4,11 @@ from .products import Product
from .payments import PaymentMethods, ReconciliationJar from .payments import PaymentMethods, ReconciliationJar
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from datetime import datetime from datetime import datetime
import uuid
def generate_order_code():
return uuid.uuid4().hex
class SaleAbstractModel(models.Model): class SaleAbstractModel(models.Model):
@@ -11,6 +16,9 @@ class SaleAbstractModel(models.Model):
date = models.DateTimeField("Date") date = models.DateTimeField("Date")
phone = models.CharField(max_length=13, null=True, blank=True) phone = models.CharField(max_length=13, null=True, blank=True)
description = models.CharField(max_length=255, null=True, blank=True) description = models.CharField(max_length=255, null=True, blank=True)
code = models.CharField(
max_length=32, default=generate_order_code, editable=False
)
class Meta: class Meta:
abstract = True abstract = True

View File

@@ -0,0 +1,21 @@
from django.db import models
class StoreSettings(models.Model):
address = models.TextField()
latitude = models.FloatField(null=True, blank=True)
longitude = models.FloatField(null=True, blank=True)
logo = models.ImageField(upload_to="logos/", null=True, blank=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Configuración de la tienda"
verbose_name_plural = "Configuración de la tienda"
def __str__(self):
return f"StoreSettings (id={self.id})"
@classmethod
def get_singleton(cls):
instance, _ = cls.objects.get_or_create(pk=1)
return instance

View File

@@ -16,6 +16,7 @@ from .payments import (
ReconciliationJarSerializer, ReconciliationJarSerializer,
PaymentMethodSerializer, PaymentMethodSerializer,
) )
from .store_settings import StoreSettingsSerializer
__all__ = [ __all__ = [
# Catalogue Images # Catalogue Images
@@ -39,4 +40,6 @@ __all__ = [
# Payments # Payments
"ReconciliationJarSerializer", "ReconciliationJarSerializer",
"PaymentMethodSerializer", "PaymentMethodSerializer",
# Store Settings
"StoreSettingsSerializer",
] ]

View File

@@ -1,4 +1,5 @@
from rest_framework import serializers from rest_framework import serializers
from django.urls import reverse
from ..models.sales import ( from ..models.sales import (
Sale, Sale,
@@ -11,6 +12,17 @@ from .products import ListProductSerializer
from .customers import ListCustomerSerializer from .customers import ListCustomerSerializer
class PublicSummaryLinkMixin:
def get_link(self, obj):
path = reverse(
"don_confiao:public_purchase_summary", args=[obj.code]
)
request = self.context.get("request")
if request:
return request.build_absolute_uri(path)
return path
class SaleLineSerializer(serializers.ModelSerializer): class SaleLineSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = SaleLine model = SaleLine
@@ -24,6 +36,7 @@ class SaleSerializer(serializers.ModelSerializer):
model = Sale model = Sale
fields = [ fields = [
"id", "id",
"code",
"customer", "customer",
"date", "date",
"saleline_set", "saleline_set",
@@ -56,6 +69,7 @@ class CatalogSaleSerializer(serializers.ModelSerializer):
model = CatalogSale model = CatalogSale
fields = [ fields = [
"id", "id",
"code",
"customer", "customer",
"date", "date",
"catalogsaleline_set", "catalogsaleline_set",
@@ -87,13 +101,14 @@ class SummarySaleLineSerializer(serializers.ModelSerializer):
fields = ["product", "quantity", "unit_price", "description"] fields = ["product", "quantity", "unit_price", "description"]
class SaleSummarySerializer(serializers.ModelSerializer): class SaleSummarySerializer(PublicSummaryLinkMixin, serializers.ModelSerializer):
customer = ListCustomerSerializer() customer = ListCustomerSerializer()
lines = SummarySaleLineSerializer(many=True, source="saleline_set") lines = SummarySaleLineSerializer(many=True, source="saleline_set")
link = serializers.SerializerMethodField()
class Meta: class Meta:
model = Sale model = Sale
fields = ["id", "date", "customer", "payment_method", "lines"] fields = ["id", "code", "date", "customer", "payment_method", "lines", "link"]
class CatalogSummarySaleLineSerializer(serializers.ModelSerializer): class CatalogSummarySaleLineSerializer(serializers.ModelSerializer):
@@ -104,15 +119,18 @@ class CatalogSummarySaleLineSerializer(serializers.ModelSerializer):
fields = ["product", "quantity", "unit_price", "description"] fields = ["product", "quantity", "unit_price", "description"]
class CatalogSaleSummarySerializer(serializers.ModelSerializer): class CatalogSaleSummarySerializer(
PublicSummaryLinkMixin, serializers.ModelSerializer
):
customer = ListCustomerSerializer() customer = ListCustomerSerializer()
lines = CatalogSummarySaleLineSerializer( lines = CatalogSummarySaleLineSerializer(
many=True, source="catalogsaleline_set" many=True, source="catalogsaleline_set"
) )
link = serializers.SerializerMethodField()
class Meta: class Meta:
model = CatalogSale model = CatalogSale
fields = ["id", "date", "customer", "lines"] fields = ["id", "code", "date", "customer", "lines", "link"]
class SaleForRenconciliationSerializer(serializers.Serializer): class SaleForRenconciliationSerializer(serializers.Serializer):

View File

@@ -0,0 +1,22 @@
from django.conf import settings
from rest_framework import serializers
from ..models.store_settings import StoreSettings
class StoreSettingsSerializer(serializers.ModelSerializer):
class Meta:
model = StoreSettings
fields = ["id", "address", "latitude", "longitude", "logo", "updated_at"]
read_only_fields = ["id", "updated_at"]
def validate_logo(self, value):
if value is None:
return value
max_size = settings.LOGO_MAX_UPLOAD_SIZE
if value.size > max_size:
raise serializers.ValidationError(
f"Logo size exceeds the maximum allowed size of "
f"{max_size // (1024 * 1024)}MB."
)
return value

View File

@@ -3,9 +3,10 @@ from unittest.mock import patch, MagicMock
from django.test import TestCase from django.test import TestCase
from ..models.sales import Sale, SaleLine from ..models.sales import Sale, SaleLine, CatalogSale, CatalogSaleLine
from ..models.customers import Customer from ..models.customers import Customer
from ..models.products import Product from ..models.products import Product
from ..services.tryton.client import TrytonSale, TrytonCatalogSale
from .Mixins import LoginMixin from .Mixins import LoginMixin
@@ -44,6 +45,27 @@ class TestSendSalesToTryton(TestCase, LoginMixin):
sale=self.sale, sale=self.sale,
) )
self.catalog_sale = CatalogSale.objects.create(
customer=self.customer,
date="2024-09-02",
customer_name="Camilo",
customer_phone="3001234567",
customer_address="Calle 123",
pickup_method="Recogida en tienda",
)
CatalogSaleLine.objects.create(
catalog_sale=self.catalog_sale,
product=self.product,
quantity=2,
unit_price=3000,
)
CatalogSaleLine.objects.create(
catalog_sale=self.catalog_sale,
product=self.product,
quantity=3,
unit_price=5000,
)
@patch("don_confiao.api.sales.get_tryton_client") @patch("don_confiao.api.sales.get_tryton_client")
def test_send_sales_to_tryton(self, mock_get_tryton_client): def test_send_sales_to_tryton(self, mock_get_tryton_client):
external_id = "23423" external_id = "23423"
@@ -56,7 +78,9 @@ class TestSendSalesToTryton(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, {"successful": [self.sale.id], "failed": []}) self.assertEqual(
content, {"successful": [self.sale.id], "failed": []}
)
updated_sale = Sale.objects.get(id=self.sale.id) updated_sale = Sale.objects.get(id=self.sale.id)
self.assertEqual(updated_sale.external_id, external_id) self.assertEqual(updated_sale.external_id, external_id)
@@ -89,6 +113,110 @@ class TestSendSalesToTryton(TestCase, LoginMixin):
) )
self.assertTrue(sale_params["self_pick_up"]) self.assertTrue(sale_params["self_pick_up"])
sale_tryton = TrytonSale(
self.sale, SaleLine.objects.filter(sale=self.sale)
)
self.assertEqual(
sale_tryton.build_tryton_comment(), self.sale.description
)
lines_block = sale_params["lines"]
self.assertEqual(len(lines_block), 1)
self.assertEqual(lines_block[0][0], "create")
lines = lines_block[0][1]
self.assertEqual(len(lines), 2)
self.assertEqual(lines[0]["product"], "1")
self.assertEqual(
lines[0]["quantity"],
{"__class__": "Decimal", "decimal": "2.00"},
)
self.assertEqual(lines[0]["type"], "line")
self.assertEqual(lines[0]["unit"], "1")
self.assertEqual(
lines[0]["unit_price"],
{"__class__": "Decimal", "decimal": "3000.00"},
)
self.assertEqual(lines[1]["product"], "1")
self.assertEqual(
lines[1]["quantity"],
{"__class__": "Decimal", "decimal": "3.00"},
)
self.assertEqual(lines[1]["type"], "line")
self.assertEqual(lines[1]["unit"], "1")
self.assertEqual(
lines[1]["unit_price"],
{"__class__": "Decimal", "decimal": "5000.00"},
)
@patch("don_confiao.api.sales.get_tryton_client")
def test_send_catalog_sales_to_tryton(self, mock_get_tryton_client):
external_id = "23423"
mock_client = MagicMock()
mock_client.call.return_value = [external_id]
mock_get_tryton_client.return_value = mock_client
url = "/don_confiao/api/enviar_catalog_sales_a_tryton"
response = self.client.post(url)
self.assertEqual(response.status_code, 200)
content = json.loads(response.content.decode("utf-8"))
self.assertEqual(
content,
{"successful": [self.catalog_sale.id], "failed": []},
)
updated = CatalogSale.objects.get(id=self.catalog_sale.id)
self.assertEqual(updated.external_id, external_id)
mock_get_tryton_client.assert_called_once()
mock_client.call.assert_called_once()
call_args = mock_client.call.call_args
method, params = call_args[0]
self.assertEqual(method, "model.sale.sale.create")
tryton_context = params[1]
self.assertEqual(tryton_context, {"company": 1, "shops": [1]})
sale_params = params[0][0]
self.assertEqual(sale_params["company"], 1)
self.assertEqual(sale_params["shipment_address"], "307")
self.assertEqual(sale_params["invoice_address"], "307")
self.assertEqual(sale_params["currency"], 31)
self.assertEqual(sale_params["description"], "Venta de catálogo")
self.assertEqual(sale_params["party"], "1")
self.assertEqual(
sale_params["reference"],
f"don_confiao_catalog {self.catalog_sale.id}",
)
self.assertEqual(
sale_params["sale_date"],
{"__class__": "date", "year": 2024, "month": 9, "day": 2},
)
self.assertTrue(sale_params["self_pick_up"])
expected_comment = (
"Cliente: Camilo\n"
"Teléfono: 3001234567\n"
"Dirección: Calle 123\n"
"Método de recogida: Recogida en tienda\n"
)
self.assertEqual(sale_params["comment"], expected_comment)
catalog_tryton = TrytonCatalogSale(
self.catalog_sale,
CatalogSaleLine.objects.filter(
catalog_sale=self.catalog_sale
),
)
self.assertEqual(
catalog_tryton.build_tryton_comment(), expected_comment
)
lines_block = sale_params["lines"] lines_block = sale_params["lines"]
self.assertEqual(len(lines_block), 1) self.assertEqual(len(lines_block), 1)
self.assertEqual(lines_block[0][0], "create") self.assertEqual(lines_block[0][0], "create")

View File

@@ -0,0 +1,222 @@
from datetime import datetime, timezone
from django.contrib.auth.models import User
from rest_framework import status
from rest_framework.test import APIClient, APITestCase
from ..admin import CatalogSaleAdmin, SaleAdmin
from ..models.customers import Customer
from ..models.products import Product
from ..models.sales import CatalogSale, Sale
from ..serializers import CatalogSaleSerializer, SaleSerializer
from .Mixins import LoginMixin
class TestOrderSummaryPublic(APITestCase, LoginMixin):
def setUp(self):
self.product = Product.objects.create(
name="Panela", price=5000, measuring_unit="UNIT"
)
self.customer = Customer.objects.create(
name="Camilo", external_id="18"
)
def _create_sale(self):
sale = Sale.objects.create(
customer=self.customer,
date=datetime(2024, 9, 2, tzinfo=timezone.utc),
payment_method="CASH",
)
sale.saleline_set.create(
product=self.product, quantity=2, unit_price=3000
)
return sale
def _create_catalog_sale(self):
catalog_sale = CatalogSale.objects.create(
customer=self.customer,
date=datetime(2024, 9, 2, tzinfo=timezone.utc),
customer_name="Camilo",
)
catalog_sale.catalogsaleline_set.create(
product=self.product, quantity=2, unit_price=3000
)
return catalog_sale
def test_sale_has_auto_generated_code(self):
sale = self._create_sale()
self.assertTrue(sale.code)
self.assertEqual(len(sale.code), 32)
int(sale.code, 16)
def test_sale_codes_are_distinct(self):
sale1 = self._create_sale()
sale2 = self._create_sale()
self.assertNotEqual(sale1.code, sale2.code)
def test_catalog_sale_has_auto_generated_code(self):
catalog_sale = self._create_catalog_sale()
self.assertTrue(catalog_sale.code)
self.assertEqual(len(catalog_sale.code), 32)
int(catalog_sale.code, 16)
def test_public_summary_by_code_without_auth(self):
sale = self._create_sale()
response = self.client.get(
f"/don_confiao/resumen_publico/{sale.code}"
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
self.assertEqual(data["code"], sale.code)
self.assertIn("link", data)
self.assertTrue(
data["link"].endswith(
f"/don_confiao/resumen_publico/{sale.code}"
)
)
self.assertEqual(data["type"], "sale")
self.assertIn("lines", data)
self.assertGreaterEqual(len(data["lines"]), 1)
def test_public_summary_invalid_code_returns_404(self):
response = self.client.get(
"/don_confiao/resumen_publico/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
def test_public_summary_by_query_param(self):
sale = self._create_sale()
response = self.client.get(
"/don_confiao/resumen_publico", {"code": sale.code}
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.json()["code"], sale.code)
def test_public_summary_for_catalog_sale(self):
catalog_sale = self._create_catalog_sale()
response = self.client.get(
f"/don_confiao/resumen_publico/{catalog_sale.code}"
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
self.assertEqual(data["code"], catalog_sale.code)
self.assertEqual(data["type"], "catalog")
def test_public_summary_with_duplicate_codes_does_not_500(self):
sale1 = self._create_sale()
sale2 = self._create_sale()
shared = "a" * 32
Sale.objects.filter(pk__in=[sale1.pk, sale2.pk]).update(code=shared)
response = self.client.get(
f"/don_confiao/resumen_publico/{shared}"
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_existing_authenticated_summaries_include_code_and_link(self):
self.login()
sale = self._create_sale()
catalog_sale = self._create_catalog_sale()
response = self.client.get(
f"/don_confiao/resumen_compra_json/{sale.id}"
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
self.assertEqual(data["code"], sale.code)
self.assertIn("link", data)
response = self.client.get(
f"/don_confiao/resumen_compra_catalogo_json/{catalog_sale.id}"
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
self.assertEqual(data["code"], catalog_sale.code)
self.assertIn("link", data)
def test_fill_codes_sales_endpoint_rewrites_all_codes(self):
self.login()
sale1 = self._create_sale()
sale2 = self._create_sale()
old_codes = {sale1.code, sale2.code}
response = self.client.post("/don_confiao/api/sales/fill_codes/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.json()["sales_updated"], 2)
sale1.refresh_from_db()
sale2.refresh_from_db()
new_codes = {sale1.code, sale2.code}
self.assertNotIn(sale1.code, old_codes)
self.assertEqual(len(new_codes), 2)
def test_fill_codes_catalog_sales_endpoint_rewrites_all_codes(self):
self.login()
catalog_sale = self._create_catalog_sale()
old_code = catalog_sale.code
response = self.client.post(
"/don_confiao/api/catalog_sales/fill_codes/"
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.json()["catalog_sales_updated"], 1)
catalog_sale.refresh_from_db()
self.assertNotEqual(catalog_sale.code, old_code)
def test_fill_codes_endpoints_require_authentication(self):
response = self.client.post("/don_confiao/api/sales/fill_codes/")
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
response = self.client.post(
"/don_confiao/api/catalog_sales/fill_codes/"
)
self.assertEqual(
response.status_code, status.HTTP_401_UNAUTHORIZED
)
def test_fill_codes_endpoints_require_admin(self):
regular_user = User.objects.create_user(
username="regular", password="regularpass"
)
client = APIClient()
client.force_authenticate(user=regular_user)
response = client.post("/don_confiao/api/sales/fill_codes/")
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
response = client.post(
"/don_confiao/api/catalog_sales/fill_codes/"
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_admin_list_display_includes_code(self):
self.assertIn("code", SaleAdmin.list_display)
self.assertIn("code", CatalogSaleAdmin.list_display)
def test_sale_serializer_includes_code(self):
sale = self._create_sale()
data = SaleSerializer(sale).data
self.assertIn("code", data)
self.assertEqual(data["code"], sale.code)
def test_catalog_sale_serializer_includes_code(self):
catalog_sale = self._create_catalog_sale()
data = CatalogSaleSerializer(catalog_sale).data
self.assertIn("code", data)
self.assertEqual(data["code"], catalog_sale.code)
def test_sales_list_endpoint_includes_code(self):
self.login()
sale = self._create_sale()
response = self.client.get("/don_confiao/api/sales/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
item = next(i for i in response.json() if i["id"] == sale.id)
self.assertEqual(item["code"], sale.code)
def test_catalog_sales_list_endpoint_includes_code(self):
self.login()
catalog_sale = self._create_catalog_sale()
response = self.client.get("/don_confiao/api/catalog_sales/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
item = next(i for i in response.json() if i["id"] == catalog_sale.id)
self.assertEqual(item["code"], catalog_sale.code)

View File

@@ -0,0 +1,246 @@
import base64
from django.contrib.auth.models import User
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import override_settings
from rest_framework import status
from rest_framework.test import APIClient, APITestCase
from rest_framework_simplejwt.tokens import RefreshToken
from ..models.store_settings import StoreSettings
from .Mixins import LoginMixin
URL = "/don_confiao/api/store_settings"
PNG_1X1 = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk"
"+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
)
class TestStoreSettingsModel(APITestCase, LoginMixin):
def test_singleton_returns_single_instance(self):
first = StoreSettings.get_singleton()
second = StoreSettings.get_singleton()
self.assertEqual(first.pk, second.pk)
self.assertEqual(StoreSettings.objects.count(), 1)
def test_singleton_created_without_address(self):
instance = StoreSettings.get_singleton()
self.assertEqual(instance.address, "")
def test_str(self):
instance = StoreSettings.get_singleton()
self.assertEqual(str(instance), f"StoreSettings (id={instance.id})")
class TestStoreSettingsAPIPermissions(APITestCase, LoginMixin):
def setUp(self):
self.url = URL
def test_get_unauthenticated(self):
client = APIClient()
response = client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_patch_unauthenticated(self):
client = APIClient()
response = client.patch(
self.url,
{"address": "Calle falsa 123"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_patch_non_admin(self):
user = User.objects.create_user(
username="regularuser",
email="regular@example.com",
password="regularpass",
)
refresh = RefreshToken.for_user(user)
client = APIClient()
client.credentials(
HTTP_AUTHORIZATION=f"Bearer {str(refresh.access_token)}"
)
response = client.patch(
self.url,
{"address": "Calle falsa 123"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_patch_publico(self):
user = User.objects.create_user(
username="publico",
email="publico@example.com",
password="publicopass",
)
user.profile.user_type = "publico"
user.profile.save()
refresh = RefreshToken.for_user(user)
client = APIClient()
client.credentials(
HTTP_AUTHORIZATION=f"Bearer {str(refresh.access_token)}"
)
response = client.patch(
self.url,
{"address": "Calle falsa 123"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
class TestStoreSettingsAPI(APITestCase, LoginMixin):
def setUp(self):
self.login()
self.url = URL
def test_get_creates_singleton(self):
self.assertEqual(StoreSettings.objects.count(), 0)
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(StoreSettings.objects.count(), 1)
data = response.json()
self.assertIn("id", data)
self.assertIn("address", data)
self.assertIn("latitude", data)
self.assertIn("longitude", data)
self.assertIn("logo", data)
self.assertIn("updated_at", data)
def test_patch_updates_settings(self):
self.client.get(self.url)
response = self.client.patch(
self.url,
{
"address": "Carrera 5 # 10-20, Bogotá",
"latitude": 4.6097,
"longitude": -74.0817,
},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
self.assertEqual(data["address"], "Carrera 5 # 10-20, Bogotá")
self.assertEqual(data["latitude"], 4.6097)
self.assertEqual(data["longitude"], -74.0817)
settings = StoreSettings.objects.get(pk=1)
self.assertEqual(settings.address, "Carrera 5 # 10-20, Bogotá")
self.assertEqual(settings.latitude, 4.6097)
self.assertEqual(settings.longitude, -74.0817)
def test_patch_partial(self):
self.client.get(self.url)
response = self.client.patch(
self.url,
{"address": "Solo dirección"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
settings = StoreSettings.objects.get(pk=1)
self.assertEqual(settings.address, "Solo dirección")
self.assertIsNone(settings.latitude)
def test_patch_reuses_singleton(self):
self.client.get(self.url)
self.client.patch(
self.url,
{"address": "Primera"},
format="json",
)
self.client.patch(
self.url,
{"address": "Segunda"},
format="json",
)
self.assertEqual(StoreSettings.objects.count(), 1)
def test_patch_invalid_coordinates(self):
self.client.get(self.url)
response = self.client.patch(
self.url,
{"latitude": "not-a-number"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_patch_upload_logo(self):
self.client.get(self.url)
logo = SimpleUploadedFile("logo.png", PNG_1X1, content_type="image/png")
response = self.client.patch(
self.url,
{"logo": logo},
format="multipart",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
self.assertIn("/media/logos/", data["logo"])
self.assertTrue(data["logo"].startswith("http"))
settings = StoreSettings.objects.get(pk=1)
self.assertIsNotNone(settings.logo)
self.assertTrue(settings.logo.name.startswith("logos/logo"))
def test_get_returns_absolute_logo_url(self):
self.client.get(self.url)
logo = SimpleUploadedFile("logo.png", PNG_1X1, content_type="image/png")
self.client.patch(self.url, {"logo": logo}, format="multipart")
response = self.client.get(self.url)
data = response.json()
self.assertTrue(data["logo"].startswith("http"))
def test_patch_logo_non_image(self):
self.client.get(self.url)
fake = SimpleUploadedFile("logo.txt", b"not an image", content_type="text/plain")
response = self.client.patch(
self.url,
{"logo": fake},
format="multipart",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
@override_settings(LOGO_MAX_UPLOAD_SIZE=10)
def test_patch_logo_too_large(self):
self.client.get(self.url)
logo = SimpleUploadedFile("logo.png", PNG_1X1, content_type="image/png")
response = self.client.patch(
self.url,
{"logo": logo},
format="multipart",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
settings = StoreSettings.objects.get(pk=1)
self.assertFalse(settings.logo)
def test_patch_clear_logo(self):
self.client.get(self.url)
logo = SimpleUploadedFile("logo.png", PNG_1X1, content_type="image/png")
self.client.patch(self.url, {"logo": logo}, format="multipart")
response = self.client.patch(
self.url,
{"logo": None},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
self.assertIsNone(data["logo"])
settings = StoreSettings.objects.get(pk=1)
self.assertFalse(settings.logo)
def test_patch_upload_logo_keeps_other_fields(self):
self.client.get(self.url)
logo = SimpleUploadedFile("logo.png", PNG_1X1, content_type="image/png")
response = self.client.patch(
self.url,
{
"address": "Avenida Siempre Viva 742",
"logo": logo,
},
format="multipart",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
settings = StoreSettings.objects.get(pk=1)
self.assertEqual(settings.address, "Avenida Siempre Viva 742")
self.assertIsNotNone(settings.logo)

View File

@@ -16,6 +16,7 @@ from .api import (
CatalogSaleView, CatalogSaleView,
SaleSummary, SaleSummary,
CatalogSaleSummary, CatalogSaleSummary,
PublicSaleSummaryView,
SalesForTrytonView, SalesForTrytonView,
SalesToTrytonView, SalesToTrytonView,
CatalogSalesToTrytonView, CatalogSalesToTrytonView,
@@ -26,6 +27,8 @@ from .api import (
SalesForReconciliationView, SalesForReconciliationView,
# Admin # Admin
AdminCodeValidateView, AdminCodeValidateView,
# Store Settings
StoreSettingsView,
) )
app_name = "don_confiao" app_name = "don_confiao"
@@ -58,6 +61,16 @@ urlpatterns = [
CatalogSaleSummary.as_view(), CatalogSaleSummary.as_view(),
name="catalog_purchase_json_summary", name="catalog_purchase_json_summary",
), ),
path(
"resumen_publico/<str:code>",
PublicSaleSummaryView.as_view(),
name="public_purchase_summary",
),
path(
"resumen_publico",
PublicSaleSummaryView.as_view(),
name="public_purchase_summary_query",
),
path( path(
"payment_methods/all/select_format", "payment_methods/all/select_format",
PaymentMethodView.as_view(), PaymentMethodView.as_view(),
@@ -95,4 +108,5 @@ urlpatterns = [
AdminCodeValidateView.as_view(), AdminCodeValidateView.as_view(),
), ),
path("api/sales/for_tryton", SalesForTrytonView.as_view()), path("api/sales/for_tryton", SalesForTrytonView.as_view()),
path("api/store_settings", StoreSettingsView.as_view()),
] ]