We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN ISSUE ---
Fix reference face upload UI
The reference face UI should display better buttons for deleting a reference face, and for displaying whether a reference face is processing or accepted / rejected.

--- END ISSUE ---

Below are some code segments, each from a relevant file. One or more of these files may contain bugs.
--- BEGIN FILES ---
Path: `website/facedetection/views.py`
Content:
```
1 from django.conf import settings
2 from django.contrib import messages
3 from django.contrib.auth.mixins import LoginRequiredMixin
4 from django.core.exceptions import ValidationError
5 from django.http import HttpResponseRedirect
6 from django.shortcuts import redirect
7 from django.urls import reverse_lazy
8 from django.utils import timezone
9 from django.utils.translation import gettext_lazy as _
10 from django.views.generic import DeleteView, FormView, ListView
11 
12 from photos.models import Photo
13 from thaliawebsite.views import PagedView
14 
15 from .forms import ReferenceFaceUploadForm
16 from .models import ReferenceFace
17 
18 
19 class YourPhotosView(LoginRequiredMixin, PagedView):
20     model = Photo
21     paginate_by = 16
22     template_name = "facedetection/your-photos.html"
23     context_object_name = "photos"
24 
25     def get(self, request, *args, **kwargs):
26         if not request.member or request.member.current_membership is None:
27             messages.error(request, _("You need to be a member to use this feature."))
28             return redirect("index")
29 
30         return super().get(request, *args, **kwargs)
31 
32     def get_queryset(self):
33         member = self.request.member
34         photos = (
35             Photo.objects.select_related("album")
36             .filter(album__hidden=False, hidden=False)
37             .filter(
38                 facedetectionphoto__encodings__matches__reference__user=self.request.member
39             )
40         )
41 
42         # Filter out matches from long before the member's first membership.
43         albums_since = member.earliest_membership.since - timezone.timedelta(days=31)
44         photos.filter(album__date__gte=albums_since)
45 
46         # Filter out matches from after the member's last membership.
47         if member.latest_membership.until is not None:
48             photos = photos.filter(album__date__lte=member.latest_membership.until)
49 
50         return photos.select_properties("num_likes").order_by("-album__date")
51 
52     def get_context_data(self, **kwargs):
53         context = super().get_context_data(**kwargs)
54 
55         context[
56             "has_processing_reference_faces"
57         ] = self.request.member.reference_faces.filter(
58             status=ReferenceFace.Status.PROCESSING,
59             marked_for_deletion_at__isnull=True,
60         ).exists()
61 
62         context[
63             "has_rejected_reference_faces"
64         ] = self.request.member.reference_faces.filter(
65             status=ReferenceFace.Status.REJECTED,
66             marked_for_deletion_at__isnull=True,
67         ).exists()
68 
69         context["has_reference_faces"] = self.request.member.reference_faces.filter(
70             marked_for_deletion_at__isnull=True
71         ).exists()
72 
73         return context
74 
75 
76 class ReferenceFaceView(LoginRequiredMixin, ListView):
77     template_name = "facedetection/reference-faces.html"
78     context_object_name = "reference_faces"
79 
80     def get_context_data(self, **kwargs):
81         context = super().get_context_data(**kwargs)
82         context[
83             "reference_faces_limit"
84         ] = settings.FACEDETECTION_MAX_NUM_REFERENCE_FACES
85         context[
86             "storage_period_after_delete"
87         ] = settings.FACEDETECTION_REFERENCE_FACE_STORAGE_PERIOD_AFTER_DELETE_DAYS
88         context["reference_faces_limit_reached"] = bool(
89             self.request.member.reference_faces.filter(
90                 marked_for_deletion_at__isnull=True
91             ).count()
92             >= settings.FACEDETECTION_MAX_NUM_REFERENCE_FACES
93         )
94         return context
95 
96     def get_queryset(self):
97         return self.request.member.reference_faces.filter(
98             marked_for_deletion_at__isnull=True
99         ).all()
100 
101 
102 class ReferenceFaceUploadView(LoginRequiredMixin, FormView):
103     template_name = "facedetection/reference-face-upload.html"
104     form_class = ReferenceFaceUploadForm
105     success_url = reverse_lazy("facedetection:reference-faces")
106 
107     def dispatch(self, request, *args, **kwargs):
108         if not request.member or request.member.current_membership is None:
109             messages.error(request, "You need to be a member to use this feature.")
110             return redirect("index")
111         return super().dispatch(request, *args, **kwargs)
112 
113     def form_valid(self, form):
114         try:
115             form.save(user=self.request.member)
116         except ValidationError as e:
117             for error in e:
118                 messages.error(self.request, error)
119             return self.form_invalid(form)
120         messages.success(self.request, "Your reference face has been uploaded.")
121         return super().form_valid(form)
122 
123 
124 class ReferenceFaceDeleteView(LoginRequiredMixin, DeleteView):
125     model = ReferenceFace
126     success_url = reverse_lazy("facedetection:reference-faces")
127 
128     def get_context_data(self, **kwargs):
129         context = super().get_context_data(**kwargs)
130         context[
131             "storage_period_after_delete"
132         ] = settings.FACEDETECTION_REFERENCE_FACE_STORAGE_PERIOD_AFTER_DELETE_DAYS
133         return context
134 
135     def get_queryset(self):
136         return self.request.member.reference_faces.filter(
137             marked_for_deletion_at__isnull=True
138         ).all()
139 
140     def form_valid(self, form):
141         success_url = self.get_success_url()
142 
143         instance = self.get_object()
144         instance.marked_for_deletion_at = timezone.now()
145         instance.save()
146         messages.success(self.request, "Your reference face has been deleted.")
147 
148         return HttpResponseRedirect(success_url)
149
```

