rcnn/backend/apps/splats/views.py
Marius Unsel d93412cd0d Initial commit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 01:12:40 +02:00

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")
)