From 129b0c753659585993325ed987b8f8c2eb6b393f Mon Sep 17 00:00:00 2001
From: Christoph Berg <myon@debian.org>
Date: Fri, 16 Oct 2026 18:41:54 +0200
Subject: [PATCH v14 08/12] Purge on delete as well as on save

Deletes never purged anything - only post_save was hooked. This is generic
infrastructure: it is shared by every model that declares purge_urls or
purge_xkeys, so the fix covers all of them, not just contributor badges.
News articles, events, products, professional services, organisations,
versions, mailing lists, quotes and the rest all get it too.

That breadth is the point, and is why it is fixed here instead of per app.
The gap was identical for every item type and there is no reason for one of
them to behave differently, so a single central fix means no model has to
remember to purge its own deletions, and the behaviour stays consistent
with the purge_urls/purge_xkeys declarations - those already describe
everything that shows an object, so deletes now simply honour them as well.
Doing it per app would have meant re-implementing the same receiver over
and over, and, more importantly, the gap would likely reappear with the
next model that gets added.

It matters most where stale content is user visible or private: deleting a
contributor left their public profile page, which can include their email,
in the cache, and deleting a badge or a badge holder left its pages behind.

Extract the purge logic into a helper and connect a post_delete handler to
it as well. post_delete is used instead of overriding Model.delete()
because it also fires for cascading deletes (deleting a User removes the
Contributor and Badgeholder rows) and for bulk deletes from the admin,
both of which bypass Model.delete().

Wrap the purge generation in a try/except: it reads related rows and can
fail, for example when a cascade delete has already removed one of them,
and an exception raised in a signal handler would abort the save or delete
transaction.
---
 pgweb/util/signals.py | 51 +++++++++++++++++++++++++++++++------------
 1 file changed, 37 insertions(+), 14 deletions(-)

diff --git a/pgweb/util/signals.py b/pgweb/util/signals.py
index 8b69dd88..646986b0 100644
--- a/pgweb/util/signals.py
+++ b/pgweb/util/signals.py
@@ -1,8 +1,9 @@
-from django.db.models.signals import pre_save, post_save, pre_delete
+from django.db.models.signals import pre_save, post_save, pre_delete, post_delete
 from django.db import models
 from django.conf import settings
 
 import difflib
+import logging
 
 from pgweb.util.middleware import get_current_user
 from pgweb.util.misc import varnish_purge, varnish_purge_xkey
@@ -10,6 +11,9 @@ from pgweb.util.moderation import ModerationState
 from pgweb.mailqueue.util import send_simple_mail
 
 
+log = logging.getLogger(__name__)
+
+
 def _build_url(obj):
     if obj.id:
         return "%s/admin/%s/%s/%s/" % (
@@ -149,23 +153,42 @@ def my_pre_delete_handler(sender, **kwargs):
                          _get_full_text_representation(instance))
 
 
+def _run_purges(instance):
+    # Purge generation reads related rows and can fail, for example when a
+    # cascade delete has already removed one of them. Swallow the error: this
+    # runs from a signal handler inside the save/delete transaction, and a
+    # purge failure must never abort that transaction.
+    try:
+        if hasattr(instance, 'purge_urls'):
+            if callable(instance.purge_urls):
+                purgelist = instance.purge_urls()
+            else:
+                purgelist = instance.purge_urls
+            list(map(varnish_purge, purgelist))
+        if hasattr(instance, 'purge_xkeys'):
+            if callable(instance.purge_xkeys):
+                purgelist = instance.purge_xkeys()
+            else:
+                purgelist = instance.purge_xkeys
+            list(map(varnish_purge_xkey, purgelist))
+    except Exception as e:
+        log.warning("Failed to purge for %s: %s", instance, e)
+
+
 def my_post_save_handler(sender, **kwargs):
-    instance = kwargs['instance']
-    if hasattr(instance, 'purge_urls'):
-        if callable(instance.purge_urls):
-            purgelist = instance.purge_urls()
-        else:
-            purgelist = instance.purge_urls
-        list(map(varnish_purge, purgelist))
-    if hasattr(instance, 'purge_xkeys'):
-        if callable(instance.purge_xkeys):
-            purgelist = instance.purge_xkeys()
-        else:
-            purgelist = instance.purge_xkeys
-        list(map(varnish_purge_xkey, purgelist))
+    _run_purges(kwargs['instance'])
+
+
+def my_post_delete_handler(sender, **kwargs):
+    # Deletes have to purge as well: a deleted contributor's profile page (which
+    # can contain their email) or a deleted badge must not stay in the cache.
+    # post_delete is used because it also fires for cascade and bulk deletes,
+    # which never call Model.delete().
+    _run_purges(kwargs['instance'])
 
 
 def register_basic_signal_handlers():
     pre_save.connect(my_pre_save_handler)
     pre_delete.connect(my_pre_delete_handler)
     post_save.connect(my_post_save_handler)
+    post_delete.connect(my_post_delete_handler)
-- 
2.53.0

