diff --git a/apps/minutes/models.py b/apps/minutes/models.py index 3147d0a32..1e4773151 100644 --- a/apps/minutes/models.py +++ b/apps/minutes/models.py @@ -1,12 +1,15 @@ """Models for PSF board meeting minutes.""" from django.conf import settings -from django.db import models +from django.db import models, transaction +from django.db.models.signals import post_delete, post_save, pre_save +from django.dispatch import receiver from django.urls import reverse from markupfield.fields import MarkupField from apps.cms.models import ContentManageable from apps.minutes.managers import MinutesQuerySet +from fastly.utils import purge_url DEFAULT_MARKUP_TYPE = getattr(settings, "DEFAULT_MARKUP_TYPE", "restructuredtext") @@ -53,3 +56,39 @@ def get_date_month(self): def get_date_day(self): """Return the meeting date's zero-padded day string.""" return self.date.strftime("%d").zfill(2) + + +@receiver(pre_save, sender=Minutes) +def remember_previous_url(sender, instance, **kwargs): + """Remember the stored detail URL so a changed meeting date also purges the old page.""" + instance.previous_url = None + if kwargs.get("raw", False) or instance.pk is None: + return + previous = Minutes.objects.filter(pk=instance.pk).only("date").first() + if previous is not None: + instance.previous_url = previous.get_absolute_url() + + +@receiver(post_save, sender=Minutes) +@receiver(post_delete, sender=Minutes) +def purge_fastly_cache(sender, instance, **kwargs): + """Purge the minutes detail, list, and feed pages once the change is committed. + + Purges regardless of publish state so unpublished or deleted minutes disappear too. + Deferred to commit so a request racing the admin transaction can't re-cache stale content. + """ + # Skip in fixtures + if kwargs.get("raw", False): + return + + current_url = instance.get_absolute_url() + urls = [current_url, reverse("minutes_list"), reverse("minutes_feed")] + previous_url = getattr(instance, "previous_url", None) + if previous_url and previous_url != current_url: + urls.append(previous_url) + + def purge(): + for url in urls: + purge_url(url) + + transaction.on_commit(purge, using=kwargs["using"]) diff --git a/apps/minutes/tests/test_models.py b/apps/minutes/tests/test_models.py index eedbd88c6..a0149601b 100644 --- a/apps/minutes/tests/test_models.py +++ b/apps/minutes/tests/test_models.py @@ -1,4 +1,5 @@ import datetime +from unittest import mock from django.test import TestCase @@ -29,3 +30,41 @@ def test_date_methods(self): self.assertEqual(self.m1.get_date_year(), "2012") self.assertEqual(self.m1.get_date_month(), "01") self.assertEqual(self.m1.get_date_day(), "01") + + +@mock.patch("apps.minutes.models.purge_url") +class MinutesPurgeTests(TestCase): + def purged(self, purge_url): + return {c.args[0] for c in purge_url.call_args_list} + + def test_save_purges_detail_list_and_feed(self, purge_url): + with self.captureOnCommitCallbacks(execute=True): + Minutes.objects.create(date=datetime.date(2024, 3, 5), content="x", is_published=True) + self.assertEqual( + self.purged(purge_url), + { + "/psf/records/board/minutes/2024-03-05/", + "/psf/records/board/minutes/", + "/psf/records/board/minutes/feed/", + }, + ) + + def test_no_purge_before_commit(self, purge_url): + with self.captureOnCommitCallbacks(execute=False) as callbacks: + Minutes.objects.create(date=datetime.date(2024, 3, 5), content="x", is_published=True) + purge_url.assert_not_called() + self.assertEqual(len(callbacks), 1) + + def test_changing_date_purges_old_and_new_detail(self, purge_url): + minutes = Minutes.objects.create(date=datetime.date(2024, 3, 5), content="x", is_published=True) + minutes.date = datetime.date(2024, 3, 6) + with self.captureOnCommitCallbacks(execute=True): + minutes.save() + self.assertIn("/psf/records/board/minutes/2024-03-05/", self.purged(purge_url)) + self.assertIn("/psf/records/board/minutes/2024-03-06/", self.purged(purge_url)) + + def test_delete_purges_detail(self, purge_url): + minutes = Minutes.objects.create(date=datetime.date(2024, 3, 5), content="x", is_published=True) + with self.captureOnCommitCallbacks(execute=True): + minutes.delete() + self.assertIn("/psf/records/board/minutes/2024-03-05/", self.purged(purge_url))