From 2d9339cc6ad73fa2f6ffd0bd74baa449e4c02612 Mon Sep 17 00:00:00 2001 From: Marco Acierno Date: Mon, 9 Nov 2026 03:39:01 +0100 Subject: [PATCH 1/6] Fix RecursionError when loading grants with deferred fields Grant.__init__ snapshots status, pending_status and country_type to detect changes on save. When a field is deferred (for example when the deletion collector loads related grants with .only()), reading it refetches the row, which instantiates another Grant whose __init__ reads the next deferred field, recursing until RecursionError. Deleting a conference that has grants crashed this way. Read the snapshots from __dict__ so deferred fields stay unloaded. --- backend/grants/models.py | 8 +++++--- backend/grants/tests/test_models.py | 9 +++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/backend/grants/models.py b/backend/grants/models.py index 296f55507a..4f6a140ebd 100644 --- a/backend/grants/models.py +++ b/backend/grants/models.py @@ -233,9 +233,11 @@ class GrantType(models.TextChoices): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._original_status = self.status - self._original_pending_status = self.pending_status - self._original_country_type = self.country_type + # Read through __dict__: touching a deferred field here refetches the + # row, which builds another Grant and recurses. + self._original_status = self.__dict__.get("status") + self._original_pending_status = self.__dict__.get("pending_status") + self._original_country_type = self.__dict__.get("country_type") def __str__(self): return f"{self.full_name}" diff --git a/backend/grants/tests/test_models.py b/backend/grants/tests/test_models.py index 3688fda17c..9dc87412e3 100644 --- a/backend/grants/tests/test_models.py +++ b/backend/grants/tests/test_models.py @@ -309,6 +309,15 @@ def test_sets_country_type_does_nothing_if_unset(): assert grant.country_type is None +def test_deleting_conference_deletes_its_grants(): + grant = GrantFactory() + other_grant = GrantFactory() + + grant.conference.delete() + + assert list(Grant.objects.all()) == [other_grant] + + def test_pending_status_no_longer_syncs_with_status(): grant = GrantFactory( pending_status=Grant.Status.pending, From ea4800753ba3c5f1e7634531eff48261f0434e7d Mon Sep 17 00:00:00 2001 From: Marco Acierno Date: Mon, 9 Nov 2026 03:39:02 +0100 Subject: [PATCH 2/6] Fix grant CSV export and Pretix failures in create_grant_vouchers - GrantResource.export_field did not accept the keyword arguments that django-import-export 4 passes (export_fields), so every export from the admin raised TypeError. With that fixed, the admin's field selection, built from get_export_order(), would also have dropped the per-question columns added in before_export; they are now always exported. - create_grant_vouchers let a Pretix error abort the whole action with a 500. Failures are now reported per grant and the remaining grants still get their vouchers. The export tests now pass the field selection the way the admin does. --- backend/grants/admin.py | 34 ++++++++++++++++++++++-------- backend/grants/tests/test_admin.py | 33 ++++++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/backend/grants/admin.py b/backend/grants/admin.py index d340b3fb55..d70600ac16 100644 --- a/backend/grants/admin.py +++ b/backend/grants/admin.py @@ -10,6 +10,7 @@ from django.utils import timezone from django.utils.html import format_html, format_html_join from django.utils.safestring import mark_safe +from requests import RequestException from import_export.admin import ExportMixin from import_export.fields import Field from import_export.resources import ModelResource @@ -202,14 +203,22 @@ def _add_form_question_fields(self, conference_id): self.fields[key] = Field(column_name=question.label) self._question_fields[key] = question - def export_field(self, field, obj): + def export_field(self, field, instance, **kwargs): question = self._question_fields.get(self.get_field_name(field)) if question is not None: - if obj.form_answer_id is None: + if instance.form_answer_id is None: return "" - answers = unwrap_answers(obj.form_answer.answers) + answers = unwrap_answers(instance.form_answer.answers) return display_answer_value(question, answers.get(str(question.pk))) - return super().export_field(field, obj) + return super().export_field(field, instance, **kwargs) + + def get_export_fields(self, selected_fields=None): + # The admin's export form lists fields from get_export_order(), which + # cannot know the per-conference question columns. + export_fields = super().get_export_fields(selected_fields) + if selected_fields: + export_fields += [self.fields[key] for key in self._question_fields] + return export_fields class Meta: model = Grant @@ -376,11 +385,18 @@ def create_grant_vouchers(modeladmin, request, queryset): existing = existing_by_user.get(grant.user_id) if not existing: - new_voucher = create_conference_voucher( - conference=grant.conference, - user=grant.user, - voucher_type=ConferenceVoucher.VoucherType.GRANT, - ) + try: + new_voucher = create_conference_voucher( + conference=grant.conference, + user=grant.user, + voucher_type=ConferenceVoucher.VoucherType.GRANT, + ) + except RequestException as e: + messages.error( + request, + f"Could not create a voucher for {grant.name} on Pretix: {e}", + ) + continue existing_by_user[grant.user_id] = new_voucher create_addition_admin_log_entry( request.user, diff --git a/backend/grants/tests/test_admin.py b/backend/grants/tests/test_admin.py index 5b5988229b..0318fb8025 100644 --- a/backend/grants/tests/test_admin.py +++ b/backend/grants/tests/test_admin.py @@ -6,6 +6,7 @@ from django.contrib.admin.models import LogEntry from django.contrib.admin.sites import AdminSite from django.utils import timezone +from requests import HTTPError from conferences.models.conference_voucher import ConferenceVoucher from conferences.tests.factories import ConferenceFactory, ConferenceVoucherFactory @@ -313,6 +314,35 @@ def test_send_reply_email_waiting_list_update(rf, mocker, admin_user): ).exists() +def test_create_grant_vouchers_reports_pretix_failures_and_continues( + rf, mocker, admin_user +): + mock_messages = mocker.patch("grants.admin.messages") + mocker.patch( + "conferences.vouchers.create_voucher", + side_effect=[HTTPError("Pretix is down"), {"id": 999}], + ) + conference = ConferenceFactory() + failed_grant = GrantFactory(status=Grant.Status.confirmed, conference=conference) + grant = GrantFactory(status=Grant.Status.confirmed, conference=conference) + request = rf.get("/") + request.user = admin_user + + create_grant_vouchers( + None, + request=request, + queryset=Grant.objects.filter(conference=conference), + ) + + assert list(ConferenceVoucher.objects.values_list("user_id", flat=True)) == [ + grant.user_id + ] + mock_messages.error.assert_called_once_with( + request, + f"Could not create a voucher for {failed_grant.name} on Pretix: Pretix is down", + ) + + def test_create_grant_vouchers(rf, mocker, admin_user): mock_messages = mocker.patch("grants.admin.messages") mocker.patch("conferences.vouchers.create_voucher", return_value={"id": 999}) @@ -851,7 +881,8 @@ def test_dynamic_answers_has_an_empty_state_for_legacy_grants(): def _export_grants(conference): queryset = Grant.objects.filter(conference=conference).order_by("id") - return GrantResource().export(queryset=queryset) + resource = GrantResource() + return resource.export(queryset=queryset, export_fields=resource.get_export_order()) def test_export_includes_one_column_per_form_question(): From 67208b6f7c0930d233ba6e4f4a390528663c73d4 Mon Sep 17 00:00:00 2001 From: Marco Acierno Date: Mon, 9 Nov 2026 03:39:02 +0100 Subject: [PATCH 3/6] Show a form error instead of a 500 for invalid deadline dates validate_deadlines_form read cleaned_data["start"], ["end"] and ["type"] unconditionally, so a deadline row whose fields failed validation raised KeyError. Empty dates passed field validation (they are optional on TimeFramedModel) and then failed comparing None. Rows with field errors are now left to those errors, and both dates are required. --- backend/conferences/admin/conference.py | 9 +++++++++ backend/conferences/tests/test_admin.py | 27 +++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/backend/conferences/admin/conference.py b/backend/conferences/admin/conference.py index 5ee03fb24d..f4252324d3 100644 --- a/backend/conferences/admin/conference.py +++ b/backend/conferences/admin/conference.py @@ -49,6 +49,9 @@ def validate_deadlines_form(forms): if not form.cleaned_data: return + if form.errors: + continue + start = form.cleaned_data["start"] end = form.cleaned_data["end"] delete = form.cleaned_data["DELETE"] @@ -74,6 +77,12 @@ class Meta: model = Deadline fields = ["start", "end", "name", "description", "type", "conference"] + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # TimeFramedModel leaves both dates optional; a deadline needs them. + self.fields["start"].required = True + self.fields["end"].required = True + class DeadlineFormSet(BaseInlineFormSet): def clean(self): diff --git a/backend/conferences/tests/test_admin.py b/backend/conferences/tests/test_admin.py index 89b1cf4d0d..b5b7e1122a 100644 --- a/backend/conferences/tests/test_admin.py +++ b/backend/conferences/tests/test_admin.py @@ -154,6 +154,33 @@ def test_start_date_comes_before_end(add_delete_field_to_form): validate_deadlines_form(forms) +@mark.parametrize( + "field,value", [("start", "not a date"), ("end", ""), ("type", "unknown")] +) +def test_invalid_deadline_field_is_left_to_its_field_error( + field, value, add_delete_field_to_form +): + conference = ConferenceFactory() + data = { + "start": "2021-11-10 01:43:58", + "end": "2022-11-10 01:43:58", + "name_0": "en", + "name_1": "it", + "description_0": "descen", + "description_1": "descit", + "type": "cfp", + "conference": conference.id, + "DELETE": False, + } + data[field] = value + form = DeadlineForm(data=data) + form.is_valid() + + validate_deadlines_form([form]) + + assert field in form.errors + + def test_can_have_as_many_custom_deadlines_as_we_want(add_delete_field_to_form): conference = ConferenceFactory() form_1 = DeadlineForm( From 71fb1a7ea5e829ad3aa8b51d840e2adf35350cb2 Mon Sep 17 00:00:00 2001 From: Marco Acierno Date: Mon, 9 Nov 2026 03:39:03 +0100 Subject: [PATCH 4/6] Fix admin search on files and video import requests Both admins listed a foreign key directly in search_fields, which Django cannot search with icontains, so any search returned a 500. Search the uploader's email and the conference code instead. --- backend/files_upload/admin.py | 2 +- backend/files_upload/tests/test_admin.py | 22 ++++++++++++++++++++++ backend/video_uploads/admin.py | 2 +- backend/video_uploads/tests/test_admin.py | 17 +++++++++++++++++ 4 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 backend/files_upload/tests/test_admin.py diff --git a/backend/files_upload/admin.py b/backend/files_upload/admin.py index 6140c6b96a..920a1aacca 100644 --- a/backend/files_upload/admin.py +++ b/backend/files_upload/admin.py @@ -20,7 +20,7 @@ class FileAdmin(admin.ModelAdmin): list_display = ("id", "file", "created", "uploaded_by") search_fields = ( "file", - "uploaded_by", + "uploaded_by__email", "id", ) list_filter = ("created", "type", "virus", "mime_type") diff --git a/backend/files_upload/tests/test_admin.py b/backend/files_upload/tests/test_admin.py new file mode 100644 index 0000000000..f93af37d8e --- /dev/null +++ b/backend/files_upload/tests/test_admin.py @@ -0,0 +1,22 @@ +import pytest +from django.contrib.admin.sites import AdminSite + +from files_upload.admin import FileAdmin +from files_upload.models import File +from files_upload.tests.factories import FileFactory +from users.tests.factories import UserFactory + +pytestmark = pytest.mark.django_db + + +def test_search_files_by_uploader_email(rf, admin_superuser): + file = FileFactory(uploaded_by=UserFactory(email="uploader@example.org")) + FileFactory(uploaded_by=UserFactory(email="someone@example.org")) + request = rf.get("/") + request.user = admin_superuser + + results, _ = FileAdmin(File, AdminSite()).get_search_results( + request, File.objects.all(), "uploader@" + ) + + assert list(results) == [file] diff --git a/backend/video_uploads/admin.py b/backend/video_uploads/admin.py index 46b1490fb9..16798dc756 100644 --- a/backend/video_uploads/admin.py +++ b/backend/video_uploads/admin.py @@ -58,7 +58,7 @@ class VideosImportRequestAdmin(admin.ModelAdmin): "status", "source_url", ] - search_fields = ["conference", "source_url"] + search_fields = ["conference__code", "source_url"] list_filter = ["conference"] ordering = ["-created"] date_hierarchy = "created" diff --git a/backend/video_uploads/tests/test_admin.py b/backend/video_uploads/tests/test_admin.py index 1a11c2f657..c9c2b7dc8e 100644 --- a/backend/video_uploads/tests/test_admin.py +++ b/backend/video_uploads/tests/test_admin.py @@ -1,7 +1,9 @@ import pytest +from django.contrib.admin.sites import AdminSite from conferences.tests.factories import ConferenceFactory from video_uploads.models import VideosImportRequest from video_uploads.admin import ( + VideosImportRequestAdmin, VideosImportRequestAdminForm, queue_videos_import_request, retry_transfer, @@ -11,6 +13,21 @@ pytestmark = pytest.mark.django_db +def test_search_import_requests_by_conference_code(rf, admin_superuser): + request_for_conference = VideosImportRequestFactory( + conference=ConferenceFactory(code="pycon2030") + ) + VideosImportRequestFactory(conference=ConferenceFactory(code="europython")) + request = rf.get("/") + request.user = admin_superuser + + results, _ = VideosImportRequestAdmin( + VideosImportRequest, AdminSite() + ).get_search_results(request, VideosImportRequest.objects.all(), "pycon2030") + + assert list(results) == [request_for_conference] + + def test_queue_videos_import_request(mocker, django_capture_on_commit_callbacks): mock_process_videos_import_request = mocker.patch( "video_uploads.admin.process_videos_import_request" From 55e5158b13ee7a8055aedb141d830185211f9053 Mon Sep 17 00:00:00 2001 From: Marco Acierno Date: Mon, 9 Nov 2026 03:39:04 +0100 Subject: [PATCH 5/6] Keep the schedule invitations admin working when Pretix fails The speaker ticket column in the invitations list and the invitations CSV export called Pretix without handling errors, so a failing request turned the whole page or export into a 500. Log the error and show the ticket status as unknown, as the grants list already does. --- backend/schedule/admin.py | 31 ++++++++++++++++++---------- backend/schedule/tests/test_admin.py | 30 ++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/backend/schedule/admin.py b/backend/schedule/admin.py index 8da7259a46..36575ac3ea 100644 --- a/backend/schedule/admin.py +++ b/backend/schedule/admin.py @@ -1,4 +1,7 @@ +import logging + from django.db import transaction +from requests import RequestException from custom_admin.admin import ( HTTPS_URL_FORMFIELD_OVERRIDES, formfield_with_https_urls, @@ -47,6 +50,8 @@ Slot, ) +logger = logging.getLogger(__name__) + @admin.action(description="Mark Speakers to receive Vouchers") @validate_single_conference_selection @@ -541,6 +546,18 @@ def invitation_link(self, obj: ScheduleItem) -> str: return f"https://pycon.it/schedule/invitation/{obj.submission.hashid}" +def _speaker_has_ticket(schedule_item: ScheduleItem) -> bool | None: + try: + return user_has_admission_ticket( + email=schedule_item.submission.speaker.email, + event_organizer=schedule_item.conference.pretix_organizer_id, + event_slug=schedule_item.conference.pretix_event_id, + ) + except RequestException: + logger.exception("Could not check the speaker ticket on Pretix") + return None + + SCHEDULE_ITEM_INVITATION_FIELDS = [ "id", "title", @@ -572,11 +589,7 @@ def dehydrate_speaker_has_ticket(self, obj): if not obj.submission.speaker_id: return "" - return user_has_admission_ticket( - email=obj.submission.speaker.email, - event_organizer=obj.conference.pretix_organizer_id, - event_slug=obj.conference.pretix_event_id, - ) + return _speaker_has_ticket(obj) class Meta: model = ScheduleItem @@ -666,15 +679,11 @@ def speaker_display_name(self, obj): @admin.display( boolean=True, ) - def speaker_has_ticket(self, obj) -> bool: + def speaker_has_ticket(self, obj) -> bool | None: if not obj.submission.speaker_id: return None - return user_has_admission_ticket( - email=obj.submission.speaker.email, - event_organizer=obj.conference.pretix_organizer_id, - event_slug=obj.conference.pretix_event_id, - ) + return _speaker_has_ticket(obj) def open_schedule_item(self, obj) -> str: url = reverse("admin:schedule_scheduleitem_change", args=[obj.id]) diff --git a/backend/schedule/tests/test_admin.py b/backend/schedule/tests/test_admin.py index d571f8a921..e35cf5cfa7 100644 --- a/backend/schedule/tests/test_admin.py +++ b/backend/schedule/tests/test_admin.py @@ -1,3 +1,4 @@ +import re from submissions.tests.factories import SubmissionFactory from conferences.tests.factories import ConferenceFactory, ConferenceVoucherFactory from django.contrib import messages @@ -10,6 +11,8 @@ from conferences.models import ConferenceVoucher from schedule.admin import ( ScheduleItemAdmin, + ScheduleItemInvitationAdmin, + ScheduleItemInvitationResource, mark_as_confirmed_action, mark_speakers_to_receive_vouchers, send_schedule_invitation_reminder_to_waiting, @@ -17,7 +20,11 @@ send_schedule_invitation_to_uninvited, upload_videos_to_youtube, ) -from schedule.models import ScheduleItem, ScheduleItemSentForVideoUpload +from schedule.models import ( + ScheduleItem, + ScheduleItemInvitation, + ScheduleItemSentForVideoUpload, +) from schedule.tests.factories import ( ScheduleItemAdditionalSpeakerFactory, ScheduleItemFactory, @@ -738,3 +745,24 @@ def test_upload_videos_to_youtube_skips_do_not_record_submissions(rf, mocker): ) assert scheduled_ids == {recordable.id, without_submission.id} assert do_not_record.id not in scheduled_ids + + +def test_invitation_ticket_status_is_unknown_when_pretix_fails(requests_mock): + requests_mock.post(re.compile("attendee-has-ticket"), status_code=500) + conference = ConferenceFactory() + schedule_item = ScheduleItemFactory( + type=ScheduleItem.TYPES.talk, + conference=conference, + submission=SubmissionFactory(conference=conference), + ) + invitation = ScheduleItemInvitation.objects.get(pk=schedule_item.pk) + + listed = ScheduleItemInvitationAdmin( + ScheduleItemInvitation, AdminSite() + ).speaker_has_ticket(invitation) + exported = ScheduleItemInvitationResource().export( + queryset=ScheduleItem.objects.filter(pk=schedule_item.pk) + ) + + assert listed is None + assert exported.dict[0]["speaker_has_ticket"] is None From e9e7df6443fa154a8c2135931ebcf2e9c8ee3d7a Mon Sep 17 00:00:00 2001 From: Marco Acierno Date: Mon, 9 Nov 2026 03:39:04 +0100 Subject: [PATCH 6/6] Keep the Resonate worker receiving work after five idle minutes resonate-sdk opens its long-lived SSE poll stream with aiohttp's default ClientSession timeout (total=300s). When it expires aiohttp raises a bare TimeoutError, which is not an aiohttp.ClientError, so it escapes the SDK's reconnect loop and the listener task dies silently. The server then deregisters the worker and pending workflows are never dispatched until the process restarts. Give the worker its own SSE source with no total timeout (connect timeout kept at 30s), sharing the process id with the SDK. --- backend/pycon/resonate_app.py | 33 +++++++++++++++++-- backend/pycon/tests/test_resonate_worker.py | 36 +++++++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/backend/pycon/resonate_app.py b/backend/pycon/resonate_app.py index 168eba53c3..ee7b3a1104 100644 --- a/backend/pycon/resonate_app.py +++ b/backend/pycon/resonate_app.py @@ -19,27 +19,54 @@ import asyncio import functools import inspect +import uuid from typing import Any, Callable, TypeVar, get_type_hints +import aiohttp from asgiref.sync import sync_to_async from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.db import connections from django.utils.module_loading import autodiscover_modules +from resonate.connections.sse import SSEConnection from resonate.resonate import Resonate _resonate: Resonate | None = None + +class WorkerPollConnection(SSEConnection): + # The SDK opens its long-lived poll stream with aiohttp's default 5 minute + # total timeout, and the bare TimeoutError escapes its reconnect loop. + def _ensure_session(self) -> aiohttp.ClientSession: + if self._session is None and not self._stopped: + self._session = aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=None, sock_connect=30) + ) + return super()._ensure_session() + + T = TypeVar("T") def _build(*, send_only: bool) -> Resonate: + pid = uuid.uuid4().hex + sources: list[WorkerPollConnection] | None = None + if send_only: + # An empty list of sources says this process only creates promises and + # never picks work up. + sources = [] + elif settings.RESONATE_URL: + sources = [ + WorkerPollConnection( + url=settings.RESONATE_URL, pid=pid, group=settings.RESONATE_GROUP + ) + ] + return Resonate( group=settings.RESONATE_GROUP, url=settings.RESONATE_URL or None, - # An empty list of sources says this process only creates promises and - # never picks work up; ``None`` lets the SDK give it the usual one. - sources=[] if send_only else None, + pid=pid, + sources=sources, # Nothing here needs an event loop: connections are opened by # ``start()``, so building at import time is safe. autostart=False, diff --git a/backend/pycon/tests/test_resonate_worker.py b/backend/pycon/tests/test_resonate_worker.py index 664ee502a5..81efdb89a7 100644 --- a/backend/pycon/tests/test_resonate_worker.py +++ b/backend/pycon/tests/test_resonate_worker.py @@ -1,8 +1,44 @@ +import asyncio + +import aiohttp +from aiohttp import web +from aiohttp.test_utils import TestServer from django.core.exceptions import ImproperlyConfigured from django.core.management import call_command from pytest import raises from pycon.management.commands.resonate_worker import Command +from pycon.resonate_app import WorkerPollConnection + + +def test_worker_keeps_receiving_work_past_aiohttp_default_timeout(mocker): + mocker.patch("aiohttp.client.DEFAULT_TIMEOUT", aiohttp.ClientTimeout(total=0.5)) + received = [] + + async def stream(request): + response = web.StreamResponse(headers={"Content-Type": "text/event-stream"}) + await response.prepare(request) + await response.write(b"data: first\n\n") + await asyncio.sleep(1) + await response.write(b"data: second\n\n") + await asyncio.sleep(5) + return response + + async def listen(): + app = web.Application() + app.router.add_get("/poll/{group}/{pid}", stream) + async with TestServer(app) as server: + connection = WorkerPollConnection( + url=str(server.make_url("")), group="pycon" + ) + connection.recv(received.append) + await connection.start() + await asyncio.sleep(1.5) + await connection.stop() + + asyncio.run(listen()) + + assert received == ["first", "second"] def test_worker_reloads_on_code_changes_by_default(mocker, settings):