Add multi-image compositions, docker setup, tests, and docs
- Add CompositionImage model with multi-image upload and main image flag - Add composition endpoints: add-images, set-main-image, delete image, by-created-at filter - Make contact-us by_category public - Add 24 API tests covering all endpoints - Add Dockerfile, docker-compose, entrypoint and docker docs - Add Persian API docs and update README and Postman collection Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
from rest_framework import serializers
|
||||
from .models import ContactUs, Composition, Campaign
|
||||
from .models import ContactUs, Composition, Campaign, CompositionImage
|
||||
|
||||
|
||||
class ContactUsSerializer(serializers.ModelSerializer):
|
||||
@@ -31,17 +31,36 @@ class ContactUsSerializer(serializers.ModelSerializer):
|
||||
return value.strip()
|
||||
|
||||
|
||||
class CompositionSerializer(serializers.ModelSerializer):
|
||||
# Accepted image content types and max upload size (5 MB).
|
||||
ALLOWED_IMAGE_CONTENT_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
|
||||
MAX_IMAGE_SIZE = 5 * 1024 * 1024
|
||||
|
||||
|
||||
def validate_uploaded_image(image):
|
||||
"""Reject anything that is not a real, reasonably sized image file."""
|
||||
content_type = getattr(image, 'content_type', None)
|
||||
if content_type and content_type not in ALLOWED_IMAGE_CONTENT_TYPES:
|
||||
raise serializers.ValidationError(
|
||||
f"Unsupported image type '{content_type}'. "
|
||||
f"Allowed: {', '.join(ALLOWED_IMAGE_CONTENT_TYPES)}."
|
||||
)
|
||||
if image.size > MAX_IMAGE_SIZE:
|
||||
raise serializers.ValidationError(
|
||||
f"Image too large ({image.size} bytes). Max allowed is {MAX_IMAGE_SIZE} bytes."
|
||||
)
|
||||
return image
|
||||
|
||||
|
||||
class CompositionImageSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serializer for Composition model.
|
||||
Serializer for a single image belonging to a Composition.
|
||||
"""
|
||||
image_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Composition
|
||||
fields = ['id', 'name', 'description', 'image', 'image_url',
|
||||
'created_at', 'updated_at']
|
||||
read_only_fields = ['id', 'created_at', 'updated_at', 'image_url']
|
||||
model = CompositionImage
|
||||
fields = ['id', 'image', 'image_url', 'is_main', 'created_at']
|
||||
read_only_fields = ['id', 'image_url', 'created_at']
|
||||
|
||||
def get_image_url(self, obj):
|
||||
"""Get the full URL of the image."""
|
||||
@@ -51,6 +70,57 @@ class CompositionSerializer(serializers.ModelSerializer):
|
||||
return request.build_absolute_uri(obj.image.url)
|
||||
return obj.image.url
|
||||
return None
|
||||
|
||||
|
||||
class CompositionSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serializer for Composition model.
|
||||
|
||||
Read: returns the nested ``images`` list plus ``main_image``.
|
||||
Write: accepts ``uploaded_images`` (one or more files) and an optional
|
||||
``main_image_index`` pointing at which uploaded file is the main image.
|
||||
"""
|
||||
# Legacy single image URL (kept for backward compatibility).
|
||||
image_url = serializers.SerializerMethodField()
|
||||
images = CompositionImageSerializer(many=True, read_only=True)
|
||||
main_image = serializers.SerializerMethodField()
|
||||
|
||||
# Write-only upload fields.
|
||||
uploaded_images = serializers.ListField(
|
||||
child=serializers.ImageField(validators=[validate_uploaded_image]),
|
||||
write_only=True,
|
||||
required=False,
|
||||
)
|
||||
main_image_index = serializers.IntegerField(
|
||||
write_only=True,
|
||||
required=False,
|
||||
min_value=0,
|
||||
help_text='0-based index into uploaded_images marking the main image.'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Composition
|
||||
fields = ['id', 'name', 'description', 'image', 'image_url',
|
||||
'images', 'main_image', 'uploaded_images', 'main_image_index',
|
||||
'created_at', 'updated_at']
|
||||
read_only_fields = ['id', 'created_at', 'updated_at', 'image_url',
|
||||
'images', 'main_image']
|
||||
|
||||
def get_image_url(self, obj):
|
||||
"""Get the full URL of the legacy image field."""
|
||||
if obj.image:
|
||||
request = self.context.get('request')
|
||||
if request:
|
||||
return request.build_absolute_uri(obj.image.url)
|
||||
return obj.image.url
|
||||
return None
|
||||
|
||||
def get_main_image(self, obj):
|
||||
"""Return the serialized main image, if any."""
|
||||
main = obj.main_image
|
||||
if main:
|
||||
return CompositionImageSerializer(main, context=self.context).data
|
||||
return None
|
||||
|
||||
def validate_name(self, value):
|
||||
"""Validate name field."""
|
||||
@@ -63,6 +133,65 @@ class CompositionSerializer(serializers.ModelSerializer):
|
||||
if not value or len(value.strip()) == 0:
|
||||
raise serializers.ValidationError("Description is required.")
|
||||
return value.strip()
|
||||
|
||||
def to_internal_value(self, data):
|
||||
"""
|
||||
MultiPartParser stores repeated file fields as a QueryDict where only
|
||||
the last value is returned by .get(). Pull the full list explicitly.
|
||||
"""
|
||||
if hasattr(data, 'getlist'):
|
||||
uploaded = data.getlist('uploaded_images')
|
||||
if uploaded:
|
||||
mutable = data.copy()
|
||||
mutable.setlist('uploaded_images', uploaded)
|
||||
data = mutable
|
||||
return super().to_internal_value(data)
|
||||
|
||||
def validate(self, data):
|
||||
"""Ensure main_image_index points at an actually uploaded image."""
|
||||
uploaded = data.get('uploaded_images')
|
||||
index = data.get('main_image_index')
|
||||
if index is not None:
|
||||
if not uploaded:
|
||||
raise serializers.ValidationError({
|
||||
'main_image_index': 'Cannot set a main image without uploaded_images.'
|
||||
})
|
||||
if index >= len(uploaded):
|
||||
raise serializers.ValidationError({
|
||||
'main_image_index': f'Index out of range (got {index}, '
|
||||
f'{len(uploaded)} image(s) uploaded).'
|
||||
})
|
||||
return data
|
||||
|
||||
def _save_images(self, composition, uploaded_images, main_index):
|
||||
"""Persist uploaded images, flagging the chosen one as main."""
|
||||
# If the composition has no main image yet, default the first upload to main.
|
||||
has_main = composition.images.filter(is_main=True).exists()
|
||||
for i, image_file in enumerate(uploaded_images):
|
||||
is_main = (i == main_index) if main_index is not None else (
|
||||
i == 0 and not has_main
|
||||
)
|
||||
CompositionImage.objects.create(
|
||||
composition=composition,
|
||||
image=image_file,
|
||||
is_main=is_main,
|
||||
)
|
||||
|
||||
def create(self, validated_data):
|
||||
uploaded_images = validated_data.pop('uploaded_images', [])
|
||||
main_index = validated_data.pop('main_image_index', None)
|
||||
composition = super().create(validated_data)
|
||||
if uploaded_images:
|
||||
self._save_images(composition, uploaded_images, main_index)
|
||||
return composition
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
uploaded_images = validated_data.pop('uploaded_images', [])
|
||||
main_index = validated_data.pop('main_image_index', None)
|
||||
composition = super().update(instance, validated_data)
|
||||
if uploaded_images:
|
||||
self._save_images(composition, uploaded_images, main_index)
|
||||
return composition
|
||||
|
||||
|
||||
class CampaignSerializer(serializers.ModelSerializer):
|
||||
|
||||
Reference in New Issue
Block a user