import logging import re from collections import defaultdict from urllib.parse import urlsplit, urlunsplit from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.utils.module_loading import import_string from wagtail.coreutils import get_content_languages logger = logging.getLogger("wagtail.frontendcache") class InvalidFrontendCacheBackendError(ImproperlyConfigured): pass def get_backends(backend_settings=None, backends=None): # Get backend settings from WAGTAILFRONTENDCACHE setting if backend_settings is None: backend_settings = getattr(settings, "WAGTAILFRONTENDCACHE", None) # Fallback to using WAGTAILFRONTENDCACHE_LOCATION setting (backwards compatibility) if backend_settings is None: cache_location = getattr(settings, "WAGTAILFRONTENDCACHE_LOCATION", None) if cache_location is not None: backend_settings = { "default": { "BACKEND": "wagtail.contrib.frontend_cache.backends.HTTPBackend", "LOCATION": cache_location, }, } # No settings found, return empty list if backend_settings is None: return {} backend_objects = {} for backend_name, _backend_config in backend_settings.items(): if backends is not None and backend_name not in backends: continue backend_config = _backend_config.copy() backend = backend_config.pop("BACKEND") # Try to import the backend try: backend_cls = import_string(backend) except ImportError as e: raise InvalidFrontendCacheBackendError( f"Could not find backend '{backend}': {e}" ) backend_objects[backend_name] = backend_cls(backend_config) return backend_objects def purge_url_from_cache(url, backend_settings=None, backends=None): purge_urls_from_cache([url], backend_settings=backend_settings, backends=backends) def purge_urls_from_cache(urls, backend_settings=None, backends=None): # Convert each url to urls one for each managed language (WAGTAILFRONTENDCACHE_LANGUAGES setting). # The managed languages are common to all the defined backends. # This depends on settings.USE_I18N # If WAGTAIL_I18N_ENABLED is True, this defaults to WAGTAIL_CONTENT_LANGUAGES wagtail_i18n_enabled = getattr(settings, "WAGTAIL_I18N_ENABLED", False) content_languages = get_content_languages() if wagtail_i18n_enabled else {} languages = getattr( settings, "WAGTAILFRONTENDCACHE_LANGUAGES", list(content_languages.keys()) ) if settings.USE_I18N and languages: langs_regex = "^/(%s)/" % "|".join(languages) new_urls = [] # Purge the given url for each managed language for isocode in languages: for url in urls: up = urlsplit(url) new_url = urlunsplit( ( up.scheme, up.netloc, re.sub(langs_regex, "/%s/" % isocode, up.path), up.query, up.fragment, ) ) # Check for best performance. True if re.sub found no match # It happens when i18n_patterns was not used in urls.py to serve content for different languages from different URLs if new_url in new_urls: continue new_urls.append(new_url) urls = new_urls urls_by_hostname = defaultdict(list) for url in urls: urls_by_hostname[urlsplit(url).netloc].append(url) backends = get_backends(backend_settings, backends) for hostname, urls in urls_by_hostname.items(): backends_for_hostname = { backend_name: backend for backend_name, backend in backends.items() if backend.invalidates_hostname(hostname) } if not backends_for_hostname: logger.info("Unable to find purge backend for %s", hostname) continue for backend_name, backend in backends_for_hostname.items(): for url in urls: logger.info("[%s] Purging URL: %s", backend_name, url) backend.purge_batch(urls) def _get_page_cached_urls(page): page_url = page.full_url if page_url is None: # nothing to be done if the page has no routable URL return [] return [page_url + path.lstrip("/") for path in page.specific.get_cached_paths()] def purge_page_from_cache(page, backend_settings=None, backends=None): purge_pages_from_cache([page], backend_settings=backend_settings, backends=backends) def purge_pages_from_cache(pages, backend_settings=None, backends=None): urls = [] for page in pages: urls.extend(_get_page_cached_urls(page)) if urls: purge_urls_from_cache(urls, backend_settings, backends) class PurgeBatch: """Represents a list of URLs to be purged in a single request""" def __init__(self, urls=None): self.urls = [] if urls is not None: self.add_urls(urls) def add_url(self, url): """Adds a single URL""" self.urls.append(url) def add_urls(self, urls): """ Adds multiple URLs from an iterable This is equivalent to running ``.add_url(url)`` on each URL individually """ self.urls.extend(urls) def add_page(self, page): """ Adds all URLs for the specified page This combines the page's full URL with each path that is returned by the page's `.get_cached_paths` method """ self.add_urls(_get_page_cached_urls(page)) def add_pages(self, pages): """ Adds multiple pages from a QuerySet or an iterable This is equivalent to running ``.add_page(page)`` on each page individually """ for page in pages: self.add_page(page) def purge(self, backend_settings=None, backends=None): """ Performs the purge of all the URLs in this batch This method takes two optional keyword arguments: backend_settings and backends - backend_settings can be used to override the WAGTAILFRONTENDCACHE setting for just this call - backends can be set to a list of backend names. When set, the invalidation request will only be sent to these backends """ purge_urls_from_cache(self.urls, backend_settings, backends)