196 lines
7.1 KiB
Python
196 lines
7.1 KiB
Python
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")
|
|
)
|