From a802341468f31f28bb7f7fc1dee8dd5af6f37d80 Mon Sep 17 00:00:00 2001
From: Christoph Berg <myon@debian.org>
Date: Fri, 16 Oct 2026 18:07:11 +0200
Subject: [PATCH v14 07/12] Purge badge pages and holder profiles through xkeys

A badge change (title, image, approval, ...) has to invalidate the badge
page, its image and every holder's profile page, and removing a holder has
to invalidate that holder's profile. Enumerating the holder profile URLs is
not practical (a badge can have hundreds of holders), so tag the responses
and purge by tag instead:

- badge_view and badge_image tag the response with pgbadge_<id>,
- profile tags the response with pgbadge_<id> for every badge it shows,
- Badge.purge_xkeys() yields pgbadge_<id>.

Saving the badge then purges all of those pages. That also covers removing
a holder: BadgeForm removes it and simple_form saves the badge right after.

Badge.purge_urls() now only purges /community/people/: the all-people page
shows every badge and every person, so it is invalidated explicitly by URL
rather than tagged, since tagging it would need one tag per badge in a
single response.

Contributor.purge_urls() keeps purging the badge pages that list the
contributor: a rename has to invalidate them, but the badge-side xkey would
also clear every other holder's profile page, and tagging the badge page
per holder is not an option either.
---
 pgweb/contributors/models.py | 20 +++++++++++++++-----
 pgweb/contributors/views.py  | 22 +++++++++++++++++-----
 2 files changed, 32 insertions(+), 10 deletions(-)

diff --git a/pgweb/contributors/models.py b/pgweb/contributors/models.py
index e02a896d..d3bd8eef 100644
--- a/pgweb/contributors/models.py
+++ b/pgweb/contributors/models.py
@@ -70,6 +70,12 @@ class Contributor(models.Model):
         if url:
             yield url
         if self.user_id:
+            # These badge pages could also be purged with an xkey, but not with
+            # the pgbadge_<id> one: that would clear every other holder's
+            # profile page as well. A per-holder pguser_<id> tag on the badge
+            # page is not an option either, since it would add one tag per
+            # holder to the response (hundreds for a popular badge). So purge
+            # the (few) badge pages that actually list this contributor by URL.
             for badge_slug in Badgeholder.objects.filter(user_id=self.user_id).values_list('badge__slug', flat=True):
                 yield '/community/badge/%s/$' % badge_slug
 
@@ -132,13 +138,17 @@ class Badge(TwostateModerateModel):
         super().save(*args, **kwargs)
 
     def purge_urls(self):
-        # The badge's own page and image, plus the badge grid on the
-        # all-people page. Holder profiles are not purged here; they get
-        # refreshed when their holder rows are edited.
-        yield '/community/badge/%s/$' % self.slug
-        yield '/community/badge/%s/image/$' % self.slug
+        # The badge page, its image and every holder profile page carry the
+        # pgbadge_<id> xkey (see purge_xkeys below). The all-people page shows
+        # every badge and has no xkey of its own, so it is purged by URL.
         yield '/community/people/$'
 
+    def purge_xkeys(self):
+        # Badge pages, badge images and holder profile pages are tagged with
+        # this key, so a badge change purges all of them without having to
+        # enumerate the holder profile URLs.
+        yield 'pgbadge_%d' % self.pk
+
     @property
     def image_preview(self):
         # Rendered into the moderation preview. The image can't be served
diff --git a/pgweb/contributors/views.py b/pgweb/contributors/views.py
index 3dc04a55..c0faf542 100644
--- a/pgweb/contributors/views.py
+++ b/pgweb/contributors/views.py
@@ -35,33 +35,45 @@ def badge_view(request, slug):
     holders = Badgeholder.objects.filter(badge=badge, user__contributor__isnull=False). \
         select_related('user', 'user__contributor'). \
         order_by('user__contributor__lastname', 'user__contributor__firstname')
-    return render_pgweb(request, 'community', 'contributors/badge.html', {
+    r = render_pgweb(request, 'community', 'contributors/badge.html', {
         'badge': badge,
         'holders': holders,
     })
+    # Let a badge change purge this page through its xkey.
+    r['xkey'] = 'pgbadge_%d' % badge.id
+    return r
 
 
 def profile(request, username):
     contributor = get_object_or_404(Contributor, user__username=username)
     badges = (Badgeholder.objects.filter(user=contributor.user, badge__approved=True)
               .prefetch_related(Prefetch('badge', queryset=Badge.objects.with_has_image())))
-    return render_pgweb(request, 'community', 'contributors/profile.html', {
+    r = render_pgweb(request, 'community', 'contributors/profile.html', {
         'contributor': contributor,
         'badges': badges,
     })
+    # Tag this page with every badge it shows, so a badge change (title,
+    # image, approval, ...) purges it. Iterating the queryset here fills its
+    # result cache, so the template's loop below does not run it again.
+    r['xkey'] = ' '.join('pgbadge_%d' % bh.badge_id for bh in badges)
+    return r
 
 
 def badge_image(request, slug):
     # defer(None) clears the manager's default defer: .only() on its own
     # would leave an already-deferred field deferred and fetch it lazily.
-    badge = get_object_or_404(Badge.objects.defer(None).only('slug', 'imagedata'), slug=slug, approved=True)
+    badge = get_object_or_404(Badge.objects.defer(None).only('id', 'slug', 'imagedata'), slug=slug, approved=True)
     if not badge.imagedata:
         raise Http404
     etag = '"' + hashlib.md5(bytes(badge.imagedata)).hexdigest() + '"'
+    # Tag the image so a badge change purges it through its xkey. The 304 does
+    # not strictly need it (only the 200 is stored as a cache object), but it
+    # is set on both responses to keep them identical.
+    headers = {"ETag": etag, "xkey": "pgbadge_%d" % badge.id}
     if request.headers.get('If-None-Match') == etag:
-        return HttpResponseNotModified(headers={"ETag": etag})
+        return HttpResponseNotModified(headers=headers)
     return HttpResponse(
         bytes(badge.imagedata),
         content_type=get_image_contenttype_from_bytes(badge.imagedata[:8]),
-        headers={"ETag": etag},
+        headers=headers,
     )
-- 
2.53.0

