First commit of claude's rework in django + vanillajs fronted
This commit is contained in:
226
apps/photos/views.py
Normal file
226
apps/photos/views.py
Normal file
@@ -0,0 +1,226 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user