From 23d465bbba73cfb4e204f2450406ba31b2064b67 Mon Sep 17 00:00:00 2001 From: Will Barton Date: Thu, 25 Jul 2024 16:16:06 -0400 Subject: [PATCH 01/14] Pin Wagtail 6.2.1 --- requirements/wagtail.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/wagtail.txt b/requirements/wagtail.txt index da035c8fcac..dca54fb5c56 100644 --- a/requirements/wagtail.txt +++ b/requirements/wagtail.txt @@ -1 +1 @@ -wagtail==6.1.3 +wagtail==6.2.1 From 6386cb1c4994dbd62073055b5d95827f76f87d7d Mon Sep 17 00:00:00 2001 From: Will Barton Date: Fri, 26 Jul 2024 12:32:14 -0400 Subject: [PATCH 02/14] Replace deprecated user form config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `WAGTAIL_USER_CREATION_FORM` and `WAGTAIL_USER_EDIT_FORM` are deprecated in favor of subclassing Wagtail's `UserViewSet` class and providing a custom `WagtailUserAppConfig` class that overrides that of `wagtail.users`. See https://docs.wagtail.org/en/latest/releases/6.2.html#deprecation-of-wagtail-user-edit-form-wagtail-user-creation-form-and-wagtail-user-custom-fields-settings This is... not my favorite bit of Django code, and feels a little anti-idiomatic. Effectively what happens here is that the `LoginUsersAppConfig` that we define (alongside `LoginConfig` — it doesn't work when combined) both allows us to override `user_viewset` and provide our own ***AND*** replaces that of `wagtail.users`, so that `login.apps.LoginUsersAppConfig` masquerades as `wagtail.users` for the sake of Django's app registration. But this is the direction Wagtail has gone for user and group viewset overrides 🤷🏻‍♂️. --- cfgov/cfgov/settings/base.py | 4 +--- cfgov/login/apps.py | 6 ++++++ cfgov/login/forms.py | 4 ++-- cfgov/login/tests/test_viewsets.py | 28 ++++++++++++++++++++++++++++ cfgov/login/viewsets.py | 10 ++++++++++ 5 files changed, 47 insertions(+), 5 deletions(-) create mode 100644 cfgov/login/tests/test_viewsets.py create mode 100644 cfgov/login/viewsets.py diff --git a/cfgov/cfgov/settings/base.py b/cfgov/cfgov/settings/base.py index e2beb7cfb2e..742a843eff7 100644 --- a/cfgov/cfgov/settings/base.py +++ b/cfgov/cfgov/settings/base.py @@ -44,7 +44,6 @@ "wagtail.admin", "wagtail.documents", "wagtail.snippets", - "wagtail.users", "wagtail.images", "wagtail.embeds", "wagtail.contrib.frontend_cache", @@ -99,6 +98,7 @@ "django_opensearch_dsl", "corsheaders", "login", + "login.apps.LoginUsersAppConfig", "filing_instruction_guide", "health_check", "health_check.db", @@ -288,8 +288,6 @@ WAGTAILIMAGES_IMAGE_MODEL = "v1.CFGOVImage" WAGTAILIMAGES_IMAGE_FORM_BASE = "v1.forms.CFGOVImageForm" TAGGIT_CASE_INSENSITIVE = True -WAGTAIL_USER_CREATION_FORM = "login.forms.UserCreationForm" -WAGTAIL_USER_EDIT_FORM = "login.forms.UserEditForm" WAGTAILDOCS_SERVE_METHOD = "direct" # This is used for easy autocomplete search behavior in the Wagtail admin. diff --git a/cfgov/login/apps.py b/cfgov/login/apps.py index 57cba5aff26..b41ee8bbb93 100644 --- a/cfgov/login/apps.py +++ b/cfgov/login/apps.py @@ -1,7 +1,13 @@ from django.apps import AppConfig +from wagtail.users.apps import WagtailUsersAppConfig + from . import checks # noqa F401 class LoginConfig(AppConfig): name = "login" + + +class LoginUsersAppConfig(WagtailUsersAppConfig): + user_viewset = "login.viewsets.UserViewSet" diff --git a/cfgov/login/forms.py b/cfgov/login/forms.py index adf832a4176..764c2693e17 100644 --- a/cfgov/login/forms.py +++ b/cfgov/login/forms.py @@ -11,7 +11,7 @@ class UserCreationForm(wagtailforms.UserCreationForm): def clean_email(self): email = self.cleaned_data["email"] - if User.objects.filter(email=email).exists(): + if User.objects.filter(email__iexact=email).exists(): raise ValidationError("This email is already in use.") return email @@ -23,7 +23,7 @@ def clean_email(self): if ( User.objects.exclude(pk=self.instance.pk) - .filter(email=email) + .filter(email__iexact=email) .exists() ): raise ValidationError("This email is already in use.") diff --git a/cfgov/login/tests/test_viewsets.py b/cfgov/login/tests/test_viewsets.py new file mode 100644 index 00000000000..8ccb0c7a8a6 --- /dev/null +++ b/cfgov/login/tests/test_viewsets.py @@ -0,0 +1,28 @@ +from django.apps import apps +from django.test import TestCase + +from wagtail.users.wagtail_hooks import get_viewset_cls + +from login.forms import UserCreationForm, UserEditForm + + +class UserViewSetTestCase(TestCase): + """Test that the wagtailusers app config loads our UserViewSet. + + This tests both our UserViewSet class (which just returns our custom forms) + as well as our LoginUsersAppConfig in apps.py. + """ + + def test_get_form_class_edit(self): + app_config = apps.get_app_config("wagtailusers") + user_viewset_cls = get_viewset_cls(app_config, "user_viewset") + viewset = user_viewset_cls(name="wagtailusers_users") + form_class = viewset.get_form_class(for_update=True) + self.assertEqual(form_class, UserEditForm) + + def test_get_form_class_create(self): + app_config = apps.get_app_config("wagtailusers") + user_viewset_cls = get_viewset_cls(app_config, "user_viewset") + viewset = user_viewset_cls(name="wagtailusers_users") + form_class = viewset.get_form_class() + self.assertEqual(form_class, UserCreationForm) diff --git a/cfgov/login/viewsets.py b/cfgov/login/viewsets.py new file mode 100644 index 00000000000..1ff2efcf5a5 --- /dev/null +++ b/cfgov/login/viewsets.py @@ -0,0 +1,10 @@ +from wagtail.users.views.users import UserViewSet as WagtailUserViewSet + +from login.forms import UserCreationForm, UserEditForm + + +class UserViewSet(WagtailUserViewSet): + def get_form_class(self, for_update=False): + if for_update: + return UserEditForm + return UserCreationForm From 9cc4c7cb86314e9c7b3ffbe9fb703fa9f990a893 Mon Sep 17 00:00:00 2001 From: Will Barton Date: Fri, 26 Jul 2024 12:35:35 -0400 Subject: [PATCH 03/14] Update CloudFront cache configuration A dict of distribution ids for CloudFront is deprecated in Wagtail 6.2. We were only using a dict for a single distribution ID, so this isn't terribly impactful but does require us to change our configuration format slightly. See https://docs.wagtail.org/en/latest/releases/6.2.html#specifying-a-dict-of-distribution-ids-for-cloudfront-cache-invalidation-is-deprecated fixy cloud --- cfgov/cfgov/settings/base.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/cfgov/cfgov/settings/base.py b/cfgov/cfgov/settings/base.py index 742a843eff7..cc93421117a 100644 --- a/cfgov/cfgov/settings/base.py +++ b/cfgov/cfgov/settings/base.py @@ -449,6 +449,7 @@ "CLIENT_TOKEN": os.environ.get("AKAMAI_CLIENT_TOKEN"), "CLIENT_SECRET": os.environ.get("AKAMAI_CLIENT_SECRET"), "ACCESS_TOKEN": os.environ.get("AKAMAI_ACCESS_TOKEN"), + "HOSTNAMES": ["www.consumerfinance.gov"], } ENABLE_CLOUDFRONT_CACHE_PURGE = os.environ.get( @@ -457,11 +458,8 @@ if ENABLE_CLOUDFRONT_CACHE_PURGE: WAGTAILFRONTENDCACHE["files"] = { "BACKEND": "wagtail.contrib.frontend_cache.backends.CloudfrontBackend", - "DISTRIBUTION_ID": { - "files.consumerfinance.gov": os.environ.get( - "CLOUDFRONT_DISTRIBUTION_ID_FILES" - ) - }, + "DISTRIBUTION_ID": os.environ["CLOUDFRONT_DISTRIBUTION_ID_FILES"], + "HOSTNAMES": ["files.consumerfinance.gov"], } # CSP Allowlists From c6a569b2dc2f6e2aa811a24dde754dc4650c856b Mon Sep 17 00:00:00 2001 From: Will Barton Date: Tue, 20 Aug 2024 12:02:22 -0400 Subject: [PATCH 04/14] Update frontend caching and isolate in new app This change updates our CDN tools, AkamaiBackend, etc, for the Wagtail 6.2 changes in frontend caching. It also moves all our CDN tools and frontend caching to a separate package outside of v1 to isolate the functionality. --- .env_SAMPLE | 2 + cfgov/cdntools/__init__.py | 0 cfgov/cdntools/apps.py | 10 + .../caching.py => cdntools/backends.py} | 39 +--- .../{v1/admin_forms.py => cdntools/forms.py} | 0 cfgov/cdntools/migrations/0001_initial.py | 36 ++++ cfgov/cdntools/migrations/__init__.py | 0 cfgov/cdntools/models.py | 12 ++ cfgov/cdntools/signals.py | 28 +++ .../templates/cdnadmin/index.html | 0 cfgov/cdntools/tests/__init__.py | 0 .../tests/test_backends.py} | 35 ++-- cfgov/cdntools/tests/test_models.py | 0 cfgov/cdntools/tests/test_views.py | 183 ++++++++++++++++++ cfgov/cdntools/views.py | 91 +++++++++ cfgov/cdntools/wagtail_hooks.py | 31 +++ cfgov/cfgov/settings/base.py | 13 +- cfgov/core/testutils/mock_cache_backend.py | 12 -- cfgov/regulations3k/tests/test_models.py | 10 +- cfgov/v1/admin_views.py | 106 +--------- .../management/commands/delete_page_cache.py | 2 +- .../commands/invalidate_all_pages_cache.py | 2 +- cfgov/v1/migrations/0036_delete_cdnhistory.py | 23 +++ cfgov/v1/models/__init__.py | 1 - cfgov/v1/signals.py | 8 +- .../commands/test_delete_page_cache.py | 4 +- .../commands/test_invalidate_all_pages.py | 4 +- cfgov/v1/tests/test_admin_views.py | 131 +------------ cfgov/v1/tests/test_blocks_email_signup.py | 2 +- cfgov/v1/wagtail_hooks.py | 24 --- docs/caching.md | 2 +- 31 files changed, 467 insertions(+), 344 deletions(-) create mode 100644 cfgov/cdntools/__init__.py create mode 100644 cfgov/cdntools/apps.py rename cfgov/{v1/models/caching.py => cdntools/backends.py} (77%) rename cfgov/{v1/admin_forms.py => cdntools/forms.py} (100%) create mode 100644 cfgov/cdntools/migrations/0001_initial.py create mode 100644 cfgov/cdntools/migrations/__init__.py create mode 100644 cfgov/cdntools/models.py create mode 100644 cfgov/cdntools/signals.py rename cfgov/{v1 => cdntools}/templates/cdnadmin/index.html (100%) create mode 100644 cfgov/cdntools/tests/__init__.py rename cfgov/{v1/tests/models/test_caching.py => cdntools/tests/test_backends.py} (84%) create mode 100644 cfgov/cdntools/tests/test_models.py create mode 100644 cfgov/cdntools/tests/test_views.py create mode 100644 cfgov/cdntools/views.py create mode 100644 cfgov/cdntools/wagtail_hooks.py delete mode 100644 cfgov/core/testutils/mock_cache_backend.py create mode 100644 cfgov/v1/migrations/0036_delete_cdnhistory.py diff --git a/.env_SAMPLE b/.env_SAMPLE index b041939a001..3b0e339cc59 100755 --- a/.env_SAMPLE +++ b/.env_SAMPLE @@ -47,9 +47,11 @@ export ALLOW_ADMIN_URL=True #export AKAMAI_CLIENT_TOKEN= #export AKAMAI_FAST_PURGE_URL= #export AKAMAI_PURGE_ALL_URL= +#export AKAMAI_PURGE_HOSTNAMES= # export ENABLE_CLOUDFRONT_CACHE_PURGE=True # export CLOUDFRONT_DISTRIBUTION_ID_FILES= +#export CLOUDFRONT_PURGE_HOSTNAMES= ######################################################################### # Postgres Environment Vars. diff --git a/cfgov/cdntools/__init__.py b/cfgov/cdntools/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/cfgov/cdntools/apps.py b/cfgov/cdntools/apps.py new file mode 100644 index 00000000000..85af008c08e --- /dev/null +++ b/cfgov/cdntools/apps.py @@ -0,0 +1,10 @@ +from django.apps import AppConfig + + +class CDNToolsAppConfig(AppConfig): + name = "cdntools" + label = "cdntools" + verbose_name = "CDN Tools" + + def ready(self): + import cdntools.signals # noqa: F401 diff --git a/cfgov/v1/models/caching.py b/cfgov/cdntools/backends.py similarity index 77% rename from cfgov/v1/models/caching.py rename to cfgov/cdntools/backends.py index 152e5804c45..22b1e6cd95d 100644 --- a/cfgov/v1/models/caching.py +++ b/cfgov/cdntools/backends.py @@ -2,32 +2,15 @@ import logging import os -from django.conf import settings -from django.contrib.auth.models import User -from django.db import models -from django.db.models.signals import post_save -from django.dispatch import receiver - from wagtail.contrib.frontend_cache.backends import BaseBackend -from wagtail.contrib.frontend_cache.utils import PurgeBatch -from wagtail.documents.models import Document import requests from akamai.edgegrid import EdgeGridAuth -from v1.models.images import CFGOVRendition - logger = logging.getLogger(__name__) -class CDNHistory(models.Model): - created = models.DateTimeField(auto_now_add=True) - subject = models.CharField(max_length=2083) - message = models.CharField(max_length=255) - user = models.ForeignKey(User, on_delete=models.CASCADE) - - class AkamaiBackend(BaseBackend): """Akamai backend that performs an 'invalidate' purge""" @@ -132,19 +115,13 @@ def purge_all(self): ) -@receiver(post_save, sender=Document) -@receiver(post_save, sender=CFGOVRendition) -def cloudfront_cache_invalidation(sender, instance, **kwargs): - if not settings.ENABLE_CLOUDFRONT_CACHE_PURGE: - return - - if not instance.file: - return - - url = instance.file.url +class MockCacheBackend(BaseBackend): + def __init__(self, params): + super().__init__(params) + self.cached_urls = params.get("CACHED_URLS") - logger.info(f'Purging {url} from "files" cache') + def purge(self, url): + self.cached_urls.append(url) - batch = PurgeBatch() - batch.add_url(url) - batch.purge(backends=["files"]) + def purge_all(self): + self.cached_urls.append("__all__") diff --git a/cfgov/v1/admin_forms.py b/cfgov/cdntools/forms.py similarity index 100% rename from cfgov/v1/admin_forms.py rename to cfgov/cdntools/forms.py diff --git a/cfgov/cdntools/migrations/0001_initial.py b/cfgov/cdntools/migrations/0001_initial.py new file mode 100644 index 00000000000..d7162f07afc --- /dev/null +++ b/cfgov/cdntools/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# Generated by Django 4.2.14 on 2024-08-20 12:49 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + # This model moved from v1. We'll reuse that existing table. + migrations.SeparateDatabaseAndState( + state_operations=[ + migrations.CreateModel( + name='CDNHistory', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('subject', models.CharField(max_length=2083)), + ('message', models.CharField(max_length=255)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'v1_cdnhistory', + }, + ), + ], + database_operations=[], + ) + ] diff --git a/cfgov/cdntools/migrations/__init__.py b/cfgov/cdntools/migrations/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/cfgov/cdntools/models.py b/cfgov/cdntools/models.py new file mode 100644 index 00000000000..0f673b3b588 --- /dev/null +++ b/cfgov/cdntools/models.py @@ -0,0 +1,12 @@ +from django.contrib.auth.models import User +from django.db import models + + +class CDNHistory(models.Model): + created = models.DateTimeField(auto_now_add=True) + subject = models.CharField(max_length=2083) + message = models.CharField(max_length=255) + user = models.ForeignKey(User, on_delete=models.CASCADE) + + class Meta: + db_table = "v1_cdnhistory" diff --git a/cfgov/cdntools/signals.py b/cfgov/cdntools/signals.py new file mode 100644 index 00000000000..a1b46881258 --- /dev/null +++ b/cfgov/cdntools/signals.py @@ -0,0 +1,28 @@ +import logging + +from django.conf import settings +from django.db.models.signals import post_save +from django.dispatch import receiver + +from wagtail.contrib.frontend_cache.utils import PurgeBatch +from wagtail.documents.models import Document + + +logger = logging.getLogger(__name__) + + +@receiver(post_save, sender=Document) +def cloudfront_cache_invalidation(sender, instance, **kwargs): + if not settings.ENABLE_CLOUDFRONT_CACHE_PURGE: + return + + if not instance.file: + return + + url = instance.file.url + + logger.info(f'Purging {url} from "files" cache') + + batch = PurgeBatch() + batch.add_url(url) + batch.purge(backends=["files"]) diff --git a/cfgov/v1/templates/cdnadmin/index.html b/cfgov/cdntools/templates/cdnadmin/index.html similarity index 100% rename from cfgov/v1/templates/cdnadmin/index.html rename to cfgov/cdntools/templates/cdnadmin/index.html diff --git a/cfgov/cdntools/tests/__init__.py b/cfgov/cdntools/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/cfgov/v1/tests/models/test_caching.py b/cfgov/cdntools/tests/test_backends.py similarity index 84% rename from cfgov/v1/tests/models/test_caching.py rename to cfgov/cdntools/tests/test_backends.py index 9621424d158..6582edc15d5 100644 --- a/cfgov/v1/tests/models/test_caching.py +++ b/cfgov/cdntools/tests/test_backends.py @@ -5,15 +5,12 @@ from django.test import TestCase, override_settings from wagtail.documents.models import Document -from wagtail.images.tests.utils import get_test_image_file -from core.testutils.mock_cache_backend import CACHE_PURGED_URLS -from v1.models.caching import ( +from cdntools.backends import ( AkamaiBackend, AkamaiDeletingBackend, - cloudfront_cache_invalidation, ) -from v1.models.images import CFGOVImage +from cdntools.signals import cloudfront_cache_invalidation class TestAkamaiBackend(TestCase): @@ -158,10 +155,14 @@ def test_purge_all(self): akamai_backend.purge_all() +CACHED_FILES_URLS = [] + + @override_settings( WAGTAILFRONTENDCACHE={ "files": { - "BACKEND": "core.testutils.mock_cache_backend.MockCacheBackend", + "BACKEND": "cdntools.backends.MockCacheBackend", + "CACHED_URLS": CACHED_FILES_URLS, }, }, ) @@ -172,35 +173,21 @@ def setUp(self): self.document.file.save( "example.txt", ContentFile("A boring example document") ) - self.image = CFGOVImage.objects.create( - title="test", file=get_test_image_file() - ) - self.rendition = self.image.get_rendition("original") - - CACHE_PURGED_URLS[:] = [] + CACHED_FILES_URLS[:] = [] def tearDown(self): self.document.file.delete() - def test_rendition_saved_cache_purge_disabled(self): - cloudfront_cache_invalidation(None, self.rendition) - self.assertEqual(CACHE_PURGED_URLS, []) - def test_document_saved_cache_purge_disabled(self): cloudfront_cache_invalidation(None, self.document) - self.assertEqual(CACHE_PURGED_URLS, []) + self.assertEqual(CACHED_FILES_URLS, []) @override_settings(ENABLE_CLOUDFRONT_CACHE_PURGE=True) def test_document_saved_cache_purge_without_file(self): cloudfront_cache_invalidation(None, self.document_without_file) - self.assertEqual(CACHE_PURGED_URLS, []) - - @override_settings(ENABLE_CLOUDFRONT_CACHE_PURGE=True) - def test_rendition_saved_cache_invalidation(self): - cloudfront_cache_invalidation(None, self.rendition) - self.assertIn(self.rendition.file.url, CACHE_PURGED_URLS) + self.assertEqual(CACHED_FILES_URLS, []) @override_settings(ENABLE_CLOUDFRONT_CACHE_PURGE=True) def test_document_saved_cache_invalidation(self): cloudfront_cache_invalidation(None, self.document) - self.assertIn(self.document.file.url, CACHE_PURGED_URLS) + self.assertIn(self.document.file.url, CACHED_FILES_URLS) diff --git a/cfgov/cdntools/tests/test_models.py b/cfgov/cdntools/tests/test_models.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/cfgov/cdntools/tests/test_views.py b/cfgov/cdntools/tests/test_views.py new file mode 100644 index 00000000000..26cc5b20720 --- /dev/null +++ b/cfgov/cdntools/tests/test_views.py @@ -0,0 +1,183 @@ +from unittest import mock + +from django.contrib.auth.models import Group, Permission, User +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase, override_settings +from django.urls import reverse + +from requests.exceptions import HTTPError + + +def create_admin_access_permissions(): + """ + This is to ensure that Wagtail's non-model permissions are set-up + (needed for some of the tests below) + Adapted from function of the same name in + https://github.com/wagtail/wagtail/blob/master/wagtail/wagtailadmin/migrations/0001_create_admin_access_permissions.py + """ + # Add a fake content type to hang the 'can access Wagtail admin' + # permission off. + wagtailadmin_content_type, created = ContentType.objects.get_or_create( + app_label="wagtailadmin", model="admin" + ) + + # Create admin permission + admin_permission, created = Permission.objects.get_or_create( + content_type=wagtailadmin_content_type, + codename="access_admin", + name="Can access Wagtail admin", + ) + + # Assign it to Editors and Moderators groups + for group in Group.objects.filter(name__in=["Editors", "Moderators"]): + group.permissions.add(admin_permission) + + +CACHED_AKAMAI_URLS = [] +CACHED_FILES_URLS = [] +PURGE_ALL_STATE = False + + +@override_settings( + WAGTAILFRONTENDCACHE={ + "akamai": { + "BACKEND": "cdntools.backends.MockCacheBackend", + "CACHED_URLS": CACHED_AKAMAI_URLS, + "HOSTNAMES": [ + "www.fake.gov", + ], + }, + "files": { + "BACKEND": "cdntools.backends.MockCacheBackend", + "CACHED_URLS": CACHED_FILES_URLS, + "HOSTNAMES": [ + "files.fake.gov", + ], + }, + } +) +class TestCDNManagementView(TestCase): + def setUp(self): + # Reset cache purged URLs after each test + global CACHED_AKAMAI_URLS, CACHED_FILES_URLS, PURGE_ALL_STATE + CACHED_AKAMAI_URLS[:] = [] + CACHED_FILES_URLS[:] = [] + + create_admin_access_permissions() + + self.no_permission = User.objects.create_user( + username="noperm", email="", password="password" + ) + self.cdn_manager = User.objects.create_user( + username="cdn", email="", password="password" + ) + + # Give CDN Manager permission to add history items + cdn_permission = Permission.objects.get(name="Can add cdn history") + self.cdn_manager.user_permissions.add(cdn_permission) + + # Add no_permission and cdn_manager to Editors group + editors = Group.objects.get(name="Editors") + self.no_permission.groups.add(editors) + self.cdn_manager.groups.add(editors) + + def test_requires_authentication(self): + response = self.client.get(reverse("manage-cdn")) + expected_url = "/admin/login/?next=/admin/cdn/" + self.assertRedirects( + response, expected_url, fetch_redirect_response=False + ) + + def test_form_hiding(self): + # users without 'Can add cdn history' can view the page, + # but the form is hidden + self.client.login(username="noperm", password="password") + response = self.client.get(reverse("manage-cdn")) + self.assertContains(response, "You do not have permission") + + def test_post_blocking(self): + # similarly, users without 'Can add cdn history' are also + # blocked from POST'ing + self.client.login(username="noperm", password="password") + response = self.client.post(reverse("manage-cdn")) + self.assertEqual(response.status_code, 403) + + def test_user_with_permission(self): + self.client.login(username="cdn", password="password") + response = self.client.get(reverse("manage-cdn")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Enter a full URL") + + def test_submission_with_url_akamai(self): + self.client.login(username="cdn", password="password") + self.client.post( + reverse("manage-cdn"), + {"url": "http://www.fake.gov/about-us"}, + ) + self.assertIn( + "http://www.fake.gov/about-us", + CACHED_AKAMAI_URLS, + ) + self.assertNotIn( + "http://www.fake.gov/about-us", + CACHED_FILES_URLS, + ) + + def test_submission_with_url_cloudfront(self): + self.client.login(username="cdn", password="password") + self.client.post( + reverse("manage-cdn"), + {"url": "http://files.fake.gov/test.pdf"}, + ) + self.assertIn( + "http://files.fake.gov/test.pdf", + CACHED_FILES_URLS, + ) + self.assertNotIn( + "http://files.fake.gov/test.pdf", + CACHED_AKAMAI_URLS, + ) + + def test_submission_without_url(self): + self.client.login(username="cdn", password="password") + self.client.post(reverse("manage-cdn")) + self.assertIn( + "__all__", + CACHED_AKAMAI_URLS, + ) + + def test_bad_submission(self): + self.client.login(username="cdn", password="password") + response = self.client.post( + reverse("manage-cdn"), {"url": "not a URL"} + ) + self.assertContains(response, "url: Enter a valid URL.") + + @override_settings(WAGTAILFRONTENDCACHE=None) + def test_cdnmanager_not_enabled(self): + self.client.login(username="cdn", password="password") + response = self.client.get(reverse("manage-cdn")) + self.assertEqual(response.status_code, 404) + + @mock.patch("cdntools.views.purge") + def test_purge_raises_exception(self, mock_purge): + mock_response = mock.MagicMock() + mock_response.json.return_value = { + "title": "HTTPERROR", + "detail": "With details", + } + mock_purge.side_effect = [ + HTTPError(response=mock_response), + Exception("A generic exception"), + ] + + self.client.login(username="cdn", password="password") + response = self.client.post( + reverse("manage-cdn"), {"url": "http://fake.gov/test"} + ) + self.assertContains(response, "HTTPERROR: With details") + + response = self.client.post( + reverse("manage-cdn"), {"url": "http://fake.gov/test"} + ) + self.assertContains(response, "A generic exception") diff --git a/cfgov/cdntools/views.py b/cfgov/cdntools/views.py new file mode 100644 index 00000000000..b0176aec4a8 --- /dev/null +++ b/cfgov/cdntools/views.py @@ -0,0 +1,91 @@ +import logging + +from django.conf import settings +from django.contrib import messages +from django.http import Http404, HttpResponseForbidden +from django.shortcuts import render + +from wagtail.contrib.frontend_cache.utils import PurgeBatch, get_backends + +from requests.exceptions import HTTPError + +from cdntools.forms import CacheInvalidationForm +from cdntools.models import CDNHistory + + +logger = logging.getLogger(__name__) + + +def cdn_is_configured(): + return bool(getattr(settings, "WAGTAILFRONTENDCACHE", None)) + + +def purge(url=None): + if url: + # Use the Wagtail frontendcache PurgeBatch to perform the purge + batch = PurgeBatch() + batch.add_url(url) + logger.info(f"Purging {url}") + batch.purge() + return f"Submitted invalidation for {url}" + + else: + # purge_all only exists on our AkamaiBackend + backends = get_backends() + backend = backends["akamai"] + logger.info('Purging entire site from "akamai" cache') + backend.purge_all() + return "Submitted invalidation for the entire site." + + +def manage_cdn(request): + if not cdn_is_configured(): + raise Http404 + + user_can_purge = request.user.has_perm("cdntools.add_cdnhistory") + + if request.method == "GET": + form = CacheInvalidationForm() + + elif request.method == "POST": + if not user_can_purge: + return HttpResponseForbidden() + + form = CacheInvalidationForm(request.POST) + if form.is_valid(): + url = form.cleaned_data["url"] + history_item = CDNHistory( + subject=url or "entire site", user=request.user + ) + + try: + message = purge(url) + history_item.message = message + history_item.save() + messages.success(request, message) + except Exception as e: + if isinstance(e, HTTPError): + error_info = e.response.json() + error_message = "{title}: {detail}".format(**error_info) + else: + error_message = repr(e) + + history_item.message = error_message + history_item.save() + messages.error(request, error_message) + + else: + for field, error_list in form.errors.items(): + for error in error_list: + messages.error(request, f"Error in {field}: {error}") + + history = CDNHistory.objects.all().order_by("-created")[:20] + return render( + request, + "cdnadmin/index.html", + context={ + "form": form, + "user_can_purge": user_can_purge, + "history": history, + }, + ) diff --git a/cfgov/cdntools/wagtail_hooks.py b/cfgov/cdntools/wagtail_hooks.py new file mode 100644 index 00000000000..5e09bc784d2 --- /dev/null +++ b/cfgov/cdntools/wagtail_hooks.py @@ -0,0 +1,31 @@ +from django.urls import path, reverse + +from wagtail import hooks +from wagtail.admin.menu import MenuItem + +from cdntools.views import ( + cdn_is_configured, + manage_cdn, +) + + +class IfCDNEnabledMenuItem(MenuItem): + def is_shown(self, request): + return cdn_is_configured() + + +@hooks.register("register_admin_menu_item") +def register_cdn_menu_item(): + return IfCDNEnabledMenuItem( + "CDN Tools", + reverse("manage-cdn"), + classname="icon icon-cogs", + order=10000, + ) + + +@hooks.register("register_admin_urls") +def register_cdn_url(): + return [ + path("cdn/", manage_cdn, name="manage-cdn"), + ] diff --git a/cfgov/cfgov/settings/base.py b/cfgov/cfgov/settings/base.py index cc93421117a..d7029c71985 100644 --- a/cfgov/cfgov/settings/base.py +++ b/cfgov/cfgov/settings/base.py @@ -77,6 +77,7 @@ "storages", "data_research", "v1", + "cdntools", "core", "django_extensions", "jobmanager", @@ -445,11 +446,11 @@ ENABLE_AKAMAI_CACHE_PURGE = os.environ.get("ENABLE_AKAMAI_CACHE_PURGE", False) if ENABLE_AKAMAI_CACHE_PURGE: WAGTAILFRONTENDCACHE["akamai"] = { - "BACKEND": "v1.models.caching.AkamaiBackend", - "CLIENT_TOKEN": os.environ.get("AKAMAI_CLIENT_TOKEN"), - "CLIENT_SECRET": os.environ.get("AKAMAI_CLIENT_SECRET"), - "ACCESS_TOKEN": os.environ.get("AKAMAI_ACCESS_TOKEN"), - "HOSTNAMES": ["www.consumerfinance.gov"], + "BACKEND": "cdntools.backends.AkamaiBackend", + "CLIENT_TOKEN": os.environ["AKAMAI_CLIENT_TOKEN"], + "CLIENT_SECRET": os.environ["AKAMAI_CLIENT_SECRET"], + "ACCESS_TOKEN": os.environ["AKAMAI_ACCESS_TOKEN"], + "HOSTNAMES": environment_json("AKAMAI_PURGE_HOSTNAMES") } ENABLE_CLOUDFRONT_CACHE_PURGE = os.environ.get( @@ -459,7 +460,7 @@ WAGTAILFRONTENDCACHE["files"] = { "BACKEND": "wagtail.contrib.frontend_cache.backends.CloudfrontBackend", "DISTRIBUTION_ID": os.environ["CLOUDFRONT_DISTRIBUTION_ID_FILES"], - "HOSTNAMES": ["files.consumerfinance.gov"], + "HOSTNAMES": environment_json("CLOUDFRONT_PURGE_HOSTNAMES") } # CSP Allowlists diff --git a/cfgov/core/testutils/mock_cache_backend.py b/cfgov/core/testutils/mock_cache_backend.py deleted file mode 100644 index 19279b86103..00000000000 --- a/cfgov/core/testutils/mock_cache_backend.py +++ /dev/null @@ -1,12 +0,0 @@ -from wagtail.contrib.frontend_cache.backends import BaseBackend - - -CACHE_PURGED_URLS = [] - - -class MockCacheBackend(BaseBackend): - def __init__(self, config): - pass - - def purge(self, url): - CACHE_PURGED_URLS.append(url) diff --git a/cfgov/regulations3k/tests/test_models.py b/cfgov/regulations3k/tests/test_models.py index 4cde2574ef5..90d655cf071 100644 --- a/cfgov/regulations3k/tests/test_models.py +++ b/cfgov/regulations3k/tests/test_models.py @@ -13,7 +13,6 @@ from model_bakery import baker -from core.testutils.mock_cache_backend import CACHE_PURGED_URLS from regulations3k.documents import SectionParagraphDocument from regulations3k.models.django import ( EffectiveVersion, @@ -41,6 +40,9 @@ ) +CACHE_PURGED_URLS = [] + + class RegModelTests(DjangoTestCase): def setUp(self): from v1.models import HomePage @@ -633,7 +635,8 @@ def test_get_urls_for_version(self): @override_settings( WAGTAILFRONTENDCACHE={ "varnish": { - "BACKEND": "core.testutils.mock_cache_backend.MockCacheBackend", # noqa: E501 + "BACKEND": "cdntools.backends.MockCacheBackend", + "CACHED_URLS": CACHE_PURGED_URLS, }, } ) @@ -651,7 +654,8 @@ def test_effective_version_saved(self): @override_settings( WAGTAILFRONTENDCACHE={ "varnish": { - "BACKEND": "core.testutils.mock_cache_backend.MockCacheBackend", # noqa: E501 + "BACKEND": "cdntools.backends.MockCacheBackend", + "CACHED_URLS": CACHE_PURGED_URLS, }, } ) diff --git a/cfgov/v1/admin_views.py b/cfgov/v1/admin_views.py index c7d1efc5007..881bd7c087e 100644 --- a/cfgov/v1/admin_views.py +++ b/cfgov/v1/admin_views.py @@ -1,108 +1,6 @@ -import logging +from django.http import Http404, HttpResponseRedirect -from django.conf import settings -from django.contrib import messages -from django.http import Http404, HttpResponseForbidden, HttpResponseRedirect -from django.shortcuts import render - -from wagtail.contrib.frontend_cache.utils import PurgeBatch - -from requests.exceptions import HTTPError - -from v1.admin_forms import CacheInvalidationForm -from v1.models import AkamaiBackend, CDNHistory, InternalDocsSettings - - -logger = logging.getLogger(__name__) - - -def cdn_is_configured(): - return bool(getattr(settings, "WAGTAILFRONTENDCACHE", None)) - - -def purge(url=None): - akamai_config = settings.WAGTAILFRONTENDCACHE.get("akamai", {}) - cloudfront_config = settings.WAGTAILFRONTENDCACHE.get("files", {}) - - if url: - # Use the Wagtail frontendcache PurgeBatch to perform the purge - batch = PurgeBatch() - batch.add_url(url) - - # If the URL matches any of our CloudFront distributions, invalidate - # with that backend - if any( - k for k in cloudfront_config.get("DISTRIBUTION_ID", {}) if k in url - ): - logger.info(f'Purging {url} from "files" cache') - batch.purge(backends=["files"]) - - # Otherwise invalidate with our default backend - else: - logger.info(f'Purging {url} from "akamai" cache') - batch.purge(backends=["akamai"]) - - return f"Submitted invalidation for {url}" - - else: - # purge_all only exists on our AkamaiBackend - backend = AkamaiBackend(akamai_config) - logger.info('Purging entire site from "akamai" cache') - backend.purge_all() - return "Submitted invalidation for the entire site." - - -def manage_cdn(request): - if not cdn_is_configured(): - raise Http404 - - user_can_purge = request.user.has_perm("v1.add_cdnhistory") - - if request.method == "GET": - form = CacheInvalidationForm() - - elif request.method == "POST": - if not user_can_purge: - return HttpResponseForbidden() - - form = CacheInvalidationForm(request.POST) - if form.is_valid(): - url = form.cleaned_data["url"] - history_item = CDNHistory( - subject=url or "entire site", user=request.user - ) - - try: - message = purge(url) - history_item.message = message - history_item.save() - messages.success(request, message) - except Exception as e: - if isinstance(e, HTTPError): - error_info = e.response.json() - error_message = "{title}: {detail}".format(**error_info) - else: - error_message = repr(e) - - history_item.message = error_message - history_item.save() - messages.error(request, error_message) - - else: - for field, error_list in form.errors.items(): - for error in error_list: - messages.error(request, f"Error in {field}: {error}") - - history = CDNHistory.objects.all().order_by("-created")[:20] - return render( - request, - "cdnadmin/index.html", - context={ - "form": form, - "user_can_purge": user_can_purge, - "history": history, - }, - ) +from v1.models import InternalDocsSettings def redirect_to_internal_docs(request): diff --git a/cfgov/v1/management/commands/delete_page_cache.py b/cfgov/v1/management/commands/delete_page_cache.py index a82c60f77fa..ab0a0b0e8ee 100644 --- a/cfgov/v1/management/commands/delete_page_cache.py +++ b/cfgov/v1/management/commands/delete_page_cache.py @@ -29,7 +29,7 @@ def handle(self, *args, **options): # Create settings specific to our deletion backend delete_settings = { "akamai_deleting": { - "BACKEND": "v1.models.caching.AkamaiDeletingBackend", + "BACKEND": "cdntools.backends.AkamaiDeletingBackend", "CLIENT_TOKEN": global_settings["akamai"]["CLIENT_TOKEN"], "CLIENT_SECRET": global_settings["akamai"]["CLIENT_SECRET"], "ACCESS_TOKEN": global_settings["akamai"]["ACCESS_TOKEN"], diff --git a/cfgov/v1/management/commands/invalidate_all_pages_cache.py b/cfgov/v1/management/commands/invalidate_all_pages_cache.py index 3c7a12196d2..aec39c6b029 100644 --- a/cfgov/v1/management/commands/invalidate_all_pages_cache.py +++ b/cfgov/v1/management/commands/invalidate_all_pages_cache.py @@ -1,7 +1,7 @@ from django.conf import settings from django.core.management.base import BaseCommand -from v1.models.caching import AkamaiBackend +from cdntools.backends import AkamaiBackend class Command(BaseCommand): diff --git a/cfgov/v1/migrations/0036_delete_cdnhistory.py b/cfgov/v1/migrations/0036_delete_cdnhistory.py new file mode 100644 index 00000000000..a543fd51bca --- /dev/null +++ b/cfgov/v1/migrations/0036_delete_cdnhistory.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.14 on 2024-08-20 12:49 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('v1', '0035_add_footnotes'), + ] + + operations = [ + # This model is moving to cdntools. + # We'll reuse the existing table. + migrations.SeparateDatabaseAndState( + state_operations=[ + migrations.DeleteModel( + name='CDNHistory', + ), + ], + database_operations=[], + ) + ] diff --git a/cfgov/v1/models/__init__.py b/cfgov/v1/models/__init__.py index 8bd9790ff65..c9adc3b166b 100644 --- a/cfgov/v1/models/__init__.py +++ b/cfgov/v1/models/__init__.py @@ -15,7 +15,6 @@ NewsroomLandingPage, ) from v1.models.browse_page import BrowsePage -from v1.models.caching import AkamaiBackend, CDNHistory from v1.models.enforcement_action_page import ( EnforcementActionPage, EnforcementActionProduct, diff --git a/cfgov/v1/signals.py b/cfgov/v1/signals.py index b9432389548..34637392c39 100644 --- a/cfgov/v1/signals.py +++ b/cfgov/v1/signals.py @@ -2,20 +2,26 @@ from django.conf import settings from django.core.cache import cache +from django.db.models.signals import post_save from django.dispatch import receiver from wagtail.signals import page_published, page_unpublished +from cdntools.backends import AkamaiBackend +from cdntools.signals import cloudfront_cache_invalidation from teachers_digital_platform.models.activity_index_page import ( ActivityPage, ActivitySetUp, ) from v1.models import AbstractFilterPage, CFGOVPage -from v1.models.caching import AkamaiBackend from v1.models.filterable_page import AbstractFilterablePage +from v1.models.images import CFGOVRendition from v1.util.ref import get_category_children +post_save.connect(cloudfront_cache_invalidation, sender=CFGOVRendition) + + def invalidate_filterable_list_caches(sender, **kwargs): """Invalidate filterable list caches when necessary diff --git a/cfgov/v1/tests/management/commands/test_delete_page_cache.py b/cfgov/v1/tests/management/commands/test_delete_page_cache.py index f8916b5bded..34e54f0a200 100644 --- a/cfgov/v1/tests/management/commands/test_delete_page_cache.py +++ b/cfgov/v1/tests/management/commands/test_delete_page_cache.py @@ -9,7 +9,7 @@ class DeletePageCacheTestCase(TestCase): @override_settings( WAGTAILFRONTENDCACHE={ "akamai": { - "BACKEND": "v1.models.caching.AkamaiBackend", + "BACKEND": "cdntools.backends.AkamaiBackend", "CLIENT_TOKEN": "fake", "CLIENT_SECRET": "fake", "ACCESS_TOKEN": "fake", @@ -25,7 +25,7 @@ def test_submission_with_url_akamai(self, mock_purge): "https://server/foo/bar", backend_settings={ "akamai_deleting": { - "BACKEND": "v1.models.caching.AkamaiDeletingBackend", + "BACKEND": "cdntools.backends.AkamaiDeletingBackend", "CLIENT_TOKEN": "fake", "CLIENT_SECRET": "fake", "ACCESS_TOKEN": "fake", diff --git a/cfgov/v1/tests/management/commands/test_invalidate_all_pages.py b/cfgov/v1/tests/management/commands/test_invalidate_all_pages.py index 63c10c9680f..070e73c4cb0 100644 --- a/cfgov/v1/tests/management/commands/test_invalidate_all_pages.py +++ b/cfgov/v1/tests/management/commands/test_invalidate_all_pages.py @@ -7,7 +7,7 @@ @override_settings( WAGTAILFRONTENDCACHE={ "akamai": { - "BACKEND": "v1.models.caching.AkamaiBackend", + "BACKEND": "cdntools.backends.AkamaiBackend", "CLIENT_TOKEN": "fake", "CLIENT_SECRET": "fake", "ACCESS_TOKEN": "fake", @@ -15,7 +15,7 @@ } ) class InvalidateAllPagesTestCase(TestCase): - @mock.patch("v1.models.caching.AkamaiBackend.purge_all") + @mock.patch("cdntools.backends.AkamaiBackend.purge_all") def test_submission_with_url_akamai(self, mock_purge_all): call_command("invalidate_all_pages_cache") mock_purge_all.assert_any_call() diff --git a/cfgov/v1/tests/test_admin_views.py b/cfgov/v1/tests/test_admin_views.py index a576efa5cde..2d4af362baf 100644 --- a/cfgov/v1/tests/test_admin_views.py +++ b/cfgov/v1/tests/test_admin_views.py @@ -1,139 +1,10 @@ -from unittest import mock - -from django.contrib.auth.models import Group, Permission, User -from django.contrib.contenttypes.models import ContentType from django.http import Http404, HttpRequest -from django.test import TestCase, override_settings -from django.urls import reverse +from django.test import TestCase from v1.admin_views import redirect_to_internal_docs from v1.models import InternalDocsSettings -def create_admin_access_permissions(): - """ - This is to ensure that Wagtail's non-model permissions are set-up - (needed for some of the tests below) - Adapted from function of the same name in - https://github.com/wagtail/wagtail/blob/master/wagtail/wagtailadmin/migrations/0001_create_admin_access_permissions.py - """ - # Add a fake content type to hang the 'can access Wagtail admin' - # permission off. - wagtailadmin_content_type, created = ContentType.objects.get_or_create( - app_label="wagtailadmin", model="admin" - ) - - # Create admin permission - admin_permission, created = Permission.objects.get_or_create( - content_type=wagtailadmin_content_type, - codename="access_admin", - name="Can access Wagtail admin", - ) - - # Assign it to Editors and Moderators groups - for group in Group.objects.filter(name__in=["Editors", "Moderators"]): - group.permissions.add(admin_permission) - - -cache_path = "wagtail.contrib.frontend_cache.backends.CloudfrontBackend" - - -@override_settings( - WAGTAILFRONTENDCACHE={ - "akamai": { - "BACKEND": "v1.models.caching.AkamaiBackend", - "CLIENT_TOKEN": "fake", - "CLIENT_SECRET": "fake", - "ACCESS_TOKEN": "fake", - }, - "files": { - "BACKEND": cache_path, - "DISTRIBUTION_ID": {"files.fake.gov": "fake"}, - }, - } -) -class TestCDNManagementView(TestCase): - def setUp(self): - create_admin_access_permissions() - - self.no_permission = User.objects.create_user( - username="noperm", email="", password="password" - ) - - self.cdn_manager = User.objects.create_user( - username="cdn", email="", password="password" - ) - - # Give CDN Manager permission to add history items - cdn_permission = Permission.objects.get(name="Can add cdn history") - self.cdn_manager.user_permissions.add(cdn_permission) - - # Add no_permission and cdn_manager to Editors group - editors = Group.objects.get(name="Editors") - self.no_permission.groups.add(editors) - self.cdn_manager.groups.add(editors) - - def test_requires_authentication(self): - response = self.client.get(reverse("manage-cdn")) - expected_url = "/admin/login/?next=/admin/cdn/" - self.assertRedirects( - response, expected_url, fetch_redirect_response=False - ) - - def test_form_hiding(self): - # users without 'Can add cdn history' can view the page, - # but the form is hidden - self.client.login(username="noperm", password="password") - response = self.client.get(reverse("manage-cdn")) - self.assertContains(response, "You do not have permission") - - def test_post_blocking(self): - # similarly, users without 'Can add cdn history' are also - # blocked from POST'ing - self.client.login(username="noperm", password="password") - response = self.client.post(reverse("manage-cdn")) - self.assertEqual(response.status_code, 403) - - def test_user_with_permission(self): - self.client.login(username="cdn", password="password") - response = self.client.get(reverse("manage-cdn")) - self.assertEqual(response.status_code, 200) - self.assertContains(response, "Enter a full URL") - - @mock.patch("v1.models.caching.AkamaiBackend.purge") - def test_submission_with_url_akamai(self, mock_purge): - self.client.login(username="cdn", password="password") - self.client.post(reverse("manage-cdn"), {"url": "http://www.fake.gov"}) - mock_purge.assert_called_with("http://www.fake.gov") - - @mock.patch(f"{cache_path}.purge_batch") - def test_submission_with_url_cloudfront(self, mock_purge_batch): - self.client.login(username="cdn", password="password") - self.client.post( - reverse("manage-cdn"), {"url": "http://files.fake.gov"} - ) - mock_purge_batch.assert_called_with(["http://files.fake.gov"]) - - @mock.patch("v1.models.caching.AkamaiBackend.purge_all") - def test_submission_without_url(self, mock_purge_all): - self.client.login(username="cdn", password="password") - self.client.post(reverse("manage-cdn")) - mock_purge_all.assert_any_call() - - def test_bad_submission(self): - self.client.login(username="cdn", password="password") - response = self.client.post( - reverse("manage-cdn"), {"url": "not a URL"} - ) - self.assertContains(response, "url: Enter a valid URL.") - - @override_settings(WAGTAILFRONTENDCACHE=None) - def test_cdnmanager_not_enabled(self): - self.client.login(username="cdn", password="password") - response = self.client.get(reverse("manage-cdn")) - self.assertEqual(response.status_code, 404) - - class InternalDocsViewTests(TestCase): def test_docs_not_defined_view_returns_404(self): with self.assertRaises(Http404): diff --git a/cfgov/v1/tests/test_blocks_email_signup.py b/cfgov/v1/tests/test_blocks_email_signup.py index 5c12b75c405..ccdc6a62493 100644 --- a/cfgov/v1/tests/test_blocks_email_signup.py +++ b/cfgov/v1/tests/test_blocks_email_signup.py @@ -46,6 +46,6 @@ def test_blog_page_content(self): def test_learn_page_content(self): self.check_page_content(LearnPage, "content") - @mock.patch("v1.models.caching.AkamaiBackend.post_tags") + @mock.patch("cdntools.backends.AkamaiBackend.post_tags") def test_newsroom_page_content(self, mock_post_tags): self.check_page_content(NewsroomPage, "content") diff --git a/cfgov/v1/wagtail_hooks.py b/cfgov/v1/wagtail_hooks.py index 40d2b669c14..b1f1b1d5e5a 100644 --- a/cfgov/v1/wagtail_hooks.py +++ b/cfgov/v1/wagtail_hooks.py @@ -14,8 +14,6 @@ from wagtail.snippets.models import register_snippet from v1.admin_views import ( - cdn_is_configured, - manage_cdn, redirect_to_internal_docs, ) from v1.models import InternalDocsSettings @@ -153,28 +151,6 @@ def register_django_admin_menu_item(): ) -class IfCDNEnabledMenuItem(MenuItem): - def is_shown(self, request): - return cdn_is_configured() - - -@hooks.register("register_admin_menu_item") -def register_cdn_menu_item(): - return IfCDNEnabledMenuItem( - "CDN Tools", - reverse("manage-cdn"), - classname="icon icon-cogs", - order=10000, - ) - - -@hooks.register("register_admin_urls") -def register_cdn_url(): - return [ - re_path(r"^cdn/$", manage_cdn, name="manage-cdn"), - ] - - @hooks.register("register_reports_menu_item") def register_page_metadata_report_menu_item(): return MenuItem( diff --git a/docs/caching.md b/docs/caching.md index a20705f36f0..daf3a4fb7d5 100644 --- a/docs/caching.md +++ b/docs/caching.md @@ -2,7 +2,7 @@ ### Akamai -We use [Akamai](https://www.akamai.com/), a content delivery network, to cache the entirety of [www.consumerfinance.gov](https://www.consumerfinance.gov/) (but not our development servers). We invalidate any given page in Wagtail when it is published or unpublished (by hooking up the custom class [`AkamaiBackend`](https://github.com/cfpb/consumerfinance.gov/blob/main/cfgov/v1/models/akamai_backend.py) to [Wagtail's frontend cache invalidator](http://docs.wagtail.io/en/v2.0.1/reference/contrib/frontendcache.html). By default, we clear the Akamai cache any time we deploy. +We use [Akamai](https://www.akamai.com/), a content delivery network, to cache the entirety of [www.consumerfinance.gov](https://www.consumerfinance.gov/) (but not our development servers). We invalidate any given page in Wagtail when it is published or unpublished (by hooking up the custom class [`AkamaiBackend`](https://github.com/cfpb/consumerfinance.gov/blob/main/cfgov/cdntools/backends.py) to [Wagtail's frontend cache invalidator](http://docs.wagtail.io/en/v2.0.1/reference/contrib/frontendcache.html). By default, we clear the Akamai cache any time we deploy. There are certain pages that do not live in Wagtail or are impacted by changes on another page (imagine our [newsroom page](https://www.consumerfinance.gov/about-us/newsroom/) that lists titles of other pages) or another process (imagine data from Socrata gets updated) and thus will display outdated content until the page's time to live (TTL) has expired, a deploy has happened, or if someone manually invalidates that page. Our default TTL is 24 hours. From d7ba6c25cbdc875a0bf51ae77a6f7d3e10b76289 Mon Sep 17 00:00:00 2001 From: Will Barton Date: Tue, 27 Aug 2024 10:17:56 -0400 Subject: [PATCH 05/14] Use a global to hold mock purged calls --- cfgov/cdntools/backends.py | 13 +++++++++--- cfgov/cdntools/tests/test_backends.py | 13 +++++------- cfgov/cdntools/tests/test_views.py | 27 ++++++------------------ cfgov/regulations3k/tests/test_models.py | 20 ++++++------------ 4 files changed, 27 insertions(+), 46 deletions(-) diff --git a/cfgov/cdntools/backends.py b/cfgov/cdntools/backends.py index 22b1e6cd95d..309668eb3fb 100644 --- a/cfgov/cdntools/backends.py +++ b/cfgov/cdntools/backends.py @@ -115,13 +115,20 @@ def purge_all(self): ) +# This global will hold URLs that were purged by the MockCacheBackend for +# inspection. +MOCK_PURGED = [] + + class MockCacheBackend(BaseBackend): def __init__(self, params): super().__init__(params) - self.cached_urls = params.get("CACHED_URLS") def purge(self, url): - self.cached_urls.append(url) + MOCK_PURGED.append(url) def purge_all(self): - self.cached_urls.append("__all__") + MOCK_PURGED.append("__all__") + + def purge_by_tags(self, tags, **kwargs): + MOCK_PURGED.extend(tags) diff --git a/cfgov/cdntools/tests/test_backends.py b/cfgov/cdntools/tests/test_backends.py index 6582edc15d5..1fa18c8e2ed 100644 --- a/cfgov/cdntools/tests/test_backends.py +++ b/cfgov/cdntools/tests/test_backends.py @@ -7,6 +7,7 @@ from wagtail.documents.models import Document from cdntools.backends import ( + MOCK_PURGED, AkamaiBackend, AkamaiDeletingBackend, ) @@ -155,14 +156,10 @@ def test_purge_all(self): akamai_backend.purge_all() -CACHED_FILES_URLS = [] - - @override_settings( WAGTAILFRONTENDCACHE={ "files": { "BACKEND": "cdntools.backends.MockCacheBackend", - "CACHED_URLS": CACHED_FILES_URLS, }, }, ) @@ -173,21 +170,21 @@ def setUp(self): self.document.file.save( "example.txt", ContentFile("A boring example document") ) - CACHED_FILES_URLS[:] = [] + MOCK_PURGED[:] = [] def tearDown(self): self.document.file.delete() def test_document_saved_cache_purge_disabled(self): cloudfront_cache_invalidation(None, self.document) - self.assertEqual(CACHED_FILES_URLS, []) + self.assertEqual(MOCK_PURGED, []) @override_settings(ENABLE_CLOUDFRONT_CACHE_PURGE=True) def test_document_saved_cache_purge_without_file(self): cloudfront_cache_invalidation(None, self.document_without_file) - self.assertEqual(CACHED_FILES_URLS, []) + self.assertEqual(MOCK_PURGED, []) @override_settings(ENABLE_CLOUDFRONT_CACHE_PURGE=True) def test_document_saved_cache_invalidation(self): cloudfront_cache_invalidation(None, self.document) - self.assertIn(self.document.file.url, CACHED_FILES_URLS) + self.assertIn(self.document.file.url, MOCK_PURGED) diff --git a/cfgov/cdntools/tests/test_views.py b/cfgov/cdntools/tests/test_views.py index 26cc5b20720..549323c414e 100644 --- a/cfgov/cdntools/tests/test_views.py +++ b/cfgov/cdntools/tests/test_views.py @@ -7,6 +7,8 @@ from requests.exceptions import HTTPError +from cdntools.backends import MOCK_PURGED + def create_admin_access_permissions(): """ @@ -33,23 +35,16 @@ def create_admin_access_permissions(): group.permissions.add(admin_permission) -CACHED_AKAMAI_URLS = [] -CACHED_FILES_URLS = [] -PURGE_ALL_STATE = False - - @override_settings( WAGTAILFRONTENDCACHE={ "akamai": { "BACKEND": "cdntools.backends.MockCacheBackend", - "CACHED_URLS": CACHED_AKAMAI_URLS, "HOSTNAMES": [ "www.fake.gov", ], }, "files": { "BACKEND": "cdntools.backends.MockCacheBackend", - "CACHED_URLS": CACHED_FILES_URLS, "HOSTNAMES": [ "files.fake.gov", ], @@ -59,9 +54,7 @@ def create_admin_access_permissions(): class TestCDNManagementView(TestCase): def setUp(self): # Reset cache purged URLs after each test - global CACHED_AKAMAI_URLS, CACHED_FILES_URLS, PURGE_ALL_STATE - CACHED_AKAMAI_URLS[:] = [] - CACHED_FILES_URLS[:] = [] + MOCK_PURGED[:] = [] create_admin_access_permissions() @@ -116,11 +109,7 @@ def test_submission_with_url_akamai(self): ) self.assertIn( "http://www.fake.gov/about-us", - CACHED_AKAMAI_URLS, - ) - self.assertNotIn( - "http://www.fake.gov/about-us", - CACHED_FILES_URLS, + MOCK_PURGED, ) def test_submission_with_url_cloudfront(self): @@ -131,11 +120,7 @@ def test_submission_with_url_cloudfront(self): ) self.assertIn( "http://files.fake.gov/test.pdf", - CACHED_FILES_URLS, - ) - self.assertNotIn( - "http://files.fake.gov/test.pdf", - CACHED_AKAMAI_URLS, + MOCK_PURGED, ) def test_submission_without_url(self): @@ -143,7 +128,7 @@ def test_submission_without_url(self): self.client.post(reverse("manage-cdn")) self.assertIn( "__all__", - CACHED_AKAMAI_URLS, + MOCK_PURGED, ) def test_bad_submission(self): diff --git a/cfgov/regulations3k/tests/test_models.py b/cfgov/regulations3k/tests/test_models.py index 90d655cf071..c34b606020a 100644 --- a/cfgov/regulations3k/tests/test_models.py +++ b/cfgov/regulations3k/tests/test_models.py @@ -13,6 +13,7 @@ from model_bakery import baker +from cdntools.backends import MOCK_PURGED from regulations3k.documents import SectionParagraphDocument from regulations3k.models.django import ( EffectiveVersion, @@ -40,9 +41,6 @@ ) -CACHE_PURGED_URLS = [] - - class RegModelTests(DjangoTestCase): def setUp(self): from v1.models import HomePage @@ -208,7 +206,7 @@ def setUp(self): self.reg_page.save() self.reg_search_page.save() - CACHE_PURGED_URLS[:] = [] + MOCK_PURGED[:] = [] def get_request(self, path="", data=None): if not data: @@ -636,34 +634,28 @@ def test_get_urls_for_version(self): WAGTAILFRONTENDCACHE={ "varnish": { "BACKEND": "cdntools.backends.MockCacheBackend", - "CACHED_URLS": CACHE_PURGED_URLS, }, } ) def test_effective_version_saved(self): effective_version_saved(None, self.effective_version) - self.assertIn("http://localhost/reg-landing/1002/", CACHE_PURGED_URLS) - self.assertIn( - "http://localhost/reg-landing/1002/4/", CACHE_PURGED_URLS - ) + self.assertIn("http://localhost/reg-landing/1002/", MOCK_PURGED) + self.assertIn("http://localhost/reg-landing/1002/4/", MOCK_PURGED) self.assertIn( - "http://localhost/reg-landing/1002/versions/", CACHE_PURGED_URLS + "http://localhost/reg-landing/1002/versions/", MOCK_PURGED ) @override_settings( WAGTAILFRONTENDCACHE={ "varnish": { "BACKEND": "cdntools.backends.MockCacheBackend", - "CACHED_URLS": CACHE_PURGED_URLS, }, } ) def test_section_saved(self): section_saved(None, self.section_num4) - self.assertEqual( - CACHE_PURGED_URLS, ["http://localhost/reg-landing/1002/4/"] - ) + self.assertEqual(MOCK_PURGED, ["http://localhost/reg-landing/1002/4/"]) def test_reg_page_can_serve_draft_versions(self): request = self.get_request() From 349af97dfe71fa4bbeec175f3360addb4e9e5d2c Mon Sep 17 00:00:00 2001 From: Will Barton Date: Tue, 27 Aug 2024 10:18:56 -0400 Subject: [PATCH 06/14] Replace custom cache backend function --- .../commands/purge_by_cache_tags.py | 6 ++-- cfgov/v1/signals.py | 23 +++++--------- .../commands/test_purge_by_cache_tags.py | 20 +++++++----- cfgov/v1/tests/test_signals.py | 31 ++++++++++--------- 4 files changed, 39 insertions(+), 41 deletions(-) diff --git a/cfgov/v1/management/commands/purge_by_cache_tags.py b/cfgov/v1/management/commands/purge_by_cache_tags.py index 31cdbac4954..46fadf182b2 100644 --- a/cfgov/v1/management/commands/purge_by_cache_tags.py +++ b/cfgov/v1/management/commands/purge_by_cache_tags.py @@ -1,6 +1,6 @@ from django.core.management.base import BaseCommand -from v1.signals import configure_akamai_backend +from wagtail.contrib.frontend_cache.utils import get_backends class Command(BaseCommand): @@ -26,5 +26,5 @@ def add_arguments(self, parser): def handle(self, *args, **options): cache_tags = options["cache_tag"] action = options["action"] - backend = configure_akamai_backend() - backend.purge_by_tags(cache_tags, action=action) + akamai_backend = get_backends(backends="akamai")["akamai"] + akamai_backend.purge_by_tags(cache_tags, action=action) diff --git a/cfgov/v1/signals.py b/cfgov/v1/signals.py index 34637392c39..a503eca5379 100644 --- a/cfgov/v1/signals.py +++ b/cfgov/v1/signals.py @@ -1,13 +1,12 @@ from itertools import chain -from django.conf import settings from django.core.cache import cache from django.db.models.signals import post_save from django.dispatch import receiver +from wagtail.contrib.frontend_cache.utils import get_backends from wagtail.signals import page_published, page_unpublished -from cdntools.backends import AkamaiBackend from cdntools.signals import cloudfront_cache_invalidation from teachers_digital_platform.models.activity_index_page import ( ActivityPage, @@ -78,8 +77,12 @@ def invalidate_filterable_list_caches(sender, **kwargs): # Get the cache backend and purge filterable list page cache tags if this # page belongs to any if len(cache_tags_to_purge) > 0: - cache_backend = configure_akamai_backend() - cache_backend.purge_by_tags(cache_tags_to_purge) + try: + akamai_backend = get_backends(backends=["akamai"])["akamai"] + except KeyError: + pass + else: + akamai_backend.purge_by_tags(cache_tags_to_purge) page_published.connect(invalidate_filterable_list_caches) @@ -94,18 +97,6 @@ def refresh_tdp_activity_cache(): activity_setup.update_setups() -def configure_akamai_backend(): - global_settings = getattr(settings, "WAGTAILFRONTENDCACHE", {}) - akamai_settings = global_settings.get("akamai", {}) - akamai_params = { - "CLIENT_TOKEN": akamai_settings.get("CLIENT_TOKEN", "test_token"), - "CLIENT_SECRET": akamai_settings.get("CLIENT_SECRET", "test_secret"), - "ACCESS_TOKEN": akamai_settings.get("ACCESS_TOKEN", "test_access"), - } - backend = AkamaiBackend(akamai_params) - return backend - - @receiver(page_published, sender=ActivityPage) @receiver(page_unpublished, sender=ActivityPage) def activity_published_handler(instance, **kwargs): diff --git a/cfgov/v1/tests/management/commands/test_purge_by_cache_tags.py b/cfgov/v1/tests/management/commands/test_purge_by_cache_tags.py index aa31f191df4..404506e297f 100644 --- a/cfgov/v1/tests/management/commands/test_purge_by_cache_tags.py +++ b/cfgov/v1/tests/management/commands/test_purge_by_cache_tags.py @@ -1,12 +1,18 @@ -from unittest import mock - from django.core.management import call_command -from django.test import TestCase +from django.test import TestCase, override_settings + +from cdntools.backends import MOCK_PURGED +@override_settings( + WAGTAILFRONTENDCACHE={ + "akamai": { + "BACKEND": "cdntools.backends.MockCacheBackend", + }, + } +) class CacheTagPurgeTestCase(TestCase): - @mock.patch("v1.signals.AkamaiBackend.purge_by_tags") - def test_submission_with_url_akamai(self, mock_purge_tags): + def test_submission_with_url_akamai(self): call_command( "purge_by_cache_tags", "--cache_tag", @@ -14,6 +20,4 @@ def test_submission_with_url_akamai(self, mock_purge_tags): "--action", "invalidate", ) - self.assertTrue( - mock_purge_tags.called_with("complaints", action="invalidate") - ) + self.assertIn("complaints", MOCK_PURGED) diff --git a/cfgov/v1/tests/test_signals.py b/cfgov/v1/tests/test_signals.py index ae900436151..6723cdad2a8 100644 --- a/cfgov/v1/tests/test_signals.py +++ b/cfgov/v1/tests/test_signals.py @@ -1,9 +1,10 @@ -from unittest import TestCase, mock +from unittest import mock -from django.test import TestCase as DjangoTestCase +from django.test import TestCase, override_settings from wagtail.models import Site +from cdntools.backends import MOCK_PURGED from teachers_digital_platform.models import ActivityPage, ActivitySetUp from v1.models import ( BlogPage, @@ -16,6 +17,13 @@ from v1.signals import invalidate_filterable_list_caches +@override_settings( + WAGTAILFRONTENDCACHE={ + "akamai": { + "BACKEND": "cdntools.backends.MockCacheBackend", + }, + } +) class FilterableListInvalidationTestCase(TestCase): def setUp(self): self.root_page = Site.objects.first().root_page @@ -42,12 +50,13 @@ def setUp(self): self.root_page.add_child(instance=self.non_filterable_page) self.non_filterable_page.save() - @mock.patch("v1.signals.AkamaiBackend.purge_by_tags") + # Reset cache purged URLs after each test + MOCK_PURGED[:] = [] + @mock.patch("v1.signals.cache") def test_invalidate_filterable_list_caches( self, mock_cache, - mock_purge, ): invalidate_filterable_list_caches(None, instance=self.blog_page) @@ -61,24 +70,18 @@ def test_invalidate_filterable_list_caches( mock_cache.delete.assert_any_call(f"{cache_key_prefix}-page_ids") mock_cache.delete.assert_any_call(f"{cache_key_prefix}-topics") - mock_purge.assert_called_once() - self.assertIn( - self.filterable_list_page.slug, mock_purge.mock_calls[0].args[0] - ) + self.assertIn(self.filterable_list_page.slug, MOCK_PURGED) - @mock.patch("v1.signals.AkamaiBackend.purge_by_tags") @mock.patch("django.core.cache.cache") - def test_invalidate_filterable_list_caches_does_nothing( - self, mock_cache, mock_purge - ): + def test_invalidate_filterable_list_caches_does_nothing(self, mock_cache): invalidate_filterable_list_caches( None, instance=self.non_filterable_page ) mock_cache.delete.assert_not_called() - mock_purge.assert_not_called() + self.assertEqual(MOCK_PURGED, []) -class RefreshActivitiesTestCase(DjangoTestCase): +class RefreshActivitiesTestCase(TestCase): fixtures = ["tdp_minimal_data"] def setUp(self): From 71ea75a351b90a0391c634fe6bce2e9fec628d56 Mon Sep 17 00:00:00 2001 From: Will Barton Date: Tue, 27 Aug 2024 13:23:28 -0400 Subject: [PATCH 07/14] Move cache management commands to cdntools --- cfgov/cdntools/management/__init__.py | 0 cfgov/cdntools/management/commands/__init__.py | 0 .../management/commands/delete_page_cache.py | 0 .../commands/invalidate_all_pages_cache.py | 0 .../commands/invalidate_page_cache.py | 6 +++++- .../management/commands/purge_by_cache_tags.py | 0 ...st_management_commands_delete_page_cache.py} | 2 +- ...management_commands_invalidate_all_pages.py} | 0 ...management_commands_invalidate_page_cache.py | 17 +++++++++++++++++ ..._management_commands_purge_by_cache_tags.py} | 0 10 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 cfgov/cdntools/management/__init__.py create mode 100644 cfgov/cdntools/management/commands/__init__.py rename cfgov/{v1 => cdntools}/management/commands/delete_page_cache.py (100%) rename cfgov/{v1 => cdntools}/management/commands/invalidate_all_pages_cache.py (100%) rename cfgov/{v1 => cdntools}/management/commands/invalidate_page_cache.py (86%) rename cfgov/{v1 => cdntools}/management/commands/purge_by_cache_tags.py (100%) rename cfgov/{v1/tests/management/commands/test_delete_page_cache.py => cdntools/tests/test_management_commands_delete_page_cache.py} (94%) rename cfgov/{v1/tests/management/commands/test_invalidate_all_pages.py => cdntools/tests/test_management_commands_invalidate_all_pages.py} (100%) create mode 100644 cfgov/cdntools/tests/test_management_commands_invalidate_page_cache.py rename cfgov/{v1/tests/management/commands/test_purge_by_cache_tags.py => cdntools/tests/test_management_commands_purge_by_cache_tags.py} (100%) diff --git a/cfgov/cdntools/management/__init__.py b/cfgov/cdntools/management/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/cfgov/cdntools/management/commands/__init__.py b/cfgov/cdntools/management/commands/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/cfgov/v1/management/commands/delete_page_cache.py b/cfgov/cdntools/management/commands/delete_page_cache.py similarity index 100% rename from cfgov/v1/management/commands/delete_page_cache.py rename to cfgov/cdntools/management/commands/delete_page_cache.py diff --git a/cfgov/v1/management/commands/invalidate_all_pages_cache.py b/cfgov/cdntools/management/commands/invalidate_all_pages_cache.py similarity index 100% rename from cfgov/v1/management/commands/invalidate_all_pages_cache.py rename to cfgov/cdntools/management/commands/invalidate_all_pages_cache.py diff --git a/cfgov/v1/management/commands/invalidate_page_cache.py b/cfgov/cdntools/management/commands/invalidate_page_cache.py similarity index 86% rename from cfgov/v1/management/commands/invalidate_page_cache.py rename to cfgov/cdntools/management/commands/invalidate_page_cache.py index e133075841a..af51003f6b4 100644 --- a/cfgov/v1/management/commands/invalidate_page_cache.py +++ b/cfgov/cdntools/management/commands/invalidate_page_cache.py @@ -19,5 +19,9 @@ def add_arguments(self, parser): def handle(self, *args, **options): batch = PurgeBatch() - batch.add_urls(options["url"]) + batch.add_urls( + [ + options["url"], + ] + ) batch.purge() diff --git a/cfgov/v1/management/commands/purge_by_cache_tags.py b/cfgov/cdntools/management/commands/purge_by_cache_tags.py similarity index 100% rename from cfgov/v1/management/commands/purge_by_cache_tags.py rename to cfgov/cdntools/management/commands/purge_by_cache_tags.py diff --git a/cfgov/v1/tests/management/commands/test_delete_page_cache.py b/cfgov/cdntools/tests/test_management_commands_delete_page_cache.py similarity index 94% rename from cfgov/v1/tests/management/commands/test_delete_page_cache.py rename to cfgov/cdntools/tests/test_management_commands_delete_page_cache.py index 34e54f0a200..8677a7856c2 100644 --- a/cfgov/v1/tests/management/commands/test_delete_page_cache.py +++ b/cfgov/cdntools/tests/test_management_commands_delete_page_cache.py @@ -17,7 +17,7 @@ class DeletePageCacheTestCase(TestCase): } ) @mock.patch( - "v1.management.commands.delete_page_cache.purge_urls_from_cache" + "cdntools.management.commands.delete_page_cache.purge_urls_from_cache" ) def test_submission_with_url_akamai(self, mock_purge): call_command("delete_page_cache", url="https://server/foo/bar") diff --git a/cfgov/v1/tests/management/commands/test_invalidate_all_pages.py b/cfgov/cdntools/tests/test_management_commands_invalidate_all_pages.py similarity index 100% rename from cfgov/v1/tests/management/commands/test_invalidate_all_pages.py rename to cfgov/cdntools/tests/test_management_commands_invalidate_all_pages.py diff --git a/cfgov/cdntools/tests/test_management_commands_invalidate_page_cache.py b/cfgov/cdntools/tests/test_management_commands_invalidate_page_cache.py new file mode 100644 index 00000000000..f2336fec7d1 --- /dev/null +++ b/cfgov/cdntools/tests/test_management_commands_invalidate_page_cache.py @@ -0,0 +1,17 @@ +from django.core.management import call_command +from django.test import TestCase, override_settings + +from cdntools.backends import MOCK_PURGED + + +@override_settings( + WAGTAILFRONTENDCACHE={ + "akamai": { + "BACKEND": "cdntools.backends.MockCacheBackend", + } + } +) +class InvalidatePageTestCase(TestCase): + def test_submission_with_url(self): + call_command("invalidate_page_cache", url="https://server/foo/bar") + self.assertIn("https://server/foo/bar", MOCK_PURGED) diff --git a/cfgov/v1/tests/management/commands/test_purge_by_cache_tags.py b/cfgov/cdntools/tests/test_management_commands_purge_by_cache_tags.py similarity index 100% rename from cfgov/v1/tests/management/commands/test_purge_by_cache_tags.py rename to cfgov/cdntools/tests/test_management_commands_purge_by_cache_tags.py From ec57c25933eb0ff99f5e43246655e33f2c8a85fe Mon Sep 17 00:00:00 2001 From: Will Barton Date: Mon, 29 Jul 2024 10:49:00 -0400 Subject: [PATCH 08/14] Remove remaining feedback report export code Feedback was removed in #7046 and the feedback report was removed in #7159. This removes the `export_feedback` permission and the template used for the feedback report. --- cfgov/v1/templates/v1/export_feedback.html | 26 ---------------------- cfgov/v1/wagtail_hooks.py | 8 ------- 2 files changed, 34 deletions(-) delete mode 100644 cfgov/v1/templates/v1/export_feedback.html diff --git a/cfgov/v1/templates/v1/export_feedback.html b/cfgov/v1/templates/v1/export_feedback.html deleted file mode 100644 index 8042d55ed3c..00000000000 --- a/cfgov/v1/templates/v1/export_feedback.html +++ /dev/null @@ -1,26 +0,0 @@ -{% extends "wagtailadmin/base.html" %} -{% load i18n %} -{% block titletag %}{% translate "Export feedback" %}{% endblock %} - -{% block extra_css %} - -{% endblock %} - -{% block content %} - {% include "wagtailadmin/shared/header.html" with title="Export feedback" icon="download" %} - -
-

