13 Commits

Author SHA1 Message Date
193198918b Merge pull request 'feat: add standalone script to upload catalogue images via API' (#41) from feature/upload-catalogue-script into main
Reviewed-on: #41
2026-06-14 00:46:45 -05:00
mono
1007584d3e Merge branch 'main' into feature/upload-catalogue-script 2026-06-14 00:46:21 -05:00
mono
ac22adb558 feat: add standalone script to upload catalogue images via API 2026-06-14 00:39:33 -05:00
2415ed3564 Merge pull request 'feat: add catalogue image management (don_confiao_catalog_generator/issues/1)' (#40) from feature/1-catalogue-images into main
Reviewed-on: #40
2026-06-13 20:31:40 -05:00
mono
539629076f fix: address PR review issues for catalogue images
- Use PNG format instead of JPEG for transparency support
- Non-strict mode only resizes if image width > target width
- Replace CATALOGUE_BACKGROUND_IMAGES_COLOR with CATALOGUE_BACKGROUND_IMAGES_RGBA (tuple default (0,0,0,0))
- Move all inline imports to top of test file
- Add test_resize_non_strict_no_upscale test case
- Add comment explaining settings.DEBUG guard for media serving
2026-06-13 14:57:25 -05:00
mono
7112197ad2 feat: add catalogue image management (#1)
- Add CatalogueImage model with FK to Product
- Add image resize service (strict/non-strict modes with configurable dimensions)
- Add CRUD API endpoints with admin-only write permissions
- Add catalogue_images field to product listing/detail endpoints
- Serve media files in development via static()
- 28 TDD tests covering model, API, permissions, resize, and product listing
2026-06-13 13:39:57 -05:00
7160d64e86 chore: Add fields to list_diplay CustomerAdmin 2026-06-09 15:49:54 -05:00
bb5ef7fed8 feat: Display fields for Customer model in webadmin. 2026-06-09 12:03:22 -05:00
77761ea8cc feat: Add customer information to CatalogSale 2026-06-05 09:11:52 -05:00
ff67720cea Add external_id to CatalogSaleSerializer
- Include external_id field in CatalogSaleSerializer response
- Allows API clients to see if catalog sale has been synced to Tryton
- Maintains consistency with SaleSerializer which already includes external_id
- Backward compatible change (field is nullable)
2026-05-30 21:20:07 -05:00
5e811c802a Add Tryton synchronization for CatalogSale
- Add external_id field to CatalogSale model for tracking synced sales
- Create migration 0047 for external_id field
- Add TrytonCatalogSale and TrytonCatalogSaleLine classes for Tryton RPC format
- Add send_catalog_sales_to_tryton() method to SaleTrytonService
- Create CatalogSalesToTrytonView API endpoint (POST)
- Register endpoint at /don_confiao/api/enviar_catalog_sales_a_tryton
- Add test for external_id field functionality
- Catalog sales sync to same Tryton model as Sale (model.sale.sale.create)
- Differentiated by reference 'don_confiao_catalog X' and description 'Venta de catálogo'
- Filters only catalog sales without external_id to avoid duplicates
2026-05-30 21:01:29 -05:00
d4a61b8340 Add catalog sale purchase summary endpoint
- Add CatalogSaleSummarySerializer and CatalogSummarySaleLineSerializer
- Add CatalogSaleSummary API view for GET requests
- Register endpoint at /don_confiao/resumen_compra_catalogo_json/<id>
- Add comprehensive test for catalog sale summary
- Include nested customer and product details in response
- Endpoint returns id, date, customer, and lines with products
2026-05-30 20:32:20 -05:00
52ff61354e chore: remove unused export_csv.py file
El archivo export_csv.py solo contenía el shebang y no era utilizado
en ninguna parte del proyecto. Removido durante la refactorización.
2026-05-29 00:22:47 -05:00
25 changed files with 1016 additions and 10 deletions

View File

@@ -15,3 +15,4 @@ python-decouple # Manage environment variables and settings
# Static files serving in production/staging
whitenoise==6.6.0 # Serve static files efficiently with compression
Pillow # Image processing for catalogue image resizing

View File

@@ -0,0 +1,126 @@
#!/usr/bin/env python3
import argparse
import getpass
import os
import re
import sys
from pathlib import Path
import requests
TOKEN_URL = "/api/token/"
PRODUCTS_URL = "/don_confiao/api/products/"
CATALOGUE_IMAGES_URL = "/don_confiao/api/catalogue_images/"
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)
data = response.json()
return data["access"]
def get_products(domain, token):
url = domain.rstrip("/") + PRODUCTS_URL
headers = {"Authorization": f"Bearer {token}"}
response = requests.get(url, headers=headers)
if response.status_code != 200:
print(f"Error al obtener productos: {response.status_code} {response.text}", file=sys.stderr)
sys.exit(1)
return response.json()
MIME_TYPES = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
}
def find_images(image_dir):
images = {}
pattern = re.compile(r"^(\d+)\.(jpg|jpeg|png)$", re.IGNORECASE)
for f in os.listdir(image_dir):
m = pattern.match(f)
if m:
external_id = m.group(1)
images[external_id] = os.path.join(image_dir, f)
return images
def main():
parser = argparse.ArgumentParser(
description="Sube imágenes de catálogo para productos usando el external_id como nombre de archivo."
)
parser.add_argument("image_dir", help="Directorio con imágenes nombradas como ##.jpg")
parser.add_argument("domain", help="Dominio del backend (ej: http://localhost:8000)")
args = parser.parse_args()
if not os.path.isdir(args.image_dir):
print(f"Error: el directorio '{args.image_dir}' no existe.", file=sys.stderr)
sys.exit(1)
username, password = get_credentials()
token = get_token(args.domain, username, password)
print("Token obtenido correctamente.")
products = get_products(args.domain, token)
ext_id_to_product_id = {}
for p in products:
if p.get("external_id"):
ext_id_to_product_id[p["external_id"]] = p["id"]
if not ext_id_to_product_id:
print("No se encontraron productos con external_id.", file=sys.stderr)
sys.exit(1)
images = find_images(args.image_dir)
if not images:
print(f"No se encontraron imágenes con el patrón ##.jpg en '{args.image_dir}'.", file=sys.stderr)
sys.exit(1)
headers = {"Authorization": f"Bearer {token}"}
upload_url = args.domain.rstrip("/") + CATALOGUE_IMAGES_URL
uploaded = 0
skipped = 0
errors = 0
for external_id, img_path in sorted(images.items()):
if external_id not in ext_id_to_product_id:
print(f" [SKIP] {Path(img_path).name}: no hay producto con external_id={external_id}")
skipped += 1
continue
product_id = ext_id_to_product_id[external_id]
filename = Path(img_path).name
ext = Path(img_path).suffix.lower()
mime = MIME_TYPES.get(ext, "application/octet-stream")
with open(img_path, "rb") as f:
files = {"image": (filename, f, mime)}
data = {"product": product_id}
response = requests.post(upload_url, headers=headers, files=files, data=data)
if response.status_code == 201:
print(f" [OK] {filename} -> producto {product_id} (id imagen: {response.json()['id']})")
uploaded += 1
else:
print(f" [ERR] {filename} -> producto {product_id}: {response.status_code} {response.text}")
errors += 1
print(f"\nResumen: {uploaded} subidas, {skipped} saltadas, {errors} errores")
if __name__ == "__main__":
main()

View File

@@ -138,6 +138,22 @@ USE_TZ = True
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
# Media files
MEDIA_ROOT = BASE_DIR / "media"
MEDIA_URL = "/media/"
# Catalogue image settings
CATALOGUE_IMAGE_WIDTH = int(os.environ.get("CATALOGUE_IMAGE_WIDTH", "600"))
CATALOGUE_IMAGE_HEIGHT = int(os.environ.get("CATALOGUE_IMAGE_HEIGHT", "600"))
CATALOGUE_STRICT_DIMENSION = os.environ.get("CATALOGUE_STRICT_DIMENSION", "False").lower() in ("true", "1", "yes")
CATALOGUE_BACKGROUND_IMAGES_RGBA = os.environ.get(
"CATALOGUE_BACKGROUND_IMAGES_RGBA", "0,0,0,0"
)
# Parse RGBA string to tuple
_rgba_parts = CATALOGUE_BACKGROUND_IMAGES_RGBA.split(",")
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)))
# Default primary key field type
# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field

