Coverage for website/facedetection/services.py: 18.35%
79 statements
« prev ^ index » next coverage.py v7.6.7, created at 2025-08-14 10:31 +0000
« prev ^ index » next coverage.py v7.6.7, created at 2025-08-14 10:31 +0000
1import json
2import logging
4from django.conf import settings
5from django.db.models import Count, Q
6from django.utils import timezone
8import boto3
9from sentry_sdk import capture_exception
11from members.models.member import Member
12from photos.models import Photo
13from utils.media.services import get_media_url
15from .models import FaceDetectionPhoto, ReferenceFace
17logger = logging.getLogger(__name__)
20def execute_data_minimisation(dry_run=False):
21 """Delete old reference faces.
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 )
31 queryset = ReferenceFace.objects.filter(
32 Q(marked_for_deletion_at__lte=delete_period_marked_for_deletion)
33 | Q(user__last_login__lte=delete_period_inactive_member)
34 )
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
40 return queryset
43def _serialize_lambda_source(source: 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_media_url(
51 source.file.thumbnails.large,
52 absolute_url=True,
53 # Lambda calls can be queued for up to 6 hours by default, so
54 # we make sure the url it uses is valid for at least that long.
55 expire_seconds=60 * 60 * 7,
56 ),
57 }
58 if isinstance(source, FaceDetectionPhoto):
59 return {
60 "type": "photo",
61 "pk": source.pk,
62 "token": source.token,
63 "photo_url": get_media_url(
64 source.photo.file.thumbnails.photo_large,
65 absolute_url=True,
66 expire_seconds=60 * 60 * 7,
67 ),
68 }
69 raise ValueError("source must be a ReferenceFace or FaceDetectionPhoto")
72def _trigger_facedetection_lambda_batch(
73 sources: list[ReferenceFace | FaceDetectionPhoto],
74):
75 """Submit a batch of sources to the facedetection lambda function.
77 If submitting the sources fails, this is logged and
78 reported to Sentry, but no exception is raised.
79 """
80 payload = {
81 "api_url": settings.BASE_URL,
82 "sources": [_serialize_lambda_source(source) for source in sources],
83 }
85 for source in sources:
86 source.submitted_at = timezone.now()
87 source.save()
89 try:
90 lambda_client = boto3.client(
91 service_name="lambda",
92 aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
93 aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
94 )
96 response = lambda_client.invoke(
97 FunctionName=settings.FACEDETECTION_LAMBDA_ARN,
98 InvocationType="Event",
99 Payload=json.dumps(payload),
100 )
102 if response["StatusCode"] != 202:
103 raise Exception("Lambda response was not 202.")
105 except Exception as e:
106 logger.error(
107 "Submitting sources to lambda failed. Reason: %s", str(e), exc_info=True
108 )
109 capture_exception(e)
112def trigger_facedetection_lambda(sources: list[ReferenceFace | FaceDetectionPhoto]):
113 """Submit a sources to the facedetection lambda function for processing.
115 This function will check if the sources are valid and, if a lambda function has
116 been configured, try to submit the sources to the lambda function in batches.
118 If no lambda function has been configured, or submitting (a batch of) sources fails,
119 this is ignored. The sources can be submitted again later.
120 """
121 if len(sources) == 0:
122 raise ValueError("No sources to process.")
124 if any(source.status != source.Status.PROCESSING for source in sources):
125 raise ValueError("A source has already been processed.")
127 if settings.FACEDETECTION_LAMBDA_ARN is None:
128 logger.warning(
129 "No Lambda ARN has been configured. Sources will not be processed."
130 )
131 return
133 batch_size = settings.FACEDETECTION_LAMBDA_BATCH_SIZE
134 for batch in [
135 sources[i : i + batch_size] for i in range(0, len(sources), batch_size)
136 ]:
137 _trigger_facedetection_lambda_batch(batch)
140def resubmit_reference_faces() -> list[ReferenceFace]:
141 """Resubmit reference faces that (should) have already been submitted but aren't done.
143 Returns a list of reference faces that have been resubmitted.
144 """
145 submitted_before = timezone.now() - timezone.timedelta(hours=7)
146 references = list(
147 ReferenceFace.objects.filter(
148 status=ReferenceFace.Status.PROCESSING,
149 ).filter(Q(submitted_at__lte=submitted_before) | Q(submitted_at__isnull=True))
150 )
151 if references:
152 trigger_facedetection_lambda(references)
153 return references
156def resubmit_photos() -> list[FaceDetectionPhoto]:
157 """Resubmit photos that (should) have already been submitted but aren't done.
159 Returns a list of photos that have been resubmitted.
160 """
161 submitted_before = timezone.now() - timezone.timedelta(hours=7)
162 photos = list(
163 FaceDetectionPhoto.objects.filter(
164 status=FaceDetectionPhoto.Status.PROCESSING,
165 )
166 .filter(Q(submitted_at__lte=submitted_before) | Q(submitted_at__isnull=True))
167 .select_related("photo")
168 )
169 if photos:
170 trigger_facedetection_lambda(photos)
171 return photos
174def submit_new_photos() -> int:
175 """Submit photos for which no FaceDetectionPhoto exists yet.
177 Returns the number of new photos that have been submitted.
178 """
179 count = 0
180 if not Photo.objects.filter(facedetectionphoto__isnull=True).exists():
181 return count
183 # We have another level of batching (outside of trigger_facedetection_lambda)
184 # for performance and responsive output when there are thousands of photos.
185 while Photo.objects.filter(facedetectionphoto__isnull=True).exists():
186 photos = FaceDetectionPhoto.objects.bulk_create(
187 [
188 FaceDetectionPhoto(photo=photo)
189 for photo in Photo.objects.filter(facedetectionphoto__isnull=True)[:400]
190 ]
191 )
193 trigger_facedetection_lambda(photos)
194 count += len(photos)
196 return count
199def get_user_photos(member: Member):
200 reference_faces = member.reference_faces.filter(
201 marked_for_deletion_at__isnull=True,
202 )
204 # Filter out matches from long before the member's first membership.
205 albums_since = member.earliest_membership.since - timezone.timedelta(days=31)
206 photos = Photo.objects.select_related("album").filter(album__date__gte=albums_since)
208 # Filter out matches from after the member's last membership.
209 if member.latest_membership.until is not None:
210 photos = photos.filter(album__date__lte=member.latest_membership.until)
212 # Actually match the reference faces.
213 photos = photos.filter(album__hidden=False, album__is_processing=False).filter(
214 facedetectionphoto__encodings__matches__reference__in=reference_faces,
215 )
217 return (
218 photos.annotate(member_likes=Count("likes", filter=Q(likes__member=member)))
219 .select_properties("num_likes")
220 .order_by("-album__date", "-pk")
221 )