diff --git a/website/facedetection/api/v2/__init__.py b/website/facedetection/api/v2/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/website/facedetection/api/v2/serializers.py b/website/facedetection/api/v2/serializers.py new file mode 100644 index 000000000..76fbdcfcd --- /dev/null +++ b/website/facedetection/api/v2/serializers.py @@ -0,0 +1,20 @@ +from facedetection.models import ReferenceFace +from thaliawebsite.api.v2.serializers.cleaned_model_serializer import ( + CleanedModelSerializer, +) +from thaliawebsite.api.v2.serializers.thumbnail import ThumbnailSerializer + + +class ReferenceFaceSerializer(CleanedModelSerializer): + class Meta: + model = ReferenceFace + fields = ( + "pk", + "status", + "created_at", + "file", + ) + + read_only_fields = ("status",) + + file = ThumbnailSerializer() diff --git a/website/facedetection/api/v2/urls.py b/website/facedetection/api/v2/urls.py new file mode 100644 index 000000000..07d3f8c30 --- /dev/null +++ b/website/facedetection/api/v2/urls.py @@ -0,0 +1,24 @@ +"""Events app calendarjs API urls.""" +from django.urls import path + +from .views import ReferenceFaceDeleteView, ReferenceFaceListView, YourPhotosView + +app_name = "facedetection" + +urlpatterns = [ + path( + "photos/facedetection/matches/", + YourPhotosView.as_view(), + name="your-photos", + ), + path( + "photos/facedetection/reference-faces/", + ReferenceFaceListView.as_view(), + name="reference-faces", + ), + path( + "photos/facedetection/reference-faces//", + ReferenceFaceDeleteView.as_view(), + name="reference-faces-delete", + ), +] diff --git a/website/facedetection/api/v2/views.py b/website/facedetection/api/v2/views.py new file mode 100644 index 000000000..69123b5f2 --- /dev/null +++ b/website/facedetection/api/v2/views.py @@ -0,0 +1,94 @@ +from django.conf import settings +from django.utils import timezone + +from oauth2_provider.contrib.rest_framework import IsAuthenticatedOrTokenHasScope +from rest_framework.exceptions import PermissionDenied +from rest_framework.generics import DestroyAPIView, ListAPIView, ListCreateAPIView +from rest_framework.schemas.openapi import AutoSchema + +from facedetection.services import get_user_photos +from photos.api.v2.serializers.photo import PhotoListSerializer +from thaliawebsite.api.v2.permissions import IsAuthenticatedOrTokenHasScopeForMethod +from utils.media.services import fetch_thumbnails + +from .serializers import ReferenceFaceSerializer + + +class YourPhotosView(ListAPIView): + serializer_class = PhotoListSerializer + permission_classes = [IsAuthenticatedOrTokenHasScope] + required_scopes = ["photos:read", "facedetection:read"] + + schema = AutoSchema(operation_id_base="FacedetectionMatches") + + def get(self, request, *args, **kwargs): + if not request.member or request.member.current_membership is None: + raise PermissionDenied( + detail="You need to be a member in order to view your facedetection photos." + ) + return self.list(request, *args, **kwargs) + + def get_serializer(self, *args, **kwargs): + if len(args) > 0: + photos = args[0] + fetch_thumbnails([photo.file for photo in photos]) + return super().get_serializer(*args, **kwargs) + + def get_queryset(self): + return get_user_photos(self.request.member) + + +class ReferenceFaceListView(ListCreateAPIView): + serializer_class = ReferenceFaceSerializer + permission_classes = [ + IsAuthenticatedOrTokenHasScopeForMethod, + ] + required_scopes_per_method = { + "GET": ["facedetection:read"], + "POST": ["facedetection:write"], + } + + def get_serializer(self, *args, **kwargs): + if len(args) > 0: + reference_faces = args[0] + fetch_thumbnails([reference.file for reference in reference_faces]) + return super().get_serializer(*args, **kwargs) + + def create(self, request, *args, **kwargs): + if request.member.current_membership is None: + raise PermissionDenied( + detail="You need to be a member to use this feature." + ) + if ( + request.member.reference_faces.filter( + marked_for_deletion_at__isnull=True, + ).count() + >= settings.FACEDETECTION_MAX_NUM_REFERENCE_FACES + ): + raise PermissionDenied( + detail="You have reached the maximum number of reference faces." + ) + return super().create(request, *args, **kwargs) + + def perform_create(self, serializer): + serializer.save(user=self.request.member) + + def get_queryset(self): + return self.request.member.reference_faces.filter( + marked_for_deletion_at__isnull=True + ).all() + + +class ReferenceFaceDeleteView(DestroyAPIView): + serializer_class = ReferenceFaceSerializer + permission_classes = [IsAuthenticatedOrTokenHasScope] + required_scopes = ["facedetection:write"] + + def get_queryset(self): + return self.request.member.reference_faces.filter( + marked_for_deletion_at__isnull=True + ).all() + + def perform_destroy(self, instance): + instance.marked_for_deletion_at = timezone.now() + instance.save() diff --git a/website/facedetection/services.py b/website/facedetection/services.py index 65fb4dc43..68ca8cf6e 100644 --- a/website/facedetection/services.py +++ b/website/facedetection/services.py @@ -2,12 +2,13 @@ import logging from django.conf import settings -from django.db.models import Q +from django.db.models import Count, Q from django.utils import timezone import boto3 from sentry_sdk import capture_exception +from members.models.member import Member from photos.models import Photo from utils.media.services import get_media_url @@ -193,3 +194,28 @@ def submit_new_photos() -> int: count += len(photos) return count + + +def get_user_photos(member: Member): + reference_faces = member.reference_faces.filter( + marked_for_deletion_at__isnull=True, + ) + + # Filter out matches from long before the member's first membership. + albums_since = member.earliest_membership.since - timezone.timedelta(days=31) + photos = Photo.objects.select_related("album").filter(album__date__gte=albums_since) + + # Filter out matches from after the member's last membership. + if member.latest_membership.until is not None: + photos = photos.filter(album__date__lte=member.latest_membership.until) + + # Actually match the reference faces. + photos = photos.filter(album__hidden=False, album__is_processing=False).filter( + facedetectionphoto__encodings__matches__reference__in=reference_faces, + ) + + return ( + photos.annotate(member_likes=Count("likes", filter=Q(likes__member=member))) + .select_properties("num_likes") + .order_by("-album__date", "-pk") + ) diff --git a/website/facedetection/views.py b/website/facedetection/views.py index 6b2270d38..cf202e7c2 100644 --- a/website/facedetection/views.py +++ b/website/facedetection/views.py @@ -14,6 +14,7 @@ from .forms import ReferenceFaceUploadForm from .models import ReferenceFace +from .services import get_user_photos class YourPhotosView(LoginRequiredMixin, PagedView): @@ -30,28 +31,7 @@ def get(self, request, *args, **kwargs): return super().get(request, *args, **kwargs) def get_queryset(self): - member = self.request.member - - reference_faces = member.reference_faces.filter( - marked_for_deletion_at__isnull=True, - ) - - # Filter out matches from long before the member's first membership. - albums_since = member.earliest_membership.since - timezone.timedelta(days=31) - photos = Photo.objects.select_related("album").filter( - album__date__gte=albums_since - ) - - # Filter out matches from after the member's last membership. - if member.latest_membership.until is not None: - photos = photos.filter(album__date__lte=member.latest_membership.until) - - # Actually match the reference faces. - photos = photos.filter(album__hidden=False, album__is_processing=False).filter( - facedetectionphoto__encodings__matches__reference__in=reference_faces, - ) - - return photos.select_properties("num_likes").order_by("-album__date", "-pk") + return get_user_photos(self.request.member) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) diff --git a/website/thaliawebsite/api/v2/urls.py b/website/thaliawebsite/api/v2/urls.py index 581ffb107..d15b15055 100644 --- a/website/thaliawebsite/api/v2/urls.py +++ b/website/thaliawebsite/api/v2/urls.py @@ -19,6 +19,7 @@ path("", include("pizzas.api.v2.urls")), path("", include("pushnotifications.api.v2.urls")), path("", include("sales.api.v2.urls")), + path("", include("facedetection.api.v2.urls")), path("", include("thabloid.api.v2.urls")), path( "schema", diff --git a/website/thaliawebsite/settings.py b/website/thaliawebsite/settings.py index e2ecf6920..339c0fb07 100644 --- a/website/thaliawebsite/settings.py +++ b/website/thaliawebsite/settings.py @@ -800,6 +800,8 @@ def show_toolbar(request): "events:read": "Read access to events and your event registrations", "events:register": "Write access to the state of your event registrations", "events:admin": "Admin access to the events", + "facedetection:read": "Read access to facedetection", + "facedetection:write": "Write access to facedetection", "food:read": "Read access to food events", "food:order": "Order access to food events", "food:admin": "Admin access to food events",