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

1import json 

2import logging 

3 

4from django.conf import settings 

5from django.db.models import Count, Q 

6from django.utils import timezone 

7 

8import boto3 

9from sentry_sdk import capture_exception 

10 

11from members.models.member import Member 

12from photos.models import Photo 

13from utils.media.services import get_media_url 

14 

15from .models import FaceDetectionPhoto, ReferenceFace 

16 

17logger = logging.getLogger(__name__) 

18 

19 

20def 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(user__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 

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") 

70 

71 

72def _trigger_facedetection_lambda_batch( 

73 sources: list[ReferenceFace | FaceDetectionPhoto], 

74): 

75 """Submit a batch of sources to the facedetection lambda function. 

76 

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 } 

84 

85 for source in sources: 

86 source.submitted_at = timezone.now() 

87 source.save() 

88 

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 ) 

95 

96 response = lambda_client.invoke( 

97 FunctionName=settings.FACEDETECTION_LAMBDA_ARN, 

98 InvocationType="Event", 

99 Payload=json.dumps(payload), 

100 ) 

101 

102 if response["StatusCode"] != 202: 

103 raise Exception("Lambda response was not 202.") 

104 

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) 

110 

111 

112def trigger_facedetection_lambda(sources: list[ReferenceFace | FaceDetectionPhoto]): 

113 """Submit a sources to the facedetection lambda function for processing. 

114 

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. 

117 

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.") 

123 

124 if any(source.status != source.Status.PROCESSING for source in sources): 

125 raise ValueError("A source has already been processed.") 

126 

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 

132 

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) 

138 

139 

140def resubmit_reference_faces() -> list[ReferenceFace]: 

141 """Resubmit reference faces that (should) have already been submitted but aren't done. 

142 

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 

154 

155 

156def resubmit_photos() -> list[FaceDetectionPhoto]: 

157 """Resubmit photos that (should) have already been submitted but aren't done. 

158 

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 

172 

173 

174def submit_new_photos() -> int: 

175 """Submit photos for which no FaceDetectionPhoto exists yet. 

176 

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 

182 

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 ) 

192 

193 trigger_facedetection_lambda(photos) 

194 count += len(photos) 

195 

196 return count 

197 

198 

199def get_user_photos(member: Member): 

200 reference_faces = member.reference_faces.filter( 

201 marked_for_deletion_at__isnull=True, 

202 ) 

203 

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) 

207 

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) 

211 

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 ) 

216 

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 )