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:
10
api/admin.py
10
api/admin.py
@@ -1,5 +1,5 @@
|
||||
from django.contrib import admin
|
||||
from .models import ContactUs, Composition, Campaign
|
||||
from .models import ContactUs, Composition, Campaign, CompositionImage
|
||||
|
||||
|
||||
@admin.register(ContactUs)
|
||||
@@ -24,12 +24,20 @@ class ContactUsAdmin(admin.ModelAdmin):
|
||||
)
|
||||
|
||||
|
||||
class CompositionImageInline(admin.TabularInline):
|
||||
model = CompositionImage
|
||||
extra = 1
|
||||
fields = ['image', 'is_main', 'created_at']
|
||||
readonly_fields = ['created_at']
|
||||
|
||||
|
||||
@admin.register(Composition)
|
||||
class CompositionAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'created_at']
|
||||
search_fields = ['name', 'description']
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
date_hierarchy = 'created_at'
|
||||
inlines = [CompositionImageInline]
|
||||
|
||||
fieldsets = (
|
||||
('اطلاعات ترکیب', {
|
||||
|
||||
33
api/migrations/0002_composition_images.py
Normal file
33
api/migrations/0002_composition_images.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 4.2.7 on 2026-06-23 14:12
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('api', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CompositionImage',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('image', models.ImageField(upload_to='compositions/', verbose_name='تصویر')),
|
||||
('is_main', models.BooleanField(default=False, verbose_name='تصویر اصلی')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='تاریخ ایجاد')),
|
||||
('composition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='api.composition', verbose_name='ترکیب')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'تصویر ترکیب',
|
||||
'verbose_name_plural': 'تصاویر ترکیب',
|
||||
'ordering': ['-is_main', '-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='compositionimage',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('is_main', True)), fields=('composition',), name='unique_main_image_per_composition'),
|
||||
),
|
||||
]
|
||||
@@ -49,6 +49,8 @@ class Composition(models.Model):
|
||||
"""
|
||||
name = models.CharField(max_length=200, verbose_name='نام')
|
||||
description = models.TextField(verbose_name='توضیحات')
|
||||
# Legacy single-image field, kept for backward compatibility.
|
||||
# New uploads use the related CompositionImage model below.
|
||||
image = models.ImageField(
|
||||
upload_to='compositions/',
|
||||
verbose_name='تصویر',
|
||||
@@ -68,6 +70,56 @@ class Composition(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def main_image(self):
|
||||
"""Return the image flagged as main, falling back to the first image."""
|
||||
return self.images.filter(is_main=True).first() or self.images.first()
|
||||
|
||||
|
||||
class CompositionImage(models.Model):
|
||||
"""
|
||||
Image belonging to a Composition. A composition can have many images,
|
||||
and at most one of them can be flagged as the main image.
|
||||
"""
|
||||
composition = models.ForeignKey(
|
||||
Composition,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='images',
|
||||
verbose_name='ترکیب'
|
||||
)
|
||||
image = models.ImageField(
|
||||
upload_to='compositions/',
|
||||
verbose_name='تصویر'
|
||||
)
|
||||
is_main = models.BooleanField(default=False, verbose_name='تصویر اصلی')
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='تاریخ ایجاد')
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'تصویر ترکیب'
|
||||
verbose_name_plural = 'تصاویر ترکیب'
|
||||
# Main image first, then newest.
|
||||
ordering = ['-is_main', '-created_at']
|
||||
constraints = [
|
||||
# At most one main image per composition.
|
||||
models.UniqueConstraint(
|
||||
fields=['composition'],
|
||||
condition=models.Q(is_main=True),
|
||||
name='unique_main_image_per_composition'
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.composition.name} - {'main' if self.is_main else 'image'} #{self.pk}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Ensure only one main image per composition: if this one is being
|
||||
# set as main, unset the flag on the others.
|
||||
if self.is_main:
|
||||
CompositionImage.objects.filter(
|
||||
composition=self.composition, is_main=True
|
||||
).exclude(pk=self.pk).update(is_main=False)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class Campaign(models.Model):
|
||||
|
||||
@@ -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):
|
||||
|
||||
280
api/tests.py
280
api/tests.py
@@ -1,3 +1,279 @@
|
||||
from django.test import TestCase
|
||||
import io
|
||||
from datetime import timedelta
|
||||
|
||||
# Create your tests here.
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.utils import timezone
|
||||
from PIL import Image
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from .models import Campaign, Composition, CompositionImage, ContactUs
|
||||
|
||||
|
||||
def make_test_image(name='test.jpg', color='red', size=(100, 100)):
|
||||
"""Create a minimal valid JPEG for upload tests."""
|
||||
buffer = io.BytesIO()
|
||||
Image.new('RGB', size, color=color).save(buffer, format='JPEG')
|
||||
buffer.seek(0)
|
||||
return SimpleUploadedFile(name, buffer.read(), content_type='image/jpeg')
|
||||
|
||||
|
||||
class ContactUsAPITests(APITestCase):
|
||||
def setUp(self):
|
||||
self.contact = ContactUs.objects.create(
|
||||
name='Ali Reza',
|
||||
email_or_phone='ali@example.com',
|
||||
description='Need support',
|
||||
category='پشتیبانی',
|
||||
)
|
||||
|
||||
def test_list_contacts(self):
|
||||
response = self.client.get('/api/contact-us/')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['count'], 1)
|
||||
|
||||
def test_create_contact(self):
|
||||
payload = {
|
||||
'name': 'Sara',
|
||||
'email_or_phone': '09121234567',
|
||||
'description': 'Sales inquiry',
|
||||
'category': 'فروش',
|
||||
}
|
||||
response = self.client.post('/api/contact-us/', payload, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
self.assertEqual(ContactUs.objects.count(), 2)
|
||||
|
||||
def test_retrieve_contact(self):
|
||||
response = self.client.get(f'/api/contact-us/{self.contact.id}/')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['name'], 'Ali Reza')
|
||||
|
||||
def test_by_category(self):
|
||||
response = self.client.get('/api/contact-us/by_category/?category=پشتیبانی')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 1)
|
||||
|
||||
def test_by_category_missing_param(self):
|
||||
response = self.client.get('/api/contact-us/by_category/')
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_update_requires_auth(self):
|
||||
response = self.client.patch(
|
||||
f'/api/contact-us/{self.contact.id}/',
|
||||
{'name': 'Updated'},
|
||||
format='json',
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
|
||||
class CompositionAPITests(APITestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.admin = User.objects.create_user(
|
||||
username='testadmin', password='testpass123', is_staff=True
|
||||
)
|
||||
self.composition = Composition.objects.create(
|
||||
name='Test Composition',
|
||||
description='Test description',
|
||||
)
|
||||
|
||||
def test_list_compositions(self):
|
||||
response = self.client.get('/api/compositions/')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['count'], 1)
|
||||
|
||||
def test_create_composition_json(self):
|
||||
payload = {'name': 'New Comp', 'description': 'Desc'}
|
||||
response = self.client.post('/api/compositions/', payload, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
self.assertEqual(response.data['name'], 'New Comp')
|
||||
self.assertEqual(response.data['images'], [])
|
||||
|
||||
def test_retrieve_composition(self):
|
||||
response = self.client.get(f'/api/compositions/{self.composition.id}/')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertIn('images', response.data)
|
||||
self.assertIn('main_image', response.data)
|
||||
self.assertIn('created_at', response.data)
|
||||
|
||||
def test_by_created_at(self):
|
||||
now = timezone.now()
|
||||
Composition.objects.create(
|
||||
name='Old Composition',
|
||||
description='Old',
|
||||
)
|
||||
Composition.objects.filter(name='Old Composition').update(
|
||||
created_at=now - timedelta(days=10)
|
||||
)
|
||||
from_dt = (now - timedelta(days=1)).strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
response = self.client.get(
|
||||
f'/api/compositions/by-created-at/?from={from_dt}'
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
names = [item['name'] for item in response.data]
|
||||
self.assertIn('Test Composition', names)
|
||||
self.assertNotIn('Old Composition', names)
|
||||
|
||||
def test_by_created_at_missing_params(self):
|
||||
response = self.client.get('/api/compositions/by-created-at/')
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_by_created_at_invalid_datetime(self):
|
||||
response = self.client.get(
|
||||
'/api/compositions/by-created-at/?from=not-a-date'
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_create_with_multiple_images(self):
|
||||
img1 = make_test_image('img1.jpg', 'red')
|
||||
img2 = make_test_image('img2.jpg', 'blue')
|
||||
response = self.client.post(
|
||||
'/api/compositions/',
|
||||
{
|
||||
'name': 'Multi Image',
|
||||
'description': 'With images',
|
||||
'uploaded_images': [img1, img2],
|
||||
'main_image_index': '1',
|
||||
},
|
||||
format='multipart',
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
self.assertEqual(len(response.data['images']), 2)
|
||||
self.assertTrue(response.data['main_image']['is_main'])
|
||||
|
||||
composition = Composition.objects.get(name='Multi Image')
|
||||
self.assertEqual(composition.images.count(), 2)
|
||||
self.assertEqual(composition.main_image.is_main, True)
|
||||
|
||||
def test_add_images(self):
|
||||
self.client.force_authenticate(user=self.admin)
|
||||
img = make_test_image('added.jpg', 'green')
|
||||
response = self.client.post(
|
||||
f'/api/compositions/{self.composition.id}/add-images/',
|
||||
{'uploaded_images': [img]},
|
||||
format='multipart',
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data['images']), 1)
|
||||
self.assertTrue(response.data['main_image']['is_main'])
|
||||
|
||||
def test_set_main_image(self):
|
||||
self.client.force_authenticate(user=self.admin)
|
||||
img1 = CompositionImage.objects.create(
|
||||
composition=self.composition,
|
||||
image=make_test_image('a.jpg'),
|
||||
is_main=True,
|
||||
)
|
||||
img2 = CompositionImage.objects.create(
|
||||
composition=self.composition,
|
||||
image=make_test_image('b.jpg'),
|
||||
is_main=False,
|
||||
)
|
||||
response = self.client.post(
|
||||
f'/api/compositions/{self.composition.id}/set-main-image/',
|
||||
{'image_id': img2.id},
|
||||
format='json',
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
img1.refresh_from_db()
|
||||
img2.refresh_from_db()
|
||||
self.assertFalse(img1.is_main)
|
||||
self.assertTrue(img2.is_main)
|
||||
|
||||
def test_delete_image(self):
|
||||
self.client.force_authenticate(user=self.admin)
|
||||
img = CompositionImage.objects.create(
|
||||
composition=self.composition,
|
||||
image=make_test_image('del.jpg'),
|
||||
is_main=True,
|
||||
)
|
||||
response = self.client.delete(
|
||||
f'/api/compositions/{self.composition.id}/images/{img.id}/'
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
|
||||
self.assertFalse(CompositionImage.objects.filter(pk=img.id).exists())
|
||||
|
||||
def test_update_requires_auth(self):
|
||||
response = self.client.patch(
|
||||
f'/api/compositions/{self.composition.id}/',
|
||||
{'name': 'Updated'},
|
||||
format='json',
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
|
||||
class CampaignAPITests(APITestCase):
|
||||
def setUp(self):
|
||||
now = timezone.now()
|
||||
self.active = Campaign.objects.create(
|
||||
name='Active Campaign',
|
||||
description='Running now',
|
||||
start_time=now - timedelta(days=1),
|
||||
end_time=now + timedelta(days=1),
|
||||
)
|
||||
self.upcoming = Campaign.objects.create(
|
||||
name='Upcoming Campaign',
|
||||
description='Starts soon',
|
||||
start_time=now + timedelta(days=2),
|
||||
end_time=now + timedelta(days=5),
|
||||
)
|
||||
self.ended = Campaign.objects.create(
|
||||
name='Ended Campaign',
|
||||
description='Already finished',
|
||||
start_time=now - timedelta(days=10),
|
||||
end_time=now - timedelta(days=5),
|
||||
)
|
||||
|
||||
def test_list_campaigns(self):
|
||||
response = self.client.get('/api/campaigns/')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_create_campaign(self):
|
||||
now = timezone.now()
|
||||
payload = {
|
||||
'name': 'New Campaign',
|
||||
'description': 'Desc',
|
||||
'start_time': (now + timedelta(days=1)).isoformat(),
|
||||
'end_time': (now + timedelta(days=3)).isoformat(),
|
||||
}
|
||||
response = self.client.post('/api/campaigns/', payload, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
|
||||
def test_retrieve_campaign(self):
|
||||
response = self.client.get(f'/api/campaigns/{self.active.id}/')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertTrue(response.data['is_active'])
|
||||
self.assertFalse(response.data['is_upcoming'])
|
||||
self.assertFalse(response.data['is_ended'])
|
||||
|
||||
def test_active_campaigns(self):
|
||||
response = self.client.get('/api/campaigns/active/')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
names = [item['name'] for item in response.data]
|
||||
self.assertIn('Active Campaign', names)
|
||||
self.assertNotIn('Upcoming Campaign', names)
|
||||
|
||||
def test_upcoming_campaigns(self):
|
||||
response = self.client.get('/api/campaigns/upcoming/')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
names = [item['name'] for item in response.data]
|
||||
self.assertIn('Upcoming Campaign', names)
|
||||
|
||||
def test_ended_campaigns(self):
|
||||
response = self.client.get('/api/campaigns/ended/')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
names = [item['name'] for item in response.data]
|
||||
self.assertIn('Ended Campaign', names)
|
||||
|
||||
def test_create_campaign_invalid_dates(self):
|
||||
now = timezone.now()
|
||||
payload = {
|
||||
'name': 'Bad Dates',
|
||||
'description': 'Desc',
|
||||
'start_time': (now + timedelta(days=3)).isoformat(),
|
||||
'end_time': (now + timedelta(days=1)).isoformat(),
|
||||
}
|
||||
response = self.client.post('/api/campaigns/', payload, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
113
api/views.py
113
api/views.py
@@ -2,10 +2,18 @@ from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
from .models import ContactUs, Composition, Campaign
|
||||
from .serializers import ContactUsSerializer, CompositionSerializer, CampaignSerializer
|
||||
from django.utils.dateparse import parse_datetime
|
||||
from .models import ContactUs, Composition, Campaign, CompositionImage
|
||||
from .serializers import (
|
||||
ContactUsSerializer,
|
||||
CompositionSerializer,
|
||||
CampaignSerializer,
|
||||
CompositionImageSerializer,
|
||||
)
|
||||
|
||||
|
||||
class ContactUsViewSet(viewsets.ModelViewSet):
|
||||
@@ -21,7 +29,7 @@ class ContactUsViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
Override to allow GET (list, retrieve) and POST (create) for anyone.
|
||||
"""
|
||||
if self.action in ['list', 'retrieve', 'create']:
|
||||
if self.action in ['list', 'retrieve', 'create', 'by_category']:
|
||||
return [AllowAny()]
|
||||
return [IsAuthenticated()]
|
||||
|
||||
@@ -42,17 +50,22 @@ class ContactUsViewSet(viewsets.ModelViewSet):
|
||||
class CompositionViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for Composition model.
|
||||
Provides CRUD operations for compositions.
|
||||
|
||||
Supports uploading one or more images at create/update time via the
|
||||
multipart ``uploaded_images`` field, with an optional ``main_image_index``
|
||||
to flag the main image. Extra actions allow adding images, choosing the
|
||||
main image, and deleting an image after creation.
|
||||
"""
|
||||
queryset = Composition.objects.all()
|
||||
queryset = Composition.objects.prefetch_related('images').all()
|
||||
serializer_class = CompositionSerializer
|
||||
permission_classes = [AllowAny] # Public read access
|
||||
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||
|
||||
def get_permissions(self):
|
||||
"""
|
||||
Override to allow GET (list, retrieve) and POST (create) for anyone.
|
||||
"""
|
||||
if self.action in ['list', 'retrieve', 'create']:
|
||||
if self.action in ['list', 'retrieve', 'create', 'by_created_at']:
|
||||
return [AllowAny()]
|
||||
return [IsAuthenticated()]
|
||||
|
||||
@@ -61,6 +74,94 @@ class CompositionViewSet(viewsets.ModelViewSet):
|
||||
context = super().get_serializer_context()
|
||||
context['request'] = self.request
|
||||
return context
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='by-created-at')
|
||||
def by_created_at(self, request):
|
||||
"""Get compositions filtered by created_at date range."""
|
||||
from_param = request.query_params.get('from')
|
||||
to_param = request.query_params.get('to')
|
||||
|
||||
if not from_param and not to_param:
|
||||
return Response(
|
||||
{'error': 'At least one of "from" or "to" query parameters is required.'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
compositions = self.queryset
|
||||
|
||||
if from_param:
|
||||
from_dt = parse_datetime(from_param)
|
||||
if from_dt is None:
|
||||
return Response(
|
||||
{'error': 'Invalid "from" datetime. Use ISO 8601 format.'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if timezone.is_naive(from_dt):
|
||||
from_dt = timezone.make_aware(from_dt)
|
||||
compositions = compositions.filter(created_at__gte=from_dt)
|
||||
|
||||
if to_param:
|
||||
to_dt = parse_datetime(to_param)
|
||||
if to_dt is None:
|
||||
return Response(
|
||||
{'error': 'Invalid "to" datetime. Use ISO 8601 format.'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if timezone.is_naive(to_dt):
|
||||
to_dt = timezone.make_aware(to_dt)
|
||||
compositions = compositions.filter(created_at__lte=to_dt)
|
||||
|
||||
serializer = self.get_serializer(compositions, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
def _composition_response(self, composition):
|
||||
"""Return a fresh composition payload with up-to-date images."""
|
||||
composition = Composition.objects.prefetch_related('images').get(
|
||||
pk=composition.pk
|
||||
)
|
||||
return self.get_serializer(composition).data
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='add-images')
|
||||
def add_images(self, request, pk=None):
|
||||
"""Add one or more images to an existing composition."""
|
||||
composition = self.get_object()
|
||||
serializer = self.get_serializer(
|
||||
composition, data=request.data, partial=True
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
composition = serializer.save()
|
||||
return Response(self._composition_response(composition))
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='set-main-image')
|
||||
def set_main_image(self, request, pk=None):
|
||||
"""Flag one of the composition's images as the main image."""
|
||||
composition = self.get_object()
|
||||
image_id = request.data.get('image_id')
|
||||
if image_id is None:
|
||||
return Response(
|
||||
{'error': 'image_id is required.'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
image = get_object_or_404(
|
||||
CompositionImage, pk=image_id, composition=composition
|
||||
)
|
||||
image.is_main = True
|
||||
image.save() # model.save() unsets is_main on the other images
|
||||
return Response(self._composition_response(composition))
|
||||
|
||||
@action(
|
||||
detail=True,
|
||||
methods=['delete'],
|
||||
url_path='images/(?P<image_id>[^/.]+)'
|
||||
)
|
||||
def delete_image(self, request, pk=None, image_id=None):
|
||||
"""Delete a single image from the composition."""
|
||||
composition = self.get_object()
|
||||
image = get_object_or_404(
|
||||
CompositionImage, pk=image_id, composition=composition
|
||||
)
|
||||
image.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class CampaignViewSet(viewsets.ModelViewSet):
|
||||
|
||||
Reference in New Issue
Block a user