Coverage for ivatar/photo_cache.py: 85%
246 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-20 05:10 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-20 05:10 +0000
1"""
2Redis and in-memory caching for resized user photo payloads with model invalidation.
3"""
5import contextlib
6import logging
7import threading
8from io import BytesIO
9from typing import Dict, List, Optional, Sequence, Set, Tuple, Union
11from django.conf import settings
12from django.core.cache import cache, caches
14logger = logging.getLogger("ivatar")
16# Thread-safe in-memory L1 cache for resized photo bytes: (photo_id, size, format) -> bytes
17_user_photos_cache: Dict[Tuple[int, int, str], bytes] = {}
18_user_photos_cache_lock = threading.Lock()
19_MAX_USER_PHOTO_CACHE_SIZE = getattr(settings, "MAX_USER_PHOTO_CACHE_SIZE", 500)
21# Common sizes used across Libravatar / Ivatar
22COMMON_SIZES = (28, 48, 80, 128, 256, 512)
23DEFAULT_PREGENERATE_SIZES = (28, 48, 80, 128, 256)
24COMMON_FORMATS = ("png", "jpg", "jpeg", "gif", "webp")
27def _get_cache_backend():
28 """
29 Get the configured cache backend for user photos, falling back to default.
30 """
31 cache_alias = getattr(settings, "PHOTO_CACHE_ALIAS", "default")
32 try:
33 return caches[cache_alias]
34 except Exception: # pylint: disable=broad-exception-caught
35 return cache
38def get_cached_photo(photo_id: int, size: int, format_type: str) -> Optional[BytesIO]:
39 """
40 Retrieve cached resized photo bytes.
41 First checks the local process memory cache (L1), then falls back to Redis/Django cache (L2).
42 Returns a BytesIO stream if found, or None.
43 """
44 if not photo_id:
45 return None
47 cache_key = (photo_id, size, format_type)
49 # 1. Check L1 in-process memory cache
50 with _user_photos_cache_lock:
51 if cache_key in _user_photos_cache:
52 return BytesIO(_user_photos_cache[cache_key])
54 # 2. Check L2 Redis / Django cache
55 redis_key = f"photo_cache:{photo_id}:{size}:{format_type}"
56 cache_backend = _get_cache_backend()
57 max_cache_size = getattr(settings, "MAX_USER_PHOTO_CACHE_SIZE", 500)
58 with contextlib.suppress(Exception):
59 data_bytes = cache_backend.get(redis_key)
60 if data_bytes is not None:
61 # Populate L1 in-process cache
62 with _user_photos_cache_lock:
63 if len(_user_photos_cache) >= max_cache_size:
64 remove_keys = list(_user_photos_cache.keys())[:50]
65 for rk in remove_keys:
66 del _user_photos_cache[rk]
67 _user_photos_cache[cache_key] = data_bytes
68 return BytesIO(data_bytes)
70 return None
73def set_cached_photo(
74 photo_id: int, size: int, format_type: str, photo_bytes: bytes
75) -> None:
76 """
77 Store resized photo bytes into L1 memory cache and L2 Redis / Django cache.
78 Also tracks cached (size, format) keys for fast and complete invalidation.
79 """
80 if not photo_id or not photo_bytes:
81 return
83 cache_key = (photo_id, size, format_type)
84 max_cache_size = getattr(settings, "MAX_USER_PHOTO_CACHE_SIZE", 500)
86 # 1. Save in L1 in-process memory cache
87 with _user_photos_cache_lock:
88 if len(_user_photos_cache) >= max_cache_size:
89 remove_keys = list(_user_photos_cache.keys())[:50]
90 for rk in remove_keys:
91 del _user_photos_cache[rk]
92 _user_photos_cache[cache_key] = photo_bytes
94 # 2. Save in L2 Redis / Django cache
95 redis_key = f"photo_cache:{photo_id}:{size}:{format_type}"
96 tracking_key = f"photo_cache_keys:{photo_id}"
97 timeout = getattr(settings, "PHOTO_CACHE_TIMEOUT", 604800) # 7 days default
98 cache_backend = _get_cache_backend()
100 with contextlib.suppress(Exception):
101 cache_backend.set(redis_key, photo_bytes, timeout=timeout)
102 # Update tracking list of cached keys for this photo
103 tracked_keys: Set[str] = set(cache_backend.get(tracking_key) or [])
104 tracked_keys.add(redis_key)
105 cache_backend.set(tracking_key, list(tracked_keys), timeout=timeout)
108def invalidate_photo_cache(photo_id: int) -> None:
109 """
110 Invalidate all cached resized variants of a photo across L1 and L2 caches.
111 Called on Photo.save() and Photo.delete() and post_save/post_delete signals.
112 """
113 if not photo_id:
114 return
116 # 1. Clear from L1 in-process memory cache
117 with _user_photos_cache_lock:
118 keys_to_remove = [k for k in _user_photos_cache if k[0] == photo_id]
119 for k in keys_to_remove:
120 del _user_photos_cache[k]
122 # 2. Clear from L2 Redis / Django cache
123 cache_backend = _get_cache_backend()
124 tracking_key = f"photo_cache_keys:{photo_id}"
126 with contextlib.suppress(Exception):
127 keys_to_delete: Set[str] = set()
129 # Check tracked keys
130 tracked = cache_backend.get(tracking_key)
131 if tracked:
132 keys_to_delete.update(tracked)
134 # Also purge common sizes across common formats as a safety net
135 for s in COMMON_SIZES:
136 for fmt in COMMON_FORMATS:
137 keys_to_delete.add(f"photo_cache:{photo_id}:{s}:{fmt}")
139 if hasattr(cache_backend, "delete_many"):
140 cache_backend.delete_many(list(keys_to_delete))
141 else:
142 for k in keys_to_delete:
143 cache_backend.delete(k)
145 cache_backend.delete(tracking_key)
147 # If cache backend supports pattern deletion (e.g. django-redis)
148 if hasattr(cache_backend, "delete_pattern"):
149 cache_backend.delete_pattern(f"photo_cache:{photo_id}:*")
152def clear_local_photo_cache() -> None:
153 """
154 Clear in-process memory cache (primarily for tests).
155 """
156 with _user_photos_cache_lock:
157 _user_photos_cache.clear()
160def render_photo_thumbnail(
161 photo_data: bytes, imgformat: str, size: int, target_format: Optional[str] = None
162) -> bytes:
163 """
164 Render and return binary bytes for a photo resized to (size, size).
165 Supports animated GIFs, dynamic resampling filters (e.g. BOX/BILINEAR/LANCZOS),
166 WebP content negotiation, and image quality settings.
167 """
168 from PIL import Image
169 from ivatar.utils import get_resample_filter, resize_animated_gif
170 from ivatar.ivataraccount.models import pil_format
172 photodata = Image.open(BytesIO(photo_data))
173 data = BytesIO()
175 output_format = target_format if target_format is not None else imgformat
177 # Animated GIFs need additional handling
178 if imgformat == "gif" and getattr(photodata, "is_animated", False):
179 data = resize_animated_gif(photodata, (size, size))
180 else:
181 # If the image is smaller than what was requested, we need to use resize
182 resample_filter = get_resample_filter(size, photodata.size)
183 if photodata.size[0] < size or photodata.size[1] < size:
184 photodata = photodata.resize((size, size), resample_filter)
185 else:
186 photodata.thumbnail((size, size), resample_filter)
188 p_format = pil_format(output_format)
189 if p_format == "JPEG" and photodata.mode in ("RGBA", "LA", "P"):
190 photodata = photodata.convert("RGB")
191 elif p_format == "WEBP" and photodata.mode in ("P", "1", "CMYK"):
192 photodata = photodata.convert("RGBA")
194 if p_format == "WEBP":
195 webp_quality = getattr(settings, "WEBP_QUALITY", 85)
196 photodata.save(data, "WEBP", quality=webp_quality, method=4)
197 elif p_format == "JPEG":
198 jpeg_quality = getattr(settings, "JPEG_QUALITY", 85)
199 photodata.save(data, p_format, quality=jpeg_quality)
200 else:
201 photodata.save(data, p_format)
203 return data.getvalue()
206def _do_pregenerate(
207 photo_id: int, photo_data: bytes, imgformat: str, target_sizes: Sequence[int]
208) -> List[int]:
209 """
210 Execute thumbnail generation and caching for the specified target sizes.
211 Pre-caches native format and WebP format (if enabled).
212 """
213 generated: List[int] = []
214 normalized_format = "jpeg" if imgformat == "jpg" else imgformat
215 pregen_webp = getattr(settings, "PREGENERATE_WEBP_THUMBNAILS", True)
217 for size in target_sizes:
218 try:
219 # 1. Native format thumbnail
220 thumb_bytes = render_photo_thumbnail(photo_data, imgformat, size)
221 set_cached_photo(photo_id, size, normalized_format, thumb_bytes)
223 # 2. WebP format thumbnail
224 if pregen_webp and normalized_format != "webp":
225 webp_bytes = render_photo_thumbnail(
226 photo_data, imgformat, size, target_format="webp"
227 )
228 set_cached_photo(photo_id, size, "webp", webp_bytes)
230 generated.append(size)
231 except Exception as exc: # pylint: disable=broad-exception-caught
232 logger.warning(
233 "Failed to pre-generate thumbnail for photo %s at size %s: %s",
234 photo_id,
235 size,
236 exc,
237 )
238 logger.debug(
239 "Pre-generated %d thumbnails for photo %s: sizes=%s",
240 len(generated),
241 photo_id,
242 generated,
243 )
244 return generated
247def pregenerate_photo_thumbnails(
248 photo_or_id,
249 photo_data: Optional[bytes] = None,
250 imgformat: Optional[str] = None,
251 sizes: Optional[Sequence[int]] = None,
252 async_generation: Optional[bool] = None,
253) -> List[int]:
254 """
255 Pre-generate and cache standard thumbnail sizes for a photo.
256 Target sizes default to PREGENERATE_PHOTO_SIZES (28, 48, 80, 128, 256).
257 If async_generation is True, executes in a background daemon thread.
258 Returns the list of successfully generated sizes.
259 """
260 photo_id = None
261 if hasattr(photo_or_id, "id") and hasattr(photo_or_id, "data"):
262 photo_id = photo_or_id.id
263 if photo_data is None and photo_or_id.data:
264 photo_data = bytes(photo_or_id.data)
265 if imgformat is None:
266 imgformat = getattr(photo_or_id, "format", None)
267 elif photo_or_id is not None:
268 with contextlib.suppress(Exception):
269 photo_id = int(photo_or_id)
271 if not photo_id:
272 return []
274 if photo_data is None or imgformat is None:
275 try:
276 from ivatar.ivataraccount.models import Photo
277 photo = Photo.objects.get(pk=photo_id)
278 if photo_data is None and photo.data:
279 photo_data = bytes(photo.data)
280 if imgformat is None:
281 imgformat = photo.format
282 except Exception as exc: # pylint: disable=broad-exception-caught
283 logger.warning(
284 "Could not load photo %s for thumbnail pre-generation: %s",
285 photo_id,
286 exc,
287 )
288 return []
290 if not photo_data or not imgformat:
291 return []
293 target_sizes = sizes or getattr(
294 settings, "PREGENERATE_PHOTO_SIZES", DEFAULT_PREGENERATE_SIZES
295 )
297 if async_generation is None:
298 async_generation = getattr(settings, "PREGENERATE_THUMBNAILS_ASYNC", False)
300 if async_generation:
301 thread = threading.Thread(
302 target=_do_pregenerate,
303 args=(photo_id, photo_data, imgformat, target_sizes),
304 daemon=True,
305 )
306 thread.start()
307 return list(target_sizes)
309 return _do_pregenerate(photo_id, photo_data, imgformat, target_sizes)
312def invalidate_digest_cache( # pylint: disable=too-many-locals,too-many-branches,too-many-statements
313 digests: Union[str, Sequence[str]]
314) -> None:
315 """
316 Invalidate all cached avatar responses, default avatar renders,
317 Gravatar proxy/test entries, and related cache entries for the given digest(s).
318 Supports single digest string or sequence of digests.
319 """
320 if isinstance(digests, str):
321 digests = [digests]
322 digest_set = {d.strip().lower() for d in digests if d and isinstance(d, str)}
323 if not digest_set:
324 return
326 # 1. Clear in-process default avatar cache in ivatar.views
327 try:
328 from ivatar.views import ( # pylint: disable=import-outside-toplevel
329 _default_avatars_png_cache,
330 _default_avatars_png_cache_lock,
331 )
333 with _default_avatars_png_cache_lock:
334 keys_to_del = [k for k in _default_avatars_png_cache if k[1] in digest_set]
335 for k in keys_to_del:
336 del _default_avatars_png_cache[k]
337 except Exception as exc: # pylint: disable=broad-exception-caught
338 logger.debug("Could not clear in-memory default avatar cache: %s", exc)
340 # 2. Collect unique cache backends
341 backends = []
342 aliases = ["default"]
343 for alias_attr in ("AVATAR_CACHE_ALIAS", "PHOTO_CACHE_ALIAS"):
344 if hasattr(settings, alias_attr):
345 aliases.append(getattr(settings, alias_attr))
346 if hasattr(settings, "CACHES") and "filesystem" in settings.CACHES:
347 aliases.append("filesystem")
349 seen_backend_ids = set()
350 for alias in aliases:
351 try:
352 b = caches[alias]
353 if id(b) not in seen_backend_ids:
354 seen_backend_ids.add(id(b))
355 backends.append(b)
356 except Exception: # pylint: disable=broad-exception-caught
357 pass
359 # 3. Build candidate keys to delete for backends that do not support delete_pattern
360 common_sizes = (0, 28, 48, 80, 128, 256, 512)
361 common_styles = (
362 "retro",
363 "monsterid",
364 "wavatar",
365 "identicon",
366 "robohash",
367 "pagan",
368 "mmng",
369 )
370 common_hosts = [
371 "https://seccdn.libravatar.org",
372 "https://cdn.libravatar.org",
373 "http://testserver",
374 ]
375 if getattr(settings, "SECURE_BASE_URL", None):
376 base = settings.SECURE_BASE_URL.rstrip("/")
377 if base.endswith("/avatar"):
378 base = base[:-7]
379 common_hosts.append(base)
380 if getattr(settings, "BASE_URL", None):
381 base = settings.BASE_URL.rstrip("/")
382 if base.endswith("/avatar"):
383 base = base[:-7]
384 common_hosts.append(base)
386 hosts = list(set(h.rstrip("/") for h in common_hosts if h))
388 keys_to_delete: Set[str] = set()
390 for digest in digest_set:
391 # Direct digest / URI suffix keys
392 keys_to_delete.add(f"{digest}")
393 keys_to_delete.add(f"{digest}:standard")
394 keys_to_delete.add(f"{digest}:webp")
396 for host in hosts:
397 for path_prefix in (f"/avatar/{digest}", f"/avatar/{digest}/"):
398 base_uri = f"{host}{path_prefix}"
399 keys_to_delete.add(base_uri)
400 keys_to_delete.add(f"{base_uri}:standard")
401 keys_to_delete.add(f"{base_uri}:webp")
403 for s in common_sizes:
404 for q in (
405 f"?s={s}",
406 f"?size={s}",
407 f"?s={s}&default=mm",
408 f"?s={s}&d=mm",
409 f"?s={s}&forcedefault=y",
410 f"?s={s}&d=404",
411 ):
412 uri_with_q = f"{base_uri}{q}"
413 keys_to_delete.add(uri_with_q)
414 keys_to_delete.add(f"{uri_with_q}:standard")
415 keys_to_delete.add(f"{uri_with_q}:webp")
417 # Gravatarproxy URLs
418 keys_to_delete.add(f"{host}/gravatarproxy/{digest}")
419 for s in common_sizes:
420 keys_to_delete.add(f"{host}/gravatarproxy/{digest}?s={s}")
421 keys_to_delete.add(f"{host}/gravatarproxy/{digest}?s={s}&default=mm")
423 # Default avatar cache keys
424 for style in common_styles:
425 for s in common_sizes:
426 keys_to_delete.add(f"default_avatar:{style}:{digest}:{s}")
428 # Gravatar proxy/error/test keys
429 for s in common_sizes:
430 keys_to_delete.add(f"gravatar_proxy_img:{digest}:{s}")
431 keys_to_delete.add(f"gravatar_proxy_err:{digest}:{s}")
432 keys_to_delete.add(f"gravatar_test:{digest}:{s}")
434 key_list = list(keys_to_delete)
436 # 4. Invalidate on each cache backend
437 for backend in backends:
438 with contextlib.suppress(Exception):
439 # If backend supports pattern deletion (e.g. django-redis)
440 if hasattr(backend, "delete_pattern"):
441 for digest in digest_set:
442 backend.delete_pattern(f"*{digest}*")
443 elif hasattr(backend, "delete_many"):
444 backend.delete_many(key_list)
445 else:
446 for k in key_list:
447 backend.delete(k)