feat/46-order-summary-public #47

Merged
mono merged 5 commits from feat/46-order-summary-public into main 2026-08-08 14:25:25 -05:00
9 changed files with 415 additions and 10 deletions

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

@@ -31,8 +31,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 +52,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,
@@ -33,6 +34,7 @@ __all__ = [
"CatalogSaleView", "CatalogSaleView",
"SaleSummary", "SaleSummary",
"CatalogSaleSummary", "CatalogSaleSummary",
"PublicSaleSummaryView",
"SalesForTrytonView", "SalesForTrytonView",
"SalesToTrytonView", "SalesToTrytonView",
"CatalogSalesToTrytonView", "CatalogSalesToTrytonView",

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,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

@@ -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

@@ -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
@@ -87,13 +99,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 +117,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,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)

View File

@@ -16,6 +16,7 @@ from .api import (
CatalogSaleView, CatalogSaleView,
SaleSummary, SaleSummary,
CatalogSaleSummary, CatalogSaleSummary,
PublicSaleSummaryView,
SalesForTrytonView, SalesForTrytonView,
SalesToTrytonView, SalesToTrytonView,
CatalogSalesToTrytonView, CatalogSalesToTrytonView,
@@ -58,6 +59,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(),