diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 96488f0..082358f 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -71,11 +71,10 @@ stages: testResultsFiles: '**/test-*.xml' testRunTitle: 'Publish test results for Python $(python.version)' - - task: PublishCodeCoverageResults@1 + - task: PublishCodeCoverageResults@2 displayName: 'Publish code coverage report' condition: succeededOrFailed() inputs: - codeCoverageTool: Cobertura summaryFileLocation: '$(System.DefaultWorkingDirectory)/coverage.xml' @@ -93,7 +92,7 @@ stages: - task: UsePythonVersion@0 displayName: 'Use Python version' inputs: - versionSpec: '3.11' + versionSpec: '3.12' architecture: 'x64' - script: python -m pip install -r requirements-dev.txt @@ -102,14 +101,11 @@ stages: - script: codespell ./docs/ ./wagtailcache/ displayName: 'CR-QC: Spell check' - - script: flake8 . - displayName: 'CR-QC: Static analysis (flake8)' + - script: ruff check . + displayName: 'CR-QC: Static analysis (ruff)' - - script: isort --check . - displayName: 'CR-QC: Format check (isort)' - - - script: black --check . - displayName: 'CR-QC: Format check (black)' + - script: ruff format --check . + displayName: 'CR-QC: Format check' - script: mypy ./wagtailcache/ displayName: 'CR-QC: Type check (mypy)' @@ -144,7 +140,7 @@ stages: - task: UsePythonVersion@0 displayName: 'Use Python version' inputs: - versionSpec: '3.11' + versionSpec: '3.12' architecture: 'x64' - script: python -m pip install -r requirements-dev.txt diff --git a/ci/compare-codecov.ps1 b/ci/compare-codecov.ps1 index 690ad6a..7a138f7 100644 --- a/ci/compare-codecov.ps1 +++ b/ci/compare-codecov.ps1 @@ -68,7 +68,8 @@ foreach ($cov in $mainCoverageJson.coverageData.coverageStats) { # Get current code coverage from coverage.xml file. -$coveragePath = Get-ChildItem -Recurse -Filter "coverage.xml" $wd +# Use the first one, if there are multiple i.e. from multiple runs. +$coveragePath = (Get-ChildItem -Recurse -Filter "coverage.xml" $wd)[0] if (Test-Path -Path $coveragePath) { [xml]$BranchXML = Get-Content $coveragePath } @@ -84,7 +85,7 @@ $branchlinerate = [math]::Round([decimal]$BranchXML.coverage.'line-rate' * 100, Write-Output "" -Write-Output "Dev branch coverage rate: $mainlinerate%" +Write-Output "Main branch coverage rate: $mainlinerate%" Write-Output "This branch coverage rate: $branchlinerate%" if ($mainlinerate -eq 0) { diff --git a/docs/contributing.rst b/docs/contributing.rst index 15b36e4..8f005ca 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -58,4 +58,4 @@ the ``dist/`` directory. .. code-block:: console - $ python setup.py sdist bdist_wheel + $ python -m build diff --git a/docs/getting_started/cache_control.rst b/docs/getting_started/cache_control.rst new file mode 100644 index 0000000..900b840 --- /dev/null +++ b/docs/getting_started/cache_control.rst @@ -0,0 +1,185 @@ +Advanced Cache Control +====================== + + +Page-specific cache control +--------------------------- + +The ``WagtailCacheMixin`` also gives you the option to add a +custom Cache-Control header via ``cache_control``, which can be a dynamic +function or a string: + +.. code-block:: python + + from wagtailcache.cache import WagtailCacheMixin + + class MyPage(WagtailCacheMixin, Page): + + cache_control = 'no-cache' + + ... + + +Setting this to contain ``no-cache`` or ``private`` will tell wagtail-cache +**not** to cache this page. You could also set it to a custom value such as +"public, max-age=3600". It can also be a function: + +.. code-block:: python + + from wagtailcache.cache import WagtailCacheMixin + + class MyPage(WagtailCacheMixin, Page): + + def cache_control(self): + return 'no-cache' + + ... + + +Not caching views or URLs +------------------------- + +Across the entire Django site, Wagtail Cache will never cache a response that has a +``Cache-Control`` header containing ``no-cache`` or ``private``. Adding this +header to any response will cause it to be skipped. + +To explicitly not cache certain views or URL patterns, you could also wrap them +with the ``nocache_page`` decorator, which adds the ``Cache-Control: no-cache`` +header to all responses of that view or URL pattern. To use with a view: + +.. code-block:: python + + from wagtailcache.cache import nocache_page + + @nocache_page + def myview(request): + ... + +Or on a URL pattern: + +.. code-block:: python + + from wagtailcache.cache import nocache_page + + ... + + url(r'^url/pattern/$', nocache_page(viewname), name='viewname'), + + ... + +When using the Wagtail Cache middleware, the middleware will detect CSRF tokens and will only cache +those responses on a per-cookie basis. So Wagtail Cache should work well with CSRF tokens 🙂. +But if you still experience issues with CSRF tokens, use the mixin, the ``nocache_page`` decorator, +or set the ``Cache-Control`` header to ``no-cache`` on the response to guarantee that it will +never be cached. If you are using the ``cache_page`` decorator instead of the middleware, you +**must** use the mixin or set the ``Cache-Control`` header on responses with CSRF tokens to avoid +getting 403 forbidden errors. + + +Using a separate cache backend +------------------------------ + +For complex sites, it may be desirable to use a separate cache backend only for +the page cache, so that purging the page cache will not affect other caches: + +.. code-block:: python + + WAGTAIL_CACHE_BACKEND = 'pagecache' + + CACHES = { + 'default': { + ... + }, + 'pagecache': { + ... + } + } + + +Only cache specific views +------------------------- + +The wagtail-cache middleware will attempt to cache ALL responses that appear to be cacheable +(meaning the response does not contain a 'no-cache'/'private' Cache-Control header, the request method +is GET or HEAD, the response status code is 200, 301, 302, 404, the response did not set a cookie, +the page is not in preview mode, a user is not logged in, and many other requirements). + +To only cache specific views, remove the middleware and use the ``cache_page`` decorator on views or URLs. + +Alternatively, to continue using the middleware but explicitly not cache certain views or URLs, wrap those +views or URLs with the ``nocache_page`` decorator. + +Note that when using the ``cache_page`` decorator, it is not possible to cache Wagtail page 404s or redirects. Only the +middleware is able to cache those responses. + +Caching wagtail pages only +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Most likely you will want this on all of your wagtail pages, so you will have to +replace the inclusion of ``wagtail_urls`` in your project's ``urls.py``. You +will need to change from this: + +.. code-block:: python + + from django.conf.urls import url + + url(r'', include(wagtail_urls)), + +To this: + +.. code-block:: python + + from django.conf.urls import url + + from django.contrib.auth import views as auth_views + from wagtail.urls import serve_pattern, WAGTAIL_FRONTEND_LOGIN_TEMPLATE + from wagtail import views as wagtail_views + from wagtailcache.cache import cache_page + + # Copied from wagtail.urls: + url(r'^_util/authenticate_with_password/(\d+)/(\d+)/$', wagtail_views.authenticate_with_password, + name='wagtailcore_authenticate_with_password'), + url(r'^_util/login/$', auth_views.LoginView.as_view(template_name=WAGTAIL_FRONTEND_LOGIN_TEMPLATE), + name='wagtailcore_login'), + + # Wrap the serve function with wagtail-cache + url(serve_pattern, cache_page(wagtail_views.serve), name='wagtail_serve'), + +Caching specific wagtail page models only +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You can also use the decorator on specific wagtail pages. It is helpful in +Wagtail sites where the requirement is not to cache all pages: + +.. code-block:: python + + from django.utils.decorators import method_decorator + from wagtailcache.cache import cache_page, WagtailCacheMixin + + @method_decorator(cache_page, name='serve') + class MyPage(WagtailCacheMixin, Page): + ... + +Caching views +~~~~~~~~~~~~~ + +You can also use the decorator on views: + +.. code-block:: python + + from wagtailcache.cache import cache_page + + @cache_page + def myview(request): + ... + +To use it on class-based views: + +.. code-block:: python + + from django.utils.decorators import method_decorator + from wagtailcache.cache import cache_page + + @method_decorator(cache_page, name='dispatch') + class MyView(TemplateView): + ... diff --git a/docs/getting_started/index.rst b/docs/getting_started/index.rst index b8a4ea6..2ee6bd6 100644 --- a/docs/getting_started/index.rst +++ b/docs/getting_started/index.rst @@ -6,6 +6,7 @@ Getting Started install usage + cache_control hooks django_settings supported_backends diff --git a/docs/getting_started/install.rst b/docs/getting_started/install.rst index 77b80b6..c0bc77b 100644 --- a/docs/getting_started/install.rst +++ b/docs/getting_started/install.rst @@ -46,14 +46,20 @@ which is suitable for use on any web server: .. code-block:: python CACHES = { - 'default': { - 'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache', - 'LOCATION': os.path.join(BASE_DIR, 'cache'), - 'KEY_PREFIX': 'wagtailcache', - 'TIMEOUT': 3600, # one hour (in seconds) + "default": { + "BACKEND": "django.core.cache.backends.filebased.FileBasedCache", + "LOCATION": BASE_DIR / "cache", + "KEY_PREFIX": "wagtailcache", + "TIMEOUT": 4 * 60 * 60, # 4 hours (in seconds) } } +.. note:: + + ``TIMEOUT`` is used both for the timeout in the cache backend, and for the + Cache-Control max-age sent to the browser. Therefore it must be a positive + integer, and cannot be ``None``. + 3. Instruct pages how to cache ------------------------------ @@ -77,177 +83,6 @@ Add the mixin **to the beginning** of the class inheritance: Now ``MyPage`` will not cache if a particular instance is set to use password or -login privacy. The ``WagtailCacheMixin`` also gives you the option to add a -custom Cache-Control header via ``cache_control``, which can be a dynamic -function or a string: - -.. code-block:: python - - from wagtailcache.cache import WagtailCacheMixin - - class MyPage(WagtailCacheMixin, Page): - - cache_control = 'no-cache' - - ... - - -Setting this to contain ``no-cache`` or ``private`` will tell wagtail-cache -**not** to cache this page. You could also set it to a custom value such as -"public, max-age=3600". It can also be a function: - -.. code-block:: python - - from wagtailcache.cache import WagtailCacheMixin - - class MyPage(WagtailCacheMixin, Page): - - def cache_control(self): - return 'no-cache' - - ... - -Regardless of the mixin, Wagtail Cache will never cache a response that has a -``Cache-Control`` header containing ``no-cache`` or ``private``. Adding this -header to any response will cause it to be skipped. - -To explicitly not cache certain views or URL patterns, you could also wrap them -with the ``nocache_page`` decorator, which adds the ``Cache-Control: no-cache`` -header to all responses of that view or URL pattern. To use with a view: - -.. code-block:: python - - from wagtailcache.cache import nocache_page - - @nocache_page - def myview(request): - ... - -Or on a URL pattern: - -.. code-block:: python - - from wagtailcache.cache import nocache_page - - ... - - url(r'^url/pattern/$', nocache_page(viewname), name='viewname'), - - ... - -When using the Wagtail Cache middleware, the middleware will detect CSRF tokens and will only cache -those responses on a per-cookie basis. So Wagtail Cache should work well with CSRF tokens 🙂. -But if you still experience issues with CSRF tokens, use the mixin, the ``nocache_page`` decorator, -or set the ``Cache-Control`` header to ``no-cache`` on the response to guarantee that it will -never be cached. If you are using the ``cache_page`` decorator instead of the middleware, you -**must** use the mixin or set the ``Cache-Control`` header on responses with CSRF tokens to avoid -getting 403 forbidden errors. - - -Using a separate cache backend ------------------------------- - -For complex sites, it may be desirable to use a separate cache backend only for -the page cache, so that purging the page cache will not affect other caches: - -.. code-block:: python - - WAGTAIL_CACHE_BACKEND = 'pagecache' +login privacy. - CACHES = { - 'default': { - ... - }, - 'pagecache': { - ... - } - } - - -Only cache specific views -------------------------- - -The wagtail-cache middleware will attempt to cache ALL responses that appear to be cacheable -(meaning the response does not contain a 'no-cache'/'private' Cache-Control header, the request method -is GET or HEAD, the response status code is 200, 301, 302, 404, the response did not set a cookie, -the page is not in preview mode, a user is not logged in, and many other requirements). - -To only cache specific views, remove the middleware and use the ``cache_page`` decorator on views or URLs. - -Alternatively, to continue using the middleware but explicitly not cache certain views or URLs, wrap those -views or URLs with the ``nocache_page`` decorator. - -Note that when using the ``cache_page`` decorator, it is not possible to cache Wagtail page 404s or redirects. Only the -middleware is able to cache those responses. - -Caching wagtail pages only -~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Most likely you will want this on all of your wagtail pages, so you will have to -replace the inclusion of ``wagtail_urls`` in your project's ``urls.py``. You -will need to change from this: - -.. code-block:: python - - from django.conf.urls import url - - url(r'', include(wagtail_urls)), - -To this: - -.. code-block:: python - - from django.conf.urls import url - - from django.contrib.auth import views as auth_views - from wagtail.urls import serve_pattern, WAGTAIL_FRONTEND_LOGIN_TEMPLATE - from wagtail import views as wagtail_views - from wagtailcache.cache import cache_page - - # Copied from wagtail.urls: - url(r'^_util/authenticate_with_password/(\d+)/(\d+)/$', wagtail_views.authenticate_with_password, - name='wagtailcore_authenticate_with_password'), - url(r'^_util/login/$', auth_views.LoginView.as_view(template_name=WAGTAIL_FRONTEND_LOGIN_TEMPLATE), - name='wagtailcore_login'), - - # Wrap the serve function with wagtail-cache - url(serve_pattern, cache_page(wagtail_views.serve), name='wagtail_serve'), - -Caching specific wagtail page models only -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -You can also use the decorator on specific wagtail pages. It is helpful in -Wagtail sites where the requirement is not to cache all pages: - -.. code-block:: python - - from django.utils.decorators import method_decorator - from wagtailcache.cache import cache_page, WagtailCacheMixin - - @method_decorator(cache_page, name='serve') - class MyPage(WagtailCacheMixin, Page): - ... - -Caching views -~~~~~~~~~~~~~ - -You can also use the decorator on views: - -.. code-block:: python - - from wagtailcache.cache import cache_page - - @cache_page - def myview(request): - ... - -To use it on class-based views: - -.. code-block:: python - - from django.utils.decorators import method_decorator - from wagtailcache.cache import cache_page - - @method_decorator(cache_page, name='dispatch') - class MyView(TemplateView): - ... +At this point, your entire website will now be cached efficiently. However, if you need more fine-grained control, see :doc:`cache_control`. diff --git a/docs/releases.rst b/docs/releases.rst index ba2e66f..3f84eb1 100644 --- a/docs/releases.rst +++ b/docs/releases.rst @@ -7,6 +7,24 @@ Release Notes * Support Wagtail 6 and Django 5.1 +2.5.0 +===== + +* Bug fix: gracefully handle backend cache errors by returning the un-cached page and adding an ``X-Wagtail-Cache: err`` header. For example, if the cache backend/server is down, if the cache runs out of memory, etc. These errors are also logged using the ``wagtail-cache`` Python logger. + +* Bug fix: prevent duplicate entries in the keyring (used to show a list of cached pages). + +* Docs: clarify behavior of cache backend ``TIMEOUT`` setting. Move advanced docs from install page to a separate page to keep it simple for beginners. + +* Switch from black, flake8, and isort to ruff. Switch from ``setup.py`` to ``pyproject.toml``. + + +2.4.0 +===== + +* Support Wagtail 6 and Django 5.1 + + 2.3.0 ===== diff --git a/pyproject.toml b/pyproject.toml index e4e2566..6b9f562 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,25 +1,79 @@ -[tool.black] +# -- PACKAGE INFO --------------------- + +[build-system] +requires = ["setuptools >= 65.0"] +build-backend = "setuptools.build_meta" + +[project] +authors = [ + {name = "CodeRed LLC", email = "info@coderedcorp.com"} +] +classifiers = [ + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Framework :: Django", + "Framework :: Wagtail", + "Framework :: Wagtail :: 3", + "Framework :: Wagtail :: 4", + "Framework :: Wagtail :: 5", + "Framework :: Wagtail :: 6", +] +dependencies = [ + "wagtail>=3.0,<7", +] +description = "A simple page cache for Wagtail based on the Django cache middleware." +dynamic = ["version"] +license = {file = "LICENSE"} +name = "wagtail-cache" +readme = "README.md" +requires-python = ">=3.8" + +[project.urls] +Source = "https://github.com/coderedcorp/wagtail-cache" + +[tool.setuptools] +packages = ["wagtailcache"] + +[tool.setuptools.dynamic] +version = {attr = "wagtailcache.__version__"} + + +# -- TOOLS ---------------------------- + +[tool.codespell] +ignore-words-list = ["doubleclick"] + +[tool.django-stubs] +django_settings_module = "testproject.settings" + +[tool.mypy] +ignore_missing_imports = true +check_untyped_defs = true +exclude = [ + '^\..*', + 'migrations', + 'node_modules', + 'venv', +] + +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "testproject.settings" +junit_family = "xunit2" +addopts = "--cov wagtailcache --cov-report html --cov-report xml --junitxml junit/test-results.xml" +python_files = "tests.py test_*.py" + +[tool.ruff] +extend-exclude = ["build", "migrations"] line-length = 80 -target-version = ['py38', 'py39', 'py310', 'py311'] -# Regular expression of files to exclude. -exclude = ''' -/( - \.venv - | build - | migrations -)/ -''' - -[tool.isort] -force_alphabetical_sort_within_sections = true -force_single_line = true -lines_after_imports = 2 -lines_before_imports = 0 -lines_between_sections = 1 -lines_between_types = 0 -line_length = 80 -profile = "black" -skip_gitignore = true -extend_skip = ["migrations"] -# Specific to this project -known_first_party = ["home", "wagtailcache"] + +[tool.ruff.lint] +extend-select = ["I"] + +[tool.ruff.lint.isort] +case-sensitive = false +force-single-line = true +lines-after-imports = 2 diff --git a/requirements-dev.txt b/requirements-dev.txt index 4920eb5..8e490e5 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,13 +1,12 @@ -e ./ -black +build codespell -flake8 -isort mypy pytest pytest-cov pytest-django +ruff +setuptools>=65.0 sphinx sphinx-wagtail-theme twine -wheel diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 92c4eee..0000000 --- a/setup.cfg +++ /dev/null @@ -1,16 +0,0 @@ -[codespell] -ignore-words-list = doubleclick - -[flake8] -max-line-length = 100 -exclude = .*,migrations - -[mypy] -ignore_missing_imports = True -check_untyped_defs = True - -[tool:pytest] -DJANGO_SETTINGS_MODULE = testproject.settings -junit_family = xunit2 -addopts = --cov wagtailcache --cov-report html --cov-report xml --junitxml junit/test-results.xml -python_files = tests.py test_*.py diff --git a/setup.py b/setup.py deleted file mode 100644 index 7b2ff01..0000000 --- a/setup.py +++ /dev/null @@ -1,36 +0,0 @@ -from setuptools import find_packages -from setuptools import setup - -from wagtailcache import __version__ - - -with open("README.md", encoding="utf8") as readme_file: - readme = readme_file.read() - -setup( - name="wagtail-cache", - version=__version__, - author="CodeRed LLC", - author_email="info@coderedcorp.com", - url="https://github.com/coderedcorp/wagtail-cache", - description="A simple page cache for Wagtail based on the Django cache middleware.", - long_description=readme, - long_description_content_type="text/markdown", - license="BSD license", - include_package_data=True, - packages=find_packages(), - install_requires=["wagtail>=3.0,<7"], - classifiers=[ - "Intended Audience :: Developers", - "License :: OSI Approved :: BSD License", - "Natural Language :: English", - "Operating System :: OS Independent", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3 :: Only", - "Framework :: Django", - "Framework :: Wagtail", - "Framework :: Wagtail :: 3", - "Framework :: Wagtail :: 4", - "Framework :: Wagtail :: 5", - ], -) diff --git a/testproject/backends.py b/testproject/backends.py new file mode 100644 index 0000000..b20ede1 --- /dev/null +++ b/testproject/backends.py @@ -0,0 +1,35 @@ +from django.core.cache.backends.base import DEFAULT_TIMEOUT +from django.core.cache.backends.locmem import LocMemCache + + +class ErroneousGetCache(LocMemCache): + """ + Cache backend which throws an error when fetching from cache. + """ + + def get(self, key, default=None, version=None): + raise Exception("Error in cache backend.") + + def has_key(self, key, version=None): + raise Exception("Error in cache backend.") + + +class ErroneousSetCache(LocMemCache): + """ + Cache backend which throws an error when setting/modifying cache. + """ + + def add(self, key, value, timeout=DEFAULT_TIMEOUT, version=None): + raise Exception("Error in cache backend.") + + def set(self, key, value, timeout=DEFAULT_TIMEOUT, version=None): + raise Exception("Error in cache backend.") + + def touch(self, key, timeout=DEFAULT_TIMEOUT, version=None): + raise Exception("Error in cache backend.") + + def delete(self, key, version=None): + raise Exception("Error in cache backend.") + + def clear(self): + raise Exception("Error in cache backend.") diff --git a/testproject/home/tests.py b/testproject/home/tests.py index 650a505..88fd078 100644 --- a/testproject/home/tests.py +++ b/testproject/home/tests.py @@ -1,9 +1,11 @@ +import time + from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.core.cache import caches +from django.test import TestCase from django.test import modify_settings from django.test import override_settings -from django.test import TestCase from django.urls import reverse from wagtail import hooks from wagtail.models import PageViewRestriction @@ -15,8 +17,8 @@ from home.models import CsrfPage from home.models import WagtailPage from wagtailcache.cache import CacheControl -from wagtailcache.cache import clear_cache from wagtailcache.cache import Status +from wagtailcache.cache import clear_cache from wagtailcache.settings import wagtailcache_settings @@ -134,6 +136,7 @@ def tearDown(self): pass # --- UTILITIES ------------------------------------------------------------ + def head_hit(self, url: str): """ HEAD a page and test that it was served from the cache. @@ -198,6 +201,28 @@ def get_skip(self, url: str): ) return response + def get_error(self, url: str): + """ + Gets a page and tests that an error in the cache backend was handled. + """ + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.get(self.header_name, None), Status.ERROR.value + ) + return response + + def head_error(self, url: str): + """ + HEAD a page and tests that an error in the cache backend was handled. + """ + response = self.client.head(url) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.get(self.header_name, None), Status.ERROR.value + ) + return response + def post_skip(self, url: str): """ POSTS a page and tests that it was intentionally not served from @@ -214,6 +239,7 @@ def post_skip(self, url: str): return response # ---- TEST PAGES ---------------------------------------------------------- + def test_page_miss(self): for page in self.should_cache_pages: self.head_miss(page.get_url()) @@ -417,6 +443,7 @@ def test_page_404_without_auth(self): self.test_page_404() # ---- TEST VIEWS ---------------------------------------------------------- + # Views use the decorators and should work without the middleware. @modify_settings( MIDDLEWARE={ @@ -459,6 +486,7 @@ def test_template_response_view_hit(self): self.get_hit(reverse("template_response_view")) # ---- ADMIN VIEWS --------------------------------------------------------- + def test_admin(self): self.client.force_login(self.user) response = self.client.get(reverse("wagtailcache:index")) @@ -479,6 +507,7 @@ def test_admin_clearcache(self): self.get_miss(self.page_cachedpage.get_url()) # ---- PURGE SPECIFIC URLS & CLEAR ALL-------------------------------------- + def test_cache_keyring(self): # Check if keyring is not present self.assertEqual(self.cache.get("keyring"), None) @@ -490,6 +519,23 @@ def test_cache_keyring(self): # Compare Keys self.assertEqual(key, url) + @override_settings(WAGTAIL_CACHE_BACKEND="one_second") + def test_cache_keyring_no_uri_key_duplication(self): + # First get to populate keyring + self.get_miss(self.page_cachedpage.get_url()) + # Wait a short time + time.sleep(0.5) + # Fetch a different page + self.get_miss(self.page_wagtailpage.get_url()) + # Wait until the first page is expired, but not the keyring + time.sleep(0.6) + # Fetch the first page again + self.get_miss(self.page_cachedpage.get_url()) + # Check the keyring does not contain duplicate uri_keys + url = "http://%s%s" % ("testserver", self.page_cachedpage.get_url()) + keyring = self.cache.get("keyring") + self.assertEqual(len(keyring.get(url, [])), 1) + def test_clear_cache(self): # First get should miss cache. self.get_miss(self.page_cachedpage.get_url()) @@ -520,6 +566,7 @@ def test_clear_cache_url(self): self.get_miss(u2) # ---- ALTERNATE SETTINGS -------------------------------------------------- + @override_settings(WAGTAIL_CACHE=True) def test_enable_wagtailcache(self): # Intentionally enable wagtail-cache, make sure it works. @@ -543,7 +590,30 @@ def test_zero_timeout(self): # Load admin panel to render the zero timeout. self.test_admin() + @override_settings(WAGTAIL_CACHE_BACKEND="error_get") + def test_page_error_get(self): + # Wagtail-cache should handle errors when fetching from cache backend. + for page in self.should_cache_pages: + # First get should get an error. + self.head_error(page.get_url()) + self.get_error(page.get_url()) + # Second get should get an error too. + self.head_error(page.get_url()) + self.get_error(page.get_url()) + + @override_settings(WAGTAIL_CACHE_BACKEND="error_set") + def test_page_error_set(self): + # Wagtail-cache should handle errors when updating to cache backend. + for page in self.should_cache_pages: + # First get should get an error. + self.head_error(page.get_url()) + self.get_error(page.get_url()) + # Second get should get an error too. + self.head_error(page.get_url()) + self.get_error(page.get_url()) + # ---- HOOKS --------------------------------------------------------------- + def test_request_hook_true(self): # A POST should never be cached. response = self.client.post(reverse("cached_view")) diff --git a/testproject/testproject/settings.py b/testproject/testproject/settings.py index fd1bc65..d7ee484 100644 --- a/testproject/testproject/settings.py +++ b/testproject/testproject/settings.py @@ -116,9 +116,6 @@ MEDIA_URL = "/media/" # Wagtail settings WAGTAIL_SITE_NAME = "testproject" -# Base URL to use when referring to full URLs within the Wagtail admin backend - -# e.g. in notification emails. Don't include '/admin' or a trailing slash -BASE_URL = "http://example.com" # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True # SECURITY WARNING: keep the secret key used in production secret! @@ -133,8 +130,20 @@ "BACKEND": "django.core.cache.backends.locmem.LocMemCache", "TIMEOUT": 90061, # 1 day, 1 hour, 1 minute, 1 second. }, + "one_second": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "TIMEOUT": 1, + }, "zero": { "BACKEND": "django.core.cache.backends.locmem.LocMemCache", "TIMEOUT": 0, }, + "error_get": { + "BACKEND": "backends.ErroneousGetCache", + "TIMEOUT": 90061, # 1 day, 1 hour, 1 minute, 1 second. + }, + "error_set": { + "BACKEND": "backends.ErroneousSetCache", + "TIMEOUT": 90061, # 1 day, 1 hour, 1 minute, 1 second. + }, } diff --git a/testproject/testproject/urls.py b/testproject/testproject/urls.py index b2a35ae..cb8b09e 100644 --- a/testproject/testproject/urls.py +++ b/testproject/testproject/urls.py @@ -2,12 +2,11 @@ from django.contrib import admin from django.urls import include from django.urls import path +from home import views from wagtail import urls as wagtail_urls from wagtail.admin import urls as wagtailadmin_urls from wagtail.documents import urls as wagtaildocs_urls -from home import views - urlpatterns = [ path("django-admin/", admin.site.urls), diff --git a/wagtailcache/__init__.py b/wagtailcache/__init__.py index d76bad5..ab41bfd 100644 --- a/wagtailcache/__init__.py +++ b/wagtailcache/__init__.py @@ -1,3 +1,3 @@ -release = ["2", "4", "0"] +release = ["2", "5", "0"] __version__ = "{0}.{1}.{2}".format(release[0], release[1], release[2]) __shortversion__ = "{0}.{1}".format(release[0], release[1]) diff --git a/wagtailcache/cache.py b/wagtailcache/cache.py index 3f4eaec..0d92c75 100644 --- a/wagtailcache/cache.py +++ b/wagtailcache/cache.py @@ -2,6 +2,7 @@ Functionality to set, serve from, and clear the cache. """ +import logging import re from enum import Enum from functools import wraps @@ -28,6 +29,9 @@ from wagtailcache.settings import wagtailcache_settings +logger = logging.getLogger("wagtail-cache") + + class CacheControl(Enum): """ ``Cache-Control`` header values. @@ -42,6 +46,7 @@ class Status(Enum): WAGTAIL_CACHE_HEADER header values. """ + ERROR = "err" HIT = "hit" MISS = "miss" SKIP = "skip" @@ -186,6 +191,7 @@ def __init__(self, get_response=None): def process_request(self, request: WSGIRequest) -> Optional[HttpResponse]: if not wagtailcache_settings.WAGTAIL_CACHE: return None + # Check if request is cacheable # Only cache GET and HEAD requests. # Don't cache requests that are previews. @@ -197,6 +203,7 @@ def process_request(self, request: WSGIRequest) -> Optional[HttpResponse]: and not getattr(request, "is_preview", False) and not (hasattr(request, "user") and request.user.is_authenticated) ) + # Allow the user to override our caching decision. for fn in hooks.get_hooks("is_request_cacheable"): result = fn(request, is_cacheable) @@ -207,17 +214,31 @@ def process_request(self, request: WSGIRequest) -> Optional[HttpResponse]: setattr(request, "_wagtailcache_update", False) setattr(request, "_wagtailcache_skip", True) return None # Don't bother checking the cache. - # Try and get the cached response. - cache_key = _get_cache_key(request, self._wagcache) - if cache_key is None: - setattr(request, "_wagtailcache_update", True) - return None # No cache information available, need to rebuild. - response = self._wagcache.get(cache_key) + # Try and get the cached response. + try: + cache_key = _get_cache_key(request, self._wagcache) + + # No cache information available, need to rebuild. + if cache_key is None: + setattr(request, "_wagtailcache_update", True) + return None + + # We have a key, get the cached response. + response = self._wagcache.get(cache_key) + + except Exception: + # If the cache backend is currently unresponsive or errors out, + # return None and log the error. + setattr(request, "_wagtailcache_error", True) + logger.exception("Could not fetch page from cache backend.") + return None + # No cache information available, need to rebuild. if response is None: setattr(request, "_wagtailcache_update", True) - return None # No cache information available, need to rebuild. + return None + # Hit. Return cached response. setattr(request, "_wagtailcache_update", False) return response @@ -248,6 +269,15 @@ def process_response( _patch_header(response, Status.SKIP) return response + if ( + hasattr(request, "_wagtailcache_error") + and request._wagtailcache_error + ): + # There was an error trying to fetch this response from the cache. + # Do not try to update, simply return. + _patch_header(response, Status.ERROR) + return response + if ( hasattr(request, "_wagtailcache_update") and not request._wagtailcache_update @@ -258,6 +288,7 @@ def process_response( _chop_response_vary(request, response) # We don't need to update the cache, just return. return response + # Check if the response is cacheable # Don't cache private or no-cache responses. # Do cache 200, 301, 302, 304, and 404 codes so that wagtail doesn't @@ -277,16 +308,19 @@ def process_response( and has_vary_header(response, "Cookie") ) ) + # Allow the user to override our caching decision. for fn in hooks.get_hooks("is_response_cacheable"): result = fn(response, is_cacheable) if isinstance(result, bool): is_cacheable = result + # If we are not allowed to cache the response, just return. if not is_cacheable: # Add response header to indicate this was intentionally not cached. _patch_header(response, Status.SKIP) return response + # Potentially remove the ``Vary: Cookie`` header. _chop_response_vary(request, response) # Try to get the timeout from the ``max-age`` section of the @@ -297,32 +331,36 @@ def process_response( timeout = self._wagcache.default_timeout patch_response_headers(response, timeout) if timeout: - cache_key = _learn_cache_key( - request, response, timeout, self._wagcache - ) - # Track cache keys based on URI. - # (of the chopped request, not the real one). - cr = _chop_querystring(request) - uri = unquote(cr.build_absolute_uri()) - keyring = self._wagcache.get("keyring", {}) - # Get current cache keys belonging to this URI. - # This should be a list of keys. - uri_keys: List[str] = keyring.get(uri, []) - # Append the key to this list and save. - uri_keys.append(cache_key) - keyring[uri] = uri_keys - self._wagcache.set("keyring", keyring) - - if isinstance(response, SimpleTemplateResponse): - - def callback(r): - self._wagcache.set(cache_key, r, timeout) - - response.add_post_render_callback(callback) - else: - self._wagcache.set(cache_key, response, timeout) - # Add a response header to indicate this was a cache miss. - _patch_header(response, Status.MISS) + try: + cache_key = _learn_cache_key( + request, response, timeout, self._wagcache + ) + # Track cache keys based on URI. + # (of the chopped request, not the real one). + cr = _chop_querystring(request) + uri = unquote(cr.build_absolute_uri()) + keyring = self._wagcache.get("keyring", {}) + # Get current cache keys belonging to this URI. + # This should be a list of keys. + uri_keys: List[str] = keyring.get(uri, []) + # Append the key to this list if not already present and save. + if cache_key not in uri_keys: + uri_keys.append(cache_key) + keyring[uri] = uri_keys + self._wagcache.set("keyring", keyring) + if isinstance(response, SimpleTemplateResponse): + + def callback(r): + self._wagcache.set(cache_key, r, timeout) + + response.add_post_render_callback(callback) + else: + self._wagcache.set(cache_key, response, timeout) + # Add a response header to indicate this was a cache miss. + _patch_header(response, Status.MISS) + except Exception: + _patch_header(response, Status.ERROR) + logger.exception("Could not update page in cache backend.") return response