- 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>
256 lines
9.6 KiB
Python
256 lines
9.6 KiB
Python
from rest_framework import serializers
|
|
from .models import ContactUs, Composition, Campaign, CompositionImage
|
|
|
|
|
|
class ContactUsSerializer(serializers.ModelSerializer):
|
|
"""
|
|
Serializer for ContactUs model.
|
|
"""
|
|
class Meta:
|
|
model = ContactUs
|
|
fields = ['id', 'name', 'email_or_phone', 'description', 'category',
|
|
'created_at', 'updated_at']
|
|
read_only_fields = ['id', 'created_at', 'updated_at']
|
|
|
|
def validate_email_or_phone(self, value):
|
|
"""Validate that the field contains either email or phone."""
|
|
if not value or len(value.strip()) == 0:
|
|
raise serializers.ValidationError("Email or phone number is required.")
|
|
return value.strip()
|
|
|
|
def validate_name(self, value):
|
|
"""Validate name field."""
|
|
if not value or len(value.strip()) == 0:
|
|
raise serializers.ValidationError("Name is required.")
|
|
return value.strip()
|
|
|
|
def validate_description(self, value):
|
|
"""Validate description field."""
|
|
if not value or len(value.strip()) == 0:
|
|
raise serializers.ValidationError("Description is required.")
|
|
return value.strip()
|
|
|
|
|
|
# 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 a single image belonging to a Composition.
|
|
"""
|
|
image_url = serializers.SerializerMethodField()
|
|
|
|
class Meta:
|
|
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."""
|
|
if obj.image:
|
|
request = self.context.get('request')
|
|
if request:
|
|
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."""
|
|
if not value or len(value.strip()) == 0:
|
|
raise serializers.ValidationError("Name is required.")
|
|
return value.strip()
|
|
|
|
def validate_description(self, value):
|
|
"""Validate description field."""
|
|
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):
|
|
"""
|
|
Serializer for Campaign model.
|
|
"""
|
|
image_url = serializers.SerializerMethodField()
|
|
is_active = serializers.SerializerMethodField()
|
|
is_upcoming = serializers.SerializerMethodField()
|
|
is_ended = serializers.SerializerMethodField()
|
|
|
|
class Meta:
|
|
model = Campaign
|
|
fields = ['id', 'name', 'description', 'start_time', 'end_time',
|
|
'image', 'image_url', 'is_active', 'is_upcoming', 'is_ended',
|
|
'created_at', 'updated_at']
|
|
read_only_fields = ['id', 'created_at', 'updated_at', 'image_url',
|
|
'is_active', 'is_upcoming', 'is_ended']
|
|
|
|
def get_image_url(self, obj):
|
|
"""Get the full URL of the image."""
|
|
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_is_active(self, obj):
|
|
"""Get campaign active status."""
|
|
return obj.is_active()
|
|
|
|
def get_is_upcoming(self, obj):
|
|
"""Get campaign upcoming status."""
|
|
return obj.is_upcoming()
|
|
|
|
def get_is_ended(self, obj):
|
|
"""Get campaign ended status."""
|
|
return obj.is_ended()
|
|
|
|
def validate(self, data):
|
|
"""Validate that end_time is after start_time."""
|
|
if 'start_time' in data and 'end_time' in data:
|
|
if data['end_time'] <= data['start_time']:
|
|
raise serializers.ValidationError({
|
|
'end_time': 'End time must be after start time.'
|
|
})
|
|
return data
|
|
|
|
def validate_name(self, value):
|
|
"""Validate name field."""
|
|
if not value or len(value.strip()) == 0:
|
|
raise serializers.ValidationError("Name is required.")
|
|
return value.strip()
|
|
|
|
def validate_description(self, value):
|
|
"""Validate description field."""
|
|
if not value or len(value.strip()) == 0:
|
|
raise serializers.ValidationError("Description is required.")
|
|
return value.strip()
|
|
|