From 20e26762ec96e82796348692ff0455bb7bbb1f6b Mon Sep 17 00:00:00 2001 From: Ramiro Juan Nocelli Date: Sat, 11 Oct 2025 19:06:31 +0200 Subject: [PATCH 01/10] Refs #5815, #31938, #34271 -- Created invalidate_view_cache function to manually invalidate cache key. Thanks Laurent Tramoy for the implementation idea and Carlton Gibson for the support. --- django/utils/cache.py | 55 ++++++++++++++++++++++++++++++++++++++----- tests/cache/tests.py | 38 ++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/django/utils/cache.py b/django/utils/cache.py index f2cbd1d033..34bcae62e4 100644 --- a/django/utils/cache.py +++ b/django/utils/cache.py @@ -18,10 +18,13 @@ An example: i18n middleware would need to distinguish caches by the import time from collections import defaultdict from hashlib import md5 +from typing import Annotated, Dict, Optional from django.conf import settings -from django.core.cache import caches +from django.core.cache import cache, caches +from django.core.handlers.wsgi import WSGIRequest from django.http import 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 @@ -375,15 +378,17 @@ def _generate_cache_header_key(key_prefix, request): return _i18n_cache_key_suffix(request, cache_key) -def get_cache_key(request, key_prefix=None, method="GET", cache=None): +def get_cache_key( + request, key_prefix=None, method="GET", cache=None, ignore_headers=False +): """ Return a cache key based on the request URL and query. It can be used in the request phase because it pulls the list of headers to take into account from the global URL registry and uses those to build a cache key to check against. - If there isn't a headerlist stored, return None, indicating that the page - needs to be rebuilt. + If there isn't a headerlist stored and `ignore_headers` argument is False, + return None, indicating that the page needs to be rebuilt. """ if key_prefix is None: key_prefix = settings.CACHE_MIDDLEWARE_KEY_PREFIX @@ -391,8 +396,8 @@ def get_cache_key(request, key_prefix=None, method="GET", cache=None): if cache is None: cache = caches[settings.CACHE_MIDDLEWARE_ALIAS] headerlist = cache.get(cache_key) - if headerlist is not None: - return _generate_cache_key(request, method, headerlist, key_prefix) + if headerlist is not None or ignore_headers: + return _generate_cache_key(request, method, headerlist or [], key_prefix) else: return None @@ -443,3 +448,41 @@ def _to_tuple(s): if len(t) == 2: return t[0].lower(), t[1] return t[0].lower(), True + + +def invalidate_view_cache( + path: str = None, + request: WSGIRequest = None, + vary_headers: Optional[Dict[str, str]] = None, + key_prefix: Optional[str] = None, +) -> Annotated[int, "Number of cache keys deleted"]: + """ + This function first creates a fake WSGIRequest to compute the cache key. + The key looks like: + views.decorators.cache.cache_page.key_prefix.GET.0fcb3cd9d5b34c8fe83f615913d8509b.c4ca4238a0b923820dcc509a6f75849b.en-us.UTC + The first hash corresponds to the full url (including query params), + the second to the header values + + vary_headers should be a dict of every header used for this particular view + In local environment, we have two defined renderers (default of DRF), + thus DRF adds `Accept` to the Vary headers + + either `path` or `request` arguments should be passed; + if both are passed `path` will be ignored + + Note: If LocaleMiddleware is used, + we'll need to use the same language code as the one in the cached request + """ + if not request: + assert path is not None, "either `path` or `request` arguments needed" + factory = RequestFactory() + request = factory.get(path) + + if vary_headers: + request.META.update(vary_headers) + + cache_key = get_cache_key(request, key_prefix=key_prefix, ignore_headers=True) + if cache_key is None: + return 0 + + return cache.delete(cache_key) diff --git a/tests/cache/tests.py b/tests/cache/tests.py index c4cf0a84e3..1b20a3db0d 100644 --- a/tests/cache/tests.py +++ b/tests/cache/tests.py @@ -57,6 +57,7 @@ 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, @@ -2625,6 +2626,43 @@ 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="", ignore_headers=True) + 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=request, key_prefix="") + 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="", ignore_headers=True) + 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=path, key_prefix="") + 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) From ec6612b7bc2acbb3710c4268582c4f092222dae8 Mon Sep 17 00:00:00 2001 From: Ramiro Juan Nocelli Date: Mon, 13 Oct 2025 22:39:43 +0200 Subject: [PATCH 02/10] remove type annotations --- django/utils/cache.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/django/utils/cache.py b/django/utils/cache.py index 34bcae62e4..b9308fc03c 100644 --- a/django/utils/cache.py +++ b/django/utils/cache.py @@ -18,11 +18,9 @@ An example: i18n middleware would need to distinguish caches by the import time from collections import defaultdict from hashlib import md5 -from typing import Annotated, Dict, Optional from django.conf import settings from django.core.cache import cache, caches -from django.core.handlers.wsgi import WSGIRequest from django.http import HttpResponse, HttpResponseNotModified from django.test import RequestFactory from django.utils.http import http_date, parse_etags, parse_http_date_safe, quote_etag @@ -451,11 +449,11 @@ def _to_tuple(s): def invalidate_view_cache( - path: str = None, - request: WSGIRequest = None, - vary_headers: Optional[Dict[str, str]] = None, - key_prefix: Optional[str] = None, -) -> Annotated[int, "Number of cache keys deleted"]: + path=None, + request=None, + vary_headers=None, + key_prefix=None, +): """ This function first creates a fake WSGIRequest to compute the cache key. The key looks like: From 951d78a5d2433347971eff73ef4d54de82e75db2 Mon Sep 17 00:00:00 2001 From: Ramiro Juan Nocelli Date: Tue, 14 Oct 2025 00:49:07 +0200 Subject: [PATCH 03/10] add test for response with Vary headers case --- django/utils/cache.py | 14 ++++++++------ tests/cache/tests.py | 42 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/django/utils/cache.py b/django/utils/cache.py index b9308fc03c..d5c8720e98 100644 --- a/django/utils/cache.py +++ b/django/utils/cache.py @@ -20,7 +20,7 @@ from collections import defaultdict from hashlib import md5 from django.conf import settings -from django.core.cache import cache, caches +from django.core.cache import caches from django.http import HttpResponse, HttpResponseNotModified from django.test import RequestFactory from django.utils.http import http_date, parse_etags, parse_http_date_safe, quote_etag @@ -449,10 +449,7 @@ def _to_tuple(s): def invalidate_view_cache( - path=None, - request=None, - vary_headers=None, - key_prefix=None, + path=None, request=None, vary_headers=None, key_prefix=None, cache=None ): """ This function first creates a fake WSGIRequest to compute the cache key. @@ -479,7 +476,12 @@ def invalidate_view_cache( if vary_headers: request.META.update(vary_headers) - cache_key = get_cache_key(request, key_prefix=key_prefix, ignore_headers=True) + if cache is None: + cache = caches[settings.CACHE_MIDDLEWARE_ALIAS] + + cache_key = get_cache_key( + request, key_prefix=key_prefix, ignore_headers=True, cache=cache + ) if cache_key is None: return 0 diff --git a/tests/cache/tests.py b/tests/cache/tests.py index 1b20a3db0d..b10940ff72 100644 --- a/tests/cache/tests.py +++ b/tests/cache/tests.py @@ -63,6 +63,7 @@ from django.utils.cache import ( 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 @@ -2631,14 +2632,16 @@ class CacheMiddlewareTest(SimpleTestCase): view = cache_page(10)(hello_world_view) request = self.factory.get("/view/") _ = view(request, "0") - cache_key = get_cache_key(request=request, key_prefix="", ignore_headers=True) + cache_key = get_cache_key( + request=request, key_prefix="", ignore_headers=True, 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=request, key_prefix="") + invalidate_view_cache(request=request, key_prefix="", cache=cache) cached_response = cache.get(cache_key) # Confirm key/value has been deleted from cache @@ -2650,14 +2653,45 @@ class CacheMiddlewareTest(SimpleTestCase): path = "/view/" request = self.factory.get(path) _ = view(request, "0") - cache_key = get_cache_key(request=request, key_prefix="", ignore_headers=True) + cache_key = get_cache_key( + request=request, key_prefix="", ignore_headers=True, 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=path, key_prefix="") + invalidate_view_cache(path=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")(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="", ignore_headers=False, 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=path, key_prefix="", cache=cache) cached_response = cache.get(cache_key) # Confirm key/value has been deleted from cache From b530fd299ccbb2cc46a1b6f1c12622d7cb6557c2 Mon Sep 17 00:00:00 2001 From: Ramiro Juan Nocelli Date: Tue, 14 Oct 2025 19:13:05 +0200 Subject: [PATCH 04/10] remove ignore_headers argument from get_cache_key --- django/utils/cache.py | 46 +++++++++++++++++-------------------------- tests/cache/tests.py | 40 +++++++++++++++++++++++++++---------- 2 files changed, 48 insertions(+), 38 deletions(-) diff --git a/django/utils/cache.py b/django/utils/cache.py index d5c8720e98..c7fcb7b733 100644 --- a/django/utils/cache.py +++ b/django/utils/cache.py @@ -376,17 +376,15 @@ def _generate_cache_header_key(key_prefix, request): return _i18n_cache_key_suffix(request, cache_key) -def get_cache_key( - request, key_prefix=None, method="GET", cache=None, ignore_headers=False -): +def get_cache_key(request, key_prefix=None, method="GET", cache=None): """ Return a cache key based on the request URL and query. It can be used in the request phase because it pulls the list of headers to take into account from the global URL registry and uses those to build a cache key to check against. - If there isn't a headerlist stored and `ignore_headers` argument is False, - return None, indicating that the page needs to be rebuilt. + If there is no headerlist stored, the page needs to be rebuilt, so this + function returns ``None``. """ if key_prefix is None: key_prefix = settings.CACHE_MIDDLEWARE_KEY_PREFIX @@ -394,8 +392,8 @@ def get_cache_key( if cache is None: cache = caches[settings.CACHE_MIDDLEWARE_ALIAS] headerlist = cache.get(cache_key) - if headerlist is not None or ignore_headers: - return _generate_cache_key(request, method, headerlist or [], key_prefix) + if headerlist is not None: + return _generate_cache_key(request, method, headerlist, key_prefix) else: return None @@ -448,40 +446,32 @@ def _to_tuple(s): return t[0].lower(), True -def invalidate_view_cache( - path=None, request=None, vary_headers=None, key_prefix=None, cache=None -): +def invalidate_view_cache(path=None, request=None, key_prefix=None, cache=None): """ - This function first creates a fake WSGIRequest to compute the cache key. - The key looks like: - views.decorators.cache.cache_page.key_prefix.GET.0fcb3cd9d5b34c8fe83f615913d8509b.c4ca4238a0b923820dcc509a6f75849b.en-us.UTC - The first hash corresponds to the full url (including query params), - the second to the header values + Delete a view cache key based on either a relative URL (``path``) + or a request object (``request``). - vary_headers should be a dict of every header used for this particular view - In local environment, we have two defined renderers (default of DRF), - thus DRF adds `Accept` to the Vary headers + 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. - either `path` or `request` arguments should be passed; - if both are passed `path` will be ignored + The ``key_prefix`` must match the value used in the ``cache_page`` + decorator for the corresponding view. - Note: If LocaleMiddleware is used, - we'll need to use the same language code as the one in the cached request + Either the ``path`` or ``request`` parameter must be provided. + If both are given, ``path`` takes precedence. """ if not request: assert path is not None, "either `path` or `request` arguments needed" factory = RequestFactory() request = factory.get(path) - if vary_headers: - request.META.update(vary_headers) - if cache is None: cache = caches[settings.CACHE_MIDDLEWARE_ALIAS] - cache_key = get_cache_key( - request, key_prefix=key_prefix, ignore_headers=True, cache=cache - ) + cache_key = get_cache_key(request, key_prefix=key_prefix, cache=cache) if cache_key is None: return 0 diff --git a/tests/cache/tests.py b/tests/cache/tests.py index b10940ff72..92968d7827 100644 --- a/tests/cache/tests.py +++ b/tests/cache/tests.py @@ -2632,9 +2632,7 @@ class CacheMiddlewareTest(SimpleTestCase): view = cache_page(10)(hello_world_view) request = self.factory.get("/view/") _ = view(request, "0") - cache_key = get_cache_key( - request=request, key_prefix="", ignore_headers=True, cache=cache - ) + cache_key = get_cache_key(request=request, key_prefix="", cache=cache) cached_response = cache.get(cache_key) # Verify request.content has been chached @@ -2653,9 +2651,7 @@ class CacheMiddlewareTest(SimpleTestCase): path = "/view/" request = self.factory.get(path) _ = view(request, "0") - cache_key = get_cache_key( - request=request, key_prefix="", ignore_headers=True, cache=cache - ) + cache_key = get_cache_key(request=request, key_prefix="", cache=cache) cached_response = cache.get(cache_key) # Verify request.content has been chached @@ -2673,7 +2669,7 @@ class CacheMiddlewareTest(SimpleTestCase): # Cache view and inject Vary headers to Response object view = cache_page(10, key_prefix="")( - vary_on_headers("Accept-Encoding")(hello_world_view) + vary_on_headers("Accept-Encoding", "Accept")(hello_world_view) ) path = "/view/" request = self.factory.get(path) @@ -2682,9 +2678,7 @@ class CacheMiddlewareTest(SimpleTestCase): # Check response headers self.assertTrue(response.has_header("Vary")) - cache_key = get_cache_key( - request=request, key_prefix="", ignore_headers=False, cache=cache - ) + cache_key = get_cache_key(request=request, key_prefix="", cache=cache) cached_response = cache.get(cache_key) # Verify request.content has been chached @@ -2697,6 +2691,32 @@ class CacheMiddlewareTest(SimpleTestCase): # 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_view_decorator(self): # decorate the same view with different cache decorators default_view = cache_page(3)(hello_world_view) From dc07fac1a745c4909f2aced65c7f5ed15f579c93 Mon Sep 17 00:00:00 2001 From: Ramiro Juan Nocelli Date: Tue, 14 Oct 2025 21:00:45 +0200 Subject: [PATCH 05/10] add documentation --- docs/ref/utils.txt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/ref/utils.txt b/docs/ref/utils.txt index 69af23ad83..f61593f81f 100644 --- a/docs/ref/utils.txt +++ b/docs/ref/utils.txt @@ -103,6 +103,23 @@ 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(path=None, request=None, key_prefix=None, cache=None): + + Delete a view cache key based on either a relative URL (``path``) + or a request object (``request``). + + 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. + + Either the ``path`` or ``request`` parameter must be provided. + If both are given, ``path`` takes precedence. + ``django.utils.dateparse`` ========================== From cbff6d1807e5b3f571d24fcbfe00f5e6f931046f Mon Sep 17 00:00:00 2001 From: Ramiro Nocelli Date: Thu, 16 Oct 2025 20:38:30 +0200 Subject: [PATCH 06/10] apply review comments --- django/utils/cache.py | 20 ++++++++------------ docs/ref/utils.txt | 10 +++------- 2 files changed, 11 insertions(+), 19 deletions(-) diff --git a/django/utils/cache.py b/django/utils/cache.py index c7fcb7b733..4773fa2b65 100644 --- a/django/utils/cache.py +++ b/django/utils/cache.py @@ -21,7 +21,7 @@ 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 @@ -383,8 +383,8 @@ def get_cache_key(request, key_prefix=None, method="GET", cache=None): account from the global URL registry and uses those to build a cache key to check against. - If there is no headerlist stored, the page needs to be rebuilt, so this - function returns ``None``. + If there isn't a headerlist stored, return None, indicating that the page + needs to be rebuilt. """ if key_prefix is None: key_prefix = settings.CACHE_MIDDLEWARE_KEY_PREFIX @@ -446,10 +446,10 @@ def _to_tuple(s): return t[0].lower(), True -def invalidate_view_cache(path=None, request=None, key_prefix=None, cache=None): +def invalidate_view_cache(request, key_prefix=None, cache=None): """ - Delete a view cache key based on either a relative URL (``path``) - or a request object (``request``). + 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, @@ -459,14 +459,10 @@ def invalidate_view_cache(path=None, request=None, key_prefix=None, cache=None): The ``key_prefix`` must match the value used in the ``cache_page`` decorator for the corresponding view. - - Either the ``path`` or ``request`` parameter must be provided. - If both are given, ``path`` takes precedence. """ - if not request: - assert path is not None, "either `path` or `request` arguments needed" + if not isinstance(request, HttpRequest): factory = RequestFactory() - request = factory.get(path) + request = factory.get(request) if cache is None: cache = caches[settings.CACHE_MIDDLEWARE_ALIAS] diff --git a/docs/ref/utils.txt b/docs/ref/utils.txt index f61593f81f..d16180afdb 100644 --- a/docs/ref/utils.txt +++ b/docs/ref/utils.txt @@ -103,10 +103,9 @@ 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(path=None, request=None, key_prefix=None, cache=None): - - Delete a view cache key based on either a relative URL (``path``) - or a request object (``request``). +.. function:: invalidate_view_cache(request=None, 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, @@ -117,9 +116,6 @@ need to distinguish caches by the ``Accept-language`` header. The ``key_prefix`` must match the value used in the ``cache_page`` decorator for the corresponding view. - Either the ``path`` or ``request`` parameter must be provided. - If both are given, ``path`` takes precedence. - ``django.utils.dateparse`` ========================== From 32e45ff914cc39421768dbcb9e4977655c9293e1 Mon Sep 17 00:00:00 2001 From: Ramiro Nocelli Date: Thu, 16 Oct 2025 20:38:42 +0200 Subject: [PATCH 07/10] add test to cover case invalidate_view_cache with async request --- tests/cache/tests.py | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/tests/cache/tests.py b/tests/cache/tests.py index 92968d7827..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, @@ -2536,6 +2537,7 @@ def csrf_view(request): ) class CacheMiddlewareTest(SimpleTestCase): factory = RequestFactory() + async_factory = AsyncRequestFactory() def setUp(self): self.default_cache = caches["default"] @@ -2639,7 +2641,7 @@ class CacheMiddlewareTest(SimpleTestCase): self.assertEqual(cached_response.content, b"Hello World 0") # Delete cache key/value - invalidate_view_cache(request=request, key_prefix="", cache=cache) + invalidate_view_cache(request, key_prefix="", cache=cache) cached_response = cache.get(cache_key) # Confirm key/value has been deleted from cache @@ -2658,7 +2660,7 @@ class CacheMiddlewareTest(SimpleTestCase): self.assertEqual(cached_response.content, b"Hello World 0") # Delete cache key/value - invalidate_view_cache(path=path, key_prefix="", cache=cache) + invalidate_view_cache(path, key_prefix="", cache=cache) cached_response = cache.get(cache_key) # Confirm key/value has been deleted from cache @@ -2685,7 +2687,7 @@ class CacheMiddlewareTest(SimpleTestCase): self.assertEqual(cached_response.content, b"Hello World 0") # Delete cache key/value - invalidate_view_cache(path=path, key_prefix="", cache=cache) + invalidate_view_cache(path, key_prefix="", cache=cache) cached_response = cache.get(cache_key) # Confirm key/value has been deleted from cache @@ -2717,6 +2719,24 @@ class CacheMiddlewareTest(SimpleTestCase): 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) From 81d76f9be263edbd1217e5a137298b899ada766f Mon Sep 17 00:00:00 2001 From: Ramiro Nocelli Date: Fri, 17 Oct 2025 10:31:01 +0200 Subject: [PATCH 08/10] ammend docs --- docs/ref/utils.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ref/utils.txt b/docs/ref/utils.txt index d16180afdb..a8ba330256 100644 --- a/docs/ref/utils.txt +++ b/docs/ref/utils.txt @@ -103,7 +103,7 @@ 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=None, key_prefix=None, cache=None): +.. 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``). From 93d3077b8305be3ceacb282741de59b8dfe156f7 Mon Sep 17 00:00:00 2001 From: Ramiro Nocelli Date: Sat, 25 Oct 2025 18:33:31 +0200 Subject: [PATCH 09/10] create decorator to use different key prefix per item --- django/views/decorators/cache.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/django/views/decorators/cache.py b/django/views/decorators/cache.py index aa1679baff..45ab8a189e 100644 --- a/django/views/decorators/cache.py +++ b/django/views/decorators/cache.py @@ -82,3 +82,30 @@ 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.""" + + 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 From fc1c08f7b8395722a84ca5070749a5d7a0cda207 Mon Sep 17 00:00:00 2001 From: Ramiro Nocelli Date: Sun, 26 Oct 2025 17:41:38 +0100 Subject: [PATCH 10/10] add invalidate_whole_prefix attribute to match all keys based on key_prefix --- django/utils/cache.py | 22 +++++++++++++++++++++- django/views/decorators/cache.py | 15 ++++++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/django/utils/cache.py b/django/utils/cache.py index 4773fa2b65..bb71e3a01d 100644 --- a/django/utils/cache.py +++ b/django/utils/cache.py @@ -446,7 +446,9 @@ def _to_tuple(s): return t[0].lower(), True -def invalidate_view_cache(request, key_prefix=None, cache=None): +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). @@ -457,6 +459,11 @@ def invalidate_view_cache(request, key_prefix=None, cache=None): 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. """ @@ -467,6 +474,19 @@ def invalidate_view_cache(request, key_prefix=None, cache=None): 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 diff --git a/django/views/decorators/cache.py b/django/views/decorators/cache.py index 45ab8a189e..a9796851f3 100644 --- a/django/views/decorators/cache.py +++ b/django/views/decorators/cache.py @@ -85,7 +85,20 @@ def never_cache(view_func): def cache_page_per_item(timeout, key_prefix=None): - """Decorator that applies cache_page with a dynamic key_prefix based on item id.""" + """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)