View File

@@ -48,6 +48,10 @@ EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
STATIC_ROOT = BASE_DIR / "staticfiles"
STATIC_URL = "/static/"
# Media files configuration
MEDIA_ROOT = BASE_DIR / "media"
MEDIA_URL = "/media/"
# WhiteNoise configuration for development (optional, for consistency)
# In development with DEBUG=True, Django serves static files automatically
# But this ensures consistent behavior across all environments

View File

@@ -14,6 +14,8 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path
from rest_framework_simplejwt.views import (
@@ -32,3 +34,8 @@ urlpatterns = [
name='token_refresh'),
path('api/users/', include('users.urls')),
]
# Serve media files through Django only in development.
# In production/staging, media is served by the web server (nginx).
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View File

@@ -11,7 +11,19 @@ from .models.sales import (
from .models.products import Product, ProductCategory
from .models.payments import ReconciliationJar
admin.site.register(Customer)
@admin.register(Customer)
class CustomerAdmin(admin.ModelAdmin):
list_display = (
"name",
"email",
"phone",
"external_id",
"address_external_id",
)
search_fields = ("name", "email", "phone")
admin.site.register(Sale)
admin.site.register(SaleLine)
admin.site.register(CatalogSale)

View File

@@ -1,11 +1,14 @@
from .catalogue_images import CatalogueImageViewSet
from .products import ProductView, ProductsFromTrytonView
from .customers import CustomerView, CustomersFromTrytonView
from .sales import (
SaleView,
CatalogSaleView,
SaleSummary,
CatalogSaleSummary,
SalesForTrytonView,
SalesToTrytonView,
CatalogSalesToTrytonView,
)
from .payments import (
ReconciliateJarView,
@@ -17,6 +20,8 @@ from .payments import (
from .admin import AdminCodeValidateView
__all__ = [
# Catalogue Images
"CatalogueImageViewSet",
# Products
"ProductView",
"ProductsFromTrytonView",
@@ -27,8 +32,10 @@ __all__ = [
"SaleView",
"CatalogSaleView",
"SaleSummary",
"CatalogSaleSummary",
"SalesForTrytonView",
"SalesToTrytonView",
"CatalogSalesToTrytonView",
# Payments
"ReconciliateJarView",
"ReconciliateJarModelView",

View File

@@ -0,0 +1,25 @@
from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated
from ..image_service import resize_catalogue_image
from ..models.catalogue_images import CatalogueImage
from ..permissions import IsAdministrator
from ..serializers.catalogue_images import CatalogueImageSerializer
class CatalogueImageViewSet(viewsets.ModelViewSet):
queryset = CatalogueImage.objects.all()
serializer_class = CatalogueImageSerializer
def get_permissions(self):
if self.action in ("create", "update", "partial_update", "destroy"):
return [IsAuthenticated(), IsAdministrator()]
return [IsAuthenticated()]
def perform_create(self, serializer):
instance = serializer.save()
resize_catalogue_image(instance.image)
def perform_update(self, serializer):
instance = serializer.save()
resize_catalogue_image(instance.image)

View File

@@ -12,6 +12,7 @@ from ..serializers import (
SaleSerializer,
CatalogSaleSerializer,
SaleSummarySerializer,
CatalogSaleSummarySerializer,
)
from ..permissions import IsAdministrator
from ..services.tryton.sales import SaleTrytonService
@@ -66,6 +67,13 @@ class SaleSummary(APIView):
return Response(serializer.data)
class CatalogSaleSummary(APIView):
def get(self, request, id):
catalog_sale = CatalogSale.objects.get(pk=id)
serializer = CatalogSaleSummarySerializer(catalog_sale)
return Response(serializer.data)
class SalesForTrytonView(APIView):
permission_classes = [IsAuthenticated, IsAdministrator]
@@ -92,3 +100,13 @@ class SalesToTrytonView(APIView):
service = SaleTrytonService(tryton_client)
result = service.send_to_tryton()
return Response(result, status=200)
class CatalogSalesToTrytonView(APIView):
permission_classes = [IsAuthenticated, IsAdministrator]
def post(self, request):
tryton_client = get_tryton_client()
service = SaleTrytonService(tryton_client)
result = service.send_catalog_sales_to_tryton()
return Response(result, status=200)

View File

@@ -1 +0,0 @@
#!/usr/bin/env python3

View File

@@ -0,0 +1,33 @@
from django.conf import settings
from PIL import Image
def resize_catalogue_image(image_field):
width = settings.CATALOGUE_IMAGE_WIDTH
height = settings.CATALOGUE_IMAGE_HEIGHT
strict = settings.CATALOGUE_STRICT_DIMENSION
bg_color = settings.CATALOGUE_BACKGROUND_IMAGES_RGBA
img = Image.open(image_field.path)
img = img.convert("RGBA")
if strict:
img = resize_to_fit(img, width, height)
canvas = Image.new("RGBA", (width, height), bg_color)
x = (width - img.width) // 2
y = (height - img.height) // 2
canvas.paste(img, (x, y), img)
img = canvas
else:
if img.width > width:
new_height = int(img.height * width / img.width)
img = img.resize((width, new_height), Image.LANCZOS)
img.save(image_field.path, format="PNG")
def resize_to_fit(img, max_width, max_height):
ratio = min(max_width / img.width, max_height / img.height)
new_width = int(img.width * ratio)
new_height = int(img.height * ratio)
return img.resize((new_width, new_height), Image.LANCZOS)

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.0.6 on 2026-05-31 01:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('don_confiao', '0046_product_active'),
]
operations = [
migrations.AddField(
model_name='catalogsale',
name='external_id',
field=models.CharField(blank=True, max_length=100, null=True),
),
]

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.0.6 on 2026-06-05 14:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('don_confiao', '0047_catalogsale_external_id'),
]
operations = [
migrations.AddField(
model_name='catalogsale',
name='customer_address',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='catalogsale',
name='customer_name',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='catalogsale',
name='customer_phone',
field=models.CharField(blank=True, max_length=13, null=True),
),
migrations.AddField(
model_name='catalogsale',
name='pickup_method',
field=models.CharField(blank=True, max_length=30, null=True),
),
]

View File

@@ -0,0 +1,24 @@
# Generated by Django 5.0.6 on 2026-06-13 18:31
import django.db.models.deletion
import don_confiao.models.catalogue_images
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('don_confiao', '0048_catalogsale_customer_address_and_more'),
]
operations = [
migrations.CreateModel(
name='CatalogueImage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('image', models.ImageField(upload_to=don_confiao.models.catalogue_images._catalogue_image_upload_path)),
('uploaded_at', models.DateTimeField(auto_now_add=True)),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='catalogue_images', to='don_confiao.product')),
],
),
]