Path: `website/facedetection/services.py`
Content:
```
1 import json
2 import logging
3 from typing import Union
4 
5 from django.conf import settings
6 from django.db.models import Q
7 from django.utils import timezone
8 
9 import boto3
10 from sentry_sdk import capture_exception
11 
12 from photos.models import Photo
13 from utils.media.services import get_thumbnail_url
14 
15 from .models import FaceDetectionPhoto, ReferenceFace
16 
17 logger = logging.getLogger(__name__)
18 
19 
20 def execute_data_minimisation(dry_run=False):
21     """Delete old reference faces.
22 
23     This deletes reference faces that have been marked for deletion by the user for
24     some time, as well as reference faces of users that have not logged in for a year.
25     """
26     delete_period_inactive_member = timezone.now() - timezone.timedelta(days=365)
27     delete_period_marked_for_deletion = timezone.now() - timezone.timedelta(
28         days=settings.FACEDETECTION_REFERENCE_FACE_STORAGE_PERIOD_AFTER_DELETE_DAYS
29     )
30 
31     queryset = ReferenceFace.objects.filter(
32         Q(marked_for_deletion_at__lte=delete_period_marked_for_deletion)
33         | Q(member__last_login__lte=delete_period_inactive_member)
34     )
35 
36     if not dry_run:
37         for reference_face in queryset:
38             reference_face.delete()  # Don't run the queryset method, this will also delete the file
39 
40     return queryset
41 
42 
43 def _serialize_lambda_source(source: Union[ReferenceFace, FaceDetectionPhoto]):
44     """Serialize a source object to be sent to the lambda function."""
45     if isinstance(source, ReferenceFace):
46         return {
47             "type": "reference",
48             "pk": source.pk,
49             "token": source.token,
50             "photo_url": get_thumbnail_url(
51                 source.file,
52                 "medium",
53                 absolute_url=True,
54                 # Lambda calls can be queued for up to 6 hours by default, so
55                 # we make sure the url it uses is valid for at least that long.
56                 expire_seconds=60 * 60 * 7,
57             ),
58         }
59     if isinstance(source, FaceDetectionPhoto):
60         return {
61             "type": "photo",
62             "pk": source.pk,
63             "token": source.token,
64             "photo_url": get_thumbnail_url(
65                 source.photo.file,
66                 "large",
67                 absolute_url=True,
68                 expire_seconds=60 * 60 * 7,
69             ),
70         }
71     raise ValueError("source must be a ReferenceFace or FaceDetectionPhoto")
72 
73 
74 def _trigger_facedetection_lambda_batch(
75     sources: list[Union[ReferenceFace, FaceDetectionPhoto]]
76 ):
77     """Submit a batch of sources to the facedetection lambda function.
78 
79     If submitting the sources fails, this is logged and
80     reported to Sentry, but no exception is raised.
81     """
82     payload = {
83         "api_url": settings.BASE_URL,
84         "sources": [_serialize_lambda_source(source) for source in sources],
85     }
86 
87     for source in sources:
88         source.submitted_at = timezone.now()
89         source.save()
90 
91     try:
92         lambda_client = boto3.client(
93             service_name="lambda",
94             aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
95             aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
96         )
97 
98         response = lambda_client.invoke(
99             FunctionName=settings.FACEDETECTION_LAMBDA_ARN,
100             InvocationType="Event",
101             Payload=json.dumps(payload),
102         )
103 
104         if response["StatusCode"] != 202:
105             # pylint: disable=broad-exception-raised
106             raise Exception("Lambda response was not 202.")
107 
108     # pylint: disable=broad-exception-caught
109     except Exception as e:
110         logger.error(
111             "Submitting sources to lambda failed. Reason: %s", str(e), exc_info=True
112         )
113         capture_exception(e)
114 
115 
116 def trigger_facedetection_lambda(
117     sources: list[Union[ReferenceFace, FaceDetectionPhoto]]
118 ):
119     """Submit a sources to the facedetection lambda function for processing.
120 
121     This function will check if the sources are valid and, if a lambda function has
122     been configured, try to submit the sources to the lambda function in batches.
123 
124     If no lambda function has been configured, or submitting (a batch of) sources fails,
125     this is ignored. The sources can be submitted again later.
126     """
127     if len(sources) == 0:
128         raise ValueError("No sources to process.")
129 
130     if any(source.status != source.Status.PROCESSING for source in sources):
131         raise ValueError("A source has already been processed.")
132 
133     if settings.FACEDETECTION_LAMBDA_ARN is None:
134         logger.warning(
135             "No Lambda ARN has been configured. Sources will not be processed."
136         )
137         return
138 
139     batch_size = settings.FACEDETECTION_LAMBDA_BATCH_SIZE
140     for batch in [
141         sources[i : i + batch_size] for i in range(0, len(sources), batch_size)
142     ]:
143         _trigger_facedetection_lambda_batch(batch)
144 
145 
146 def resubmit_reference_faces() -> list[ReferenceFace]:
147     """Resubmit reference faces that (should) have already been submitted but aren't done.
148 
149     Returns a list of reference faces that have been resubmitted.
150     """
151     submitted_before = timezone.now() - timezone.timedelta(hours=7)
152     references = list(
153         ReferenceFace.objects.filter(
154             status=ReferenceFace.Status.PROCESSING,
155         ).filter(Q(submitted_at__lte=submitted_before) | Q(submitted_at__isnull=True))
156     )
157     if references:
158         trigger_facedetection_lambda(references)
159     return references
160 
161 
162 def resubmit_photos() -> list[FaceDetectionPhoto]:
163     """Resubmit photos that (should) have already been submitted but aren't done.
164 
165     Returns a list of photos that have been resubmitted.
166     """
167     submitted_before = timezone.now() - timezone.timedelta(hours=7)
168     photos = list(
169         FaceDetectionPhoto.objects.filter(
170             status=FaceDetectionPhoto.Status.PROCESSING,
171         )
172         .filter(Q(submitted_at__lte=submitted_before) | Q(submitted_at__isnull=True))
173         .select_related("photo")
174     )
175     if photos:
176         trigger_facedetection_lambda(photos)
177     return photos
178 
179 
180 def submit_new_photos() -> int:
181     """Submit photos for which no FaceDetectionPhoto exists yet.
182 
183     Returns the number of new photos that have been submitted.
184     """
185     count = 0
186     if not Photo.objects.filter(facedetectionphoto__isnull=True).exists():
187         return count
188 
189     # We have another level of batching (outside of trigger_facedetection_lambda)
190     # for performance and responsive output when there are thousands of photos.
191     while Photo.objects.filter(facedetectionphoto__isnull=True).exists():
192         photos = FaceDetectionPhoto.objects.bulk_create(
193             [
194                 FaceDetectionPhoto(photo=photo)
195                 for photo in Photo.objects.filter(facedetectionphoto__isnull=True)[:400]
196             ]
197         )
198 
199         trigger_facedetection_lambda(photos)
200         count += len(photos)
201 
202     return count
203
```
--- END FILES ---

