From cc3958d18351573986243289e2bd475e512045f4 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 8 Oct 2026 15:07:07 -0500 Subject: [PATCH 1/2] purge minutes (details, list, rss) pages on save/delete --- apps/minutes/models.py | 34 +++++++++++++++++++++++++++++++ apps/minutes/tests/test_models.py | 32 +++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/apps/minutes/models.py b/apps/minutes/models.py index 3147d0a32..f4e860bab 100644 --- a/apps/minutes/models.py +++ b/apps/minutes/models.py @@ -2,11 +2,14 @@ from django.conf import settings from django.db import models +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,34 @@ 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 so edits show up immediately. + + Purges regardless of publish state so unpublished or deleted minutes disappear too. + """ + # Skip in fixtures + if kwargs.get("raw", False): + return + + current_url = instance.get_absolute_url() + purge_url(current_url) + previous_url = getattr(instance, "previous_url", None) + if previous_url and previous_url != current_url: + purge_url(previous_url) + purge_url(reverse("minutes_list")) + purge_url(reverse("minutes_feed")) diff --git a/apps/minutes/tests/test_models.py b/apps/minutes/tests/test_models.py index eedbd88c6..9f120f790 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,34 @@ 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): + 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_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) + purge_url.reset_mock() + minutes.date = datetime.date(2024, 3, 6) + 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) + purge_url.reset_mock() + minutes.delete() + self.assertIn("/psf/records/board/minutes/2024-03-05/", self.purged(purge_url)) From 0cccb088b96568ec4de1c7ecb3a265c7bbd49ed2 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 8 Oct 2026 15:20:45 -0500 Subject: [PATCH 2/2] code review --- apps/minutes/models.py | 17 +++++++++++------ apps/minutes/tests/test_models.py | 17 ++++++++++++----- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/apps/minutes/models.py b/apps/minutes/models.py index f4e860bab..1e4773151 100644 --- a/apps/minutes/models.py +++ b/apps/minutes/models.py @@ -1,7 +1,7 @@ """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 @@ -72,18 +72,23 @@ def remember_previous_url(sender, instance, **kwargs): @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 so edits show up immediately. + """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() - purge_url(current_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: - purge_url(previous_url) - purge_url(reverse("minutes_list")) - purge_url(reverse("minutes_feed")) + 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 9f120f790..a0149601b 100644 --- a/apps/minutes/tests/test_models.py +++ b/apps/minutes/tests/test_models.py @@ -38,7 +38,8 @@ 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): - Minutes.objects.create(date=datetime.date(2024, 3, 5), content="x", is_published=True) + 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), { @@ -48,16 +49,22 @@ def test_save_purges_detail_list_and_feed(self, purge_url): }, ) + 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) - purge_url.reset_mock() minutes.date = datetime.date(2024, 3, 6) - minutes.save() + 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) - purge_url.reset_mock() - minutes.delete() + with self.captureOnCommitCallbacks(execute=True): + minutes.delete() self.assertIn("/psf/records/board/minutes/2024-03-05/", self.purged(purge_url))