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
This commit is contained in:
2026-06-13 20:31:40 -05:00
14 changed files with 595 additions and 1 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

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

@@ -1,3 +1,4 @@
from .catalogue_images import CatalogueImageViewSet
from .products import ProductView, ProductsFromTrytonView
from .customers import CustomerView, CustomersFromTrytonView
from .sales import (
@@ -19,6 +20,8 @@ from .payments import (
from .admin import AdminCodeValidateView
__all__ = [
# Catalogue Images
"CatalogueImageViewSet",
# Products
"ProductView",
"ProductsFromTrytonView",

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

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

@@ -1,3 +1,4 @@
from .catalogue_images import CatalogueImageSerializer
from .products import ProductSerializer, ListProductSerializer
from .customers import CustomerSerializer, ListCustomerSerializer
from .sales import (
@@ -17,6 +18,8 @@ from .payments import (
)
__all__ = [
# Catalogue Images
"CatalogueImageSerializer",
# Products
"ProductSerializer",
"ListProductSerializer",

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

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