Please first localize the bug based on the issue statement, and then generate a patch according to the `git diff` format fenced by three backticks.

Here is an example:

```diff
diff --git a/examples/server_async.py b/examples/server_async.py
--- a/examples/server_async.py
+++ b/examples/server_async.py
@@ -313,4 +313,4 @@
 
 
 if __name__ == "__main__":
-    asyncio.run(run_async_server("."), debug=True)
+    asyncio.run(run_async_server(), debug=True)
diff --git a/examples/server_sync.py b/examples/server_sync.py
--- a/examples/server_sync.py
+++ b/examples/server_sync.py
@@ -313,5 +313,5 @@
 
 
 if __name__ == "__main__":
-    server = run_sync_server(".")
+    server = run_sync_server()
     server.shutdown()

```

diff --git a/website/facedetection/services.py b/website/facedetection/services.py
--- a/website/facedetection/services.py
+++ b/website/facedetection/services.py
@@ -30,7 +30,7 @@
 
     queryset = ReferenceFace.objects.filter(
         Q(marked_for_deletion_at__lte=delete_period_marked_for_deletion)
-        | Q(member__last_login__lte=delete_period_inactive_member)
+        | Q(user__last_login__lte=delete_period_inactive_member)
     )
 
     if not dry_run:
diff --git a/website/facedetection/views.py b/website/facedetection/views.py
--- a/website/facedetection/views.py
+++ b/website/facedetection/views.py
@@ -75,7 +75,6 @@
 
 class ReferenceFaceView(LoginRequiredMixin, ListView):
     template_name = "facedetection/reference-faces.html"
-    context_object_name = "reference_faces"
 
     def get_context_data(self, **kwargs):
         context = super().get_context_data(**kwargs)
@@ -91,6 +90,12 @@
             ).count()
             >= settings.FACEDETECTION_MAX_NUM_REFERENCE_FACES
         )
+        context[
+            "has_rejected_reference_faces"
+        ] = self.request.member.reference_faces.filter(
+            status=ReferenceFace.Status.REJECTED,
+            marked_for_deletion_at__isnull=True,
+        ).exists()
         return context
 
     def get_queryset(self):

