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= 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[^/.]+)', ) 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)