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( 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/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/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_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(): 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, 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): 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 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"