- Download a ZIP file containing feedback submitted to the website. -

-
- {% csrf_token %} - {{ form }} - -
-
-{% endblock %} diff --git a/cfgov/v1/wagtail_hooks.py b/cfgov/v1/wagtail_hooks.py index b1f1b1d5e5a..af15b62f318 100644 --- a/cfgov/v1/wagtail_hooks.py +++ b/cfgov/v1/wagtail_hooks.py @@ -2,7 +2,6 @@ import re from django.conf import settings -from django.contrib.auth.models import Permission from django.core.exceptions import PermissionDenied from django.shortcuts import redirect from django.urls import reverse @@ -348,13 +347,6 @@ def clean_up_report_menu_items(request, report_menu_items): register_snippet(BannerViewSet) -@hooks.register("register_permissions") -def add_export_feedback_permission_to_wagtail_admin_group_view(): - return Permission.objects.filter( - content_type__app_label="v1", codename="export_feedback" - ) - - register_template_debug( "v1", "call_to_action", From ebb6fa45a0338349b002e1b177c6c952e1a756c6 Mon Sep 17 00:00:00 2001 From: Will Barton Date: Mon, 29 Jul 2024 10:59:40 -0400 Subject: [PATCH 09/14] Update reports for universal listings Wagtail 6.2 changes reports to use the new universal listing templates. This necessitates several changes, outlined in the release notes here: https://docs.wagtail.org/en/latest/releases/6.2.html#changes-to-report-views-with-the-new-universal-listings-ui This change makes all of these required changes to our existing reports and report templates. In the process, I have also removed the "no-XSLX" base template and option; all our reports are now available as both CSV and XSLX. fix http --- .../v1/templates/v1/active_users_report.html | 2 +- cfgov/v1/templates/v1/ask_report.html | 2 +- .../v1/templates/v1/category_icon_report.html | 2 +- cfgov/v1/templates/v1/documents_report.html | 2 +- .../v1/enforcement_actions_report.html | 2 +- cfgov/v1/templates/v1/images_report.html | 2 +- cfgov/v1/templates/v1/page_draft_report.html | 6 +- .../v1/templates/v1/page_metadata_report.html | 4 +- .../v1/templates/v1/report_no-xlsx_base.html | 10 --- .../templates/v1/translated_pages_report.html | 6 +- cfgov/v1/tests/wagtail_pages/helpers.py | 4 +- cfgov/v1/views/reports.py | 54 +++++++---- cfgov/v1/wagtail_hooks.py | 89 +++++++++++++------ 13 files changed, 116 insertions(+), 69 deletions(-) delete mode 100644 cfgov/v1/templates/v1/report_no-xlsx_base.html diff --git a/cfgov/v1/templates/v1/active_users_report.html b/cfgov/v1/templates/v1/active_users_report.html index 3ee33dc03fc..a36a750d5ec 100644 --- a/cfgov/v1/templates/v1/active_users_report.html +++ b/cfgov/v1/templates/v1/active_users_report.html @@ -1,4 +1,4 @@ -{% extends 'wagtailadmin/reports/base_report.html' %} +{% extends 'wagtailadmin/reports/base_page_report_results.html' %} {% block results %} {% if object_list %} diff --git a/cfgov/v1/templates/v1/ask_report.html b/cfgov/v1/templates/v1/ask_report.html index f3523feccaa..069323bfa8d 100644 --- a/cfgov/v1/templates/v1/ask_report.html +++ b/cfgov/v1/templates/v1/ask_report.html @@ -1,4 +1,4 @@ -{% extends 'v1/report_no-xlsx_base.html' %} +{% extends 'wagtailadmin/reports/base_page_report_results.html' %} {% block results %} {% if object_list %} diff --git a/cfgov/v1/templates/v1/category_icon_report.html b/cfgov/v1/templates/v1/category_icon_report.html index a92b0a9f20a..a3a20257ab8 100644 --- a/cfgov/v1/templates/v1/category_icon_report.html +++ b/cfgov/v1/templates/v1/category_icon_report.html @@ -1,4 +1,4 @@ -{% extends 'v1/report_no-xlsx_base.html' %} +{% extends 'wagtailadmin/reports/base_page_report_results.html' %} {% load svg_icon %} diff --git a/cfgov/v1/templates/v1/documents_report.html b/cfgov/v1/templates/v1/documents_report.html index fee7618528d..76e93a555aa 100644 --- a/cfgov/v1/templates/v1/documents_report.html +++ b/cfgov/v1/templates/v1/documents_report.html @@ -1,4 +1,4 @@ -{% extends 'v1/report_no-xlsx_base.html' %} +{% extends 'wagtailadmin/reports/base_page_report_results.html' %} {% block results %} {% if object_list %} diff --git a/cfgov/v1/templates/v1/enforcement_actions_report.html b/cfgov/v1/templates/v1/enforcement_actions_report.html index 85cbb121e3e..c723f6ea246 100644 --- a/cfgov/v1/templates/v1/enforcement_actions_report.html +++ b/cfgov/v1/templates/v1/enforcement_actions_report.html @@ -1,4 +1,4 @@ -{% extends 'v1/report_no-xlsx_base.html' %} +{% extends 'wagtailadmin/reports/base_page_report_results.html' %} {% block results %} {% if object_list %} diff --git a/cfgov/v1/templates/v1/images_report.html b/cfgov/v1/templates/v1/images_report.html index 3add382adfd..6f15d5da19f 100644 --- a/cfgov/v1/templates/v1/images_report.html +++ b/cfgov/v1/templates/v1/images_report.html @@ -1,4 +1,4 @@ -{% extends 'v1/report_no-xlsx_base.html' %} +{% extends 'wagtailadmin/reports/base_page_report_results.html' %} {% block results %} {% if object_list %} diff --git a/cfgov/v1/templates/v1/page_draft_report.html b/cfgov/v1/templates/v1/page_draft_report.html index c431c0ce0ff..4a80aecca3f 100644 --- a/cfgov/v1/templates/v1/page_draft_report.html +++ b/cfgov/v1/templates/v1/page_draft_report.html @@ -1,4 +1,4 @@ -{% extends 'wagtailadmin/reports/base_page_report.html' %} +{% extends 'wagtailadmin/reports/base_page_report_results.html' %} {% load i18n wagtailadmin_tags %} {% block actions %} @@ -9,10 +9,10 @@ {% endif %} {% endblock %} -{% block listing %} +{% block results %} {% include 'v1/_list_page_drafts.html' %} {% endblock %} -{% block no_results %} +{% block no_results_message %}

No unpublished drafts.

{% endblock %} diff --git a/cfgov/v1/templates/v1/page_metadata_report.html b/cfgov/v1/templates/v1/page_metadata_report.html index 9e23c1617f1..fdb902730e9 100644 --- a/cfgov/v1/templates/v1/page_metadata_report.html +++ b/cfgov/v1/templates/v1/page_metadata_report.html @@ -1,4 +1,4 @@ -{% extends 'wagtailadmin/reports/base_page_report.html' %} +{% extends 'wagtailadmin/reports/base_page_report_results.html' %} {% load i18n wagtailadmin_tags %} {% block actions %} @@ -9,7 +9,7 @@ {% endif %} {% endblock %} -{% block listing %} +{% block results %} {% include 'v1/_list_page_metadata.html' %} {% endblock %} diff --git a/cfgov/v1/templates/v1/report_no-xlsx_base.html b/cfgov/v1/templates/v1/report_no-xlsx_base.html deleted file mode 100644 index ae52684baf1..00000000000 --- a/cfgov/v1/templates/v1/report_no-xlsx_base.html +++ /dev/null @@ -1,10 +0,0 @@ -{% extends 'wagtailadmin/reports/base_report.html' %} -{% load i18n wagtailadmin_tags %} - -{% block actions %} - {% if view.list_export %} - - {% endif %} -{% endblock %} diff --git a/cfgov/v1/templates/v1/translated_pages_report.html b/cfgov/v1/templates/v1/translated_pages_report.html index da7240b2aa2..445e944e89f 100644 --- a/cfgov/v1/templates/v1/translated_pages_report.html +++ b/cfgov/v1/templates/v1/translated_pages_report.html @@ -1,10 +1,10 @@ -{% extends 'wagtailadmin/reports/base_page_report.html' %} +{% extends 'wagtailadmin/reports/base_page_report_results.html' %} {% load i18n %} -{% block listing %} +{% block results %} {% include 'v1/_list_translated_page.html' %} {% endblock %} -{% block no_results %} +{% block no_results_message %}

{% translate "No pages found." %}

{% endblock %} diff --git a/cfgov/v1/tests/wagtail_pages/helpers.py b/cfgov/v1/tests/wagtail_pages/helpers.py index 50fa87945c2..36b3bcb5b77 100644 --- a/cfgov/v1/tests/wagtail_pages/helpers.py +++ b/cfgov/v1/tests/wagtail_pages/helpers.py @@ -2,7 +2,7 @@ from datetime import date from django.core.exceptions import ValidationError -from django.http import Http404 +from django.http import Http404, HttpRequest from wagtail.models import Site @@ -52,7 +52,7 @@ def get_parent_route(site, parent_path=None): ] try: - route = root.route(None, path_components) + route = root.route(HttpRequest(), path_components) except Http404: return diff --git a/cfgov/v1/views/reports.py b/cfgov/v1/views/reports.py index 302b9ea7ec7..e9b43d364be 100644 --- a/cfgov/v1/views/reports.py +++ b/cfgov/v1/views/reports.py @@ -76,7 +76,7 @@ def generate_filename(type): class PageMetadataReportView(PageReportView): header_icon = "doc-empty-inverse" - title = "Page Metadata (for Live Pages)" + page_title = "Page Metadata (for Live Pages)" list_export = PageReportView.list_export + [ "url", @@ -109,7 +109,9 @@ class PageMetadataReportView(PageReportView): } } - template_name = "v1/page_metadata_report.html" + index_url_name = "page_metadata_report" + index_results_url_name = "page_metadata_report_results" + results_template_name = "v1/page_metadata_report.html" def get_filename(self): return generate_filename("pages") @@ -122,7 +124,7 @@ def get_queryset(self): class DraftReportView(PageReportView): header_icon = "doc-empty" - title = "Draft Pages" + page_title = "Draft Pages" list_export = PageReportView.list_export + [ "url", @@ -151,7 +153,9 @@ class DraftReportView(PageReportView): } } - template_name = "v1/page_draft_report.html" + index_url_name = "page_drafts_report" + index_results_url_name = "page_drafts_report_results" + results_template_name = "v1/page_draft_report.html" def get_filename(self): return generate_filename("pages") @@ -167,7 +171,7 @@ def get_queryset(self): class DocumentsReportView(ReportView): header_icon = "doc-full" - title = "Documents" + page_title = "Documents" list_export = [ "id", @@ -195,7 +199,9 @@ class DocumentsReportView(ReportView): "url": {"csv": construct_absolute_url}, } - template_name = "v1/documents_report.html" + index_url_name = "documents_report" + index_results_url_name = "documents_report_results" + results_template_name = "v1/documents_report.html" def get_filename(self): return generate_filename("documents") @@ -206,7 +212,7 @@ def get_queryset(self): class ImagesReportView(ReportView): header_icon = "image" - title = "Images" + page_title = "Images" list_export = [ "title", @@ -231,7 +237,9 @@ class ImagesReportView(ReportView): "tags.names": {"csv": process_tags}, } - template_name = "v1/images_report.html" + index_url_name = "images_report" + index_results_url_name = "images_report_results" + results_template_name = "v1/images_report.html" def get_filename(self): return generate_filename("images") @@ -247,7 +255,7 @@ def get_queryset(self): class EnforcementActionsReportView(ReportView): header_icon = "form" - title = "Enforcement Actions" + page_title = "Enforcement Actions" list_export = [ "title", @@ -278,7 +286,9 @@ class EnforcementActionsReportView(ReportView): "url": {"csv": construct_absolute_url}, } - template_name = "v1/enforcement_actions_report.html" + index_url_name = "enforcement_report" + index_results_url_name = "enforcement_report_results" + results_template_name = "v1/enforcement_actions_report.html" def get_filename(self): """Get a better filename than the default 'spreadsheet-export'.""" @@ -292,7 +302,7 @@ def get_queryset(self): class AskReportView(ReportView): header_icon = "help" - title = "Ask CFPB" + page_title = "Ask CFPB" list_export = [ "answer_base", @@ -369,7 +379,9 @@ def process_answer_content(answer_content): }, } - template_name = "v1/ask_report.html" + index_url_name = "ask_report" + index_results_url_name = "ask_report_results" + results_template_name = "v1/ask_report.html" def get_filename(self): return generate_filename("ask-cfpb") @@ -383,9 +395,11 @@ def get_queryset(self): class CategoryIconReportView(ReportView): - title = "Category Icons" + page_title = "Category Icons" header_icon = "site" - template_name = "v1/category_icon_report.html" + index_url_name = "category_icons_report" + index_results_url_name = "category_icons_report_results" + results_template_name = "v1/category_icon_report.html" paginate_by = 0 list_export = [ @@ -431,9 +445,11 @@ class Meta: class TranslatedPagesReportView(PageReportView): - title = "Translated Pages" + page_title = "Translated Pages" header_icon = "site" - template_name = "v1/translated_pages_report.html" + index_url_name = "translated_pages_report" + index_results_url_name = "translated_pages_report_results" + results_template_name = "v1/translated_pages_report.html" filterset_class = TranslatedPagesReportFilterSet def get_queryset(self): @@ -448,9 +464,11 @@ def get_context_data(self, *args, **kwargs): class ActiveUsersReportView(ReportView): - title = "Active Users" + page_title = "Active Users" header_icon = "user" - template_name = "v1/active_users_report.html" + index_url_name = "active_users_report" + index_results_url_name = "active_users_report_results" + results_template_name = "v1/active_users_report.html" paginate_by = 0 list_export = [ diff --git a/cfgov/v1/wagtail_hooks.py b/cfgov/v1/wagtail_hooks.py index af15b62f318..6e072d3e9fa 100644 --- a/cfgov/v1/wagtail_hooks.py +++ b/cfgov/v1/wagtail_hooks.py @@ -4,7 +4,7 @@ from django.conf import settings from django.core.exceptions import PermissionDenied from django.shortcuts import redirect -from django.urls import reverse +from django.urls import path, re_path, reverse from django.utils.html import format_html_join from wagtail import hooks @@ -43,12 +43,6 @@ from v1.views.snippets import BannerViewSet -try: - from django.urls import re_path -except ImportError: - from django.conf.urls import url as re_path - - logger = logging.getLogger(__name__) @@ -162,11 +156,16 @@ def register_page_metadata_report_menu_item(): @hooks.register("register_admin_urls") def register_page_metadata_report_url(): return [ - re_path( - r"^reports/page-metadata/$", + path( + "reports/page-metadata/", PageMetadataReportView.as_view(), name="page_metadata_report", ), + path( + "reports/page-metadata/results/", + PageMetadataReportView.as_view(results_only=True), + name="page_metadata_report_results", + ), ] @@ -182,11 +181,16 @@ def register_page_drafts_report_menu_item(): @hooks.register("register_admin_urls") def register_page_drafts_report_url(): return [ - re_path( - r"^reports/page-drafts/$", + path( + "reports/page-drafts/", DraftReportView.as_view(), name="page_drafts_report", ), + path( + "reports/page-drafts/results/", + DraftReportView.as_view(results_only=True), + name="page_drafts_report_results", + ), ] @@ -202,11 +206,16 @@ def register_documents_report_menu_item(): @hooks.register("register_admin_urls") def register_documents_report_url(): return [ - re_path( - r"^reports/documents/$", + path( + "reports/documents/", DocumentsReportView.as_view(), name="documents_report", ), + path( + "reports/documents/results/", + DocumentsReportView.as_view(results_only=True), + name="documents_report_results", + ), ] @@ -222,11 +231,16 @@ def register_enforcements_actions_report_menu_item(): @hooks.register("register_admin_urls") def register_enforcements_actions_documents_report_url(): return [ - re_path( - r"^reports/enforcement-actions/$", + path( + "reports/enforcement-actions/", EnforcementActionsReportView.as_view(), name="enforcement_report", ), + path( + "reports/enforcement-actions/results/", + EnforcementActionsReportView.as_view(results_only=True), + name="enforcement_report_results", + ), ] @@ -242,11 +256,16 @@ def register_images_report_menu_item(): @hooks.register("register_admin_urls") def register_images_report_url(): return [ - re_path( - r"^reports/images/$", + path( + "reports/images/", ImagesReportView.as_view(), name="images_report", ), + path( + "reports/images/results/", + ImagesReportView.as_view(results_only=True), + name="images_report_results", + ), ] @@ -262,11 +281,16 @@ def register_ask_report_menu_item(): @hooks.register("register_admin_urls") def register_ask_report_url(): return [ - re_path( - r"^reports/ask-cfpb/$", + path( + "reports/ask-cfpb/", AskReportView.as_view(), name="ask_report", ), + path( + "reports/ask-cfpb/results/", + AskReportView.as_view(results_only=True), + name="ask_report_results", + ), ] @@ -282,11 +306,16 @@ def register_category_icons_report_menu_item(): @hooks.register("register_admin_urls") def register_category_icons_report_url(): return [ - re_path( - r"^reports/category-icons/$", + path( + "reports/category-icons/", CategoryIconReportView.as_view(), name="category_icons_report", ), + path( + "reports/category-icons/results/", + CategoryIconReportView.as_view(results_only=True), + name="category_icons_report_results", + ), ] @@ -302,11 +331,16 @@ def register_translated_pages_report_menu_item(): @hooks.register("register_admin_urls") def register_translated_pages_report_url(): return [ - re_path( - r"^reports/translated-pages/$", + path( + "reports/translated-pages/", TranslatedPagesReportView.as_view(), name="translated_pages_report", ), + path( + "reports/translated-pages/results/", + TranslatedPagesReportView.as_view(results_only=True), + name="translated_pages_report_results", + ), ] @@ -322,11 +356,16 @@ def register_active_users_report_menu_item(): @hooks.register("register_admin_urls") def register_active_users_report_url(): return [ - re_path( - r"^reports/active-users/$", + path( + "reports/active-users/", ActiveUsersReportView.as_view(), name="active_users_report", ), + path( + "reports/active-users/results/", + ActiveUsersReportView.as_view(), + name="active_users_report_results", + ), ] From 684f449080a402bab55344a61131d39bc3796ebd Mon Sep 17 00:00:00 2001 From: Will Barton Date: Thu, 1 Aug 2024 14:00:12 -0400 Subject: [PATCH 10/14] Submit a `main form` that doesn't have the `w-editing-sessions` class --- test/cypress/integration/admin/admin-helpers.cy.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/cypress/integration/admin/admin-helpers.cy.js b/test/cypress/integration/admin/admin-helpers.cy.js index 72ff32c5f74..2b6730550e9 100644 --- a/test/cypress/integration/admin/admin-helpers.cy.js +++ b/test/cypress/integration/admin/admin-helpers.cy.js @@ -193,7 +193,7 @@ export class AdminPage { } submitForm() { - cy.get('main form[method="POST"]').submit(); + cy.get('main form[method="POST"]:not(.w-editing-sessions)').submit(); } getFirstTableRow() { From ef4ea8ae53a3d3aef4f9f49db311755fff9038e9 Mon Sep 17 00:00:00 2001 From: Will Barton Date: Fri, 16 Aug 2024 10:23:00 -0400 Subject: [PATCH 11/14] Update lxml --- requirements/libraries.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/libraries.txt b/requirements/libraries.txt index 830601261d5..71ebad1c642 100644 --- a/requirements/libraries.txt +++ b/requirements/libraries.txt @@ -20,7 +20,7 @@ edgegrid-python==1.3.1 elasticsearch<7.11 # Keep pinned to the deployed ES version govdelivery==1.4.0 Jinja2==3.1.4 -lxml==5.1.0 +lxml==5.2.2 matplotlib==3.7.5 mozilla-django-oidc==4.0.1 opensearch-py==2.6.0 From e39d11e7fcf73bf2c21d5997e036467136e2d9dc Mon Sep 17 00:00:00 2001 From: Will Barton Date: Tue, 20 Aug 2024 12:51:25 -0400 Subject: [PATCH 12/14] Add `else` to FIG layout ternary --- .../jinja2/filing_instruction_guide/layout-fig.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cfgov/filing_instruction_guide/jinja2/filing_instruction_guide/layout-fig.html b/cfgov/filing_instruction_guide/jinja2/filing_instruction_guide/layout-fig.html index d42e7aacfb9..f031c2b75c7 100644 --- a/cfgov/filing_instruction_guide/jinja2/filing_instruction_guide/layout-fig.html +++ b/cfgov/filing_instruction_guide/jinja2/filing_instruction_guide/layout-fig.html @@ -6,8 +6,8 @@ 'archived': 'Archived' } %} {% set version_status = version_statuses[page.version_status] %} -{% set effective_start_date = page.effective_start_date.strftime('%B %d, %Y') if page.effective_start_date %} -{% set effective_end_date = page.effective_end_date.strftime('%B %d, %Y') if page.effective_end_date %} +{% set effective_start_date = page.effective_start_date.strftime('%B %d, %Y') if page.effective_start_date else None %} +{% set effective_end_date = page.effective_end_date.strftime('%B %d, %Y') if page.effective_end_date else None %} {% set version_message = 'This version has been archived.' if version_status == 'Archived' else 'This version is not the current FIG.' %} {% set date_message = 'You are viewing a previous version of the FIG.' %} {% if effective_start_date and effective_end_date %} From c3a8e0658b054a3a82591c518abec4d7fecce5d2 Mon Sep 17 00:00:00 2001 From: Will Barton Date: Tue, 27 Aug 2024 09:30:45 -0400 Subject: [PATCH 13/14] Untrue doc statement --- docs/caching.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/caching.md b/docs/caching.md index daf3a4fb7d5..da5323d5a1f 100644 --- a/docs/caching.md +++ b/docs/caching.md @@ -2,7 +2,7 @@ ### Akamai -We use [Akamai](https://www.akamai.com/), a content delivery network, to cache the entirety of [www.consumerfinance.gov](https://www.consumerfinance.gov/) (but not our development servers). We invalidate any given page in Wagtail when it is published or unpublished (by hooking up the custom class [`AkamaiBackend`](https://github.com/cfpb/consumerfinance.gov/blob/main/cfgov/cdntools/backends.py) to [Wagtail's frontend cache invalidator](http://docs.wagtail.io/en/v2.0.1/reference/contrib/frontendcache.html). By default, we clear the Akamai cache any time we deploy. +We use [Akamai](https://www.akamai.com/), a content delivery network, to cache the entirety of [www.consumerfinance.gov](https://www.consumerfinance.gov/) (but not our development servers). We invalidate any given page in Wagtail when it is published or unpublished (by hooking up the custom class [`AkamaiBackend`](https://github.com/cfpb/consumerfinance.gov/blob/main/cfgov/cdntools/backends.py) to [Wagtail's frontend cache invalidator](http://docs.wagtail.io/en/v2.0.1/reference/contrib/frontendcache.html). There are certain pages that do not live in Wagtail or are impacted by changes on another page (imagine our [newsroom page](https://www.consumerfinance.gov/about-us/newsroom/) that lists titles of other pages) or another process (imagine data from Socrata gets updated) and thus will display outdated content until the page's time to live (TTL) has expired, a deploy has happened, or if someone manually invalidates that page. Our default TTL is 24 hours. From c52572f5598566f354723f2e7689304749ea8164 Mon Sep 17 00:00:00 2001 From: Will Barton Date: Wed, 4 Sep 2024 14:49:33 -0400 Subject: [PATCH 14/14] Update .env_SAMPLE Co-authored-by: Andy Chosak --- .env_SAMPLE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env_SAMPLE b/.env_SAMPLE index 3b0e339cc59..6a3e040721b 100755 --- a/.env_SAMPLE +++ b/.env_SAMPLE @@ -51,7 +51,7 @@ export ALLOW_ADMIN_URL=True # export ENABLE_CLOUDFRONT_CACHE_PURGE=True # export CLOUDFRONT_DISTRIBUTION_ID_FILES= -#export CLOUDFRONT_PURGE_HOSTNAMES= +# export CLOUDFRONT_PURGE_HOSTNAMES= ######################################################################### # Postgres Environment Vars.