diff --git a/backend/notifications/migrations/0024_alter_emailtemplate_identifier.py b/backend/notifications/migrations/0024_alter_emailtemplate_identifier.py new file mode 100644 index 0000000000..c61e946605 --- /dev/null +++ b/backend/notifications/migrations/0024_alter_emailtemplate_identifier.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.8 on 2026-08-15 14:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0023_alter_sentemail_status'), + ] + + operations = [ + migrations.AlterField( + model_name='emailtemplate', + name='identifier', + field=models.CharField(choices=[('proposal_accepted', 'Proposal accepted'), ('proposal_scheduled', 'Proposal scheduled'), ('proposal_rejected', 'Proposal rejected'), ('proposal_in_waiting_list', 'Proposal in waiting list'), ('proposal_scheduled_time_changed', 'Proposal scheduled time changed'), ('proposal_received_confirmation', 'Proposal received confirmation'), ('speaker_communication', 'Speaker communication'), ('voucher_code', 'Voucher code'), ('reset_password', '[System] Reset password'), ('grant_application_confirmation', 'Grant application confirmation'), ('grant_approved', 'Grant approved'), ('grant_rejected', 'Grant rejected'), ('grant_waiting_list', 'Grant waiting list'), ('grant_waiting_list_update', 'Grant waiting list update'), ('sponsorship_brochure', 'Sponsorship brochure'), ('visa_invitation_letter_download', 'Visa invitation letter download'), ('speaker_video_recording_uploaded', 'Speaker: Video recording uploaded'), ('custom', 'Custom')], max_length=200, verbose_name='identifier'), + ), + ] diff --git a/backend/notifications/models.py b/backend/notifications/models.py index a19907d270..c30ab64082 100644 --- a/backend/notifications/models.py +++ b/backend/notifications/models.py @@ -53,6 +53,11 @@ class EmailTemplateIdentifier(models.TextChoices): _("Visa invitation letter download"), ) + speaker_video_recording_uploaded = ( + "speaker_video_recording_uploaded", + _("Speaker: Video recording uploaded"), + ) + custom = "custom", _("Custom") @@ -165,6 +170,13 @@ class EmailTemplate(TimeStampedModel): "has_grant", "user_name", ], + EmailTemplateIdentifier.speaker_video_recording_uploaded: [ + *BASE_PLACEHOLDERS, + "user_name", + "video_recording_url", + "schedule_item_title", + "schedule_item_type", + ], } conference = models.ForeignKey( diff --git a/backend/schedule/admin.py b/backend/schedule/admin.py index 758f68390a..0e288e1cf3 100644 --- a/backend/schedule/admin.py +++ b/backend/schedule/admin.py @@ -751,6 +751,7 @@ class ScheduleItemSentForVideoUploadAdmin(admin.ModelAdmin): "status", "video_uploaded", "thumbnail_uploaded", + "emails_scheduled", "attempts", "last_attempt_at", ) diff --git a/backend/schedule/migrations/0058_scheduleitemsentforvideoupload_emails_scheduled.py b/backend/schedule/migrations/0058_scheduleitemsentforvideoupload_emails_scheduled.py new file mode 100644 index 0000000000..c77f009e91 --- /dev/null +++ b/backend/schedule/migrations/0058_scheduleitemsentforvideoupload_emails_scheduled.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.8 on 2026-08-15 16:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('schedule', '0057_room_attendees_total_capacity'), + ] + + operations = [ + migrations.AddField( + model_name='scheduleitemsentforvideoupload', + name='emails_scheduled', + field=models.BooleanField(default=False, verbose_name='Emails scheduled'), + ), + ] diff --git a/backend/schedule/models.py b/backend/schedule/models.py index 2101813aab..fd2aaf3d8b 100644 --- a/backend/schedule/models.py +++ b/backend/schedule/models.py @@ -19,6 +19,11 @@ from pycon.constants import COLORS from submissions.models import Submission +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from users.models import User + @dataclass class SpeakerEntity: @@ -156,6 +161,7 @@ class Status(models.TextChoices): last_attempt_at = models.DateTimeField(_("Last attempt at"), null=True, blank=True) video_uploaded = models.BooleanField(_("Video uploaded"), default=False) thumbnail_uploaded = models.BooleanField(_("Thumbnail uploaded"), default=False) + emails_scheduled = models.BooleanField(_("Emails scheduled"), default=False) failed_reason = models.TextField( _("Failed reason"), blank=True, @@ -334,7 +340,7 @@ def actual_attendees_total_capacity(self): return room.attendees_total_capacity if room else None @cached_property - def speakers(self): + def speakers(self) -> list["User"]: speakers = [] if self.submission_id: @@ -354,7 +360,9 @@ def speakers(self): speaker.user for speaker in sorted(additional_speakers, key=lambda speaker: speaker.id) ) - return [speaker for speaker in speakers if speaker is not None] + return list( + dict.fromkeys(speaker for speaker in speakers if speaker is not None) + ) def clean(self): if self.type == ScheduleItem.TYPES.submission and not self.submission: diff --git a/backend/schedule/tasks.py b/backend/schedule/tasks.py index 3ef8e292d6..c7b088280b 100644 --- a/backend/schedule/tasks.py +++ b/backend/schedule/tasks.py @@ -1,3 +1,4 @@ +from django.db import transaction from django.db.models import Q from conferences.tasks import send_conference_voucher_email from conferences.vouchers import create_conference_voucher @@ -262,7 +263,7 @@ def upload_schedule_item_video(*, sent_for_video_upload_state_id: int): schedule_item = sent_for_video_upload.schedule_item remote_video_path = schedule_item.video_uploaded_path - video_id = None + video_id = schedule_item.youtube_video_id if not sent_for_video_upload.video_uploaded: logger.info("Uploading video for schedule_item_id=%s", schedule_item.id) @@ -292,10 +293,9 @@ def upload_schedule_item_video(*, sent_for_video_upload_state_id: int): else: logger.info("Video already uploaded for schedule_item_id=%s", schedule_item.id) - if not sent_for_video_upload.thumbnail_uploaded: - video_id = video_id or schedule_item.youtube_video_id - assert video_id, "Video marked as uploaded but Video ID is missing" + assert video_id, "Video marked as uploaded but Video ID is missing" + if not sent_for_video_upload.thumbnail_uploaded: logger.info("Extracting thumbnail for schedule_item_id=%s", schedule_item.id) thumbnail_path = extract_video_thumbnail( @@ -331,7 +331,29 @@ def upload_schedule_item_video(*, sent_for_video_upload_state_id: int): cleanup_local_files(schedule_item.id) - logger.info("Video uploaded for schedule_item_id=%s", schedule_item.id) + if not sent_for_video_upload.emails_scheduled: + conference = schedule_item.conference + all_speakers = schedule_item.speakers + email_template = EmailTemplate.objects.for_conference( + conference + ).get_by_identifier(EmailTemplateIdentifier.speaker_video_recording_uploaded) + + with transaction.atomic(): + for speaker in all_speakers: + email_template.draft_email( + recipient=speaker, + placeholders={ + "user_name": get_name(speaker, "there"), + "video_recording_url": f"https://www.youtube.com/watch?v={video_id}", + "schedule_item_title": schedule_item.title, + "schedule_item_type": schedule_item.get_type_display(), + }, + ) + + sent_for_video_upload.emails_scheduled = True + sent_for_video_upload.save(update_fields=["emails_scheduled"]) + + logger.info("Workflow completed for schedule_item_id=%s", schedule_item.id) sent_for_video_upload.status = ScheduleItemSentForVideoUpload.Status.completed sent_for_video_upload.save(update_fields=["status"]) diff --git a/backend/schedule/tests/test_tasks.py b/backend/schedule/tests/test_tasks.py index fa06c65eb9..a84bb7c584 100644 --- a/backend/schedule/tests/test_tasks.py +++ b/backend/schedule/tests/test_tasks.py @@ -33,7 +33,7 @@ import time_machine from users.tests.factories import UserFactory from schedule.models import ScheduleItem, ScheduleItemSentForVideoUpload -from notifications.models import EmailTemplateIdentifier +from notifications.models import EmailTemplateIdentifier, SentEmail import pytest @@ -69,18 +69,24 @@ def test_send_schedule_invitation_email(sent_emails): # Verify that the correct email template was used and email was sent emails_sent = sent_emails() assert emails_sent.count() == 1 - + sent_email = emails_sent.first() - assert sent_email.email_template.identifier == EmailTemplateIdentifier.proposal_scheduled + assert ( + sent_email.email_template.identifier + == EmailTemplateIdentifier.proposal_scheduled + ) assert sent_email.email_template.conference == schedule_item.conference assert sent_email.recipient == user - + # Verify placeholders were processed correctly assert sent_email.placeholders["proposal_title"] == "Title Submission" assert sent_email.placeholders["conference_name"] == "Conf" - assert sent_email.placeholders["invitation_url"] == f"https://frontend/schedule/invitation/{schedule_item.submission.hashid}" + assert ( + sent_email.placeholders["invitation_url"] + == f"https://frontend/schedule/invitation/{schedule_item.submission.hashid}" + ) assert sent_email.placeholders["speaker_name"] == "Marco Acierno" - assert sent_email.placeholders["is_reminder"] == False + assert sent_email.placeholders["is_reminder"] is False schedule_item.refresh_from_db() @@ -116,18 +122,24 @@ def test_send_schedule_invitation_email_reminder(sent_emails): # Verify that the correct email template was used and email was sent emails_sent = sent_emails() assert emails_sent.count() == 1 - + sent_email = emails_sent.first() - assert sent_email.email_template.identifier == EmailTemplateIdentifier.proposal_scheduled + assert ( + sent_email.email_template.identifier + == EmailTemplateIdentifier.proposal_scheduled + ) assert sent_email.email_template.conference == schedule_item.conference assert sent_email.recipient == user - + # Verify placeholders were processed correctly assert sent_email.placeholders["proposal_title"] == "Title Submission" assert sent_email.placeholders["conference_name"] == "Conf" - assert sent_email.placeholders["invitation_url"] == f"https://frontend/schedule/invitation/{schedule_item.submission.hashid}" + assert ( + sent_email.placeholders["invitation_url"] + == f"https://frontend/schedule/invitation/{schedule_item.submission.hashid}" + ) assert sent_email.placeholders["speaker_name"] == "Marco Acierno" - assert sent_email.placeholders["is_reminder"] == True + assert sent_email.placeholders["is_reminder"] is True @override_settings(FRONTEND_URL="https://frontend/") @@ -144,7 +156,7 @@ def test_send_submission_time_slot_changed_email(sent_emails): conference__name=LazyI18nString({"en": "Conf"}), type=ScheduleItem.TYPES.talk, ) - + EmailTemplateFactory( conference=schedule_item.conference, identifier=EmailTemplateIdentifier.proposal_scheduled_time_changed, @@ -155,16 +167,22 @@ def test_send_submission_time_slot_changed_email(sent_emails): # Verify that the correct email template was used and email was sent emails_sent = sent_emails() assert emails_sent.count() == 1 - + sent_email = emails_sent.first() - assert sent_email.email_template.identifier == EmailTemplateIdentifier.proposal_scheduled_time_changed + assert ( + sent_email.email_template.identifier + == EmailTemplateIdentifier.proposal_scheduled_time_changed + ) assert sent_email.email_template.conference == schedule_item.conference assert sent_email.recipient == schedule_item.submission.speaker - + # Verify placeholders were processed correctly assert sent_email.placeholders["proposal_title"] == "Title Submission" assert sent_email.placeholders["speaker_name"] == "Marco Acierno" - assert sent_email.placeholders["invitation_url"] == f"https://frontend/schedule/invitation/{schedule_item.submission.hashid}" + assert ( + sent_email.placeholders["invitation_url"] + == f"https://frontend/schedule/invitation/{schedule_item.submission.hashid}" + ) assert sent_email.placeholders["conference_name"] == "Conf" @@ -214,7 +232,7 @@ def test_send_speaker_communication_email_to_speakers_without_ticket( name="Marco", username="marco", ) - + EmailTemplateFactory( conference=conference, identifier=EmailTemplateIdentifier.speaker_communication, @@ -235,18 +253,23 @@ def test_send_speaker_communication_email_to_speakers_without_ticket( # Verify that the correct email template was used and email was sent emails_sent = sent_emails() - + if not has_ticket: assert emails_sent.count() == 1 - + sent_email = emails_sent.first() - assert sent_email.email_template.identifier == EmailTemplateIdentifier.speaker_communication + assert ( + sent_email.email_template.identifier + == EmailTemplateIdentifier.speaker_communication + ) assert sent_email.email_template.conference == conference assert sent_email.recipient == user - + # Verify placeholders were processed correctly assert sent_email.placeholders["user_name"] == "Marco Acierno" - assert sent_email.placeholders["conference_name"] == conference.name.localize("en") + assert sent_email.placeholders["conference_name"] == conference.name.localize( + "en" + ) assert sent_email.placeholders["body"] == "test body" assert sent_email.placeholders["subject"] == "test subject" else: @@ -265,7 +288,7 @@ def test_send_speaker_communication_email_to_everyone( username="marco", ) conference = ConferenceFactory() - + EmailTemplateFactory( conference=conference, identifier=EmailTemplateIdentifier.speaker_communication, @@ -287,12 +310,15 @@ def test_send_speaker_communication_email_to_everyone( # Verify that the correct email template was used and email was sent emails_sent = sent_emails() assert emails_sent.count() == 1 - + sent_email = emails_sent.first() - assert sent_email.email_template.identifier == EmailTemplateIdentifier.speaker_communication + assert ( + sent_email.email_template.identifier + == EmailTemplateIdentifier.speaker_communication + ) assert sent_email.email_template.conference == conference assert sent_email.recipient == user - + # Verify placeholders were processed correctly assert sent_email.placeholders["user_name"] == "Marco Acierno" assert sent_email.placeholders["body"] == "test body" @@ -499,6 +525,11 @@ def test_upload_schedule_item_video_flow(mocker): schedule_item__conference__video_title_template="{{ title }}", schedule_item__conference__video_description_template="{{ abstract }}", ) + EmailTemplateFactory( + conference=sent_for_upload.schedule_item.conference, + identifier=EmailTemplateIdentifier.speaker_video_recording_uploaded, + ) + upload_schedule_item_video( sent_for_video_upload_state_id=sent_for_upload.id, ) @@ -582,6 +613,11 @@ def test_upload_schedule_item_with_only_thumbnail_to_upload(mocker): schedule_item__conference__video_title_template="{{ title }}", schedule_item__conference__video_description_template="{{ abstract }}", ) + EmailTemplateFactory( + conference=sent_for_upload.schedule_item.conference, + identifier=EmailTemplateIdentifier.speaker_video_recording_uploaded, + ) + upload_schedule_item_video( sent_for_video_upload_state_id=sent_for_upload.id, ) @@ -601,6 +637,236 @@ def test_upload_schedule_item_with_only_thumbnail_to_upload(mocker): assert sent_for_upload.schedule_item.youtube_video_id == "vid_10" +def _sent_for_upload_ready_to_notify(**kwargs): + """ + A job where the video and the thumbnail are already on YouTube, so the only + thing left for `upload_schedule_item_video` to do is drafting the emails. + """ + defaults = { + "last_attempt_at": None, + "status": ScheduleItemSentForVideoUpload.Status.pending, + "video_uploaded": True, + "thumbnail_uploaded": True, + "schedule_item__youtube_video_id": "vid_123", + "schedule_item__title": "Test Title", + **kwargs, + } + return ScheduleItemSentForVideoUploadFactory(**defaults) + + +def test_upload_schedule_item_video_drafts_an_email_to_each_speaker(sent_emails): + speaker = UserFactory(full_name="Marco Acierno", email="marco@placeholder.it") + co_speaker = UserFactory(full_name="Ester Beltrami", email="ester@placeholder.it") + + sent_for_upload = _sent_for_upload_ready_to_notify( + schedule_item__type=ScheduleItem.TYPES.talk, + schedule_item__submission=None, + ) + schedule_item = sent_for_upload.schedule_item + + ScheduleItemAdditionalSpeakerFactory(scheduleitem=schedule_item, user=speaker) + ScheduleItemAdditionalSpeakerFactory(scheduleitem=schedule_item, user=co_speaker) + + EmailTemplateFactory( + conference=schedule_item.conference, + identifier=EmailTemplateIdentifier.speaker_video_recording_uploaded, + ) + + upload_schedule_item_video(sent_for_video_upload_state_id=sent_for_upload.id) + + drafts = sent_emails() + assert drafts.count() == 2 + assert set(drafts.values_list("recipient_id", flat=True)) == { + speaker.id, + co_speaker.id, + } + + for draft in drafts: + assert draft.status == SentEmail.Status.draft + assert draft.conference == schedule_item.conference + assert ( + draft.email_template.identifier + == EmailTemplateIdentifier.speaker_video_recording_uploaded + ) + + draft = drafts.get(recipient=speaker) + assert draft.placeholders == { + "user_name": "Marco Acierno", + "video_recording_url": "https://www.youtube.com/watch?v=vid_123", + "schedule_item_title": "Test Title", + "schedule_item_type": "Talk", + } + + sent_for_upload.refresh_from_db() + assert sent_for_upload.emails_scheduled + assert sent_for_upload.status == ScheduleItemSentForVideoUpload.Status.completed + + +def test_upload_schedule_item_video_drafts_one_email_per_speaker(sent_emails): + """The same person can be both the proposal speaker and an additional speaker.""" + speaker = UserFactory(full_name="Marco Acierno", email="marco@placeholder.it") + + sent_for_upload = _sent_for_upload_ready_to_notify( + schedule_item__type=ScheduleItem.TYPES.submission, + schedule_item__submission__speaker=speaker, + ) + ScheduleItemAdditionalSpeakerFactory( + scheduleitem=sent_for_upload.schedule_item, user=speaker + ) + + EmailTemplateFactory( + conference=sent_for_upload.schedule_item.conference, + identifier=EmailTemplateIdentifier.speaker_video_recording_uploaded, + ) + + upload_schedule_item_video(sent_for_video_upload_state_id=sent_for_upload.id) + + assert sent_emails().count() == 1 + + +def test_upload_schedule_item_video_notifies_the_speaker_of_each_of_their_talks( + sent_emails, +): + speaker = UserFactory(full_name="Marco Acierno", email="marco@placeholder.it") + conference = ConferenceFactory() + + template = EmailTemplateFactory( + conference=conference, + identifier=EmailTemplateIdentifier.speaker_video_recording_uploaded, + ) + + for title in ("First Talk", "Second Talk"): + sent_for_upload = _sent_for_upload_ready_to_notify( + schedule_item__conference=conference, + schedule_item__type=ScheduleItem.TYPES.talk, + schedule_item__submission=None, + schedule_item__title=title, + ) + ScheduleItemAdditionalSpeakerFactory( + scheduleitem=sent_for_upload.schedule_item, user=speaker + ) + + upload_schedule_item_video(sent_for_video_upload_state_id=sent_for_upload.id) + + drafts = sent_emails().filter(email_template=template, recipient=speaker) + assert drafts.count() == 2 + assert {draft.placeholders["schedule_item_title"] for draft in drafts} == { + "First Talk", + "Second Talk", + } + + +def test_upload_schedule_item_video_does_not_draft_emails_twice(sent_emails): + speaker = UserFactory(full_name="Marco Acierno", email="marco@placeholder.it") + + sent_for_upload = _sent_for_upload_ready_to_notify( + emails_scheduled=True, + schedule_item__type=ScheduleItem.TYPES.talk, + schedule_item__submission=None, + ) + ScheduleItemAdditionalSpeakerFactory( + scheduleitem=sent_for_upload.schedule_item, user=speaker + ) + + EmailTemplateFactory( + conference=sent_for_upload.schedule_item.conference, + identifier=EmailTemplateIdentifier.speaker_video_recording_uploaded, + ) + + upload_schedule_item_video(sent_for_video_upload_state_id=sent_for_upload.id) + + assert sent_emails().count() == 0 + + sent_for_upload.refresh_from_db() + assert sent_for_upload.status == ScheduleItemSentForVideoUpload.Status.completed + + +def test_upload_schedule_item_video_completes_when_there_are_no_speakers(sent_emails): + """Breaks, socials and registration items have no speakers to notify.""" + sent_for_upload = _sent_for_upload_ready_to_notify( + schedule_item__type=ScheduleItem.TYPES.social, + schedule_item__submission=None, + ) + EmailTemplateFactory( + conference=sent_for_upload.schedule_item.conference, + identifier=EmailTemplateIdentifier.speaker_video_recording_uploaded, + ) + + upload_schedule_item_video(sent_for_video_upload_state_id=sent_for_upload.id) + + assert sent_emails().count() == 0 + + sent_for_upload.refresh_from_db() + assert sent_for_upload.status == ScheduleItemSentForVideoUpload.Status.completed + assert sent_for_upload.failed_reason == "" + + +def test_upload_schedule_item_video_drafts_email_with_the_uploaded_video_id( + mocker, sent_emails +): + file_content = BytesIO(b"File") + file_content.name = "test.mp4" + file_content.seek(0) + + conferencevideos_storage = storages["default"] + conferencevideos_storage.save( + "videos/test.mp4", + InMemoryUploadedFile( + file=file_content, + field_name="file_field", + name="test.txt", + content_type="text/plain", + size=file_content.getbuffer().nbytes, + charset="utf-8", + content_type_extra=None, + ), + ) + + mocker.patch( + "schedule.tasks.youtube_videos_insert", + autospec=True, + return_value=[{"id": "vid_from_youtube"}], + ) + mocker.patch("schedule.tasks.youtube_videos_set_thumbnail", autospec=True) + mocker.patch( + "schedule.video_upload.cv2.VideoCapture.read", + return_value=(True, np.array([1])), + ) + + speaker = UserFactory(full_name="Marco Acierno", email="marco@placeholder.it") + sent_for_upload = ScheduleItemSentForVideoUploadFactory( + last_attempt_at=None, + status=ScheduleItemSentForVideoUpload.Status.pending, + video_uploaded=False, + thumbnail_uploaded=False, + schedule_item__type=ScheduleItem.TYPES.talk, + schedule_item__submission=None, + schedule_item__title="Test Title", + schedule_item__description="Test Description", + schedule_item__video_uploaded_path=conferencevideos_storage.path( + "videos/test.mp4" + ), + schedule_item__conference__video_title_template="{{ title }}", + schedule_item__conference__video_description_template="{{ abstract }}", + ) + ScheduleItemAdditionalSpeakerFactory( + scheduleitem=sent_for_upload.schedule_item, user=speaker + ) + + EmailTemplateFactory( + conference=sent_for_upload.schedule_item.conference, + identifier=EmailTemplateIdentifier.speaker_video_recording_uploaded, + ) + + upload_schedule_item_video(sent_for_video_upload_state_id=sent_for_upload.id) + + draft = sent_emails().get(recipient=speaker) + assert ( + draft.placeholders["video_recording_url"] + == "https://www.youtube.com/watch?v=vid_from_youtube" + ) + + @pytest.mark.parametrize( "status", [