This commit is contained in:
Ramiro Nocelli 2025-11-17 16:30:39 +02:00 committed by GitHub
commit 130c1b73d8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 215 additions and 1 deletions

View file

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

View file

@ -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_<pk>'
allowing cache keys to be unique per item and identifiable.
If key_prefix is not provided it will default to 'my_view_<pk>' 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

View file

@ -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``
==========================

112
tests/cache/tests.py vendored
View file

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