Files
site_backend/api/serializers.py
Shayan Azadi 7e71d922d3 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>
2026-06-24 13:43:34 +03:30

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()