Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 121 additions & 6 deletions backend/api/submissions/mutations.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -21,15 +23,84 @@
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

FACEBOOK_LINK_MATCH = re.compile(r"^http(s)?:\/\/(www\.)?facebook\.com\/")
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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@claude current_user is never None

Copy link
Contributor

@claude claude bot Jan 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude encountered an error —— View job


I'll analyze this and get back to you.

errors = SendSubmissionErrors()

if not self.tags:
Expand All @@ -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 = {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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__
Expand All @@ -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!")
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions backend/notifications/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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",
Expand Down
9 changes: 8 additions & 1 deletion backend/submissions/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@


from .models import (
SubmissionCoSpeaker,
ProposalMaterial,
Submission,
SubmissionComment,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {}
Expand Down
33 changes: 33 additions & 0 deletions backend/submissions/migrations/0031_cospeaker.py
Original file line number Diff line number Diff line change
@@ -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')},
},
),
]
24 changes: 24 additions & 0 deletions backend/submissions/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]]
Loading