Files
site_backend/api/views.py
Shayan Azadi 50c9cf613f Add admin login API and remove hardcoded secrets
- Add token-based admin login/logout/me endpoints
- Bootstrap admin from ADMIN_USERNAME/ADMIN_PASSWORD in Docker entrypoint
- Read ALLOWED_HOSTS and CORS from env only (no hardcoded server IPs)
- Keep docs/Postman/tests free of real credentials
- Cover login and auth flows with 31 local API tests

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-16 17:29:47 +03:30

264 lines
9.5 KiB
Python

from rest_framework import viewsets, status
from rest_framework.decorators import action, api_view, permission_classes
from rest_framework.response import Response
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
from rest_framework.authtoken.models import Token
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,
AdminLoginSerializer,
)
@api_view(['POST'])
@permission_classes([AllowAny])
def admin_login(request):
"""
Admin login with username and password.
Returns an auth token to use as:
Authorization: Token <token>
"""
serializer = AdminLoginSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = serializer.validated_data['user']
token, _ = Token.objects.get_or_create(user=user)
return Response({
'token': token.key,
'user': {
'id': user.id,
'username': user.username,
'is_staff': user.is_staff,
'is_superuser': user.is_superuser,
},
})
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def admin_logout(request):
"""Delete the current admin auth token (logout)."""
Token.objects.filter(user=request.user).delete()
return Response({'detail': 'Logged out successfully.'})
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def admin_me(request):
"""Return the currently authenticated admin user."""
user = request.user
return Response({
'id': user.id,
'username': user.username,
'is_staff': user.is_staff,
'is_superuser': user.is_superuser,
'is_active': user.is_active,
})
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)