diff --git a/django/utils/cache.py b/django/utils/cache.py index f2cbd1d033..bb71e3a01d 100644 --- a/django/utils/cache.py +++ b/django/utils/cache.py @@ -21,7 +21,8 @@ from hashlib import md5 from django.conf import settings from django.core.cache import caches -from django.http import HttpResponse, HttpResponseNotModified +from django.http import HttpRequest, HttpResponse, HttpResponseNotModified +from django.test import RequestFactory from django.utils.http import http_date, parse_etags, parse_http_date_safe, quote_etag from django.utils.log import log_response from django.utils.regex_helper import _lazy_re_compile @@ -443,3 +444,51 @@ def _to_tuple(s): if len(t) == 2: return t[0].lower(), t[1] return t[0].lower(), True + + +def invalidate_view_cache( + request, key_prefix=None, cache=None, invalidate_whole_prefix=False +): + """ + Delete a cached page based on either a relative URL (type: any) + or a request object (type: django.http.HttpRequest). + + The cache key is reconstructed in two steps: + 1. A headers cache key is built using the absolute URL, + key prefix, and locale code. + 2. A response cache key is then built using the absolute URL, + key prefix, HTTP method, and recovered headers. + + invalidate_whole_prefix: If True, the function will + attempt to invalidate all cached entries that share the given key_prefix + (i.e. all pages cached under that prefix). The exact scope and method of + prefix invalidation depends on the cache backend capabilities. + + The ``key_prefix`` must match the value used in the ``cache_page`` + decorator for the corresponding view. + """ + if not isinstance(request, HttpRequest): + factory = RequestFactory() + request = factory.get(request) + + if cache is None: + cache = caches[settings.CACHE_MIDDLEWARE_ALIAS] + + if invalidate_whole_prefix: + cache_class = f"{cache.__class__.__module__}.{cache.__class__.__name__}" + if cache_class != "django_redis.cache.RedisCache": + raise ValueError("invalidate_view_cache only supports RedisCache backend") + try: + # Get all keys matching the prefix + keys = cache.iter_keys(f"*{key_prefix}*") + # Get all `cache_header` keys to delete associated entries + keys = cache.iter_keys(f"*{next(keys).split('.')[6]}*") + except StopIteration: + return 0 + return cache.delete_many(keys) + + cache_key = get_cache_key(request, key_prefix=key_prefix, cache=cache) + if cache_key is None: + return 0 + + return cache.delete(cache_key) diff --git a/django/views/decorators/cache.py b/django/views/decorators/cache.py index aa1679baff..a9796851f3 100644 --- a/django/views/decorators/cache.py +++ b/django/views/decorators/cache.py @@ -82,3 +82,43 @@ def never_cache(view_func): return response return wraps(view_func)(_view_wrapper) + + +def cache_page_per_item(timeout, key_prefix=None): + """Decorator that applies cache_page with a dynamic key_prefix + based on item id. The item id is expected to be passed as + 'pk' or 'id' keyword argument to the view. If key_prefix + is not provided, the view name will be used as base for the key prefix. + Example usage: + @cache_page_per_item(60 * 15, key_prefix="prefix") + def my_view(request, pk): + ... + + This will cache the view for 15 minutes with a key prefix of 'prefix_' + allowing cache keys to be unique per item and identifiable. + If key_prefix is not provided it will default to 'my_view_' where + 'my_view' is the name of the view function. + """ + + def decorator(view_func): + @wraps(view_func) + def _wrapped_view(request, *args, **kwargs): + item_id = kwargs.get("pk") or kwargs.get("id") + if key_prefix is None: + rm = getattr(request, "resolver_match", None) + if rm and rm.view_name: + base = rm.view_name.replace(":", "_") + else: + base = getattr(view_func, "__name__", "view") + else: + base = key_prefix + + prefix = f"{base}_{item_id}" if item_id is not None else base + + return cache_page(timeout, key_prefix=prefix)(view_func)( + request, *args, **kwargs + ) + + return _wrapped_view + + return decorator diff --git a/docs/ref/utils.txt b/docs/ref/utils.txt index 69af23ad83..a8ba330256 100644 --- a/docs/ref/utils.txt +++ b/docs/ref/utils.txt @@ -103,6 +103,19 @@ need to distinguish caches by the ``Accept-language`` header. cache, this means that we have to build the response once to get at the Vary header and so at the list of headers to use for the cache key. +.. function:: invalidate_view_cache(request, key_prefix=None, cache=None): + Delete a cached page based on either a relative URL (type: ``any``) + or a request object (type: ``django.http.HttpRequest``). + + The cache key is reconstructed in two steps: + 1. A headers cache key is built using the absolute URL, + key prefix, and locale code. + 2. A response cache key is then built using the absolute URL, + key prefix, HTTP method, and recovered headers. + + The ``key_prefix`` must match the value used in the ``cache_page`` + decorator for the corresponding view. + ``django.utils.dateparse`` ========================== diff --git a/tests/cache/tests.py b/tests/cache/tests.py index c4cf0a84e3..72a83cf3db 100644 --- a/tests/cache/tests.py +++ b/tests/cache/tests.py @@ -46,6 +46,7 @@ from django.template import engines from django.template.context_processors import csrf from django.template.response import TemplateResponse from django.test import ( + AsyncRequestFactory, RequestFactory, SimpleTestCase, TestCase, @@ -57,11 +58,13 @@ from django.test.utils import CaptureQueriesContext from django.utils import timezone, translation from django.utils.cache import ( get_cache_key, + invalidate_view_cache, learn_cache_key, patch_cache_control, patch_vary_headers, ) from django.views.decorators.cache import cache_control, cache_page +from django.views.decorators.vary import vary_on_headers from .models import Poll, expensive_calculation @@ -2534,6 +2537,7 @@ def csrf_view(request): ) class CacheMiddlewareTest(SimpleTestCase): factory = RequestFactory() + async_factory = AsyncRequestFactory() def setUp(self): self.default_cache = caches["default"] @@ -2625,6 +2629,114 @@ class CacheMiddlewareTest(SimpleTestCase): self.assertIsNotNone(result) self.assertEqual(result.content, b"Hello World 1") + def test_invalidate_view_decorator_cache_from_request(self): + """Invalidate cache key/value from request object""" + view = cache_page(10)(hello_world_view) + request = self.factory.get("/view/") + _ = view(request, "0") + cache_key = get_cache_key(request=request, key_prefix="", cache=cache) + cached_response = cache.get(cache_key) + + # Verify request.content has been chached + self.assertEqual(cached_response.content, b"Hello World 0") + + # Delete cache key/value + invalidate_view_cache(request, key_prefix="", cache=cache) + cached_response = cache.get(cache_key) + + # Confirm key/value has been deleted from cache + self.assertIsNone(cached_response) + + def test_invalidate_view_decorator_cache_from_path(self): + """Invalidate cache key/value from path""" + view = cache_page(10)(hello_world_view) + path = "/view/" + request = self.factory.get(path) + _ = view(request, "0") + cache_key = get_cache_key(request=request, key_prefix="", cache=cache) + cached_response = cache.get(cache_key) + + # Verify request.content has been chached + self.assertEqual(cached_response.content, b"Hello World 0") + + # Delete cache key/value + invalidate_view_cache(path, key_prefix="", cache=cache) + cached_response = cache.get(cache_key) + + # Confirm key/value has been deleted from cache + self.assertIsNone(cached_response) + + def test_invalidate_view_decorator_cache_from_path_with_vary_headers(self): + """Invalidate cache key/value from path with Vary headers""" + + # Cache view and inject Vary headers to Response object + view = cache_page(10, key_prefix="")( + vary_on_headers("Accept-Encoding", "Accept")(hello_world_view) + ) + path = "/view/" + request = self.factory.get(path) + response = view(request, "0") + + # Check response headers + self.assertTrue(response.has_header("Vary")) + + cache_key = get_cache_key(request=request, key_prefix="", cache=cache) + cached_response = cache.get(cache_key) + + # Verify request.content has been chached + self.assertEqual(cached_response.content, b"Hello World 0") + + # Delete cache key/value + invalidate_view_cache(path, key_prefix="", cache=cache) + cached_response = cache.get(cache_key) + + # Confirm key/value has been deleted from cache + self.assertIsNone(cached_response) + + def test_cache_key_prefix_missmatch(self): + # Wrap the view with the cache_page decorator (no key_prefix specified) + view = cache_page(10)(hello_world_view) + path = "/view/" + request = self.factory.get(path) + _ = view(request, "0") + + # Attempt to retrieve the cache key without specifying key_prefix + cache_key = get_cache_key(request=request, cache=cache) + + # Because get_cache_key defaults to using + # settings.CACHE_MIDDLEWARE_KEY_PREFIX when key_prefix is None, + # this should not match the cached key + self.assertIsNone(cache_key) + + # Try again, explicitly passing the default cache_page key_prefix + # (empty string) + cache_key = get_cache_key(request=request, cache=cache, key_prefix="") + + # The key should now be found + self.assertIsNotNone(cache_key) + + # Confirm that the cached response content matches the view output + cached_response = cache.get(cache_key) + self.assertEqual(cached_response.content, b"Hello World 0") + + def test_invalidate_view_decorator_cache_from_async_request(self): + """Invalidate cache key/value from async request object""" + view = cache_page(10)(hello_world_view) + async_request = self.async_factory.get("/view/") + _ = view(async_request, "0") + cache_key = get_cache_key(request=async_request, key_prefix="", cache=cache) + cached_response = cache.get(cache_key) + + # Verify request.content has been chached + self.assertEqual(cached_response.content, b"Hello World 0") + + # Delete cache key/value + invalidate_view_cache(async_request, key_prefix="", cache=cache) + cached_response = cache.get(cache_key) + + # Confirm key/value has been deleted from cache + self.assertIsNone(cached_response) + def test_view_decorator(self): # decorate the same view with different cache decorators default_view = cache_page(3)(hello_world_view)