Merge pull request 'feat/46-order-summary-public' (#47) from feat/46-order-summary-public into main
Reviewed-on: #47
This commit is contained in:
80
scripts/fill_sale_codes.py
Normal file
80
scripts/fill_sale_codes.py
Normal 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()
|
||||
@@ -37,8 +37,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)
|
||||
@@ -57,8 +58,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")
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from .sales import (
|
||||
CatalogSaleView,
|
||||
SaleSummary,
|
||||
CatalogSaleSummary,
|
||||
PublicSaleSummaryView,
|
||||
SalesForTrytonView,
|
||||
SalesToTrytonView,
|
||||
CatalogSalesToTrytonView,
|
||||
@@ -34,6 +35,7 @@ __all__ = [
|
||||
"CatalogSaleView",
|
||||
"SaleSummary",
|
||||
"CatalogSaleSummary",
|
||||
"PublicSaleSummaryView",
|
||||
"SalesForTrytonView",
|
||||
"SalesToTrytonView",
|
||||
"CatalogSalesToTrytonView",
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
193
tienda_ilusion/don_confiao/tests/test_order_summary_public.py
Normal file
193
tienda_ilusion/don_confiao/tests/test_order_summary_public.py
Normal 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)
|
||||
@@ -16,6 +16,7 @@ from .api import (
|
||||
CatalogSaleView,
|
||||
SaleSummary,
|
||||
CatalogSaleSummary,
|
||||
PublicSaleSummaryView,
|
||||
SalesForTrytonView,
|
||||
SalesToTrytonView,
|
||||
CatalogSalesToTrytonView,
|
||||
@@ -60,6 +61,16 @@ urlpatterns = [
|
||||
CatalogSaleSummary.as_view(),
|
||||
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(
|
||||
"payment_methods/all/select_format",
|
||||
PaymentMethodView.as_view(),
|
||||
|
||||
Reference in New Issue
Block a user