From b2312edff571991ed5a9e06fba0b8d03ff0c7788 Mon Sep 17 00:00:00 2001 From: mono Date: Sat, 8 Aug 2026 13:28:44 -0500 Subject: [PATCH 1/5] test(#46): add order summary public and fill_codes tests --- .../tests/test_order_summary_public.py | 193 ++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 tienda_ilusion/don_confiao/tests/test_order_summary_public.py diff --git a/tienda_ilusion/don_confiao/tests/test_order_summary_public.py b/tienda_ilusion/don_confiao/tests/test_order_summary_public.py new file mode 100644 index 0000000..fc96915 --- /dev/null +++ b/tienda_ilusion/don_confiao/tests/test_order_summary_public.py @@ -0,0 +1,193 @@ +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 .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) From 332884907cc0c357b38d1fa0a429e434cf24ea02 Mon Sep 17 00:00:00 2001 From: mono Date: Sat, 8 Aug 2026 13:28:49 -0500 Subject: [PATCH 2/5] feat(#46): add code to Sale and CatalogSale models --- .../0050_catalogsale_code_sale_code.py | 24 +++++++++++++++++++ tienda_ilusion/don_confiao/models/sales.py | 8 +++++++ 2 files changed, 32 insertions(+) create mode 100644 tienda_ilusion/don_confiao/migrations/0050_catalogsale_code_sale_code.py diff --git a/tienda_ilusion/don_confiao/migrations/0050_catalogsale_code_sale_code.py b/tienda_ilusion/don_confiao/migrations/0050_catalogsale_code_sale_code.py new file mode 100644 index 0000000..55e9dfa --- /dev/null +++ b/tienda_ilusion/don_confiao/migrations/0050_catalogsale_code_sale_code.py @@ -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), + ), + ] diff --git a/tienda_ilusion/don_confiao/models/sales.py b/tienda_ilusion/don_confiao/models/sales.py index 8a4d466..5fac042 100644 --- a/tienda_ilusion/don_confiao/models/sales.py +++ b/tienda_ilusion/don_confiao/models/sales.py @@ -4,6 +4,11 @@ from .products import Product from .payments import PaymentMethods, ReconciliationJar from django.core.exceptions import ValidationError from datetime import datetime +import uuid + + +def generate_order_code(): + return uuid.uuid4().hex class SaleAbstractModel(models.Model): @@ -11,6 +16,9 @@ class SaleAbstractModel(models.Model): date = models.DateTimeField("Date") phone = models.CharField(max_length=13, 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: abstract = True From 2438cf2576ca86f99a3efac8bae8759ae72e3072 Mon Sep 17 00:00:00 2001 From: mono Date: Sat, 8 Aug 2026 13:28:53 -0500 Subject: [PATCH 3/5] feat(#46): add public order summary and fill_codes endpoints --- tienda_ilusion/don_confiao/api/__init__.py | 2 + tienda_ilusion/don_confiao/api/sales.py | 77 ++++++++++++++++++- .../don_confiao/serializers/sales.py | 24 +++++- tienda_ilusion/don_confiao/urls.py | 11 +++ 4 files changed, 106 insertions(+), 8 deletions(-) diff --git a/tienda_ilusion/don_confiao/api/__init__.py b/tienda_ilusion/don_confiao/api/__init__.py index 275997b..ae91708 100644 --- a/tienda_ilusion/don_confiao/api/__init__.py +++ b/tienda_ilusion/don_confiao/api/__init__.py @@ -6,6 +6,7 @@ from .sales import ( CatalogSaleView, SaleSummary, CatalogSaleSummary, + PublicSaleSummaryView, SalesForTrytonView, SalesToTrytonView, CatalogSalesToTrytonView, @@ -33,6 +34,7 @@ __all__ = [ "CatalogSaleView", "SaleSummary", "CatalogSaleSummary", + "PublicSaleSummaryView", "SalesForTrytonView", "SalesToTrytonView", "CatalogSalesToTrytonView", diff --git a/tienda_ilusion/don_confiao/api/sales.py b/tienda_ilusion/don_confiao/api/sales.py index 125422c..3f702f9 100644 --- a/tienda_ilusion/don_confiao/api/sales.py +++ b/tienda_ilusion/don_confiao/api/sales.py @@ -1,11 +1,17 @@ from rest_framework import viewsets from rest_framework.views import APIView 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 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.products import Product from ..serializers import ( @@ -20,11 +26,27 @@ from ..services.tryton.client import get_tryton_client 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): queryset = Sale.objects.all() serializer_class = SaleSerializer 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): data = request.data customer = Customer.objects.get(pk=data["customer"]) @@ -60,7 +82,17 @@ class CatalogSaleView(viewsets.ModelViewSet): queryset = CatalogSale.objects.all() 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): + if self.action == "fill_codes": + return [IsAuthenticated(), IsAdministrator()] if self.action == "create": return [IsAuthenticated()] return [IsNotPublico()] @@ -71,7 +103,9 @@ class SaleSummary(APIView): def get(self, request, id): sale = Sale.objects.get(pk=id) - serializer = SaleSummarySerializer(sale) + serializer = SaleSummarySerializer( + sale, context={"request": request} + ) return Response(serializer.data) @@ -80,10 +114,45 @@ class CatalogSaleSummary(APIView): def get(self, request, id): catalog_sale = CatalogSale.objects.get(pk=id) - serializer = CatalogSaleSummarySerializer(catalog_sale) + serializer = CatalogSaleSummarySerializer( + catalog_sale, context={"request": request} + ) 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): permission_classes = [IsAuthenticated, IsAdministrator] diff --git a/tienda_ilusion/don_confiao/serializers/sales.py b/tienda_ilusion/don_confiao/serializers/sales.py index 72db3cf..17ea8fd 100644 --- a/tienda_ilusion/don_confiao/serializers/sales.py +++ b/tienda_ilusion/don_confiao/serializers/sales.py @@ -1,4 +1,5 @@ from rest_framework import serializers +from django.urls import reverse from ..models.sales import ( Sale, @@ -11,6 +12,17 @@ from .products import ListProductSerializer 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 Meta: model = SaleLine @@ -87,13 +99,14 @@ class SummarySaleLineSerializer(serializers.ModelSerializer): fields = ["product", "quantity", "unit_price", "description"] -class SaleSummarySerializer(serializers.ModelSerializer): +class SaleSummarySerializer(PublicSummaryLinkMixin, serializers.ModelSerializer): customer = ListCustomerSerializer() lines = SummarySaleLineSerializer(many=True, source="saleline_set") + link = serializers.SerializerMethodField() class Meta: model = Sale - fields = ["id", "date", "customer", "payment_method", "lines"] + fields = ["id", "code", "date", "customer", "payment_method", "lines", "link"] class CatalogSummarySaleLineSerializer(serializers.ModelSerializer): @@ -104,15 +117,18 @@ class CatalogSummarySaleLineSerializer(serializers.ModelSerializer): fields = ["product", "quantity", "unit_price", "description"] -class CatalogSaleSummarySerializer(serializers.ModelSerializer): +class CatalogSaleSummarySerializer( + PublicSummaryLinkMixin, serializers.ModelSerializer +): customer = ListCustomerSerializer() lines = CatalogSummarySaleLineSerializer( many=True, source="catalogsaleline_set" ) + link = serializers.SerializerMethodField() class Meta: model = CatalogSale - fields = ["id", "date", "customer", "lines"] + fields = ["id", "code", "date", "customer", "lines", "link"] class SaleForRenconciliationSerializer(serializers.Serializer): diff --git a/tienda_ilusion/don_confiao/urls.py b/tienda_ilusion/don_confiao/urls.py index e7e5592..d7a8a44 100644 --- a/tienda_ilusion/don_confiao/urls.py +++ b/tienda_ilusion/don_confiao/urls.py @@ -16,6 +16,7 @@ from .api import ( CatalogSaleView, SaleSummary, CatalogSaleSummary, + PublicSaleSummaryView, SalesForTrytonView, SalesToTrytonView, CatalogSalesToTrytonView, @@ -58,6 +59,16 @@ urlpatterns = [ CatalogSaleSummary.as_view(), name="catalog_purchase_json_summary", ), + path( + "resumen_publico/", + PublicSaleSummaryView.as_view(), + name="public_purchase_summary", + ), + path( + "resumen_publico", + PublicSaleSummaryView.as_view(), + name="public_purchase_summary_query", + ), path( "payment_methods/all/select_format", PaymentMethodView.as_view(), From 4f4115605668a816bb21afe469e1e7d847037ea9 Mon Sep 17 00:00:00 2001 From: mono Date: Sat, 8 Aug 2026 13:29:14 -0500 Subject: [PATCH 4/5] feat(#46): add fill_codes client script --- scripts/fill_sale_codes.py | 80 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 scripts/fill_sale_codes.py diff --git a/scripts/fill_sale_codes.py b/scripts/fill_sale_codes.py new file mode 100644 index 0000000..5666b35 --- /dev/null +++ b/scripts/fill_sale_codes.py @@ -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() From 805565aa8e59d73f7e492f065e819cf14ea53b7b Mon Sep 17 00:00:00 2001 From: mono Date: Sat, 8 Aug 2026 13:29:14 -0500 Subject: [PATCH 5/5] feat(#46): show order code in admin --- tienda_ilusion/don_confiao/admin.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tienda_ilusion/don_confiao/admin.py b/tienda_ilusion/don_confiao/admin.py index bb3ceed..38f5659 100644 --- a/tienda_ilusion/don_confiao/admin.py +++ b/tienda_ilusion/don_confiao/admin.py @@ -31,8 +31,9 @@ class SaleAdmin(admin.ModelAdmin): "customer", "payment_method", "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") @admin.register(SaleLine) @@ -51,8 +52,9 @@ class CatalogSaleAdmin(admin.ModelAdmin): "customer_address", "pickup_method", "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")