From c49c813003e78768d6f59283b7fda40d9412040e Mon Sep 17 00:00:00 2001 From: Kevin Rocker Date: Wed, 2 Sep 2026 02:30:47 +0200 Subject: [PATCH 01/13] Add ?include=threads and enterdate/leavedate to per-cf patches Threads for a whole commitfest in one prefetch query instead of a request per patch. enterdate/leavedate were already stored, just not exposed. --- pgcommitfest/commitfest/apiv1.py | 76 +++++--- pgcommitfest/commitfest/tests/test_apiv1.py | 192 +++++++++++++++++++- 2 files changed, 243 insertions(+), 25 deletions(-) diff --git a/pgcommitfest/commitfest/apiv1.py b/pgcommitfest/commitfest/apiv1.py index a0efea18..ffd00a33 100644 --- a/pgcommitfest/commitfest/apiv1.py +++ b/pgcommitfest/commitfest/apiv1.py @@ -1,3 +1,4 @@ +from django.db.models import Exists, OuterRef, Prefetch from django.http import ( HttpResponse, ) @@ -8,6 +9,8 @@ from .models import ( CommitFest, + MailThread, + MailThreadAttachment, Patch, PatchOnCommitFest, ) @@ -55,14 +58,43 @@ def commitfestst_that_need_ci(request): return api_response({"commitfests": cfs}) +def with_has_attachment(threads): + """Annotate a MailThread queryset with has_attachment. + + Doing this as an annotation rather than checking each thread's attachments + separately keeps the number of queries constant. + """ + return threads.annotate( + has_attachment=Exists( + MailThreadAttachment.objects.filter(mailthread=OuterRef("pk")) + ) + ) + + +def mailthread_json(thread): + return { + "messageid": thread.messageid, + "subject": thread.subject, + "latest_message_id": thread.latestmsgid, + "latest_message_time": thread.latestmessage, + "has_attachment": thread.has_attachment, + } + + def commitfest_patches(request, cfid): """Return all patches for a commitfest. This endpoint provides the data that cfbot previously scraped from the commitfest HTML page. + + Threads for every patch can be included with ?include=threads. Unknown + tokens are ignored. """ cf = get_object_or_404(CommitFest, pk=cfid) + include = set(request.GET.get("include", "").split(",")) + include_threads = "threads" in include + pocs = ( PatchOnCommitFest.objects.filter(commitfest=cf) .select_related("patch") @@ -70,19 +102,30 @@ def commitfest_patches(request, cfid): .order_by("patch__id") ) + if include_threads: + pocs = pocs.prefetch_related( + Prefetch( + "patch__mailthread_set", + queryset=with_has_attachment(MailThread.objects.all()), + ) + ) + patches = [] for poc in pocs: patch = poc.patch authors = [f"{a.first_name} {a.last_name}" for a in patch.authors.all()] - patches.append( - { - "id": patch.id, - "name": patch.name, - "status": poc.statusstring, - "authors": authors, - "last_email_time": patch.lastmail, - } - ) + entry = { + "id": patch.id, + "name": patch.name, + "status": poc.statusstring, + "authors": authors, + "last_email_time": patch.lastmail, + "enterdate": poc.enterdate, + "leavedate": poc.leavedate, + } + if include_threads: + entry["threads"] = [mailthread_json(t) for t in patch.mailthread_set.all()] + patches.append(entry) return api_response( { @@ -100,18 +143,9 @@ def patch_threads(request, patch_id): """ patch = get_object_or_404(Patch, pk=patch_id) - threads = [] - for thread in patch.mailthread_set.all(): - latest_attachment = thread.mailthreadattachment_set.first() - threads.append( - { - "messageid": thread.messageid, - "subject": thread.subject, - "latest_message_id": thread.latestmsgid, - "latest_message_time": thread.latestmessage, - "has_attachment": latest_attachment is not None, - } - ) + threads = [ + mailthread_json(t) for t in with_has_attachment(patch.mailthread_set.all()) + ] return api_response( { diff --git a/pgcommitfest/commitfest/tests/test_apiv1.py b/pgcommitfest/commitfest/tests/test_apiv1.py index 5454da8a..2df4bb68 100644 --- a/pgcommitfest/commitfest/tests/test_apiv1.py +++ b/pgcommitfest/commitfest/tests/test_apiv1.py @@ -1,3 +1,6 @@ +from django.db import connection +from django.test.utils import CaptureQueriesContext + import json from datetime import datetime, timezone @@ -5,6 +8,7 @@ from pgcommitfest.commitfest.models import ( MailThread, + MailThreadAttachment, Patch, PatchOnCommitFest, ) @@ -136,14 +140,17 @@ def test_commitfest_patches_endpoint(client, open_cf, alice, bob): PatchOnCommitFest.objects.create( patch=patch1, commitfest=open_cf, - enterdate=datetime.now(), + enterdate=datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc), status=PatchOnCommitFest.STATUS_REVIEW, ) PatchOnCommitFest.objects.create( patch=patch2, commitfest=open_cf, - enterdate=datetime.now(), - status=PatchOnCommitFest.STATUS_AUTHOR, + enterdate=datetime(2025, 1, 2, 8, 0, 0, tzinfo=timezone.utc), + # A leavedate is only allowed for statuses that close the patch out of + # the commitfest, per the status_and_leavedate_correlation constraint. + leavedate=datetime(2025, 1, 20, 16, 45, 0, tzinfo=timezone.utc), + status=PatchOnCommitFest.STATUS_COMMITTED, ) response = client.get(f"/api/v1/commitfests/{open_cf.id}/patches") @@ -164,13 +171,17 @@ def test_commitfest_patches_endpoint(client, open_cf, alice, bob): assert p1["status"] == "Needs review" assert p1["authors"] == ["Alice Anderson"] assert p1["last_email_time"] == "2025-01-15T10:30:00+00:00" + assert p1["enterdate"] == "2025-01-01T12:00:00+00:00" + assert p1["leavedate"] is None p2 = data["patches"][1] assert p2["id"] == patch2.id assert p2["name"] == "Fix bug Y" - assert p2["status"] == "Waiting on Author" + assert p2["status"] == "Committed" assert sorted(p2["authors"]) == ["Alice Anderson", "Bob Brown"] assert p2["last_email_time"] is None + assert p2["enterdate"] == "2025-01-02T08:00:00+00:00" + assert p2["leavedate"] == "2025-01-20T16:45:00+00:00" def test_commitfest_patches_endpoint_not_found(client, commitfests): @@ -227,3 +238,176 @@ def test_patch_threads_endpoint_not_found(client, commitfests): """Test the threads endpoint returns 404 for non-existent patch.""" response = client.get("/api/v1/patches/99999/threads") assert response.status_code == 404 + + +def create_patch_on_cf(commitfest, name, author): + """Create a patch and put it on a commitfest.""" + patch = Patch.objects.create(name=name) + patch.authors.add(author) + PatchOnCommitFest.objects.create( + patch=patch, + commitfest=commitfest, + enterdate=datetime(2025, 1, 1, tzinfo=timezone.utc), + status=PatchOnCommitFest.STATUS_REVIEW, + ) + return patch + + +def create_thread(messageid, subject, firstmessage, latestmessage, latestmsgid): + """Create a mail thread.""" + return MailThread.objects.create( + messageid=messageid, + subject=subject, + firstmessage=firstmessage, + firstauthor="alice@example.com", + latestmessage=latestmessage, + latestauthor="bob@example.com", + latestsubject=f"Re: {subject}", + latestmsgid=latestmsgid, + ) + + +def test_commitfest_patches_include_threads(client, open_cf, alice): + """Test ?include=threads inlines the same thread data as the patch endpoint.""" + patch1 = create_patch_on_cf(open_cf, "Add feature X", alice) + patch2 = create_patch_on_cf(open_cf, "Fix bug Y", alice) + + # Created out of order, to check they come back oldest-first + thread2 = create_thread( + "second@example.com", + "[PATCH] Add feature X v2", + datetime(2025, 1, 10, 9, 0, 0, tzinfo=timezone.utc), + datetime(2025, 1, 12, 14, 30, 0, tzinfo=timezone.utc), + "second-latest@example.com", + ) + thread1 = create_thread( + "first@example.com", + "[PATCH] Add feature X v1", + datetime(2025, 1, 5, 9, 0, 0, tzinfo=timezone.utc), + datetime(2025, 1, 6, 11, 0, 0, tzinfo=timezone.utc), + "first-latest@example.com", + ) + patch1.mailthread_set.add(thread1, thread2) + + MailThreadAttachment.objects.create( + mailthread=thread2, + messageid="second@example.com", + attachmentid=1, + filename="v2.patch", + date=datetime(2025, 1, 10, 9, 0, 0, tzinfo=timezone.utc), + author="alice@example.com", + ispatch=True, + ) + + response = client.get(f"/api/v1/commitfests/{open_cf.id}/patches?include=threads") + + assert response.status_code == 200 + + data = json.loads(response.content) + + p1, p2 = data["patches"] + assert p1["id"] == patch1.id + assert p1["threads"] == [ + { + "messageid": "first@example.com", + "subject": "[PATCH] Add feature X v1", + "latest_message_id": "first-latest@example.com", + "latest_message_time": "2025-01-06T11:00:00+00:00", + "has_attachment": False, + }, + { + "messageid": "second@example.com", + "subject": "[PATCH] Add feature X v2", + "latest_message_id": "second-latest@example.com", + "latest_message_time": "2025-01-12T14:30:00+00:00", + "has_attachment": True, + }, + ] + + # A patch without threads still gets the key + assert p2["id"] == patch2.id + assert p2["threads"] == [] + + +def test_commitfest_patches_no_threads_by_default(client, open_cf, alice): + """Test threads are only included when asked for.""" + patch = create_patch_on_cf(open_cf, "Add feature X", alice) + patch.mailthread_set.add( + create_thread( + "abc123@example.com", + "[PATCH] Add feature X", + datetime(2025, 1, 5, 9, 0, 0, tzinfo=timezone.utc), + datetime(2025, 1, 6, 11, 0, 0, tzinfo=timezone.utc), + "def456@example.com", + ) + ) + + for url in ( + f"/api/v1/commitfests/{open_cf.id}/patches", + f"/api/v1/commitfests/{open_cf.id}/patches?include=bogus", + ): + response = client.get(url) + + assert response.status_code == 200 + + data = json.loads(response.content) + + assert "threads" not in data["patches"][0] + + +def test_commitfest_patches_include_unknown_token(client, open_cf, alice): + """Test unknown include tokens are ignored, rather than rejecting the request.""" + patch = create_patch_on_cf(open_cf, "Add feature X", alice) + patch.mailthread_set.add( + create_thread( + "abc123@example.com", + "[PATCH] Add feature X", + datetime(2025, 1, 5, 9, 0, 0, tzinfo=timezone.utc), + datetime(2025, 1, 6, 11, 0, 0, tzinfo=timezone.utc), + "def456@example.com", + ) + ) + + response = client.get( + f"/api/v1/commitfests/{open_cf.id}/patches?include=bogus,threads" + ) + + assert response.status_code == 200 + + data = json.loads(response.content) + + assert len(data["patches"][0]["threads"]) == 1 + + +def test_commitfest_patches_include_threads_query_count(client, open_cf, alice): + """Test including threads costs a fixed number of queries, not one per patch.""" + for i in range(5): + patch = create_patch_on_cf(open_cf, f"Patch {i}", alice) + patch.mailthread_set.add( + create_thread( + f"thread{i}@example.com", + f"[PATCH] Patch {i}", + datetime(2025, 1, 5, 9, 0, 0, tzinfo=timezone.utc), + datetime(2025, 1, 6, 11, 0, 0, tzinfo=timezone.utc), + f"thread{i}-latest@example.com", + ) + ) + + url = f"/api/v1/commitfests/{open_cf.id}/patches" + + with CaptureQueriesContext(connection) as without_threads: + client.get(url) + + with CaptureQueriesContext(connection) as with_threads: + response = client.get(f"{url}?include=threads") + + assert response.status_code == 200 + + data = json.loads(response.content) + + assert len(data["patches"]) == 5 + assert all(len(p["threads"]) == 1 for p in data["patches"]) + + # The threads for all patches are fetched by a single extra query. If they + # were fetched per patch this would be 5 extra queries, not 1. + assert len(with_threads) == len(without_threads) + 1 From 874c6a51fa8ac9b3264d190957ca1994432d4a2d Mon Sep 17 00:00:00 2001 From: Kevin Rocker Date: Wed, 2 Sep 2026 16:30:04 +0200 Subject: [PATCH 02/13] Match test_apiv1 test style to the rest of the suite Helpers above the tests, create_thread called with keyword arguments, comments trimmed to the terser house style. --- pgcommitfest/commitfest/tests/test_apiv1.py | 112 ++++++++++---------- 1 file changed, 55 insertions(+), 57 deletions(-) diff --git a/pgcommitfest/commitfest/tests/test_apiv1.py b/pgcommitfest/commitfest/tests/test_apiv1.py index 2df4bb68..8d53c1bb 100644 --- a/pgcommitfest/commitfest/tests/test_apiv1.py +++ b/pgcommitfest/commitfest/tests/test_apiv1.py @@ -16,6 +16,33 @@ pytestmark = pytest.mark.django_db +def create_patch_on_cf(commitfest, name, author): + """Create a patch and put it on a commitfest.""" + patch = Patch.objects.create(name=name) + patch.authors.add(author) + PatchOnCommitFest.objects.create( + patch=patch, + commitfest=commitfest, + enterdate=datetime(2025, 1, 1, tzinfo=timezone.utc), + status=PatchOnCommitFest.STATUS_REVIEW, + ) + return patch + + +def create_thread(messageid, subject, firstmessage, latestmessage, latestmsgid): + """Create a mail thread.""" + return MailThread.objects.create( + messageid=messageid, + subject=subject, + firstmessage=firstmessage, + firstauthor="alice@example.com", + latestmessage=latestmessage, + latestauthor="bob@example.com", + latestsubject=f"Re: {subject}", + latestmsgid=latestmsgid, + ) + + def test_commitfests_endpoint(client, commitfests): """Test the /api/v1/commitfests endpoint returns all commitfests.""" response = client.get("/api/v1/commitfests") @@ -147,8 +174,7 @@ def test_commitfest_patches_endpoint(client, open_cf, alice, bob): patch=patch2, commitfest=open_cf, enterdate=datetime(2025, 1, 2, 8, 0, 0, tzinfo=timezone.utc), - # A leavedate is only allowed for statuses that close the patch out of - # the commitfest, per the status_and_leavedate_correlation constraint. + # leavedate is only allowed on statuses that close the patch out leavedate=datetime(2025, 1, 20, 16, 45, 0, tzinfo=timezone.utc), status=PatchOnCommitFest.STATUS_COMMITTED, ) @@ -240,52 +266,25 @@ def test_patch_threads_endpoint_not_found(client, commitfests): assert response.status_code == 404 -def create_patch_on_cf(commitfest, name, author): - """Create a patch and put it on a commitfest.""" - patch = Patch.objects.create(name=name) - patch.authors.add(author) - PatchOnCommitFest.objects.create( - patch=patch, - commitfest=commitfest, - enterdate=datetime(2025, 1, 1, tzinfo=timezone.utc), - status=PatchOnCommitFest.STATUS_REVIEW, - ) - return patch - - -def create_thread(messageid, subject, firstmessage, latestmessage, latestmsgid): - """Create a mail thread.""" - return MailThread.objects.create( - messageid=messageid, - subject=subject, - firstmessage=firstmessage, - firstauthor="alice@example.com", - latestmessage=latestmessage, - latestauthor="bob@example.com", - latestsubject=f"Re: {subject}", - latestmsgid=latestmsgid, - ) - - def test_commitfest_patches_include_threads(client, open_cf, alice): """Test ?include=threads inlines the same thread data as the patch endpoint.""" patch1 = create_patch_on_cf(open_cf, "Add feature X", alice) patch2 = create_patch_on_cf(open_cf, "Fix bug Y", alice) - # Created out of order, to check they come back oldest-first + # Created out of order; they come back oldest-first thread2 = create_thread( - "second@example.com", - "[PATCH] Add feature X v2", - datetime(2025, 1, 10, 9, 0, 0, tzinfo=timezone.utc), - datetime(2025, 1, 12, 14, 30, 0, tzinfo=timezone.utc), - "second-latest@example.com", + messageid="second@example.com", + subject="[PATCH] Add feature X v2", + firstmessage=datetime(2025, 1, 10, 9, 0, 0, tzinfo=timezone.utc), + latestmessage=datetime(2025, 1, 12, 14, 30, 0, tzinfo=timezone.utc), + latestmsgid="second-latest@example.com", ) thread1 = create_thread( - "first@example.com", - "[PATCH] Add feature X v1", - datetime(2025, 1, 5, 9, 0, 0, tzinfo=timezone.utc), - datetime(2025, 1, 6, 11, 0, 0, tzinfo=timezone.utc), - "first-latest@example.com", + messageid="first@example.com", + subject="[PATCH] Add feature X v1", + firstmessage=datetime(2025, 1, 5, 9, 0, 0, tzinfo=timezone.utc), + latestmessage=datetime(2025, 1, 6, 11, 0, 0, tzinfo=timezone.utc), + latestmsgid="first-latest@example.com", ) patch1.mailthread_set.add(thread1, thread2) @@ -334,11 +333,11 @@ def test_commitfest_patches_no_threads_by_default(client, open_cf, alice): patch = create_patch_on_cf(open_cf, "Add feature X", alice) patch.mailthread_set.add( create_thread( - "abc123@example.com", - "[PATCH] Add feature X", - datetime(2025, 1, 5, 9, 0, 0, tzinfo=timezone.utc), - datetime(2025, 1, 6, 11, 0, 0, tzinfo=timezone.utc), - "def456@example.com", + messageid="abc123@example.com", + subject="[PATCH] Add feature X", + firstmessage=datetime(2025, 1, 5, 9, 0, 0, tzinfo=timezone.utc), + latestmessage=datetime(2025, 1, 6, 11, 0, 0, tzinfo=timezone.utc), + latestmsgid="def456@example.com", ) ) @@ -360,11 +359,11 @@ def test_commitfest_patches_include_unknown_token(client, open_cf, alice): patch = create_patch_on_cf(open_cf, "Add feature X", alice) patch.mailthread_set.add( create_thread( - "abc123@example.com", - "[PATCH] Add feature X", - datetime(2025, 1, 5, 9, 0, 0, tzinfo=timezone.utc), - datetime(2025, 1, 6, 11, 0, 0, tzinfo=timezone.utc), - "def456@example.com", + messageid="abc123@example.com", + subject="[PATCH] Add feature X", + firstmessage=datetime(2025, 1, 5, 9, 0, 0, tzinfo=timezone.utc), + latestmessage=datetime(2025, 1, 6, 11, 0, 0, tzinfo=timezone.utc), + latestmsgid="def456@example.com", ) ) @@ -385,11 +384,11 @@ def test_commitfest_patches_include_threads_query_count(client, open_cf, alice): patch = create_patch_on_cf(open_cf, f"Patch {i}", alice) patch.mailthread_set.add( create_thread( - f"thread{i}@example.com", - f"[PATCH] Patch {i}", - datetime(2025, 1, 5, 9, 0, 0, tzinfo=timezone.utc), - datetime(2025, 1, 6, 11, 0, 0, tzinfo=timezone.utc), - f"thread{i}-latest@example.com", + messageid=f"thread{i}@example.com", + subject=f"[PATCH] Patch {i}", + firstmessage=datetime(2025, 1, 5, 9, 0, 0, tzinfo=timezone.utc), + latestmessage=datetime(2025, 1, 6, 11, 0, 0, tzinfo=timezone.utc), + latestmsgid=f"thread{i}-latest@example.com", ) ) @@ -408,6 +407,5 @@ def test_commitfest_patches_include_threads_query_count(client, open_cf, alice): assert len(data["patches"]) == 5 assert all(len(p["threads"]) == 1 for p in data["patches"]) - # The threads for all patches are fetched by a single extra query. If they - # were fetched per patch this would be 5 extra queries, not 1. + # One extra query for all patches, not one per patch assert len(with_threads) == len(without_threads) + 1 From 92d82bfa23c8b9b3cb31ac9e1439d30858802357 Mon Sep 17 00:00:00 2001 From: Jelterminator FennemAI-Neo Date: Sun, 18 Oct 2026 09:59:17 +0200 Subject: [PATCH 03/13] Fold the include=threads tests into the existing patches test The four separate tests for `?include=threads` each rebuilt the same patch/thread fixtures to check one property. They now share the setup of `test_commitfest_patches_endpoint`, which already has two patches on a commitfest: one gets a thread with an attachment, so the same test checks that threads are opt-in, that unknown include tokens are ignored, the inlined thread payload, the empty list for a patch without threads, and that including threads costs a single extra query rather than one per patch. The remaining naive `datetime.now()` in the file is replaced with a fixed aware datetime so the pre-commit ruff check passes. --- pgcommitfest/commitfest/tests/test_apiv1.py | 222 +++++--------------- 1 file changed, 48 insertions(+), 174 deletions(-) diff --git a/pgcommitfest/commitfest/tests/test_apiv1.py b/pgcommitfest/commitfest/tests/test_apiv1.py index 8d53c1bb..50523bf8 100644 --- a/pgcommitfest/commitfest/tests/test_apiv1.py +++ b/pgcommitfest/commitfest/tests/test_apiv1.py @@ -16,33 +16,6 @@ pytestmark = pytest.mark.django_db -def create_patch_on_cf(commitfest, name, author): - """Create a patch and put it on a commitfest.""" - patch = Patch.objects.create(name=name) - patch.authors.add(author) - PatchOnCommitFest.objects.create( - patch=patch, - commitfest=commitfest, - enterdate=datetime(2025, 1, 1, tzinfo=timezone.utc), - status=PatchOnCommitFest.STATUS_REVIEW, - ) - return patch - - -def create_thread(messageid, subject, firstmessage, latestmessage, latestmsgid): - """Create a mail thread.""" - return MailThread.objects.create( - messageid=messageid, - subject=subject, - firstmessage=firstmessage, - firstauthor="alice@example.com", - latestmessage=latestmessage, - latestauthor="bob@example.com", - latestsubject=f"Re: {subject}", - latestmsgid=latestmsgid, - ) - - def test_commitfests_endpoint(client, commitfests): """Test the /api/v1/commitfests endpoint returns all commitfests.""" response = client.get("/api/v1/commitfests") @@ -179,7 +152,30 @@ def test_commitfest_patches_endpoint(client, open_cf, alice, bob): status=PatchOnCommitFest.STATUS_COMMITTED, ) - response = client.get(f"/api/v1/commitfests/{open_cf.id}/patches") + thread = MailThread.objects.create( + messageid="abc123@example.com", + subject="[PATCH] Add feature X", + firstmessage=datetime(2025, 1, 10, 9, 0, 0, tzinfo=timezone.utc), + firstauthor="alice@example.com", + latestmessage=datetime(2025, 1, 12, 14, 30, 0, tzinfo=timezone.utc), + latestauthor="bob@example.com", + latestsubject="Re: [PATCH] Add feature X", + latestmsgid="def456@example.com", + ) + patch1.mailthread_set.add(thread) + MailThreadAttachment.objects.create( + mailthread=thread, + messageid="abc123@example.com", + attachmentid=1, + filename="v1.patch", + date=datetime(2025, 1, 10, 9, 0, 0, tzinfo=timezone.utc), + author="alice@example.com", + ispatch=True, + ) + + url = f"/api/v1/commitfests/{open_cf.id}/patches" + with CaptureQueriesContext(connection) as without_threads: + response = client.get(url) assert response.status_code == 200 assert response["Content-Type"] == "application/json" @@ -209,6 +205,29 @@ def test_commitfest_patches_endpoint(client, open_cf, alice, bob): assert p2["enterdate"] == "2025-01-02T08:00:00+00:00" assert p2["leavedate"] == "2025-01-20T16:45:00+00:00" + # Threads are opt-in, and unknown include tokens are ignored + assert "threads" not in p1 + response = client.get(f"{url}?include=bogus") + assert "threads" not in json.loads(response.content)["patches"][0] + + with CaptureQueriesContext(connection) as with_threads: + response = client.get(f"{url}?include=bogus,threads") + + assert response.status_code == 200 + p1, p2 = json.loads(response.content)["patches"] + assert p1["threads"] == [ + { + "messageid": "abc123@example.com", + "subject": "[PATCH] Add feature X", + "latest_message_id": "def456@example.com", + "latest_message_time": "2025-01-12T14:30:00+00:00", + "has_attachment": True, + } + ] + assert p2["threads"] == [] + # A single prefetch query for all patches, not one per patch + assert len(with_threads) == len(without_threads) + 1 + def test_commitfest_patches_endpoint_not_found(client, commitfests): """Test the patches endpoint returns 404 for non-existent commitfest.""" @@ -224,7 +243,7 @@ def test_patch_threads_endpoint(client, open_cf, alice): PatchOnCommitFest.objects.create( patch=patch, commitfest=open_cf, - enterdate=datetime.now(), + enterdate=datetime(2025, 1, 1, tzinfo=timezone.utc), status=PatchOnCommitFest.STATUS_REVIEW, ) @@ -264,148 +283,3 @@ def test_patch_threads_endpoint_not_found(client, commitfests): """Test the threads endpoint returns 404 for non-existent patch.""" response = client.get("/api/v1/patches/99999/threads") assert response.status_code == 404 - - -def test_commitfest_patches_include_threads(client, open_cf, alice): - """Test ?include=threads inlines the same thread data as the patch endpoint.""" - patch1 = create_patch_on_cf(open_cf, "Add feature X", alice) - patch2 = create_patch_on_cf(open_cf, "Fix bug Y", alice) - - # Created out of order; they come back oldest-first - thread2 = create_thread( - messageid="second@example.com", - subject="[PATCH] Add feature X v2", - firstmessage=datetime(2025, 1, 10, 9, 0, 0, tzinfo=timezone.utc), - latestmessage=datetime(2025, 1, 12, 14, 30, 0, tzinfo=timezone.utc), - latestmsgid="second-latest@example.com", - ) - thread1 = create_thread( - messageid="first@example.com", - subject="[PATCH] Add feature X v1", - firstmessage=datetime(2025, 1, 5, 9, 0, 0, tzinfo=timezone.utc), - latestmessage=datetime(2025, 1, 6, 11, 0, 0, tzinfo=timezone.utc), - latestmsgid="first-latest@example.com", - ) - patch1.mailthread_set.add(thread1, thread2) - - MailThreadAttachment.objects.create( - mailthread=thread2, - messageid="second@example.com", - attachmentid=1, - filename="v2.patch", - date=datetime(2025, 1, 10, 9, 0, 0, tzinfo=timezone.utc), - author="alice@example.com", - ispatch=True, - ) - - response = client.get(f"/api/v1/commitfests/{open_cf.id}/patches?include=threads") - - assert response.status_code == 200 - - data = json.loads(response.content) - - p1, p2 = data["patches"] - assert p1["id"] == patch1.id - assert p1["threads"] == [ - { - "messageid": "first@example.com", - "subject": "[PATCH] Add feature X v1", - "latest_message_id": "first-latest@example.com", - "latest_message_time": "2025-01-06T11:00:00+00:00", - "has_attachment": False, - }, - { - "messageid": "second@example.com", - "subject": "[PATCH] Add feature X v2", - "latest_message_id": "second-latest@example.com", - "latest_message_time": "2025-01-12T14:30:00+00:00", - "has_attachment": True, - }, - ] - - # A patch without threads still gets the key - assert p2["id"] == patch2.id - assert p2["threads"] == [] - - -def test_commitfest_patches_no_threads_by_default(client, open_cf, alice): - """Test threads are only included when asked for.""" - patch = create_patch_on_cf(open_cf, "Add feature X", alice) - patch.mailthread_set.add( - create_thread( - messageid="abc123@example.com", - subject="[PATCH] Add feature X", - firstmessage=datetime(2025, 1, 5, 9, 0, 0, tzinfo=timezone.utc), - latestmessage=datetime(2025, 1, 6, 11, 0, 0, tzinfo=timezone.utc), - latestmsgid="def456@example.com", - ) - ) - - for url in ( - f"/api/v1/commitfests/{open_cf.id}/patches", - f"/api/v1/commitfests/{open_cf.id}/patches?include=bogus", - ): - response = client.get(url) - - assert response.status_code == 200 - - data = json.loads(response.content) - - assert "threads" not in data["patches"][0] - - -def test_commitfest_patches_include_unknown_token(client, open_cf, alice): - """Test unknown include tokens are ignored, rather than rejecting the request.""" - patch = create_patch_on_cf(open_cf, "Add feature X", alice) - patch.mailthread_set.add( - create_thread( - messageid="abc123@example.com", - subject="[PATCH] Add feature X", - firstmessage=datetime(2025, 1, 5, 9, 0, 0, tzinfo=timezone.utc), - latestmessage=datetime(2025, 1, 6, 11, 0, 0, tzinfo=timezone.utc), - latestmsgid="def456@example.com", - ) - ) - - response = client.get( - f"/api/v1/commitfests/{open_cf.id}/patches?include=bogus,threads" - ) - - assert response.status_code == 200 - - data = json.loads(response.content) - - assert len(data["patches"][0]["threads"]) == 1 - - -def test_commitfest_patches_include_threads_query_count(client, open_cf, alice): - """Test including threads costs a fixed number of queries, not one per patch.""" - for i in range(5): - patch = create_patch_on_cf(open_cf, f"Patch {i}", alice) - patch.mailthread_set.add( - create_thread( - messageid=f"thread{i}@example.com", - subject=f"[PATCH] Patch {i}", - firstmessage=datetime(2025, 1, 5, 9, 0, 0, tzinfo=timezone.utc), - latestmessage=datetime(2025, 1, 6, 11, 0, 0, tzinfo=timezone.utc), - latestmsgid=f"thread{i}-latest@example.com", - ) - ) - - url = f"/api/v1/commitfests/{open_cf.id}/patches" - - with CaptureQueriesContext(connection) as without_threads: - client.get(url) - - with CaptureQueriesContext(connection) as with_threads: - response = client.get(f"{url}?include=threads") - - assert response.status_code == 200 - - data = json.loads(response.content) - - assert len(data["patches"]) == 5 - assert all(len(p["threads"]) == 1 for p in data["patches"]) - - # One extra query for all patches, not one per patch - assert len(with_threads) == len(without_threads) + 1 From 42c34bf2e62c2d42c558391e7f931dc3ba07045e Mon Sep 17 00:00:00 2001 From: Jelterminator FennemAI-Neo Date: Sun, 18 Oct 2026 10:13:24 +0200 Subject: [PATCH 04/13] Allow spaces around include tokens `?include=bogus, threads` used to silently return no threads, because the token was compared as " threads". Strip whitespace from each token so a space after the comma works as people would expect. --- pgcommitfest/commitfest/apiv1.py | 2 +- pgcommitfest/commitfest/tests/test_apiv1.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pgcommitfest/commitfest/apiv1.py b/pgcommitfest/commitfest/apiv1.py index ffd00a33..c12e21af 100644 --- a/pgcommitfest/commitfest/apiv1.py +++ b/pgcommitfest/commitfest/apiv1.py @@ -92,7 +92,7 @@ def commitfest_patches(request, cfid): """ cf = get_object_or_404(CommitFest, pk=cfid) - include = set(request.GET.get("include", "").split(",")) + include = {i.strip() for i in request.GET.get("include", "").split(",")} include_threads = "threads" in include pocs = ( diff --git a/pgcommitfest/commitfest/tests/test_apiv1.py b/pgcommitfest/commitfest/tests/test_apiv1.py index 50523bf8..0e02dd55 100644 --- a/pgcommitfest/commitfest/tests/test_apiv1.py +++ b/pgcommitfest/commitfest/tests/test_apiv1.py @@ -211,7 +211,7 @@ def test_commitfest_patches_endpoint(client, open_cf, alice, bob): assert "threads" not in json.loads(response.content)["patches"][0] with CaptureQueriesContext(connection) as with_threads: - response = client.get(f"{url}?include=bogus,threads") + response = client.get(f"{url}?include=bogus, threads") assert response.status_code == 200 p1, p2 = json.loads(response.content)["patches"] From d6375d8cfb9abd5942feab651146a64e3628d362 Mon Sep 17 00:00:00 2001 From: Jelterminator FennemAI-Neo Date: Sun, 18 Oct 2026 11:19:56 +0200 Subject: [PATCH 05/13] Replace mailthread_json with MailThread.to_json This matches how `CommitFest` already provides its own API representation through `to_json`. --- pgcommitfest/commitfest/apiv1.py | 16 ++-------------- pgcommitfest/commitfest/models.py | 12 ++++++++++++ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/pgcommitfest/commitfest/apiv1.py b/pgcommitfest/commitfest/apiv1.py index c12e21af..7e1e2fe2 100644 --- a/pgcommitfest/commitfest/apiv1.py +++ b/pgcommitfest/commitfest/apiv1.py @@ -71,16 +71,6 @@ def with_has_attachment(threads): ) -def mailthread_json(thread): - return { - "messageid": thread.messageid, - "subject": thread.subject, - "latest_message_id": thread.latestmsgid, - "latest_message_time": thread.latestmessage, - "has_attachment": thread.has_attachment, - } - - def commitfest_patches(request, cfid): """Return all patches for a commitfest. @@ -124,7 +114,7 @@ def commitfest_patches(request, cfid): "leavedate": poc.leavedate, } if include_threads: - entry["threads"] = [mailthread_json(t) for t in patch.mailthread_set.all()] + entry["threads"] = [t.to_json() for t in patch.mailthread_set.all()] patches.append(entry) return api_response( @@ -143,9 +133,7 @@ def patch_threads(request, patch_id): """ patch = get_object_or_404(Patch, pk=patch_id) - threads = [ - mailthread_json(t) for t in with_has_attachment(patch.mailthread_set.all()) - ] + threads = [t.to_json() for t in with_has_attachment(patch.mailthread_set.all())] return api_response( { diff --git a/pgcommitfest/commitfest/models.py b/pgcommitfest/commitfest/models.py index 2d21a1ec..c046d631 100644 --- a/pgcommitfest/commitfest/models.py +++ b/pgcommitfest/commitfest/models.py @@ -855,6 +855,18 @@ class MailThread(models.Model): def __str__(self): return self.subject + def to_json(self): + # has_attachment is not a field. It needs to be annotated on the + # queryset (see with_has_attachment in apiv1.py), so that listing many + # threads doesn't need a query per thread. + return { + "messageid": self.messageid, + "subject": self.subject, + "latest_message_id": self.latestmsgid, + "latest_message_time": self.latestmessage, + "has_attachment": self.has_attachment, + } + class Meta: ordering = ("firstmessage",) From e6bd0782ea649fff22c4c9a708dd0a2d2cf7d71f Mon Sep 17 00:00:00 2001 From: Jelterminator FennemAI-Neo Date: Sun, 18 Oct 2026 11:20:30 +0200 Subject: [PATCH 06/13] Add to_json to Patch and PatchOnCommitFest The per-commitfest patches endpoint built each patch entry inline. Like `CommitFest` and `MailThread`, the models now provide their own API representation: `Patch.to_json` has the patch fields, and `PatchOnCommitFest.to_json` adds the commitfest-specific status and enter/leave dates on top. --- pgcommitfest/commitfest/apiv1.py | 14 ++------------ pgcommitfest/commitfest/models.py | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/pgcommitfest/commitfest/apiv1.py b/pgcommitfest/commitfest/apiv1.py index 7e1e2fe2..fedcb3a2 100644 --- a/pgcommitfest/commitfest/apiv1.py +++ b/pgcommitfest/commitfest/apiv1.py @@ -102,19 +102,9 @@ def commitfest_patches(request, cfid): patches = [] for poc in pocs: - patch = poc.patch - authors = [f"{a.first_name} {a.last_name}" for a in patch.authors.all()] - entry = { - "id": patch.id, - "name": patch.name, - "status": poc.statusstring, - "authors": authors, - "last_email_time": patch.lastmail, - "enterdate": poc.enterdate, - "leavedate": poc.leavedate, - } + entry = poc.to_json() if include_threads: - entry["threads"] = [t.to_json() for t in patch.mailthread_set.all()] + entry["threads"] = [t.to_json() for t in poc.patch.mailthread_set.all()] patches.append(entry) return api_response( diff --git a/pgcommitfest/commitfest/models.py b/pgcommitfest/commitfest/models.py index c046d631..813276a7 100644 --- a/pgcommitfest/commitfest/models.py +++ b/pgcommitfest/commitfest/models.py @@ -637,6 +637,14 @@ def move( return new_poc + def to_json(self): + return { + "id": self.id, + "name": self.name, + "authors": [f"{a.first_name} {a.last_name}" for a in self.authors.all()], + "last_email_time": self.lastmail, + } + def __str__(self): return self.name @@ -714,6 +722,14 @@ def needs_committer(self): def statusstring(self): return [v for k, v in self._STATUS_CHOICES if k == self.status][0] + def to_json(self): + return { + **self.patch.to_json(), + "status": self.statusstring, + "enterdate": self.enterdate, + "leavedate": self.leavedate, + } + @classmethod def current_for_patch(cls, patch_id): return get_object_or_404( From 8a9c3cb9b3247c35fd81a560ce3a9b2aabcbb59b Mon Sep 17 00:00:00 2001 From: Jelterminator FennemAI-Neo Date: Sun, 18 Oct 2026 11:23:26 +0200 Subject: [PATCH 07/13] Move with_has_attachment to a MailThread queryset method `MailThread.to_json` depends on the `has_attachment` annotation, so the helper that adds it belongs next to the model rather than in the API module. Now it's available as `MailThread.objects.with_has_attachment()` and on related managers like `patch.mailthread_set.with_has_attachment()`. --- pgcommitfest/commitfest/apiv1.py | 20 +++----------------- pgcommitfest/commitfest/models.py | 21 +++++++++++++++++---- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/pgcommitfest/commitfest/apiv1.py b/pgcommitfest/commitfest/apiv1.py index fedcb3a2..f851b393 100644 --- a/pgcommitfest/commitfest/apiv1.py +++ b/pgcommitfest/commitfest/apiv1.py @@ -1,4 +1,4 @@ -from django.db.models import Exists, OuterRef, Prefetch +from django.db.models import Prefetch from django.http import ( HttpResponse, ) @@ -10,7 +10,6 @@ from .models import ( CommitFest, MailThread, - MailThreadAttachment, Patch, PatchOnCommitFest, ) @@ -58,19 +57,6 @@ def commitfestst_that_need_ci(request): return api_response({"commitfests": cfs}) -def with_has_attachment(threads): - """Annotate a MailThread queryset with has_attachment. - - Doing this as an annotation rather than checking each thread's attachments - separately keeps the number of queries constant. - """ - return threads.annotate( - has_attachment=Exists( - MailThreadAttachment.objects.filter(mailthread=OuterRef("pk")) - ) - ) - - def commitfest_patches(request, cfid): """Return all patches for a commitfest. @@ -96,7 +82,7 @@ def commitfest_patches(request, cfid): pocs = pocs.prefetch_related( Prefetch( "patch__mailthread_set", - queryset=with_has_attachment(MailThread.objects.all()), + queryset=MailThread.objects.with_has_attachment(), ) ) @@ -123,7 +109,7 @@ def patch_threads(request, patch_id): """ patch = get_object_or_404(Patch, pk=patch_id) - threads = [t.to_json() for t in with_has_attachment(patch.mailthread_set.all())] + threads = [t.to_json() for t in patch.mailthread_set.with_has_attachment()] return api_response( { diff --git a/pgcommitfest/commitfest/models.py b/pgcommitfest/commitfest/models.py index 813276a7..0f7da9e3 100644 --- a/pgcommitfest/commitfest/models.py +++ b/pgcommitfest/commitfest/models.py @@ -1,7 +1,7 @@ from django.conf import settings from django.contrib.auth.models import User from django.db import models, transaction -from django.db.models import Q +from django.db.models import Exists, OuterRef, Q from django.shortcuts import get_object_or_404 from datetime import datetime, timedelta, timezone @@ -849,6 +849,18 @@ def save_and_notify( PendingNotification(history=self, user=u).save() +class MailThreadQuerySet(models.QuerySet): + def with_has_attachment(self): + # An annotation rather than checking each thread's attachments + # separately keeps the number of queries constant when listing many + # threads. + return self.annotate( + has_attachment=Exists( + MailThreadAttachment.objects.filter(mailthread=OuterRef("pk")) + ) + ) + + class MailThread(models.Model): # This class tracks mail threads from the main postgresql.org # mailinglist archives. For each thread, we store *one* messageid. @@ -868,13 +880,14 @@ class MailThread(models.Model): latestsubject = models.CharField(max_length=500, null=False, blank=False) latestmsgid = models.CharField(max_length=1000, null=False, blank=False) + objects = MailThreadQuerySet.as_manager() + def __str__(self): return self.subject def to_json(self): - # has_attachment is not a field. It needs to be annotated on the - # queryset (see with_has_attachment in apiv1.py), so that listing many - # threads doesn't need a query per thread. + # Only works on threads fetched with with_has_attachment(), since + # has_attachment is an annotation, not a field. return { "messageid": self.messageid, "subject": self.subject, From f6caf5960192eeced130e0580410f352666ace2b Mon Sep 17 00:00:00 2001 From: Jelterminator FennemAI-Neo Date: Sun, 18 Oct 2026 11:24:13 +0200 Subject: [PATCH 08/13] Make MailThread.has_attachment work without the annotation `to_json` raised an `AttributeError` for threads that weren't fetched through `with_has_attachment()`. Now `has_attachment` is a property that uses the annotation when it's there and otherwise checks the attachments with a query, so the annotation is only a performance optimization. --- pgcommitfest/commitfest/models.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/pgcommitfest/commitfest/models.py b/pgcommitfest/commitfest/models.py index 0f7da9e3..1d604bd0 100644 --- a/pgcommitfest/commitfest/models.py +++ b/pgcommitfest/commitfest/models.py @@ -854,8 +854,10 @@ def with_has_attachment(self): # An annotation rather than checking each thread's attachments # separately keeps the number of queries constant when listing many # threads. + # The annotation can't be called has_attachment, because Django would + # then try to assign to the MailThread.has_attachment property. return self.annotate( - has_attachment=Exists( + _has_attachment=Exists( MailThreadAttachment.objects.filter(mailthread=OuterRef("pk")) ) ) @@ -885,9 +887,15 @@ class MailThread(models.Model): def __str__(self): return self.subject + @property + def has_attachment(self): + # Use the value from with_has_attachment() when the thread was fetched + # that way, so listing many threads doesn't need a query per thread. + if hasattr(self, "_has_attachment"): + return self._has_attachment + return self.mailthreadattachment_set.exists() + def to_json(self): - # Only works on threads fetched with with_has_attachment(), since - # has_attachment is an annotation, not a field. return { "messageid": self.messageid, "subject": self.subject, From 06923609145ac07fa1430a30311be101d7502783 Mon Sep 17 00:00:00 2001 From: Jelterminator FennemAI-Neo Date: Sun, 18 Oct 2026 11:24:57 +0200 Subject: [PATCH 09/13] Revert "Make MailThread.has_attachment work without the annotation" This reverts commit f6caf5960192eeced130e0580410f352666ace2b. --- pgcommitfest/commitfest/models.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/pgcommitfest/commitfest/models.py b/pgcommitfest/commitfest/models.py index 1d604bd0..0f7da9e3 100644 --- a/pgcommitfest/commitfest/models.py +++ b/pgcommitfest/commitfest/models.py @@ -854,10 +854,8 @@ def with_has_attachment(self): # An annotation rather than checking each thread's attachments # separately keeps the number of queries constant when listing many # threads. - # The annotation can't be called has_attachment, because Django would - # then try to assign to the MailThread.has_attachment property. return self.annotate( - _has_attachment=Exists( + has_attachment=Exists( MailThreadAttachment.objects.filter(mailthread=OuterRef("pk")) ) ) @@ -887,15 +885,9 @@ class MailThread(models.Model): def __str__(self): return self.subject - @property - def has_attachment(self): - # Use the value from with_has_attachment() when the thread was fetched - # that way, so listing many threads doesn't need a query per thread. - if hasattr(self, "_has_attachment"): - return self._has_attachment - return self.mailthreadattachment_set.exists() - def to_json(self): + # Only works on threads fetched with with_has_attachment(), since + # has_attachment is an annotation, not a field. return { "messageid": self.messageid, "subject": self.subject, From fe28d27dcd904622a33d0de7de23d1a0e6df70c9 Mon Sep 17 00:00:00 2001 From: Jelterminator FennemAI-Neo Date: Sun, 18 Oct 2026 11:25:19 +0200 Subject: [PATCH 10/13] Test include parsing in a dedicated test Keeps the main patches endpoint test focused on the response contents, and makes it clear what the include parsing is expected to do. --- pgcommitfest/commitfest/tests/test_apiv1.py | 23 +++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/pgcommitfest/commitfest/tests/test_apiv1.py b/pgcommitfest/commitfest/tests/test_apiv1.py index 0e02dd55..650485ce 100644 --- a/pgcommitfest/commitfest/tests/test_apiv1.py +++ b/pgcommitfest/commitfest/tests/test_apiv1.py @@ -205,13 +205,11 @@ def test_commitfest_patches_endpoint(client, open_cf, alice, bob): assert p2["enterdate"] == "2025-01-02T08:00:00+00:00" assert p2["leavedate"] == "2025-01-20T16:45:00+00:00" - # Threads are opt-in, and unknown include tokens are ignored + # Threads are opt-in assert "threads" not in p1 - response = client.get(f"{url}?include=bogus") - assert "threads" not in json.loads(response.content)["patches"][0] with CaptureQueriesContext(connection) as with_threads: - response = client.get(f"{url}?include=bogus, threads") + response = client.get(f"{url}?include=threads") assert response.status_code == 200 p1, p2 = json.loads(response.content)["patches"] @@ -229,6 +227,23 @@ def test_commitfest_patches_endpoint(client, open_cf, alice, bob): assert len(with_threads) == len(without_threads) + 1 +def test_commitfest_patches_endpoint_include_parsing(client, open_cf): + """Unknown include tokens are ignored and spaces around tokens allowed.""" + PatchOnCommitFest.objects.create( + patch=Patch.objects.create(name="Add feature X"), + commitfest=open_cf, + enterdate=datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + ) + url = f"/api/v1/commitfests/{open_cf.id}/patches" + + response = client.get(f"{url}?include=bogus") + assert response.status_code == 200 + assert "threads" not in json.loads(response.content)["patches"][0] + + response = client.get(f"{url}?include=bogus, threads") + assert json.loads(response.content)["patches"][0]["threads"] == [] + + def test_commitfest_patches_endpoint_not_found(client, commitfests): """Test the patches endpoint returns 404 for non-existent commitfest.""" response = client.get("/api/v1/commitfests/99999/patches") From 5327fb878347fb86a14f5eeb7d7d246f767e7fcd Mon Sep 17 00:00:00 2001 From: Jelterminator FennemAI-Neo Date: Sun, 18 Oct 2026 11:26:15 +0200 Subject: [PATCH 11/13] Clarify names of the captured query lists in the patches test --- pgcommitfest/commitfest/tests/test_apiv1.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pgcommitfest/commitfest/tests/test_apiv1.py b/pgcommitfest/commitfest/tests/test_apiv1.py index 650485ce..539f29e1 100644 --- a/pgcommitfest/commitfest/tests/test_apiv1.py +++ b/pgcommitfest/commitfest/tests/test_apiv1.py @@ -174,7 +174,7 @@ def test_commitfest_patches_endpoint(client, open_cf, alice, bob): ) url = f"/api/v1/commitfests/{open_cf.id}/patches" - with CaptureQueriesContext(connection) as without_threads: + with CaptureQueriesContext(connection) as queries_without_threads: response = client.get(url) assert response.status_code == 200 @@ -208,7 +208,7 @@ def test_commitfest_patches_endpoint(client, open_cf, alice, bob): # Threads are opt-in assert "threads" not in p1 - with CaptureQueriesContext(connection) as with_threads: + with CaptureQueriesContext(connection) as queries_with_threads: response = client.get(f"{url}?include=threads") assert response.status_code == 200 @@ -224,7 +224,7 @@ def test_commitfest_patches_endpoint(client, open_cf, alice, bob): ] assert p2["threads"] == [] # A single prefetch query for all patches, not one per patch - assert len(with_threads) == len(without_threads) + 1 + assert len(queries_with_threads) == len(queries_without_threads) + 1 def test_commitfest_patches_endpoint_include_parsing(client, open_cf): From 01e28a8d45bb3f726d491510b8c3a2aa77ec5efe Mon Sep 17 00:00:00 2001 From: Jelterminator FennemAI-Neo Date: Sun, 18 Oct 2026 11:28:33 +0200 Subject: [PATCH 12/13] Let the JSON serializer call MailThread.to_json `api_response` already calls `to_json` on objects that have it, so there's no need to do that explicitly for the threads. --- pgcommitfest/commitfest/apiv1.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pgcommitfest/commitfest/apiv1.py b/pgcommitfest/commitfest/apiv1.py index f851b393..1a506e8f 100644 --- a/pgcommitfest/commitfest/apiv1.py +++ b/pgcommitfest/commitfest/apiv1.py @@ -90,7 +90,7 @@ def commitfest_patches(request, cfid): for poc in pocs: entry = poc.to_json() if include_threads: - entry["threads"] = [t.to_json() for t in poc.patch.mailthread_set.all()] + entry["threads"] = list(poc.patch.mailthread_set.all()) patches.append(entry) return api_response( @@ -109,7 +109,7 @@ def patch_threads(request, patch_id): """ patch = get_object_or_404(Patch, pk=patch_id) - threads = [t.to_json() for t in patch.mailthread_set.with_has_attachment()] + threads = list(patch.mailthread_set.with_has_attachment()) return api_response( { From 4f347911c206526092b3d91685ed82bc18553335 Mon Sep 17 00:00:00 2001 From: Jelterminator FennemAI-Neo Date: Sun, 18 Oct 2026 11:31:21 +0200 Subject: [PATCH 13/13] Serialize querysets in API responses directly The JSON serializer now turns querysets into lists itself, so views can put a queryset in the response without wrapping it in `list()`. --- pgcommitfest/commitfest/apiv1.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/pgcommitfest/commitfest/apiv1.py b/pgcommitfest/commitfest/apiv1.py index 1a506e8f..9d6109ad 100644 --- a/pgcommitfest/commitfest/apiv1.py +++ b/pgcommitfest/commitfest/apiv1.py @@ -1,4 +1,4 @@ -from django.db.models import Prefetch +from django.db.models import Prefetch, QuerySet from django.http import ( HttpResponse, ) @@ -26,6 +26,9 @@ def datetime_serializer(obj): if hasattr(obj, "to_json"): return obj.to_json() + if isinstance(obj, QuerySet): + return list(obj) + raise TypeError(f"Type {type(obj)} not serializable to JSON") @@ -40,7 +43,7 @@ def api_response(payload, status=200, content_type="application/json"): def all_commitfests(request): """Return all commitfests, including closed ones.""" - return api_response({"commitfests": list(CommitFest.objects.order_by("id"))}) + return api_response({"commitfests": CommitFest.objects.order_by("id")}) def commitfestst_that_need_ci(request): @@ -90,7 +93,7 @@ def commitfest_patches(request, cfid): for poc in pocs: entry = poc.to_json() if include_threads: - entry["threads"] = list(poc.patch.mailthread_set.all()) + entry["threads"] = poc.patch.mailthread_set.all() patches.append(entry) return api_response( @@ -109,7 +112,7 @@ def patch_threads(request, patch_id): """ patch = get_object_or_404(Patch, pk=patch_id) - threads = list(patch.mailthread_set.with_has_attachment()) + threads = patch.mailthread_set.with_has_attachment() return api_response( {