{"golden_diff": "diff --git a/website/facedetection/services.py b/website/facedetection/services.py\n--- a/website/facedetection/services.py\n+++ b/website/facedetection/services.py\n@@ -30,7 +30,7 @@\n \n     queryset = ReferenceFace.objects.filter(\n         Q(marked_for_deletion_at__lte=delete_period_marked_for_deletion)\n-        | Q(member__last_login__lte=delete_period_inactive_member)\n+        | Q(user__last_login__lte=delete_period_inactive_member)\n     )\n \n     if not dry_run:\ndiff --git a/website/facedetection/views.py b/website/facedetection/views.py\n--- a/website/facedetection/views.py\n+++ b/website/facedetection/views.py\n@@ -75,7 +75,6 @@\n \n class ReferenceFaceView(LoginRequiredMixin, ListView):\n     template_name = \"facedetection/reference-faces.html\"\n-    context_object_name = \"reference_faces\"\n \n     def get_context_data(self, **kwargs):\n         context = super().get_context_data(**kwargs)\n@@ -91,6 +90,12 @@\n             ).count()\n             >= settings.FACEDETECTION_MAX_NUM_REFERENCE_FACES\n         )\n+        context[\n+            \"has_rejected_reference_faces\"\n+        ] = self.request.member.reference_faces.filter(\n+            status=ReferenceFace.Status.REJECTED,\n+            marked_for_deletion_at__isnull=True,\n+        ).exists()\n         return context\n \n     def get_queryset(self):\n", "issue": "Fix reference face upload UI\nThe reference face UI should display better buttons for deleting a reference face, and for displaying whether a reference face is processing or accepted / rejected.\n", "before_files": [{"content": "from django.conf import settings\nfrom django.contrib import messages\nfrom django.contrib.auth.mixins import LoginRequiredMixin\nfrom django.core.exceptions import ValidationError\nfrom django.http import HttpResponseRedirect\nfrom django.shortcuts import redirect\nfrom django.urls import reverse_lazy\nfrom django.utils import timezone\nfrom django.utils.translation import gettext_lazy as _\nfrom django.views.generic import DeleteView, FormView, ListView\n\nfrom photos.models import Photo\nfrom thaliawebsite.views import PagedView\n\nfrom .forms import ReferenceFaceUploadForm\nfrom .models import ReferenceFace\n\n\nclass YourPhotosView(LoginRequiredMixin, PagedView):\n    model = Photo\n    paginate_by = 16\n    template_name = \"facedetection/your-photos.html\"\n    context_object_name = \"photos\"\n\n    def get(self, request, *args, **kwargs):\n        if not request.member or request.member.current_membership is None:\n            messages.error(request, _(\"You need to be a member to use this feature.\"))\n            return redirect(\"index\")\n\n        return super().get(request, *args, **kwargs)\n\n    def get_queryset(self):\n        member = self.request.member\n        photos = (\n            Photo.objects.select_related(\"album\")\n            .filter(album__hidden=False, hidden=False)\n            .filter(\n                facedetectionphoto__encodings__matches__reference__user=self.request.member\n            )\n        )\n\n        # Filter out matches from long before the member's first membership.\n        albums_since = member.earliest_membership.since - timezone.timedelta(days=31)\n        photos.filter(album__date__gte=albums_since)\n\n        # Filter out matches from after the member's last membership.\n        if member.latest_membership.until is not None:\n            photos = photos.filter(album__date__lte=member.latest_membership.until)\n\n        return photos.select_properties(\"num_likes\").order_by(\"-album__date\")\n\n    def get_context_data(self, **kwargs):\n        context = super().get_context_data(**kwargs)\n\n        context[\n            \"has_processing_reference_faces\"\n        ] = self.request.member.reference_faces.filter(\n            status=ReferenceFace.Status.PROCESSING,\n            marked_for_deletion_at__isnull=True,\n        ).exists()\n\n        context[\n            \"has_rejected_reference_faces\"\n        ] = self.request.member.reference_faces.filter(\n            status=ReferenceFace.Status.REJECTED,\n            marked_for_deletion_at__isnull=True,\n        ).exists()\n\n        context[\"has_reference_faces\"] = self.request.member.reference_faces.filter(\n            marked_for_deletion_at__isnull=True\n        ).exists()\n\n        return context\n\n\nclass ReferenceFaceView(LoginRequiredMixin, ListView):\n    template_name = \"facedetection/reference-faces.html\"\n    context_object_name = \"reference_faces\"\n\n    def get_context_data(self, **kwargs):\n        context = super().get_context_data(**kwargs)\n        context[\n            \"reference_faces_limit\"\n        ] = settings.FACEDETECTION_MAX_NUM_REFERENCE_FACES\n        context[\n            \"storage_period_after_delete\"\n        ] = settings.FACEDETECTION_REFERENCE_FACE_STORAGE_PERIOD_AFTER_DELETE_DAYS\n        context[\"reference_faces_limit_reached\"] = bool(\n            self.request.member.reference_faces.filter(\n                marked_for_deletion_at__isnull=True\n            ).count()\n            >= settings.FACEDETECTION_MAX_NUM_REFERENCE_FACES\n        )\n        return context\n\n    def get_queryset(self):\n        return self.request.member.reference_faces.filter(\n            marked_for_deletion_at__isnull=True\n        ).all()\n\n\nclass ReferenceFaceUploadView(LoginRequiredMixin, FormView):\n    template_name = \"facedetection/reference-face-upload.html\"\n    form_class = ReferenceFaceUploadForm\n    success_url = reverse_lazy(\"facedetection:reference-faces\")\n\n    def dispatch(self, request, *args, **kwargs):\n        if not request.member or request.member.current_membership is None:\n            messages.error(request, \"You need to be a member to use this feature.\")\n            return redirect(\"index\")\n        return super().dispatch(request, *args, **kwargs)\n\n    def form_valid(self, form):\n        try:\n            form.save(user=self.request.member)\n        except ValidationError as e:\n            for error in e:\n                messages.error(self.request, error)\n            return self.form_invalid(form)\n        messages.success(self.request, \"Your reference face has been uploaded.\")\n        return super().form_valid(form)\n\n\nclass ReferenceFaceDeleteView(LoginRequiredMixin, DeleteView):\n    model = ReferenceFace\n    success_url = reverse_lazy(\"facedetection:reference-faces\")\n\n    def get_context_data(self, **kwargs):\n        context = super().get_context_data(**kwargs)\n        context[\n            \"storage_period_after_delete\"\n        ] = settings.FACEDETECTION_REFERENCE_FACE_STORAGE_PERIOD_AFTER_DELETE_DAYS\n        return context\n\n    def get_queryset(self):\n        return self.request.member.reference_faces.filter(\n            marked_for_deletion_at__isnull=True\n        ).all()\n\n    def form_valid(self, form):\n        success_url = self.get_success_url()\n\n        instance = self.get_object()\n        instance.marked_for_deletion_at = timezone.now()\n        instance.save()\n        messages.success(self.request, \"Your reference face has been deleted.\")\n\n        return HttpResponseRedirect(success_url)\n", "path": "website/facedetection/views.py"}, {"content": "import json\nimport logging\nfrom typing import Union\n\nfrom django.conf import settings\nfrom django.db.models import Q\nfrom django.utils import timezone\n\nimport boto3\nfrom sentry_sdk import capture_exception\n\nfrom photos.models import Photo\nfrom utils.media.services import get_thumbnail_url\n\nfrom .models import FaceDetectionPhoto, ReferenceFace\n\nlogger = logging.getLogger(__name__)\n\n\ndef execute_data_minimisation(dry_run=False):\n    \"\"\"Delete old reference faces.\n\n    This deletes reference faces that have been marked for deletion by the user for\n    some time, as well as reference faces of users that have not logged in for a year.\n    \"\"\"\n    delete_period_inactive_member = timezone.now() - timezone.timedelta(days=365)\n    delete_period_marked_for_deletion = timezone.now() - timezone.timedelta(\n        days=settings.FACEDETECTION_REFERENCE_FACE_STORAGE_PERIOD_AFTER_DELETE_DAYS\n    )\n\n    queryset = ReferenceFace.objects.filter(\n        Q(marked_for_deletion_at__lte=delete_period_marked_for_deletion)\n        | Q(member__last_login__lte=delete_period_inactive_member)\n    )\n\n    if not dry_run:\n        for reference_face in queryset:\n            reference_face.delete()  # Don't run the queryset method, this will also delete the file\n\n    return queryset\n\n\ndef _serialize_lambda_source(source: Union[ReferenceFace, FaceDetectionPhoto]):\n    \"\"\"Serialize a source object to be sent to the lambda function.\"\"\"\n    if isinstance(source, ReferenceFace):\n        return {\n            \"type\": \"reference\",\n            \"pk\": source.pk,\n            \"token\": source.token,\n            \"photo_url\": get_thumbnail_url(\n                source.file,\n                \"medium\",\n                absolute_url=True,\n                # Lambda calls can be queued for up to 6 hours by default, so\n                # we make sure the url it uses is valid for at least that long.\n                expire_seconds=60 * 60 * 7,\n            ),\n        }\n    if isinstance(source, FaceDetectionPhoto):\n        return {\n            \"type\": \"photo\",\n            \"pk\": source.pk,\n            \"token\": source.token,\n            \"photo_url\": get_thumbnail_url(\n                source.photo.file,\n                \"large\",\n                absolute_url=True,\n                expire_seconds=60 * 60 * 7,\n            ),\n        }\n    raise ValueError(\"source must be a ReferenceFace or FaceDetectionPhoto\")\n\n\ndef _trigger_facedetection_lambda_batch(\n    sources: list[Union[ReferenceFace, FaceDetectionPhoto]]\n):\n    \"\"\"Submit a batch of sources to the facedetection lambda function.\n\n    If submitting the sources fails, this is logged and\n    reported to Sentry, but no exception is raised.\n    \"\"\"\n    payload = {\n        \"api_url\": settings.BASE_URL,\n        \"sources\": [_serialize_lambda_source(source) for source in sources],\n    }\n\n    for source in sources:\n        source.submitted_at = timezone.now()\n        source.save()\n\n    try:\n        lambda_client = boto3.client(\n            service_name=\"lambda\",\n            aws_access_key_id=settings.AWS_ACCESS_KEY_ID,\n            aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,\n        )\n\n        response = lambda_client.invoke(\n            FunctionName=settings.FACEDETECTION_LAMBDA_ARN,\n            InvocationType=\"Event\",\n            Payload=json.dumps(payload),\n        )\n\n        if response[\"StatusCode\"] != 202:\n            # pylint: disable=broad-exception-raised\n            raise Exception(\"Lambda response was not 202.\")\n\n    # pylint: disable=broad-exception-caught\n    except Exception as e:\n        logger.error(\n            \"Submitting sources to lambda failed. Reason: %s\", str(e), exc_info=True\n        )\n        capture_exception(e)\n\n\ndef trigger_facedetection_lambda(\n    sources: list[Union[ReferenceFace, FaceDetectionPhoto]]\n):\n    \"\"\"Submit a sources to the facedetection lambda function for processing.\n\n    This function will check if the sources are valid and, if a lambda function has\n    been configured, try to submit the sources to the lambda function in batches.\n\n    If no lambda function has been configured, or submitting (a batch of) sources fails,\n    this is ignored. The sources can be submitted again later.\n    \"\"\"\n    if len(sources) == 0:\n        raise ValueError(\"No sources to process.\")\n\n    if any(source.status != source.Status.PROCESSING for source in sources):\n        raise ValueError(\"A source has already been processed.\")\n\n    if settings.FACEDETECTION_LAMBDA_ARN is None:\n        logger.warning(\n            \"No Lambda ARN has been configured. Sources will not be processed.\"\n        )\n        return\n\n    batch_size = settings.FACEDETECTION_LAMBDA_BATCH_SIZE\n    for batch in [\n        sources[i : i + batch_size] for i in range(0, len(sources), batch_size)\n    ]:\n        _trigger_facedetection_lambda_batch(batch)\n\n\ndef resubmit_reference_faces() -> list[ReferenceFace]:\n    \"\"\"Resubmit reference faces that (should) have already been submitted but aren't done.\n\n    Returns a list of reference faces that have been resubmitted.\n    \"\"\"\n    submitted_before = timezone.now() - timezone.timedelta(hours=7)\n    references = list(\n        ReferenceFace.objects.filter(\n            status=ReferenceFace.Status.PROCESSING,\n        ).filter(Q(submitted_at__lte=submitted_before) | Q(submitted_at__isnull=True))\n    )\n    if references:\n        trigger_facedetection_lambda(references)\n    return references\n\n\ndef resubmit_photos() -> list[FaceDetectionPhoto]:\n    \"\"\"Resubmit photos that (should) have already been submitted but aren't done.\n\n    Returns a list of photos that have been resubmitted.\n    \"\"\"\n    submitted_before = timezone.now() - timezone.timedelta(hours=7)\n    photos = list(\n        FaceDetectionPhoto.objects.filter(\n            status=FaceDetectionPhoto.Status.PROCESSING,\n        )\n        .filter(Q(submitted_at__lte=submitted_before) | Q(submitted_at__isnull=True))\n        .select_related(\"photo\")\n    )\n    if photos:\n        trigger_facedetection_lambda(photos)\n    return photos\n\n\ndef submit_new_photos() -> int:\n    \"\"\"Submit photos for which no FaceDetectionPhoto exists yet.\n\n    Returns the number of new photos that have been submitted.\n    \"\"\"\n    count = 0\n    if not Photo.objects.filter(facedetectionphoto__isnull=True).exists():\n        return count\n\n    # We have another level of batching (outside of trigger_facedetection_lambda)\n    # for performance and responsive output when there are thousands of photos.\n    while Photo.objects.filter(facedetectionphoto__isnull=True).exists():\n        photos = FaceDetectionPhoto.objects.bulk_create(\n            [\n                FaceDetectionPhoto(photo=photo)\n                for photo in Photo.objects.filter(facedetectionphoto__isnull=True)[:400]\n            ]\n        )\n\n        trigger_facedetection_lambda(photos)\n        count += len(photos)\n\n    return count\n", "path": "website/facedetection/services.py"}], "after_files": [{"content": "from django.conf import settings\nfrom django.contrib import messages\nfrom django.contrib.auth.mixins import LoginRequiredMixin\nfrom django.core.exceptions import ValidationError\nfrom django.http import HttpResponseRedirect\nfrom django.shortcuts import redirect\nfrom django.urls import reverse_lazy\nfrom django.utils import timezone\nfrom django.utils.translation import gettext_lazy as _\nfrom django.views.generic import DeleteView, FormView, ListView\n\nfrom photos.models import Photo\nfrom thaliawebsite.views import PagedView\n\nfrom .forms import ReferenceFaceUploadForm\nfrom .models import ReferenceFace\n\n\nclass YourPhotosView(LoginRequiredMixin, PagedView):\n    model = Photo\n    paginate_by = 16\n    template_name = \"facedetection/your-photos.html\"\n    context_object_name = \"photos\"\n\n    def get(self, request, *args, **kwargs):\n        if not request.member or request.member.current_membership is None:\n            messages.error(request, _(\"You need to be a member to use this feature.\"))\n            return redirect(\"index\")\n\n        return super().get(request, *args, **kwargs)\n\n    def get_queryset(self):\n        member = self.request.member\n        photos = (\n            Photo.objects.select_related(\"album\")\n            .filter(album__hidden=False, hidden=False)\n            .filter(\n                facedetectionphoto__encodings__matches__reference__user=self.request.member\n            )\n        )\n\n        # Filter out matches from long before the member's first membership.\n        albums_since = member.earliest_membership.since - timezone.timedelta(days=31)\n        photos.filter(album__date__gte=albums_since)\n\n        # Filter out matches from after the member's last membership.\n        if member.latest_membership.until is not None:\n            photos = photos.filter(album__date__lte=member.latest_membership.until)\n\n        return photos.select_properties(\"num_likes\").order_by(\"-album__date\")\n\n    def get_context_data(self, **kwargs):\n        context = super().get_context_data(**kwargs)\n\n        context[\n            \"has_processing_reference_faces\"\n        ] = self.request.member.reference_faces.filter(\n            status=ReferenceFace.Status.PROCESSING,\n            marked_for_deletion_at__isnull=True,\n        ).exists()\n\n        context[\n            \"has_rejected_reference_faces\"\n        ] = self.request.member.reference_faces.filter(\n            status=ReferenceFace.Status.REJECTED,\n            marked_for_deletion_at__isnull=True,\n        ).exists()\n\n        context[\"has_reference_faces\"] = self.request.member.reference_faces.filter(\n            marked_for_deletion_at__isnull=True\n        ).exists()\n\n        return context\n\n\nclass ReferenceFaceView(LoginRequiredMixin, ListView):\n    template_name = \"facedetection/reference-faces.html\"\n\n    def get_context_data(self, **kwargs):\n        context = super().get_context_data(**kwargs)\n        context[\n            \"reference_faces_limit\"\n        ] = settings.FACEDETECTION_MAX_NUM_REFERENCE_FACES\n        context[\n            \"storage_period_after_delete\"\n        ] = settings.FACEDETECTION_REFERENCE_FACE_STORAGE_PERIOD_AFTER_DELETE_DAYS\n        context[\"reference_faces_limit_reached\"] = bool(\n            self.request.member.reference_faces.filter(\n                marked_for_deletion_at__isnull=True\n            ).count()\n            >= settings.FACEDETECTION_MAX_NUM_REFERENCE_FACES\n        )\n        context[\n            \"has_rejected_reference_faces\"\n        ] = self.request.member.reference_faces.filter(\n            status=ReferenceFace.Status.REJECTED,\n            marked_for_deletion_at__isnull=True,\n        ).exists()\n        return context\n\n    def get_queryset(self):\n        return self.request.member.reference_faces.filter(\n            marked_for_deletion_at__isnull=True\n        ).all()\n\n\nclass ReferenceFaceUploadView(LoginRequiredMixin, FormView):\n    template_name = \"facedetection/reference-face-upload.html\"\n    form_class = ReferenceFaceUploadForm\n    success_url = reverse_lazy(\"facedetection:reference-faces\")\n\n    def dispatch(self, request, *args, **kwargs):\n        if not request.member or request.member.current_membership is None:\n            messages.error(request, \"You need to be a member to use this feature.\")\n            return redirect(\"index\")\n        return super().dispatch(request, *args, **kwargs)\n\n    def form_valid(self, form):\n        try:\n            form.save(user=self.request.member)\n        except ValidationError as e:\n            for error in e:\n                messages.error(self.request, error)\n            return self.form_invalid(form)\n        messages.success(self.request, \"Your reference face has been uploaded.\")\n        return super().form_valid(form)\n\n\nclass ReferenceFaceDeleteView(LoginRequiredMixin, DeleteView):\n    model = ReferenceFace\n    success_url = reverse_lazy(\"facedetection:reference-faces\")\n\n    def get_context_data(self, **kwargs):\n        context = super().get_context_data(**kwargs)\n        context[\n            \"storage_period_after_delete\"\n        ] = settings.FACEDETECTION_REFERENCE_FACE_STORAGE_PERIOD_AFTER_DELETE_DAYS\n        return context\n\n    def get_queryset(self):\n        return self.request.member.reference_faces.filter(\n            marked_for_deletion_at__isnull=True\n        ).all()\n\n    def form_valid(self, form):\n        success_url = self.get_success_url()\n\n        instance = self.get_object()\n        instance.marked_for_deletion_at = timezone.now()\n        instance.save()\n        messages.success(self.request, \"Your reference face has been deleted.\")\n\n        return HttpResponseRedirect(success_url)\n", "path": "website/facedetection/views.py"}, {"content": "import json\nimport logging\nfrom typing import Union\n\nfrom django.conf import settings\nfrom django.db.models import Q\nfrom django.utils import timezone\n\nimport boto3\nfrom sentry_sdk import capture_exception\n\nfrom photos.models import Photo\nfrom utils.media.services import get_thumbnail_url\n\nfrom .models import FaceDetectionPhoto, ReferenceFace\n\nlogger = logging.getLogger(__name__)\n\n\ndef execute_data_minimisation(dry_run=False):\n    \"\"\"Delete old reference faces.\n\n    This deletes reference faces that have been marked for deletion by the user for\n    some time, as well as reference faces of users that have not logged in for a year.\n    \"\"\"\n    delete_period_inactive_member = timezone.now() - timezone.timedelta(days=365)\n    delete_period_marked_for_deletion = timezone.now() - timezone.timedelta(\n        days=settings.FACEDETECTION_REFERENCE_FACE_STORAGE_PERIOD_AFTER_DELETE_DAYS\n    )\n\n    queryset = ReferenceFace.objects.filter(\n        Q(marked_for_deletion_at__lte=delete_period_marked_for_deletion)\n        | Q(user__last_login__lte=delete_period_inactive_member)\n    )\n\n    if not dry_run:\n        for reference_face in queryset:\n            reference_face.delete()  # Don't run the queryset method, this will also delete the file\n\n    return queryset\n\n\ndef _serialize_lambda_source(source: Union[ReferenceFace, FaceDetectionPhoto]):\n    \"\"\"Serialize a source object to be sent to the lambda function.\"\"\"\n    if isinstance(source, ReferenceFace):\n        return {\n            \"type\": \"reference\",\n            \"pk\": source.pk,\n            \"token\": source.token,\n            \"photo_url\": get_thumbnail_url(\n                source.file,\n                \"medium\",\n                absolute_url=True,\n                # Lambda calls can be queued for up to 6 hours by default, so\n                # we make sure the url it uses is valid for at least that long.\n                expire_seconds=60 * 60 * 7,\n            ),\n        }\n    if isinstance(source, FaceDetectionPhoto):\n        return {\n            \"type\": \"photo\",\n            \"pk\": source.pk,\n            \"token\": source.token,\n            \"photo_url\": get_thumbnail_url(\n                source.photo.file,\n                \"large\",\n                absolute_url=True,\n                expire_seconds=60 * 60 * 7,\n            ),\n        }\n    raise ValueError(\"source must be a ReferenceFace or FaceDetectionPhoto\")\n\n\ndef _trigger_facedetection_lambda_batch(\n    sources: list[Union[ReferenceFace, FaceDetectionPhoto]]\n):\n    \"\"\"Submit a batch of sources to the facedetection lambda function.\n\n    If submitting the sources fails, this is logged and\n    reported to Sentry, but no exception is raised.\n    \"\"\"\n    payload = {\n        \"api_url\": settings.BASE_URL,\n        \"sources\": [_serialize_lambda_source(source) for source in sources],\n    }\n\n    for source in sources:\n        source.submitted_at = timezone.now()\n        source.save()\n\n    try:\n        lambda_client = boto3.client(\n            service_name=\"lambda\",\n            aws_access_key_id=settings.AWS_ACCESS_KEY_ID,\n            aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,\n        )\n\n        response = lambda_client.invoke(\n            FunctionName=settings.FACEDETECTION_LAMBDA_ARN,\n            InvocationType=\"Event\",\n            Payload=json.dumps(payload),\n        )\n\n        if response[\"StatusCode\"] != 202:\n            # pylint: disable=broad-exception-raised\n            raise Exception(\"Lambda response was not 202.\")\n\n    # pylint: disable=broad-exception-caught\n    except Exception as e:\n        logger.error(\n            \"Submitting sources to lambda failed. Reason: %s\", str(e), exc_info=True\n        )\n        capture_exception(e)\n\n\ndef trigger_facedetection_lambda(\n    sources: list[Union[ReferenceFace, FaceDetectionPhoto]]\n):\n    \"\"\"Submit a sources to the facedetection lambda function for processing.\n\n    This function will check if the sources are valid and, if a lambda function has\n    been configured, try to submit the sources to the lambda function in batches.\n\n    If no lambda function has been configured, or submitting (a batch of) sources fails,\n    this is ignored. The sources can be submitted again later.\n    \"\"\"\n    if len(sources) == 0:\n        raise ValueError(\"No sources to process.\")\n\n    if any(source.status != source.Status.PROCESSING for source in sources):\n        raise ValueError(\"A source has already been processed.\")\n\n    if settings.FACEDETECTION_LAMBDA_ARN is None:\n        logger.warning(\n            \"No Lambda ARN has been configured. Sources will not be processed.\"\n        )\n        return\n\n    batch_size = settings.FACEDETECTION_LAMBDA_BATCH_SIZE\n    for batch in [\n        sources[i : i + batch_size] for i in range(0, len(sources), batch_size)\n    ]:\n        _trigger_facedetection_lambda_batch(batch)\n\n\ndef resubmit_reference_faces() -> list[ReferenceFace]:\n    \"\"\"Resubmit reference faces that (should) have already been submitted but aren't done.\n\n    Returns a list of reference faces that have been resubmitted.\n    \"\"\"\n    submitted_before = timezone.now() - timezone.timedelta(hours=7)\n    references = list(\n        ReferenceFace.objects.filter(\n            status=ReferenceFace.Status.PROCESSING,\n        ).filter(Q(submitted_at__lte=submitted_before) | Q(submitted_at__isnull=True))\n    )\n    if references:\n        trigger_facedetection_lambda(references)\n    return references\n\n\ndef resubmit_photos() -> list[FaceDetectionPhoto]:\n    \"\"\"Resubmit photos that (should) have already been submitted but aren't done.\n\n    Returns a list of photos that have been resubmitted.\n    \"\"\"\n    submitted_before = timezone.now() - timezone.timedelta(hours=7)\n    photos = list(\n        FaceDetectionPhoto.objects.filter(\n            status=FaceDetectionPhoto.Status.PROCESSING,\n        )\n        .filter(Q(submitted_at__lte=submitted_before) | Q(submitted_at__isnull=True))\n        .select_related(\"photo\")\n    )\n    if photos:\n        trigger_facedetection_lambda(photos)\n    return photos\n\n\ndef submit_new_photos() -> int:\n    \"\"\"Submit photos for which no FaceDetectionPhoto exists yet.\n\n    Returns the number of new photos that have been submitted.\n    \"\"\"\n    count = 0\n    if not Photo.objects.filter(facedetectionphoto__isnull=True).exists():\n        return count\n\n    # We have another level of batching (outside of trigger_facedetection_lambda)\n    # for performance and responsive output when there are thousands of photos.\n    while Photo.objects.filter(facedetectionphoto__isnull=True).exists():\n        photos = FaceDetectionPhoto.objects.bulk_create(\n            [\n                FaceDetectionPhoto(photo=photo)\n                for photo in Photo.objects.filter(facedetectionphoto__isnull=True)[:400]\n            ]\n        )\n\n        trigger_facedetection_lambda(photos)\n        count += len(photos)\n\n    return count\n", "path": "website/facedetection/services.py"}]}