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
This commit is contained in:
mono
2026-06-13 13:39:57 -05:00
parent 7160d64e86
commit 7112197ad2
14 changed files with 604 additions and 1 deletions

View File

@@ -0,0 +1,415 @@
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 APITestCase
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):
from ..models.catalogue_images import CatalogueImage
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):
from ..models.catalogue_images import CatalogueImage
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):
from ..models.catalogue_images import CatalogueImage
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):
from ..models.catalogue_images import CatalogueImage
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",
)
from rest_framework_simplejwt.tokens import RefreshToken
from rest_framework.test import APIClient
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",
)
from rest_framework_simplejwt.tokens import RefreshToken
from rest_framework.test import APIClient
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):
from ..models.catalogue_images import CatalogueImage
admin_user = User.objects.create_superuser(
username="admin2", email="admin2@example.com", password="adminpass"
)
from rest_framework_simplejwt.tokens import RefreshToken
from rest_framework.test import APIClient
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)
from ..models.catalogue_images import CatalogueImage
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=None):
from ..models.catalogue_images import CatalogueImage
settings_overrides = {
"CATALOGUE_IMAGE_WIDTH": 600,
"CATALOGUE_IMAGE_HEIGHT": 600,
"CATALOGUE_STRICT_DIMENSION": strict,
}
if bg_color is not None:
settings_overrides["CATALOGUE_BACKGROUND_IMAGES_COLOR"] = 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_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="#FFFFFF"
)
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):
from ..models.catalogue_images import CatalogueImage
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):
from ..models.catalogue_images import CatalogueImage
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):
from ..models.catalogue_images import CatalogueImage
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)