- 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>
216 lines
8.1 KiB
Python
216 lines
8.1 KiB
Python
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 django.utils.dateparse import parse_datetime
|
|
from .models import ContactUs, Composition, Campaign, CompositionImage
|
|
from .serializers import (
|
|
ContactUsSerializer,
|
|
CompositionSerializer,
|
|
CampaignSerializer,
|
|
CompositionImageSerializer,
|
|
)
|
|
|
|
|
|
class ContactUsViewSet(viewsets.ModelViewSet):
|
|
"""
|
|
ViewSet for ContactUs model.
|
|
Provides CRUD operations for contact form submissions.
|
|
"""
|
|
queryset = ContactUs.objects.all()
|
|
serializer_class = ContactUsSerializer
|
|
permission_classes = [AllowAny] # Allow anyone to submit contact forms
|
|
|
|
def get_permissions(self):
|
|
"""
|
|
Override to allow GET (list, retrieve) and POST (create) for anyone.
|
|
"""
|
|
if self.action in ['list', 'retrieve', 'create', 'by_category']:
|
|
return [AllowAny()]
|
|
return [IsAuthenticated()]
|
|
|
|
@action(detail=False, methods=['get'])
|
|
def by_category(self, request):
|
|
"""Get contacts filtered by category."""
|
|
category = request.query_params.get('category', None)
|
|
if category:
|
|
contacts = self.queryset.filter(category=category)
|
|
serializer = self.get_serializer(contacts, many=True)
|
|
return Response(serializer.data)
|
|
return Response(
|
|
{'error': 'Category parameter is required.'},
|
|
status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
|
|
|
|
class CompositionViewSet(viewsets.ModelViewSet):
|
|
"""
|
|
ViewSet for Composition model.
|
|
|
|
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.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', 'by_created_at']:
|
|
return [AllowAny()]
|
|
return [IsAuthenticated()]
|
|
|
|
def get_serializer_context(self):
|
|
"""Add request to serializer context for image URL generation."""
|
|
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):
|
|
"""
|
|
ViewSet for Campaign model.
|
|
Provides CRUD operations for campaigns.
|
|
"""
|
|
queryset = Campaign.objects.all()
|
|
serializer_class = CampaignSerializer
|
|
permission_classes = [AllowAny] # Public read access
|
|
|
|
def get_permissions(self):
|
|
"""
|
|
Override to allow GET (list, retrieve) and POST (create) for anyone.
|
|
"""
|
|
if self.action in ['list', 'retrieve', 'create', 'active', 'upcoming', 'ended']:
|
|
return [AllowAny()]
|
|
return [IsAuthenticated()]
|
|
|
|
def get_serializer_context(self):
|
|
"""Add request to serializer context for image URL generation."""
|
|
context = super().get_serializer_context()
|
|
context['request'] = self.request
|
|
return context
|
|
|
|
@action(detail=False, methods=['get'])
|
|
def active(self, request):
|
|
"""Get all currently active campaigns."""
|
|
now = timezone.now()
|
|
active_campaigns = self.queryset.filter(
|
|
start_time__lte=now,
|
|
end_time__gte=now
|
|
)
|
|
serializer = self.get_serializer(active_campaigns, many=True)
|
|
return Response(serializer.data)
|
|
|
|
@action(detail=False, methods=['get'])
|
|
def upcoming(self, request):
|
|
"""Get all upcoming campaigns."""
|
|
now = timezone.now()
|
|
upcoming_campaigns = self.queryset.filter(start_time__gt=now)
|
|
serializer = self.get_serializer(upcoming_campaigns, many=True)
|
|
return Response(serializer.data)
|
|
|
|
@action(detail=False, methods=['get'])
|
|
def ended(self, request):
|
|
"""Get all ended campaigns."""
|
|
now = timezone.now()
|
|
ended_campaigns = self.queryset.filter(end_time__lt=now)
|
|
serializer = self.get_serializer(ended_campaigns, many=True)
|
|
return Response(serializer.data)
|