View File

@@ -0,0 +1,20 @@
from django.db import models
from .products import Product
def _catalogue_image_upload_path(instance, filename):
return f"catalogue_images/{instance.product_id}/{filename}"
class CatalogueImage(models.Model):
product = models.ForeignKey(
Product,
on_delete=models.CASCADE,
related_name="catalogue_images",
)
image = models.ImageField(upload_to=_catalogue_image_upload_path)
uploaded_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"CatalogueImage for {self.product.name} ({self.id})"

View File

@@ -67,7 +67,6 @@ class Sale(SaleAbstractModel):
class SaleLine(SaleLineAbstractModel):
sale = models.ForeignKey(Sale, on_delete=models.CASCADE)
def __str__(self):
@@ -75,6 +74,13 @@ class SaleLine(SaleLineAbstractModel):
class CatalogSale(SaleAbstractModel):
external_id = models.CharField(max_length=100, null=True, blank=True)
customer_name = models.CharField(max_length=255, null=True, blank=True)
customer_phone = models.CharField(max_length=13, null=True, blank=True)
customer_address = models.CharField(
max_length=255, null=True, blank=True
)
pickup_method = models.CharField(max_length=30, null=True, blank=True)
def __str__(self):
return f"{self.date} {self.customer}"

View File

@@ -1,3 +1,4 @@
from .catalogue_images import CatalogueImageSerializer
from .products import ProductSerializer, ListProductSerializer
from .customers import CustomerSerializer, ListCustomerSerializer
from .sales import (
@@ -7,6 +8,8 @@ from .sales import (
CatalogSaleLineSerializer,
SummarySaleLineSerializer,
SaleSummarySerializer,
CatalogSummarySaleLineSerializer,
CatalogSaleSummarySerializer,
SaleForRenconciliationSerializer,
)
from .payments import (
@@ -15,6 +18,8 @@ from .payments import (
)
__all__ = [
# Catalogue Images
"CatalogueImageSerializer",
# Products
"ProductSerializer",
"ListProductSerializer",
@@ -28,6 +33,8 @@ __all__ = [
"CatalogSaleLineSerializer",
"SummarySaleLineSerializer",
"SaleSummarySerializer",
"CatalogSummarySaleLineSerializer",
"CatalogSaleSummarySerializer",
"SaleForRenconciliationSerializer",
# Payments
"ReconciliationJarSerializer",

View File

@@ -0,0 +1,20 @@
from django.conf import settings
from rest_framework import serializers
from ..models.catalogue_images import CatalogueImage
class CatalogueImageSerializer(serializers.ModelSerializer):
class Meta:
model = CatalogueImage
fields = ["id", "product", "image", "uploaded_at"]
read_only_fields = ["uploaded_at"]
def validate_image(self, value):
max_size = settings.CATALOGUE_MAX_UPLOAD_SIZE
if value.size > max_size:
raise serializers.ValidationError(
f"Image size exceeds the maximum allowed size of "
f"{max_size // (1024 * 1024)}MB."
)
return value

View File

@@ -4,6 +4,8 @@ from ..models.products import Product, ProductCategory
class ProductSerializer(serializers.ModelSerializer):
catalogue_images = serializers.SerializerMethodField()
class Meta:
model = Product
fields = [
@@ -14,10 +16,31 @@ class ProductSerializer(serializers.ModelSerializer):
"measuring_unit",
"categories",
"external_id",
"catalogue_images",
]
def get_catalogue_images(self, obj):
request = self.context.get("request")
if not request:
return [img.image.url for img in obj.catalogue_images.all()]
return [
request.build_absolute_uri(img.image.url)
for img in obj.catalogue_images.all()
]
class ListProductSerializer(serializers.ModelSerializer):
catalogue_images = serializers.SerializerMethodField()
class Meta:
model = Product
fields = ["id", "name"]
fields = ["id", "name", "catalogue_images"]
def get_catalogue_images(self, obj):
request = self.context.get("request")
if not request:
return [img.image.url for img in obj.catalogue_images.all()]
return [
request.build_absolute_uri(img.image.url)
for img in obj.catalogue_images.all()
]

View File

@@ -47,7 +47,9 @@ class CatalogSaleLineSerializer(serializers.ModelSerializer):
class CatalogSaleSerializer(serializers.ModelSerializer):
catalogsaleline_set = CatalogSaleLineSerializer(many=True, required=False)
catalogsaleline_set = CatalogSaleLineSerializer(
many=True, required=False
)
total = serializers.ReadOnlyField(source="get_total")
class Meta:
@@ -58,6 +60,11 @@ class CatalogSaleSerializer(serializers.ModelSerializer):
"date",
"catalogsaleline_set",
"total",
"external_id",
"customer_name",
"customer_phone",
"customer_address",
"pickup_method",
]
def create(self, validated_data):
@@ -65,7 +72,9 @@ class CatalogSaleSerializer(serializers.ModelSerializer):
catalog_sale = CatalogSale.objects.create(**validated_data)
for line_data in lines_data:
CatalogSaleLine.objects.create(catalog_sale=catalog_sale, **line_data)
CatalogSaleLine.objects.create(
catalog_sale=catalog_sale, **line_data
)
return catalog_sale
@@ -87,6 +96,25 @@ class SaleSummarySerializer(serializers.ModelSerializer):
fields = ["id", "date", "customer", "payment_method", "lines"]
class CatalogSummarySaleLineSerializer(serializers.ModelSerializer):
product = ListProductSerializer()
class Meta:
model = CatalogSaleLine
fields = ["product", "quantity", "unit_price", "description"]
class CatalogSaleSummarySerializer(serializers.ModelSerializer):
customer = ListCustomerSerializer()
lines = CatalogSummarySaleLineSerializer(
many=True, source="catalogsaleline_set"
)
class Meta:
model = CatalogSale
fields = ["id", "date", "customer", "lines"]
class SaleForRenconciliationSerializer(serializers.Serializer):
id = serializers.IntegerField()
date = serializers.DateTimeField()

View File

@@ -75,3 +75,58 @@ class TrytonLineSale:
"unit": self.sale_line.product.unit_external_id,
"unit_price": self._format_decimal(self.sale_line.unit_price),
}
class TrytonCatalogSale:
"""Representa una catalog sale para exportación a Tryton"""
def __init__(self, catalog_sale, lines):
self.catalog_sale = catalog_sale
self.lines = lines
def _format_date(self, _date):
return {
"__class__": "date",
"year": _date.year,
"month": _date.month,
"day": _date.day,
}
def to_tryton(self):
return {
"company": TRYTON_COMPANY_ID,
"shipment_address": self.catalog_sale.customer.address_external_id,
"invoice_address": self.catalog_sale.customer.address_external_id,
"currency": TRYTON_COP_CURRENCY,
"comment": self.catalog_sale.description or "",
"description": "Venta de catálogo",
"party": self.catalog_sale.customer.external_id,
"reference": "don_confiao_catalog " + str(self.catalog_sale.id),
"sale_date": self._format_date(self.catalog_sale.date),
"lines": [
[
"create",
[TrytonCatalogSaleLine(line).to_tryton() for line in self.lines],
]
],
"self_pick_up": True,
}
class TrytonCatalogSaleLine:
"""Representa una línea de catalog sale para exportación a Tryton"""
def __init__(self, catalog_sale_line):
self.catalog_sale_line = catalog_sale_line
def _format_decimal(self, number):
return {"__class__": "Decimal", "decimal": str(number)}
def to_tryton(self):
return {
"product": self.catalog_sale_line.product.external_id,
"quantity": self._format_decimal(self.catalog_sale_line.quantity),
"type": "line",
"unit": self.catalog_sale_line.product.unit_external_id,
"unit_price": self._format_decimal(self.catalog_sale_line.unit_price),
}

View File

@@ -1,5 +1,5 @@
from ...models.sales import Sale, SaleLine
from .client import TrytonSale, TRYTON_COMPANY_ID, TRYTON_SHOPS
from ...models.sales import Sale, SaleLine, CatalogSale, CatalogSaleLine
from .client import TrytonSale, TrytonCatalogSale, TRYTON_COMPANY_ID, TRYTON_SHOPS
class SaleTrytonService:
@@ -39,3 +39,39 @@ class SaleTrytonService:
"""Convierte venta a parámetros para Tryton"""
sale_tryton = TrytonSale(sale, lines)
return [[sale_tryton.to_tryton()], tryton_context]
def send_catalog_sales_to_tryton(self):
"""Envía catalog sales sin external_id a Tryton"""
method = "model.sale.sale.create"
tryton_context = {
"company": TRYTON_COMPANY_ID,
"shops": TRYTON_SHOPS,
}
successful = []
failed = []
catalog_sales = CatalogSale.objects.filter(external_id=None)
for catalog_sale in catalog_sales:
try:
lines = CatalogSaleLine.objects.filter(catalog_sale=catalog_sale.id)
tryton_params = self._catalog_sale_to_tryton_params(
catalog_sale, lines, tryton_context
)
external_ids = self.client.call(method, tryton_params)
catalog_sale.external_id = external_ids[0]
catalog_sale.save()
successful.append(catalog_sale.id)
except Exception as e:
print(
f"Error al enviar catalog sale: {e}, catalog_sale_id: {catalog_sale.id}"
)
failed.append(catalog_sale.id)
continue
return {"successful": successful, "failed": failed}
def _catalog_sale_to_tryton_params(self, catalog_sale, lines, tryton_context):
"""Convierte catalog sale a parámetros para Tryton"""
sale_tryton = TrytonCatalogSale(catalog_sale, lines)
return [[sale_tryton.to_tryton()], tryton_context]

View File

@@ -86,6 +86,67 @@ class TestAPI(APITestCase, LoginMixin):
self.assertIn("csv", json_response)
self.assertGreater(len(json_response["csv"]), 0)
def test_catalog_sale_summary(self):
# Create a catalog sale
response = self._create_catalog_sale()
content = json.loads(response.content.decode("utf-8"))
catalog_sale_id = content["id"]
# Get the summary
url = f"/don_confiao/resumen_compra_catalogo_json/{catalog_sale_id}"
response = self.client.get(url)
# Verify response
self.assertEqual(response.status_code, status.HTTP_200_OK)
json_response = json.loads(response.content.decode("utf-8"))
# Verify structure
self.assertIn("id", json_response)
self.assertIn("date", json_response)
self.assertIn("customer", json_response)
self.assertIn("lines", json_response)
# Verify customer details
self.assertEqual(json_response["customer"]["id"], self.customer.id)
self.assertEqual(json_response["customer"]["name"], self.customer.name)
# Verify lines
self.assertEqual(len(json_response["lines"]), 2)
# Verify first line
line1 = json_response["lines"][0]
self.assertIn("product", line1)
self.assertIn("quantity", line1)
self.assertIn("unit_price", line1)
self.assertEqual(line1["product"]["id"], self.product.id)
self.assertEqual(line1["product"]["name"], self.product.name)
self.assertEqual(line1["quantity"], "2.00")
self.assertEqual(line1["unit_price"], "3000.00")
# Verify second line
line2 = json_response["lines"][1]
self.assertEqual(line2["product"]["id"], self.product.id)
self.assertEqual(line2["quantity"], "3.00")
self.assertEqual(line2["unit_price"], "5000.00")
def test_catalog_sale_has_external_id_field(self):
"""Verifica que CatalogSale tiene el campo external_id"""
response = self._create_catalog_sale()
content = json.loads(response.content.decode("utf-8"))
catalog_sale_id = content["id"]
catalog_sale = CatalogSale.objects.get(pk=catalog_sale_id)
# Debe tener el campo external_id
self.assertIsNone(catalog_sale.external_id)
# Se puede asignar un valor
catalog_sale.external_id = "123"
catalog_sale.save()
# Verificar que se guardó
catalog_sale.refresh_from_db()
self.assertEqual(catalog_sale.external_id, "123")
def test_csv_structure_in_sales_for_tryton(self):
url = "/don_confiao/api/sales/for_tryton"
self._create_sale()

View File

@@ -0,0 +1,408 @@
import io
from django.conf import settings
from django.contrib.auth.models import User
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import override_settings
from PIL import Image
from rest_framework import status
from rest_framework.test import APIClient, APITestCase
from rest_framework_simplejwt.tokens import RefreshToken
from ..models.catalogue_images import CatalogueImage
from ..models.products import Product
from .Mixins import LoginMixin
def _create_test_image(width=600, height=400, name="test.png"):
"""Create a SimpleUploadedFile from an in-memory image using PIL."""
img = Image.new("RGBA", (width, height), (255, 0, 0, 255))
output = io.BytesIO()
img.save(output, format="PNG")
return SimpleUploadedFile(
name=name,
content=output.getvalue(),
content_type="image/png",
)
class TestCatalogueImageModel(APITestCase, LoginMixin):
def setUp(self):
self.login()
self.product = Product.objects.create(
name="Test Product", price=100.00
)
def test_create_catalogue_image(self):
image_file = _create_test_image()
catalogue_image = CatalogueImage.objects.create(
product=self.product, image=image_file
)
self.assertIsInstance(catalogue_image, CatalogueImage)
self.assertEqual(catalogue_image.product, self.product)
self.assertIsNotNone(catalogue_image.image)
self.assertIsNotNone(catalogue_image.uploaded_at)
def test_catalogue_image_product_relation(self):
image_file = _create_test_image()
ci = CatalogueImage.objects.create(
product=self.product, image=image_file
)
self.assertIn(ci, self.product.catalogue_images.all())
def test_catalogue_image_str(self):
image_file = _create_test_image()
ci = CatalogueImage.objects.create(
product=self.product, image=image_file
)
expected = f"CatalogueImage for {self.product.name} ({ci.id})"
self.assertEqual(str(ci), expected)
def test_cascade_delete_with_product(self):
image_file = _create_test_image()
CatalogueImage.objects.create(
product=self.product, image=image_file
)
self.assertEqual(CatalogueImage.objects.count(), 1)
self.product.delete()
self.assertEqual(CatalogueImage.objects.count(), 0)
class TestCatalogueImageAPIPermissions(APITestCase, LoginMixin):
def setUp(self):
self.product = Product.objects.create(
name="Perm Test Product", price=100.00
)
def test_create_catalogue_image_unauthenticated(self):
url = "/don_confiao/api/catalogue_images/"
image_file = _create_test_image()
data = {"product": self.product.id, "image": image_file}
response = self.client.post(url, data, format="multipart")
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_create_catalogue_image_non_admin(self):
self.user = User.objects.create_user(
username="regularuser",
email="regular@example.com",
password="regularpass",
)
refresh = RefreshToken.for_user(self.user)
self.client = APIClient()
self.client.credentials(
HTTP_AUTHORIZATION=f"Bearer {str(refresh.access_token)}"
)
url = "/don_confiao/api/catalogue_images/"
image_file = _create_test_image()
data = {"product": self.product.id, "image": image_file}
response = self.client.post(url, data, format="multipart")
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_get_catalogue_images_authenticated_non_admin(self):
self.user = User.objects.create_user(
username="regularuser2",
email="regular2@example.com",
password="regularpass",
)
refresh = RefreshToken.for_user(self.user)
self.client = APIClient()
self.client.credentials(
HTTP_AUTHORIZATION=f"Bearer {str(refresh.access_token)}"
)
url = "/don_confiao/api/catalogue_images/"
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_delete_catalogue_image_non_admin(self):
admin_user = User.objects.create_superuser(
username="admin2", email="admin2@example.com", password="adminpass"
)
admin_refresh = RefreshToken.for_user(admin_user)
admin_client = APIClient()
admin_client.credentials(
HTTP_AUTHORIZATION=f"Bearer {str(admin_refresh.access_token)}"
)
image_file = _create_test_image()
create_data = {
"product": self.product.id, "image": image_file
}
create_response = admin_client.post(
"/don_confiao/api/catalogue_images/", create_data, format="multipart"
)
ci_id = create_response.json()["id"]
regular_user = User.objects.create_user(
username="regularuser3",
email="regular3@example.com",
password="regularpass",
)
refresh = RefreshToken.for_user(regular_user)
self.client = APIClient()
self.client.credentials(
HTTP_AUTHORIZATION=f"Bearer {str(refresh.access_token)}"
)
response = self.client.delete(
f"/don_confiao/api/catalogue_images/{ci_id}/"
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(CatalogueImage.objects.count(), 1)
class TestCatalogueImageAPI(APITestCase, LoginMixin):
def setUp(self):
self.login()
self.product = Product.objects.create(
name="API Test Product", price=100.00
)
self.product2 = Product.objects.create(
name="API Test Product 2", price=200.00
)
def _create_image(self, product=None, width=600, height=400):
if product is None:
product = self.product
image_file = _create_test_image(width=width, height=height)
url = "/don_confiao/api/catalogue_images/"
data = {"product": product.id, "image": image_file}
return self.client.post(url, data, format="multipart")
def test_list_catalogue_images_empty(self):
url = "/don_confiao/api/catalogue_images/"
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.json(), [])
def test_create_catalogue_image(self):
response = self._create_image()
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
data = response.json()
self.assertIn("id", data)
self.assertEqual(data["product"], self.product.id)
self.assertIn("image", data)
self.assertIn("uploaded_at", data)
def test_list_catalogue_images(self):
self._create_image()
self._create_image(product=self.product2)
url = "/don_confiao/api/catalogue_images/"
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.json()), 2)
def test_retrieve_catalogue_image(self):
create_response = self._create_image()
ci_id = create_response.json()["id"]
url = f"/don_confiao/api/catalogue_images/{ci_id}/"
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.json()["id"], ci_id)
self.assertEqual(response.json()["product"], self.product.id)
def test_update_catalogue_image(self):
create_response = self._create_image()
ci_id = create_response.json()["id"]
new_image = _create_test_image(width=200, height=200, name="updated.png")
url = f"/don_confiao/api/catalogue_images/{ci_id}/"
response = self.client.put(
url,
{"product": self.product.id, "image": new_image},
format="multipart",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_delete_catalogue_image(self):
create_response = self._create_image()
ci_id = create_response.json()["id"]
url = f"/don_confiao/api/catalogue_images/{ci_id}/"
response = self.client.delete(url)
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
self.assertEqual(CatalogueImage.objects.count(), 0)
def test_create_catalogue_image_invalid_product(self):
image_file = _create_test_image()
url = "/don_confiao/api/catalogue_images/"
data = {"product": 99999, "image": image_file}
response = self.client.post(url, data, format="multipart")
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_create_catalogue_image_missing_file(self):
url = "/don_confiao/api/catalogue_images/"
data = {"product": self.product.id}
response = self.client.post(url, data, format="multipart")
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
class TestCatalogueImageResize(APITestCase, LoginMixin):
def setUp(self):
self.login()
self.product = Product.objects.create(
name="Resize Test Product", price=100.00
)
def _create_and_get_image_obj(self, width, height, strict=False,
bg_color=(0, 0, 0, 0)):
settings_overrides = {
"CATALOGUE_IMAGE_WIDTH": 600,
"CATALOGUE_IMAGE_HEIGHT": 600,
"CATALOGUE_STRICT_DIMENSION": strict,
"CATALOGUE_BACKGROUND_IMAGES_RGBA": bg_color,
}
with override_settings(**settings_overrides):
image_file = _create_test_image(width=width, height=height)
url = "/don_confiao/api/catalogue_images/"
data = {"product": self.product.id, "image": image_file}
response = self.client.post(url, data, format="multipart")
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
ci_id = response.json()["id"]
return CatalogueImage.objects.get(id=ci_id)
def test_resize_non_strict_mode_landscape(self):
ci = self._create_and_get_image_obj(
width=800, height=600, strict=False
)
with Image.open(ci.image.path) as img:
self.assertEqual(img.width, 600)
expected_height = int(600 * 600 / 800)
self.assertEqual(img.height, expected_height)
def test_resize_non_strict_mode_portrait(self):
ci = self._create_and_get_image_obj(
width=600, height=800, strict=False
)
with Image.open(ci.image.path) as img:
self.assertEqual(img.width, 600)
expected_height = int(800 * 600 / 600)
self.assertEqual(img.height, expected_height)
def test_resize_non_strict_mode_square(self):
ci = self._create_and_get_image_obj(
width=600, height=600, strict=False
)
with Image.open(ci.image.path) as img:
self.assertEqual(img.width, 600)
self.assertEqual(img.height, 600)
def test_resize_non_strict_no_upscale(self):
ci = self._create_and_get_image_obj(
width=400, height=300, strict=False
)
with Image.open(ci.image.path) as img:
self.assertEqual(img.width, 400)
self.assertEqual(img.height, 300)
def test_resize_strict_mode_landscape(self):
ci = self._create_and_get_image_obj(
width=800, height=600, strict=True
)
with Image.open(ci.image.path) as img:
self.assertEqual(img.width, 600)
self.assertEqual(img.height, 600)
def test_resize_strict_mode_portrait(self):
ci = self._create_and_get_image_obj(
width=600, height=800, strict=True
)
with Image.open(ci.image.path) as img:
self.assertEqual(img.width, 600)
self.assertEqual(img.height, 600)
def test_resize_strict_mode_with_color_background(self):
ci = self._create_and_get_image_obj(
width=800, height=600, strict=True, bg_color=(255, 255, 255, 255)
)
with Image.open(ci.image.path) as img:
self.assertEqual(img.width, 600)
self.assertEqual(img.height, 600)
@override_settings(CATALOGUE_MAX_UPLOAD_SIZE=1)
def test_resize_large_file_rejected(self):
image_file = _create_test_image(width=10, height=10)
url = "/don_confiao/api/catalogue_images/"
data = {"product": self.product.id, "image": image_file}
response = self.client.post(url, data, format="multipart")
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
class TestProductListingWithImages(APITestCase, LoginMixin):
def setUp(self):
self.login()
self.product = Product.objects.create(
name="Product With Images", price=100.00
)
self.product_no_images = Product.objects.create(
name="Product Without Images", price=200.00
)
def test_product_list_includes_catalogue_images_field(self):
url = "/don_confiao/api/products/"
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for product in response.json():
self.assertIn("catalogue_images", product)
def test_product_list_catalogue_images_urls(self):
image_file = _create_test_image()
CatalogueImage.objects.create(
product=self.product, image=image_file
)
url = "/don_confiao/api/products/"
response = self.client.get(url)
data = response.json()
product_data = next(
p for p in data if p["id"] == self.product.id
)
self.assertEqual(len(product_data["catalogue_images"]), 1)
self.assertTrue(
product_data["catalogue_images"][0].endswith(".png")
)
def test_product_list_catalogue_images_multiple(self):
img1 = _create_test_image(name="img1.png")
img2 = _create_test_image(name="img2.png")
CatalogueImage.objects.create(product=self.product, image=img1)
CatalogueImage.objects.create(product=self.product, image=img2)
url = "/don_confiao/api/products/"
response = self.client.get(url)
data = response.json()
product_data = next(
p for p in data if p["id"] == self.product.id
)
self.assertEqual(len(product_data["catalogue_images"]), 2)
def test_product_list_catalogue_images_empty(self):
url = "/don_confiao/api/products/"
response = self.client.get(url)
data = response.json()
product_data = next(
p for p in data if p["id"] == self.product_no_images.id
)
self.assertEqual(product_data["catalogue_images"], [])
def test_product_detail_includes_catalogue_images(self):
image_file = _create_test_image()
CatalogueImage.objects.create(
product=self.product, image=image_file
)
url = f"/don_confiao/api/products/{self.product.id}/"
response = self.client.get(url)
data = response.json()
self.assertIn("catalogue_images", data)
self.assertEqual(len(data["catalogue_images"]), 1)

View File

@@ -3,6 +3,8 @@ from rest_framework.routers import DefaultRouter
from . import views
from .api import (
# Catalogue Images
CatalogueImageViewSet,
# Products
ProductView,
ProductsFromTrytonView,
@@ -13,8 +15,10 @@ from .api import (
SaleView,
CatalogSaleView,
SaleSummary,
CatalogSaleSummary,
SalesForTrytonView,
SalesToTrytonView,
CatalogSalesToTrytonView,
# Payments
ReconciliateJarView,
ReconciliateJarModelView,
@@ -31,6 +35,11 @@ router.register(r"sales", SaleView, basename="sale")
router.register(r"catalog_sales", CatalogSaleView, basename="catalog_sale")
router.register(r"customers", CustomerView, basename="customer")
router.register(r"products", ProductView, basename="product")
router.register(
r"catalogue_images",
CatalogueImageViewSet,
basename="catalogue_image",
)
router.register(
r"reconciliate_jar",
ReconciliateJarModelView,
@@ -44,6 +53,11 @@ urlpatterns = [
SaleSummary.as_view(),
name="purchase_json_summary",
),
path(
"resumen_compra_catalogo_json/<int:id>",
CatalogSaleSummary.as_view(),
name="catalog_purchase_json_summary",
),
path(
"payment_methods/all/select_format",
PaymentMethodView.as_view(),
@@ -71,6 +85,11 @@ urlpatterns = [
SalesToTrytonView.as_view(),
name="send_tryton",
),
path(
"api/enviar_catalog_sales_a_tryton",
CatalogSalesToTrytonView.as_view(),
name="send_catalog_sales_tryton",
),
path(
"api/admin_code/validate/<code>",
AdminCodeValidateView.as_view(),