From c2fb000b976d490e6741938f1e880be9e5d7f7cb Mon Sep 17 00:00:00 2001
From: Christoph Berg <myon@debian.org>
Date: Fri, 16 Oct 2026 18:59:41 +0200
Subject: [PATCH v14 09/12] Store the badge image md5 in the database

badge_image() reads imagedata out of TOAST and computes its md5 on every
request, including when all it has to do is answer If-None-Match with a
304. Add a Badge.imagehash generated column holding the md5 of imagedata,
empty when there is no image, so a revalidation can be answered from this
alone.

The column is used the same way as SecurityPatch.cvenumber: the database
keeps it current, so nothing has to backfill it and no code path can
forget to update it. The following commits add the migration and make
badge_image() use it.
---
 pgweb/contributors/models.py | 15 ++++++++++++++-
 1 file changed, 14 insertions(+), 1 deletion(-)

diff --git a/pgweb/contributors/models.py b/pgweb/contributors/models.py
index d3bd8eef..bf8f8b62 100644
--- a/pgweb/contributors/models.py
+++ b/pgweb/contributors/models.py
@@ -3,7 +3,7 @@ import re
 
 from django.core.exceptions import ValidationError
 from django.db import models
-from django.db.models import Case, When
+from django.db.models import Case, F, Func, Value, When
 from django.db.models.functions import Length
 from django.db.models.lookups import GreaterThan
 from django.contrib.auth.models import User
@@ -112,6 +112,19 @@ class Badge(TwostateModerateModel):
     description = models.TextField(null=True, blank=True, help_text='What did the people do who contributed here?')
     url = models.URLField(max_length=100, null=True, blank=True, verbose_name='Contribution URL', help_text='URL for this contribution, e.g. the conference homepage. (Leave blank when there is no URL.)')
     imagedata = ImageBinaryField(max_length=1000000, resolution=(150, 150), auto_scale=False, blank=True, null=True, verbose_name="Image", help_text="Square image, must be 150x150. JPEG or PNG, up to 1 MB. When left blank, the PostgreSQL logo will be used.")
+    imagehash = models.GeneratedField(
+        # md5 of the image, computed and stored by the database, so badge_image()
+        # can answer If-None-Match from this alone, without reading imagedata out
+        # of TOAST and hashing it on every request. Empty when there is no image.
+        expression=Case(
+            When(GreaterThan(Length('imagedata'), 0),
+                 then=Func(F('imagedata'), function='MD5', output_field=models.TextField())),
+            default=Value(''),
+            output_field=models.TextField(),
+        ),
+        output_field=models.TextField(),
+        db_persist=True,
+    )
     contact = models.CharField(max_length=100, null=True, blank=True, verbose_name='Contact address', help_text='Contact address (email, URL, other) for people who want to be added as badge holder')
     holders = models.ManyToManyField(User, through="Badgeholder", through_fields=("badge", "user"), blank=True)
 
-- 
2.53.0

