diff --git a/django/middleware/cache.py b/django/middleware/cache.py index 196b1995ffbf..789e41865d23 100644 --- a/django/middleware/cache.py +++ b/django/middleware/cache.py @@ -49,6 +49,7 @@ from django.core.cache import DEFAULT_CACHE_ALIAS, caches from django.utils.cache import ( get_cache_key, + get_key_prefix, get_max_age, has_vary_header, learn_cache_key, @@ -72,7 +73,7 @@ def __init__(self, get_response): super().__init__(get_response) self.cache_timeout = settings.CACHE_MIDDLEWARE_SECONDS self.page_timeout = None - self.key_prefix = settings.CACHE_MIDDLEWARE_KEY_PREFIX + self.key_prefix = None self.cache_alias = settings.CACHE_MIDDLEWARE_ALIAS @property @@ -118,8 +119,11 @@ def process_response(self, request, response): return response patch_response_headers(response, timeout) if timeout and response.status_code == 200: + key_prefix = get_key_prefix(request, self.key_prefix) + if key_prefix is None: + return response cache_key = learn_cache_key( - request, response, timeout, self.key_prefix, cache=self.cache + request, response, timeout, key_prefix, cache=self.cache ) if hasattr(response, "render") and callable(response.render): response.add_post_render_callback( @@ -141,7 +145,7 @@ class FetchFromCacheMiddleware(MiddlewareMixin): def __init__(self, get_response): super().__init__(get_response) - self.key_prefix = settings.CACHE_MIDDLEWARE_KEY_PREFIX + self.key_prefix = None self.cache_alias = settings.CACHE_MIDDLEWARE_ALIAS @property @@ -157,17 +161,20 @@ def process_request(self, request): request._cache_update_cache = False return None # Don't bother checking the cache. + key_prefix = get_key_prefix(request, self.key_prefix) + if key_prefix is None: + request._cache_update_cache = False + return None + # try and get the cached GET response - cache_key = get_cache_key(request, self.key_prefix, "GET", cache=self.cache) + cache_key = get_cache_key(request, key_prefix, "GET", cache=self.cache) if cache_key is None: request._cache_update_cache = True return None # No cache information available, need to rebuild. response = self.cache.get(cache_key) # if it wasn't found and we are looking for a HEAD, try looking just for that if response is None and request.method == "HEAD": - cache_key = get_cache_key( - request, self.key_prefix, "HEAD", cache=self.cache - ) + cache_key = get_cache_key(request, key_prefix, "HEAD", cache=self.cache) response = self.cache.get(cache_key) if response is None: diff --git a/django/utils/cache.py b/django/utils/cache.py index 3b014fbe5141..25ff9fa84f02 100644 --- a/django/utils/cache.py +++ b/django/utils/cache.py @@ -24,6 +24,7 @@ from django.http import HttpResponse, HttpResponseNotModified 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.module_loading import import_string from django.utils.regex_helper import _lazy_re_compile from django.utils.timezone import get_current_timezone_name from django.utils.translation import get_language @@ -384,18 +385,34 @@ def get_cache_key(request, key_prefix=None, method="GET", cache=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 - cache_key = _generate_cache_header_key(key_prefix, request) + _key_prefix = get_key_prefix(request, key_prefix) + + cache_key = _generate_cache_header_key(_key_prefix, request) 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) + return _generate_cache_key(request, method, headerlist, _key_prefix) else: return None +def get_key_prefix(request, key_prefix): + """ + Returns the KEY_PREFIX to use for the cache. The key prefix can be defined by a + constant, a callable, or a dotted path to a function that returns the key prefix. + """ + _key_prefix = key_prefix + if _key_prefix is None: + _key_prefix = settings.CACHE_MIDDLEWARE_KEY_PREFIX + if callable(_key_prefix): + return _key_prefix(request) + if isinstance(_key_prefix, str) and "." in _key_prefix: + key_prefix_callable = import_string(_key_prefix) + return key_prefix_callable(request) + return _key_prefix + + def learn_cache_key(request, response, cache_timeout=None, key_prefix=None, cache=None): """ Learn what headers to take into account for some request URL from the @@ -409,11 +426,10 @@ def learn_cache_key(request, response, cache_timeout=None, key_prefix=None, cach cache, this just 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. """ - if key_prefix is None: - key_prefix = settings.CACHE_MIDDLEWARE_KEY_PREFIX + _key_prefix = get_key_prefix(request, key_prefix) if cache_timeout is None: cache_timeout = settings.CACHE_MIDDLEWARE_SECONDS - cache_key = _generate_cache_header_key(key_prefix, request) + cache_key = _generate_cache_header_key(_key_prefix, request) if cache is None: cache = caches[settings.CACHE_MIDDLEWARE_ALIAS] if response.has_header("Vary"): @@ -429,12 +445,12 @@ def learn_cache_key(request, response, cache_timeout=None, key_prefix=None, cach headerlist.append("HTTP_" + header) headerlist.sort() cache.set(cache_key, headerlist, cache_timeout) - return _generate_cache_key(request, request.method, headerlist, key_prefix) + return _generate_cache_key(request, request.method, headerlist, _key_prefix) else: # if there is no Vary header, we still need a cache key # for the request.build_absolute_uri() cache.set(cache_key, [], cache_timeout) - return _generate_cache_key(request, request.method, [], key_prefix) + return _generate_cache_key(request, request.method, [], _key_prefix) def _to_tuple(s): diff --git a/docs/intro/tutorial01.txt b/docs/intro/tutorial01.txt index cb296129c00e..e2fb7e38fdae 100644 --- a/docs/intro/tutorial01.txt +++ b/docs/intro/tutorial01.txt @@ -64,18 +64,6 @@ work, see :ref:`troubleshooting-django-admin`. ``django`` (which will conflict with Django itself) or ``test`` (which conflicts with a built-in Python package). -.. admonition:: Where should this code live? - - If your background is in plain old PHP (with no use of modern frameworks), - you're probably used to putting code under the web server's document root - (in a place such as ``/var/www``). With Django, you don't do that. It's - not a good idea to put any of this Python code within your web server's - document root, because it risks the possibility that people may be able - to view your code over the web. That's not good for security. - - Put your code in some directory **outside** of the document root, such as - :file:`/home/mycode`. - Let's look at what :djadmin:`startproject` created: .. code-block:: text @@ -163,30 +151,7 @@ Now that the server's running, visit http://127.0.0.1:8000/ with your web browser. You'll see a "Congratulations!" page, with a rocket taking off. It worked! -.. admonition:: Changing the port - - By default, the :djadmin:`runserver` command starts the development server - on the internal IP at port 8000. - - If you want to change the server's port, pass - it as a command-line argument. For instance, this command starts the server - on port 8080: - - .. console:: - - $ python manage.py runserver 8080 - - If you want to change the server's IP, pass it along with the port. For - example, to listen on all available public IPs (which is useful if you are - running Vagrant or want to show off your work on other computers on the - network), use: - - .. console:: - - $ python manage.py runserver 0.0.0.0:8000 - - Full docs for the development server can be found in the - :djadmin:`runserver` reference. +(To serve the site on a different port, see the :djadmin:`runserver` reference.) .. admonition:: Automatic reloading of :djadmin:`runserver` diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt index c1691770da82..4be4d5e4702d 100644 --- a/docs/ref/settings.txt +++ b/docs/ref/settings.txt @@ -260,9 +260,11 @@ The cache connection to use for the :ref:`cache middleware Default: ``''`` (Empty string) -A string which will be prefixed to the cache keys generated by the :ref:`cache -middleware `. This prefix is combined with the -:setting:`KEY_PREFIX ` setting; it does not replace it. +A string, a callable or a dotted path to a callable that returns a string - which +will be prefixed to the cache keys generated by the +:ref:`cache middleware `. +This prefix is combined with the :setting:`KEY_PREFIX ` setting; +it does not replace it. See :doc:`/topics/cache`. diff --git a/docs/releases/5.1.txt b/docs/releases/5.1.txt index 49741ca81cf1..349244feeb9f 100644 --- a/docs/releases/5.1.txt +++ b/docs/releases/5.1.txt @@ -112,7 +112,7 @@ Minor features * The default iteration count for the PBKDF2 password hasher is increased from 720,000 to 870,000. -* The default ``parallelism`` of the ``ScryptPasswordHasher`` is +* The default ``parallelism`` of the ``ScryptPasswordHasher`` is increased from 1 to 5, to follow OWASP recommendations. * :class:`~django.contrib.auth.forms.BaseUserCreationForm` and @@ -194,6 +194,15 @@ Minor features session engines now provide async API. The new asynchronous methods all have ``a`` prefixed names, e.g. ``aget()``, ``akeys()``, or ``acycle_key()``. +:mod:`django.contrib.syndication` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Cache +~~~~~ + +* :func:`~django.views.decorators.cache.cache_page` decorator now accepts a + callable for ``key_prefix``. + Database backends ~~~~~~~~~~~~~~~~~ diff --git a/docs/topics/cache.txt b/docs/topics/cache.txt index 1fe9d335fb99..ad62da518f67 100644 --- a/docs/topics/cache.txt +++ b/docs/topics/cache.txt @@ -705,6 +705,32 @@ setting for the middleware. It can be used like this:: @cache_page(60 * 15, key_prefix="site1") def my_view(request): ... +The ``key_prefix`` may be either a string, a callable or a dotted path to a callable +which receives the request object and returns the key prefix. This allows you to +generate a key prefix dynamically, for example to enable per-user caching:: + + def per_user_cache_prefixer(request): + if request.user.is_authenticated: + return f"user_{request.user.id}" + else: + return "anon" + + + @cache_page(60 * 60, key_prefix=per_user_cache_prefixer) + def my_view(request): ... + +The callable may also return ``None`` to skip caching:: + + def selective_cache_prefixer(request): + # skip caching if URL contains "nocache" query param + if "nocache" in request.GET: + return None + return "prefix1" + + + @cache_page(60 * 60 * 24, key_prefix=selective_cache_prefixer) + def my_view(request): ... + The ``key_prefix`` and ``cache`` arguments may be specified together. The ``key_prefix`` argument and the :setting:`KEY_PREFIX ` specified under :setting:`CACHES` will be concatenated. @@ -713,6 +739,10 @@ Additionally, ``cache_page`` automatically sets ``Cache-Control`` and ``Expires`` headers in the response which affect :ref:`downstream caches `. +.. versionchanged:: 5.1 + + Support for callable ``key_prefix`` was added. + Specifying per-view cache in the URLconf ---------------------------------------- diff --git a/tests/cache/key_prefix_generator.py b/tests/cache/key_prefix_generator.py new file mode 100644 index 000000000000..3e7b7492e120 --- /dev/null +++ b/tests/cache/key_prefix_generator.py @@ -0,0 +1,5 @@ +DOTTED_PATH_KEY_PREFIX = "callable_prefix" + + +def cache_prefixer(request): + return DOTTED_PATH_KEY_PREFIX diff --git a/tests/cache/tests.py b/tests/cache/tests.py index 978efdd9d38f..d973b61687e2 100644 --- a/tests/cache/tests.py +++ b/tests/cache/tests.py @@ -56,12 +56,14 @@ from django.utils import timezone, translation from django.utils.cache import ( get_cache_key, + get_key_prefix, learn_cache_key, patch_cache_control, patch_vary_headers, ) from django.views.decorators.cache import cache_control, cache_page +from .key_prefix_generator import DOTTED_PATH_KEY_PREFIX, cache_prefixer from .models import Poll, expensive_calculation @@ -2521,7 +2523,7 @@ def test_constructor(self): # Now test object attributes against values defined in setUp above self.assertEqual(middleware.cache_timeout, 30) - self.assertEqual(middleware.key_prefix, "middlewareprefix") + self.assertEqual(middleware.key_prefix, None) self.assertEqual(middleware.cache_alias, "other") self.assertEqual(middleware.cache, self.other_cache) @@ -2553,13 +2555,13 @@ def test_update_cache_middleware_constructor(self): middleware = UpdateCacheMiddleware(empty_response) self.assertEqual(middleware.cache_timeout, 30) self.assertIsNone(middleware.page_timeout) - self.assertEqual(middleware.key_prefix, "middlewareprefix") + self.assertEqual(middleware.key_prefix, None) self.assertEqual(middleware.cache_alias, "other") self.assertEqual(middleware.cache, self.other_cache) def test_fetch_cache_middleware_constructor(self): middleware = FetchFromCacheMiddleware(empty_response) - self.assertEqual(middleware.key_prefix, "middlewareprefix") + self.assertEqual(middleware.key_prefix, None) self.assertEqual(middleware.cache_alias, "other") self.assertEqual(middleware.cache, self.other_cache) @@ -2674,6 +2676,131 @@ def test_view_decorator(self): response = other_with_prefix_view(request, "16") self.assertEqual(response.content, b"Hello World 16") + def test_dynamic_cache_key(self): + """The key prefix is different for anonymous and authenticated users""" + + cache_alias = "default" + + anon_key_prefix = "anon" + auth_key_prefix = "auth" + + class AnonUser: + @property + def is_authenticated(self): + return False + + def __str__(self): + return "Anon" + + class User: + @property + def is_authenticated(self): + return True + + def __str__(self): + return "User" + + def _cache_prefixer(request): + return auth_key_prefix if request.user.is_authenticated else anon_key_prefix + + @cache_page(60, cache=cache_alias, key_prefix=_cache_prefixer) + def _view(request): + return HttpResponse(f"Hello, {request.user}!") + + self.assertEqual(len(self.default_cache._cache), 0) + + # anonymous hit + request = self.factory.get("/") + request.user = AnonUser() + response = _view(request) + self.assertEqual(response.content, b"Hello, Anon!") + anon_cache_key = get_cache_key( + request, anon_key_prefix, "GET", cache=self.default_cache + ) + self.assertIn(anon_cache_key, self.default_cache) + + # authenticated hit + request = self.factory.get("/") + request.user = User() + response = _view(request) + self.assertEqual(response.content, b"Hello, User!") + auth_cache_key = get_cache_key( + request, auth_key_prefix, "GET", cache=self.default_cache + ) + self.assertIn(auth_cache_key, self.default_cache) + + def test_dynamic_cache_key_defined_by_dot_path(self): + """ + Test creating a key prefix by a callable defined by a dotted path in + CACHE_MIDDLEWARE_KEY_PREFIX. + """ + + cache_alias = "default" + key_prefix = DOTTED_PATH_KEY_PREFIX + + @cache_page( + 60, + cache=cache_alias, + key_prefix="cache.key_prefix_generator.cache_prefixer", + ) + def _view(request): + return HttpResponse("Hello World!") + + self.assertEqual(len(self.default_cache._cache), 0) + + request = self.factory.get("/") + response = _view(request) + self.assertEqual(response.content, b"Hello World!") + cache_key = get_cache_key(request, key_prefix, "GET", cache=self.default_cache) + self.assertIn(cache_key, self.default_cache) + + def test_conditional_cache_key(self): + """The key prefix callback bypasses the cache on query param""" + + cache_alias = "default" + call_count = 0 + key_prefix = "dynamic_prefix" + nocache_flag = "nocache" + + def _cache_prefixer(request): + if nocache_flag in request.GET: # skip caching + return None + return key_prefix + + @cache_page(60, cache=cache_alias, key_prefix=_cache_prefixer) + def _view(request): + nonlocal call_count + call_count += 1 + return HttpResponse(str(call_count)) + + self.assertEqual(call_count, 0) + + # 1st uncached hit + response = _view(self.factory.get(f"/?{nocache_flag}")) + self.assertEqual(response.content, b"1") + self.assertEqual(call_count, 1) + + # 2nd uncached hit + response = _view(self.factory.get(f"/?{nocache_flag}")) + self.assertEqual(response.content, b"2") + self.assertEqual(call_count, 2) + + # 1st cached hit + request = self.factory.get("/") + response = _view(request) + self.assertEqual(response.content, b"3") + self.assertEqual(call_count, 3) + cache_key = get_cache_key(request, key_prefix, "GET", cache=self.default_cache) + self.assertIn(cache_key, self.default_cache) + + # 2nd cached hit + request = self.factory.get("/") + response = _view(request) + self.assertEqual(response.content, b"3") + self.assertEqual(call_count, 3) + cache_key = get_cache_key(request, key_prefix, "GET", cache=self.default_cache) + self.assertIn(cache_key, self.default_cache) + def test_cache_page_timeout(self): # Page timeout takes precedence over the "max-age" section of the # "Cache-Control". @@ -2984,3 +3111,46 @@ def test_all(self): # .all() initializes all caches. self.assertEqual(len(test_caches.all(initialized_only=True)), 2) self.assertEqual(test_caches.all(), test_caches.all(initialized_only=True)) + + +class GetKeyPrefixTest(SimpleTestCase): + """Tests the key prefix creation.""" + + factory = RequestFactory() + + def tearDown(self): + cache.clear() + + def test_prefix_by_string(self): + key_prefix = "prefix" + request = self.factory.get("/") + self.assertEqual(get_key_prefix(request, key_prefix), key_prefix) + + def test_prefix_by_string_from_settings(self): + key_prefix = "settings_prefix" + with override_settings(CACHE_MIDDLEWARE_KEY_PREFIX=key_prefix): + request = self.factory.get("/") + self.assertEqual(get_key_prefix(request, None), key_prefix) + + def test_prefix_by_given_callable(self): + key_prefix = "callable_prefix" + + def _cache_prefixer(request): + return key_prefix + + request = self.factory.get("/") + self.assertEqual(get_key_prefix(request, _cache_prefixer), key_prefix) + + def test_prefix_by_dotted_path_to_callable(self): + key_prefix = DOTTED_PATH_KEY_PREFIX + request = self.factory.get("/") + self.assertEqual( + get_key_prefix(request, "cache.key_prefix_generator.cache_prefixer"), + key_prefix, + ) + + def test_prefix_by_callable_from_settings(self): + key_prefix = DOTTED_PATH_KEY_PREFIX + request = self.factory.get("/") + with override_settings(CACHE_MIDDLEWARE_KEY_PREFIX=cache_prefixer): + self.assertEqual(get_key_prefix(request, None), key_prefix)