227 lines
8.4 KiB
Python
227 lines
8.4 KiB
Python
from io import BytesIO
|
|
|
|
from django.http import HttpResponse
|
|
from django.shortcuts import get_object_or_404
|
|
from PIL import Image as PillowImage
|
|
from rest_framework import parsers, status, viewsets
|
|
from rest_framework.decorators import action
|
|
from rest_framework.permissions import BasePermission, SAFE_METHODS
|
|
from rest_framework.response import Response
|
|
from rest_framework.views import APIView
|
|
|
|
# Reuse the same permission used by ChronographAnalysis (anonymous-friendly)
|
|
from apps.tools.permissions import IsOwnerOrUnclaimed
|
|
|
|
|
|
class IsGroupPhotoOwner(BasePermission):
|
|
"""
|
|
For GroupPhoto objects, ownership is via photo.uploaded_by.
|
|
Read is open; mutations require being the uploader (or anonymous upload).
|
|
"""
|
|
def has_permission(self, request, view):
|
|
return True
|
|
|
|
def has_object_permission(self, request, view, obj):
|
|
if request.method in SAFE_METHODS:
|
|
return True
|
|
uploader = obj.photo.uploaded_by if obj.photo_id else None
|
|
if uploader is None:
|
|
return True
|
|
return request.user.is_authenticated and uploader == request.user
|
|
|
|
from .analysis import compute_group_size as _compute_group_size
|
|
from .models import GroupPhoto, GroupPhotoAnalysis, Photo, PointOfImpact
|
|
from .serializers import (
|
|
GroupPhotoAnalysisSerializer,
|
|
GroupPhotoSerializer,
|
|
PhotoMetaSerializer,
|
|
PointOfImpactSerializer,
|
|
)
|
|
|
|
# Allowed image MIME types
|
|
_ALLOWED_CONTENT_TYPES = {'image/jpeg', 'image/png', 'image/webp', 'image/gif'}
|
|
|
|
|
|
class PhotoUploadView(APIView):
|
|
"""
|
|
POST multipart/form-data with a 'file' field → create a Photo.
|
|
|
|
Optional fields: description (string).
|
|
Returns PhotoMetaSerializer data (no binary).
|
|
Anonymous uploads are allowed (same pattern as ChronographAnalysis).
|
|
"""
|
|
parser_classes = [parsers.MultiPartParser]
|
|
permission_classes = [IsOwnerOrUnclaimed]
|
|
|
|
def post(self, request):
|
|
f = request.FILES.get('file')
|
|
if not f:
|
|
return Response({'detail': 'No file provided.'}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
if f.content_type not in _ALLOWED_CONTENT_TYPES:
|
|
return Response(
|
|
{'detail': f'Unsupported file type: {f.content_type}. '
|
|
f'Allowed: {", ".join(sorted(_ALLOWED_CONTENT_TYPES))}'},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
raw = f.read()
|
|
try:
|
|
img = PillowImage.open(BytesIO(raw))
|
|
img.verify() # check it is a valid image
|
|
img = PillowImage.open(BytesIO(raw)) # re-open after verify (verify closes the stream)
|
|
width, height = img.size
|
|
except Exception:
|
|
return Response({'detail': 'Invalid or corrupt image file.'}, status=status.HTTP_400_BAD_REQUEST)
|
|
|
|
photo = Photo.objects.create(
|
|
data=raw,
|
|
content_type=f.content_type,
|
|
size=len(raw),
|
|
width=width,
|
|
height=height,
|
|
uploaded_by=request.user if request.user.is_authenticated else None,
|
|
description=request.data.get('description', ''),
|
|
)
|
|
return Response(PhotoMetaSerializer(photo).data, status=status.HTTP_201_CREATED)
|
|
|
|
|
|
def photo_data_view(request, pk):
|
|
"""
|
|
GET /api/photos/{pk}/data/ → serve the raw image bytes.
|
|
Public — no authentication required.
|
|
"""
|
|
photo = get_object_or_404(Photo, pk=pk)
|
|
return HttpResponse(bytes(photo.data), content_type=photo.content_type)
|
|
|
|
|
|
class GroupPhotoViewSet(viewsets.ModelViewSet):
|
|
"""
|
|
CRUD for GroupPhoto objects.
|
|
|
|
Filter by ?shot_group=<id> to list photos for a specific shot group.
|
|
|
|
Nested sub-resources:
|
|
GET/PUT/PATCH .../analysis/ upsert GroupPhotoAnalysis
|
|
GET/POST .../points/ list / add PointOfImpact
|
|
PATCH/DELETE .../points/{poi_pk}/ update / remove a PointOfImpact
|
|
"""
|
|
serializer_class = GroupPhotoSerializer
|
|
permission_classes = [IsGroupPhotoOwner]
|
|
|
|
def get_queryset(self):
|
|
qs = GroupPhoto.objects.select_related('photo', 'analysis').prefetch_related('points_of_impact')
|
|
shot_group = self.request.query_params.get('shot_group')
|
|
if shot_group:
|
|
qs = qs.filter(shot_group_id=shot_group)
|
|
return qs
|
|
|
|
# ── Analysis ──────────────────────────────────────────────────────────────
|
|
|
|
@action(detail=True, methods=['get', 'put', 'patch'], url_path='analysis')
|
|
def analysis(self, request, pk=None):
|
|
group_photo = self.get_object()
|
|
|
|
if request.method == 'GET':
|
|
try:
|
|
serializer = GroupPhotoAnalysisSerializer(group_photo.analysis)
|
|
return Response(serializer.data)
|
|
except GroupPhotoAnalysis.DoesNotExist:
|
|
return Response({}, status=status.HTTP_200_OK)
|
|
|
|
# PUT / PATCH — upsert
|
|
partial = request.method == 'PATCH'
|
|
try:
|
|
instance = group_photo.analysis
|
|
except GroupPhotoAnalysis.DoesNotExist:
|
|
instance = None
|
|
|
|
serializer = GroupPhotoAnalysisSerializer(
|
|
instance,
|
|
data=request.data,
|
|
partial=partial,
|
|
)
|
|
serializer.is_valid(raise_exception=True)
|
|
serializer.save(group_photo=group_photo)
|
|
return Response(serializer.data)
|
|
|
|
# ── Points of impact ──────────────────────────────────────────────────────
|
|
|
|
@action(detail=True, methods=['get', 'post'], url_path='points')
|
|
def points(self, request, pk=None):
|
|
group_photo = self.get_object()
|
|
|
|
if request.method == 'GET':
|
|
pois = group_photo.points_of_impact.all()
|
|
return Response(PointOfImpactSerializer(pois, many=True).data)
|
|
|
|
serializer = PointOfImpactSerializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
poi = serializer.save(group_photo=group_photo)
|
|
return Response(PointOfImpactSerializer(poi).data, status=status.HTTP_201_CREATED)
|
|
|
|
@action(
|
|
detail=True,
|
|
methods=['patch', 'delete'],
|
|
url_path=r'points/(?P<poi_pk>[^/.]+)',
|
|
)
|
|
def point_detail(self, request, pk=None, poi_pk=None):
|
|
group_photo = self.get_object()
|
|
poi = get_object_or_404(PointOfImpact, pk=poi_pk, group_photo=group_photo)
|
|
|
|
if request.method == 'DELETE':
|
|
poi.delete()
|
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
|
|
serializer = PointOfImpactSerializer(poi, data=request.data, partial=True)
|
|
serializer.is_valid(raise_exception=True)
|
|
serializer.save()
|
|
return Response(serializer.data)
|
|
|
|
# ── Group size computation ─────────────────────────────────────────────────
|
|
|
|
@action(detail=True, methods=['post'], url_path='compute-group-size')
|
|
def compute_group_size(self, request, pk=None):
|
|
"""
|
|
Compute GroupPhotoAnalysis metrics from PointOfImpact real-world
|
|
coordinates (x_mm, y_mm). Requires ≥ 2 POIs with mm coordinates set.
|
|
Upserts the GroupPhotoAnalysis and returns the updated record.
|
|
"""
|
|
group_photo = self.get_object()
|
|
|
|
pois = list(
|
|
group_photo.points_of_impact
|
|
.filter(x_mm__isnull=False, y_mm__isnull=False)
|
|
.values_list('x_mm', 'y_mm')
|
|
)
|
|
|
|
if len(pois) < 2:
|
|
return Response(
|
|
{'detail': 'At least 2 points of impact with real-world '
|
|
'coordinates (x_mm, y_mm) are required.'},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
distance_m = None
|
|
if group_photo.shot_group_id and group_photo.shot_group.distance_m:
|
|
distance_m = float(group_photo.shot_group.distance_m)
|
|
|
|
metrics = _compute_group_size(
|
|
[(float(x), float(y)) for x, y in pois],
|
|
distance_m=distance_m,
|
|
)
|
|
|
|
try:
|
|
instance = group_photo.analysis
|
|
except GroupPhotoAnalysis.DoesNotExist:
|
|
instance = None
|
|
|
|
serializer = GroupPhotoAnalysisSerializer(
|
|
instance,
|
|
data=metrics,
|
|
partial=bool(instance),
|
|
)
|
|
serializer.is_valid(raise_exception=True)
|
|
serializer.save(group_photo=group_photo)
|
|
return Response(serializer.data)
|