import uuid from datetime import timedelta from django.conf import settings from django.contrib.gis.geos import Point, Polygon from django.utils import timezone from rest_framework import generics, status from rest_framework.response import Response from rest_framework.views import APIView from apps.common.permissions import IsOwner from apps.utils.storage import generate_presigned_put_url, generate_presigned_get_url, object_exists from .models import Splat from .serializers import ( SplatCreateSerializer, SplatCreateResponseSerializer, SplatDetailSerializer, SplatMapSerializer, SplatMineSerializer, ) def _parse_bbox(bbox_str): """Parse 'minLon,minLat,maxLon,maxLat' into a GEOS Polygon (SRID 4326).""" try: parts = [float(x) for x in bbox_str.split(",")] except (ValueError, AttributeError): return None, "bbox must be four comma-separated floats: minLon,minLat,maxLon,maxLat" if len(parts) != 4: return None, "bbox must have exactly 4 values" min_lon, min_lat, max_lon, max_lat = parts if (max_lon - min_lon) > settings.MAX_BBOX_DEGREES or (max_lat - min_lat) > settings.MAX_BBOX_DEGREES: return None, ( f"bbox too large. Each side must be ≤ {settings.MAX_BBOX_DEGREES}° " f"(≈{int(settings.MAX_BBOX_DEGREES * 111)} km). Zoom in and try again." ) poly = Polygon.from_bbox((min_lon, min_lat, max_lon, max_lat)) poly.srid = 4326 return poly, None class SplatCreateView(APIView): def post(self, request): serializer = SplatCreateSerializer(data=request.data) serializer.is_valid(raise_exception=True) data = serializer.validated_data challenge = None challenge_id = data.get("challenge_id") if challenge_id: from apps.challenges.models import Challenge try: challenge = Challenge.objects.get(pk=challenge_id, status=Challenge.Status.ACTIVE) except Challenge.DoesNotExist: return Response( {"error": "challenge_not_found", "detail": "Challenge does not exist or is closed."}, status=status.HTTP_404_NOT_FOUND, ) # Geofence check: first frame GPS must be inside the challenge region frames = data["capture_metadata"].get("frames", []) first = frames[0] user_point = Point(first["lon"], first["lat"], srid=4326) from apps.challenges.models import Challenge as C inside = C.objects.filter(pk=challenge.pk, region__contains=user_point).exists() if not inside: return Response( { "error": "outside_challenge_region", "detail": "Your location is not within the challenge region.", }, status=status.HTTP_403_FORBIDDEN, ) if challenge.max_submissions and challenge.submission_count >= challenge.max_submissions: return Response( {"error": "submission_limit_reached", "detail": "This challenge has reached its submission limit."}, status=status.HTTP_403_FORBIDDEN, ) splat_id = uuid.uuid4() video_key = f"videos/{splat_id}/raw.mp4" splat = Splat.objects.create( id=splat_id, owner=request.user, challenge=challenge, capture_metadata=data["capture_metadata"], video_key=video_key, ) upload_url = generate_presigned_put_url(video_key, content_type="video/mp4", expires_in=3600) expires_at = timezone.now() + timedelta(seconds=3600) response_data = { "id": splat.id, "status": splat.status, "upload_url": upload_url, "upload_expires_at": expires_at, } return Response(SplatCreateResponseSerializer(response_data).data, status=status.HTTP_201_CREATED) class SplatConfirmUploadView(APIView): def post(self, request, pk): try: splat = Splat.objects.select_related("owner").get(pk=pk, owner=request.user) except Splat.DoesNotExist: return Response(status=status.HTTP_404_NOT_FOUND) if splat.status != Splat.Status.PENDING: return Response( {"error": "upload_already_confirmed", "detail": "This splat has already been submitted for processing."}, status=status.HTTP_409_CONFLICT, ) if not object_exists(splat.video_key): return Response( {"error": "video_not_found_in_storage", "detail": "Video file was not found. Please upload the video first."}, status=status.HTTP_409_CONFLICT, ) # Lazy import avoids circular dependency at module load time from apps.jobs.models import SplatJob from apps.jobs.tasks import dispatch_splat_job job = SplatJob.objects.create(splat=splat, submitted_by=request.user) dispatch_splat_job.delay(str(job.id)) return Response({"job_id": str(job.id), "status": job.status}, status=status.HTTP_202_ACCEPTED) class SplatMapView(APIView): def get(self, request): bbox_str = request.query_params.get("bbox") if not bbox_str: return Response( {"error": "missing_parameter", "detail": "bbox is required."}, status=status.HTTP_400_BAD_REQUEST, ) bbox, error = _parse_bbox(bbox_str) if error: return Response({"error": "invalid_bbox", "detail": error}, status=status.HTTP_400_BAD_REQUEST) qs = Splat.objects.filter(is_published=True, location__within=bbox).select_related("owner") challenge_id = request.query_params.get("challenge_id") if challenge_id: qs = qs.filter(challenge_id=challenge_id) serializer = SplatMapSerializer(qs, many=True) return Response(serializer.data) class SplatDetailView(generics.RetrieveAPIView): serializer_class = SplatDetailSerializer queryset = Splat.objects.filter(is_published=True).select_related("owner") lookup_field = "pk" class SplatDownloadURLView(APIView): def get(self, request, pk): try: splat = Splat.objects.get(pk=pk, is_published=True) except Splat.DoesNotExist: return Response(status=status.HTTP_404_NOT_FOUND) if not splat.splat_key: return Response( {"error": "splat_not_ready", "detail": "Splat file is not available yet."}, status=status.HTTP_404_NOT_FOUND, ) expires_in = 3600 url = generate_presigned_get_url(splat.splat_key, expires_in=expires_in) expires_at = timezone.now() + timedelta(seconds=expires_in) return Response({"url": url, "expires_at": expires_at}) class SplatMineView(generics.ListAPIView): serializer_class = SplatMineSerializer def get_queryset(self): return ( Splat.objects.filter(owner=self.request.user) .select_related("job") .order_by("-created_at") )