diff --git a/backend/api/submissions/mutations.py b/backend/api/submissions/mutations.py index 02c9c14f12..c2ec6613c2 100644 --- a/backend/api/submissions/mutations.py +++ b/backend/api/submissions/mutations.py @@ -1,5 +1,7 @@ from urllib.parse import urljoin from django.conf import settings +from django.core.exceptions import ValidationError +from django.core.validators import validate_email from conferences.frontend import trigger_frontend_revalidate from grants.tasks import get_name from notifications.models import EmailTemplate, EmailTemplateIdentifier @@ -21,8 +23,9 @@ from i18n.strings import LazyI18nString from languages.models import Language from participants.models import Participant -from submissions.models import ProposalMaterial, Submission as SubmissionModel +from submissions.models import ProposalMaterial, Submission as SubmissionModel, SubmissionCoSpeaker from submissions.tasks import notify_new_cfp_submission +from users.models import User from .types import Submission, SubmissionMaterialInput @@ -30,6 +33,74 @@ LINKEDIN_LINK_MATCH = re.compile(r"^http(s)?:\/\/(www\.)?linkedin\.com\/") +def handle_co_speakers(submission, co_speaker_emails, submitter, conference): + """ + Handle co-speakers for a submission. + Creates users if they don't exist and sends invitation emails to new co-speakers. + """ + from users.managers import UserManager + + # Get existing co-speakers for this submission + existing_co_speakers = set( + submission.co_speakers.values_list("user__email", flat=True) + ) + existing_co_speakers_lower = {email.lower() for email in existing_co_speakers} + + # Normalize incoming emails + normalized_emails = [email.lower().strip() for email in co_speaker_emails] + + # Find co-speakers to add (new ones) + emails_to_add = [ + email for email in normalized_emails if email not in existing_co_speakers_lower + ] + + # Find co-speakers to remove (ones no longer in the list) + emails_to_remove = [ + email for email in existing_co_speakers_lower if email not in normalized_emails + ] + + # Remove co-speakers that are no longer in the list + if emails_to_remove: + SubmissionCoSpeaker.objects.filter( + submission=submission, user__email__in=emails_to_remove + ).delete() + + # Add new co-speakers + for email in emails_to_add: + # Get or create user + user = User.objects.filter(email__iexact=email).first() + user_already_had_account = user is not None + + if not user: + # Create user without password + user = User.objects.create_user(email=email, password=None) + + # Create co-speaker relationship + SubmissionCoSpeaker.objects.create(submission=submission, user=user) + + # Send invitation email + try: + email_template = EmailTemplate.objects.for_conference( + conference + ).get_by_identifier(EmailTemplateIdentifier.co_speaker_invitation) + + email_template.send_email( + recipient=user, + placeholders={ + "user_name": get_name(user, email), + "proposal_title": submission.title.localize("en"), + "proposal_type": str(submission.type), + "submitter_name": get_name(submitter, submitter.email), + "submitter_email": submitter.email, + "user_already_had_account": "true" if user_already_had_account else "false", + }, + ) + except Exception: + # If email template doesn't exist, skip sending email + # This allows the feature to work even if the template hasn't been created yet + pass + + @strawberry.type class ProposalMaterialErrors: file_id: list[str] = strawberry.field(default_factory=list) @@ -67,6 +138,8 @@ class _SendSubmissionErrors: speaker_facebook_url: list[str] = strawberry.field(default_factory=list) speaker_mastodon_handle: list[str] = strawberry.field(default_factory=list) + co_speaker_emails: list[str] = strawberry.field(default_factory=list) + non_field_errors: list[str] = strawberry.field(default_factory=list) errors: _SendSubmissionErrors = None @@ -115,7 +188,7 @@ def multi_lingual_validation( f"{to_text[language]}: Cannot be more than {max_length} chars", ) - def validate(self, conference: Conference): + def validate(self, conference: Conference, current_user=None): errors = SendSubmissionErrors() if not self.tags: @@ -133,6 +206,29 @@ def validate(self, conference: Conference): if not self.languages: errors.add_error("languages", "You need to add at least one language") + # Validate co-speaker emails + if hasattr(self, "co_speaker_emails") and self.co_speaker_emails: + if len(self.co_speaker_emails) > 2: + errors.add_error("co_speaker_emails", "You can only add up to 2 co-speakers") + + # Check for duplicate emails + seen_emails = set() + for email in self.co_speaker_emails: + email_lower = email.lower().strip() + if email_lower in seen_emails: + errors.add_error("co_speaker_emails", f"Duplicate email: {email}") + seen_emails.add(email_lower) + + # Validate email format using Django's validate_email + try: + validate_email(email) + except ValidationError: + errors.add_error("co_speaker_emails", f"Invalid email format: {email}") + + # Check if user is trying to add themselves as co-speaker + if current_user and email_lower == current_user.email.lower(): + errors.add_error("co_speaker_emails", "You cannot add yourself as a co-speaker") + self.multi_lingual_validation(errors, conference) max_lengths = { @@ -228,6 +324,7 @@ class SendSubmissionInput(BaseSubmissionInput): topic: Optional[ID] = strawberry.field(default=None) tags: list[ID] = strawberry.field(default_factory=list) do_not_record: bool = strawberry.field(default=False) + co_speaker_emails: list[str] = strawberry.field(default_factory=list) @strawberry.input @@ -259,9 +356,10 @@ class UpdateSubmissionInput(BaseSubmissionInput): tags: list[ID] = strawberry.field(default_factory=list) materials: list[SubmissionMaterialInput] = strawberry.field(default_factory=list) do_not_record: bool = strawberry.field(default=False) + co_speaker_emails: list[str] = strawberry.field(default_factory=list) - def validate(self, conference: Conference, submission: SubmissionModel): - errors = super().validate(conference) + def validate(self, conference: Conference, submission: SubmissionModel, current_user=None): + errors = super().validate(conference, current_user) if self.materials: if len(self.materials) > 3: @@ -303,7 +401,7 @@ def update_submission( conference = instance.conference - errors = input.validate(conference=conference, submission=instance) + errors = input.validate(conference=conference, submission=instance, current_user=request.user) if errors.has_errors: return errors @@ -390,6 +488,14 @@ def update_submission( }, ) + # Handle co-speakers + handle_co_speakers( + submission=instance, + co_speaker_emails=input.co_speaker_emails, + submitter=request.user, + conference=conference, + ) + trigger_frontend_revalidate(conference, instance) instance.__strawberry_definition__ = Submission.__strawberry_definition__ @@ -407,7 +513,7 @@ def send_submission( if not conference: return SendSubmissionErrors.with_error("conference", "Invalid conference") - errors = input.validate(conference=conference) + errors = input.validate(conference=conference, current_user=request.user) if not conference.is_cfp_open: errors.add_error("non_field_errors", "The call for paper is not open!") @@ -490,6 +596,15 @@ def send_submission( }, ) + # Handle co-speakers + if input.co_speaker_emails: + handle_co_speakers( + submission=instance, + co_speaker_emails=input.co_speaker_emails, + submitter=request.user, + conference=conference, + ) + def _notify_new_submission(): notify_new_cfp_submission.delay( submission_id=instance.id, diff --git a/backend/notifications/models.py b/backend/notifications/models.py index 1af21ce945..cf258bd9ef 100644 --- a/backend/notifications/models.py +++ b/backend/notifications/models.py @@ -28,6 +28,7 @@ class EmailTemplateIdentifier(models.TextChoices): "proposal_received_confirmation", _("Proposal received confirmation"), ) + co_speaker_invitation = "co_speaker_invitation", _("Co-speaker invitation") speaker_communication = "speaker_communication", _("Speaker communication") voucher_code = "voucher_code", _("Voucher code") @@ -111,6 +112,15 @@ class EmailTemplate(TimeStampedModel): "proposal_title", "proposal_url", ], + EmailTemplateIdentifier.co_speaker_invitation: [ + *BASE_PLACEHOLDERS, + "user_name", + "proposal_title", + "proposal_type", + "submitter_name", + "submitter_email", + "user_already_had_account", + ], EmailTemplateIdentifier.grant_approved: [ *BASE_PLACEHOLDERS, "reply_url", diff --git a/backend/submissions/admin.py b/backend/submissions/admin.py index 5e6773820c..26f090614d 100644 --- a/backend/submissions/admin.py +++ b/backend/submissions/admin.py @@ -25,6 +25,7 @@ from .models import ( + SubmissionCoSpeaker, ProposalMaterial, Submission, SubmissionComment, @@ -213,6 +214,12 @@ class ProposalMaterialInline(admin.TabularInline): autocomplete_fields = ("file",) +class CoSpeakerInline(admin.TabularInline): + model = SubmissionCoSpeaker + extra = 0 + autocomplete_fields = ("user",) + + @admin.register(Submission) class SubmissionAdmin(ExportMixin, ConferencePermissionMixin, admin.ModelAdmin): resource_class = SubmissionResource @@ -276,7 +283,7 @@ class SubmissionAdmin(ExportMixin, ConferencePermissionMixin, admin.ModelAdmin): send_proposal_in_waiting_list_email_action, ] autocomplete_fields = ("speaker",) - inlines = [ProposalMaterialInline] + inlines = [ProposalMaterialInline, CoSpeakerInline] def change_view(self, request, object_id, form_url="", extra_context=None): extra_context = extra_context or {} diff --git a/backend/submissions/migrations/0031_cospeaker.py b/backend/submissions/migrations/0031_cospeaker.py new file mode 100644 index 0000000000..84ae57c951 --- /dev/null +++ b/backend/submissions/migrations/0031_cospeaker.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.8 on 2026-01-05 19:22 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import model_utils.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('submissions', '0030_submissiontype_is_recordable'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='SubmissionCoSpeaker', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='co_speakers', to='submissions.submission', verbose_name='submission')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='co_speaker_submissions', to=settings.AUTH_USER_MODEL, verbose_name='co-speaker')), + ], + options={ + 'verbose_name': 'co-speaker', + 'verbose_name_plural': 'co-speakers', + 'unique_together': {('submission', 'user')}, + }, + ), + ] diff --git a/backend/submissions/models.py b/backend/submissions/models.py index c045f0c4c1..0e4f113ae7 100644 --- a/backend/submissions/models.py +++ b/backend/submissions/models.py @@ -284,3 +284,27 @@ class Meta: proxy = True verbose_name = _("Submission Confirm Pending Status") verbose_name_plural = _("Submissions Confirm Pending Status") + + +class SubmissionCoSpeaker(TimeStampedModel): + submission = models.ForeignKey( + "submissions.Submission", + on_delete=models.CASCADE, + verbose_name=_("submission"), + related_name="co_speakers", + ) + + user = models.ForeignKey( + "users.User", + on_delete=models.CASCADE, + verbose_name=_("co-speaker"), + related_name="co_speaker_submissions", + ) + + def __str__(self): + return f"{self.user.email} as co-speaker for {self.submission.title}" + + class Meta: + verbose_name = _("co-speaker") + verbose_name_plural = _("co-speakers") + unique_together = [["submission", "user"]]