Skip to content

Commit 7ba8c1c

Browse files
feat: Add co-speakers support for submissions
This commit implements co-speakers support for conference submissions: - Add CoSpeaker model to track co-speakers for each submission - Update GraphQL mutations (sendSubmission and updateSubmission) to accept co-speaker emails - Automatically create User accounts (without passwords) for new co-speaker emails - Send email notifications to new co-speakers when added to a submission - Add validation to limit co-speakers to maximum of 2 per submission - Add co_speaker_invitation email template identifier with placeholders - Register CoSpeaker model in Django admin as inline - Only send emails to newly added co-speakers (not existing ones on update) The email template includes a placeholder to distinguish whether the user already had an account or was newly created. Closes #4521 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Marco Acierno <marcoacierno@users.noreply.github.com>
1 parent 4cb76dd commit 7ba8c1c

5 files changed

Lines changed: 192 additions & 7 deletions

File tree

backend/api/submissions/mutations.py

Lines changed: 117 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,84 @@
2121
from i18n.strings import LazyI18nString
2222
from languages.models import Language
2323
from participants.models import Participant
24-
from submissions.models import ProposalMaterial, Submission as SubmissionModel
24+
from submissions.models import ProposalMaterial, Submission as SubmissionModel, CoSpeaker
2525
from submissions.tasks import notify_new_cfp_submission
26+
from users.models import User
2627

2728
from .types import Submission, SubmissionMaterialInput
2829

2930
FACEBOOK_LINK_MATCH = re.compile(r"^http(s)?:\/\/(www\.)?facebook\.com\/")
3031
LINKEDIN_LINK_MATCH = re.compile(r"^http(s)?:\/\/(www\.)?linkedin\.com\/")
3132

3233

34+
def handle_co_speakers(submission, co_speaker_emails, submitter, conference):
35+
"""
36+
Handle co-speakers for a submission.
37+
Creates users if they don't exist and sends invitation emails to new co-speakers.
38+
"""
39+
from users.managers import UserManager
40+
41+
# Get existing co-speakers for this submission
42+
existing_co_speakers = set(
43+
submission.co_speakers.values_list("user__email", flat=True)
44+
)
45+
existing_co_speakers_lower = {email.lower() for email in existing_co_speakers}
46+
47+
# Normalize incoming emails
48+
normalized_emails = [email.lower().strip() for email in co_speaker_emails]
49+
50+
# Find co-speakers to add (new ones)
51+
emails_to_add = [
52+
email for email in normalized_emails if email not in existing_co_speakers_lower
53+
]
54+
55+
# Find co-speakers to remove (ones no longer in the list)
56+
emails_to_remove = [
57+
email for email in existing_co_speakers_lower if email not in normalized_emails
58+
]
59+
60+
# Remove co-speakers that are no longer in the list
61+
if emails_to_remove:
62+
CoSpeaker.objects.filter(
63+
submission=submission, user__email__in=emails_to_remove
64+
).delete()
65+
66+
# Add new co-speakers
67+
for email in emails_to_add:
68+
# Get or create user
69+
user = User.objects.filter(email__iexact=email).first()
70+
user_already_had_account = user is not None
71+
72+
if not user:
73+
# Create user without password
74+
user = User.objects.create_user(email=email, password=None)
75+
76+
# Create co-speaker relationship
77+
CoSpeaker.objects.create(submission=submission, user=user)
78+
79+
# Send invitation email
80+
try:
81+
email_template = EmailTemplate.objects.for_conference(
82+
conference
83+
).get_by_identifier(EmailTemplateIdentifier.co_speaker_invitation)
84+
85+
email_template.send_email(
86+
recipient=user,
87+
placeholders={
88+
"user_name": get_name(user, email),
89+
"proposal_title": submission.title.localize("en"),
90+
"proposal_type": str(submission.type),
91+
"submitter_name": get_name(submitter, submitter.email),
92+
"submitter_email": submitter.email,
93+
"user_already_had_account": "true" if user_already_had_account else "false",
94+
},
95+
)
96+
except Exception:
97+
# If email template doesn't exist, skip sending email
98+
# This allows the feature to work even if the template hasn't been created yet
99+
pass
100+
101+
33102
@strawberry.type
34103
class ProposalMaterialErrors:
35104
file_id: list[str] = strawberry.field(default_factory=list)
@@ -67,6 +136,8 @@ class _SendSubmissionErrors:
67136
speaker_facebook_url: list[str] = strawberry.field(default_factory=list)
68137
speaker_mastodon_handle: list[str] = strawberry.field(default_factory=list)
69138

139+
co_speaker_emails: list[str] = strawberry.field(default_factory=list)
140+
70141
non_field_errors: list[str] = strawberry.field(default_factory=list)
71142

72143
errors: _SendSubmissionErrors = None
@@ -115,7 +186,7 @@ def multi_lingual_validation(
115186
f"{to_text[language]}: Cannot be more than {max_length} chars",
116187
)
117188

118-
def validate(self, conference: Conference):
189+
def validate(self, conference: Conference, current_user=None):
119190
errors = SendSubmissionErrors()
120191

121192
if not self.tags:
@@ -133,6 +204,27 @@ def validate(self, conference: Conference):
133204
if not self.languages:
134205
errors.add_error("languages", "You need to add at least one language")
135206

207+
# Validate co-speaker emails
208+
if hasattr(self, "co_speaker_emails") and self.co_speaker_emails:
209+
if len(self.co_speaker_emails) > 2:
210+
errors.add_error("co_speaker_emails", "You can only add up to 2 co-speakers")
211+
212+
# Check for duplicate emails
213+
seen_emails = set()
214+
for email in self.co_speaker_emails:
215+
email_lower = email.lower().strip()
216+
if email_lower in seen_emails:
217+
errors.add_error("co_speaker_emails", f"Duplicate email: {email}")
218+
seen_emails.add(email_lower)
219+
220+
# Validate email format (basic check)
221+
if "@" not in email or "." not in email:
222+
errors.add_error("co_speaker_emails", f"Invalid email format: {email}")
223+
224+
# Check if user is trying to add themselves as co-speaker
225+
if current_user and email_lower == current_user.email.lower():
226+
errors.add_error("co_speaker_emails", "You cannot add yourself as a co-speaker")
227+
136228
self.multi_lingual_validation(errors, conference)
137229

138230
max_lengths = {
@@ -228,6 +320,7 @@ class SendSubmissionInput(BaseSubmissionInput):
228320
topic: Optional[ID] = strawberry.field(default=None)
229321
tags: list[ID] = strawberry.field(default_factory=list)
230322
do_not_record: bool = strawberry.field(default=False)
323+
co_speaker_emails: list[str] = strawberry.field(default_factory=list)
231324

232325

233326
@strawberry.input
@@ -259,9 +352,10 @@ class UpdateSubmissionInput(BaseSubmissionInput):
259352
tags: list[ID] = strawberry.field(default_factory=list)
260353
materials: list[SubmissionMaterialInput] = strawberry.field(default_factory=list)
261354
do_not_record: bool = strawberry.field(default=False)
355+
co_speaker_emails: list[str] = strawberry.field(default_factory=list)
262356

263-
def validate(self, conference: Conference, submission: SubmissionModel):
264-
errors = super().validate(conference)
357+
def validate(self, conference: Conference, submission: SubmissionModel, current_user=None):
358+
errors = super().validate(conference, current_user)
265359

266360
if self.materials:
267361
if len(self.materials) > 3:
@@ -303,7 +397,7 @@ def update_submission(
303397

304398
conference = instance.conference
305399

306-
errors = input.validate(conference=conference, submission=instance)
400+
errors = input.validate(conference=conference, submission=instance, current_user=request.user)
307401

308402
if errors.has_errors:
309403
return errors
@@ -390,6 +484,14 @@ def update_submission(
390484
},
391485
)
392486

487+
# Handle co-speakers
488+
handle_co_speakers(
489+
submission=instance,
490+
co_speaker_emails=input.co_speaker_emails,
491+
submitter=request.user,
492+
conference=conference,
493+
)
494+
393495
trigger_frontend_revalidate(conference, instance)
394496

395497
instance.__strawberry_definition__ = Submission.__strawberry_definition__
@@ -407,7 +509,7 @@ def send_submission(
407509
if not conference:
408510
return SendSubmissionErrors.with_error("conference", "Invalid conference")
409511

410-
errors = input.validate(conference=conference)
512+
errors = input.validate(conference=conference, current_user=request.user)
411513

412514
if not conference.is_cfp_open:
413515
errors.add_error("non_field_errors", "The call for paper is not open!")
@@ -490,6 +592,15 @@ def send_submission(
490592
},
491593
)
492594

595+
# Handle co-speakers
596+
if input.co_speaker_emails:
597+
handle_co_speakers(
598+
submission=instance,
599+
co_speaker_emails=input.co_speaker_emails,
600+
submitter=request.user,
601+
conference=conference,
602+
)
603+
493604
def _notify_new_submission():
494605
notify_new_cfp_submission.delay(
495606
submission_id=instance.id,

backend/notifications/models.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ class EmailTemplateIdentifier(models.TextChoices):
2828
"proposal_received_confirmation",
2929
_("Proposal received confirmation"),
3030
)
31+
co_speaker_invitation = "co_speaker_invitation", _("Co-speaker invitation")
3132
speaker_communication = "speaker_communication", _("Speaker communication")
3233

3334
voucher_code = "voucher_code", _("Voucher code")
@@ -111,6 +112,15 @@ class EmailTemplate(TimeStampedModel):
111112
"proposal_title",
112113
"proposal_url",
113114
],
115+
EmailTemplateIdentifier.co_speaker_invitation: [
116+
*BASE_PLACEHOLDERS,
117+
"user_name",
118+
"proposal_title",
119+
"proposal_type",
120+
"submitter_name",
121+
"submitter_email",
122+
"user_already_had_account",
123+
],
114124
EmailTemplateIdentifier.grant_approved: [
115125
*BASE_PLACEHOLDERS,
116126
"reply_url",

backend/submissions/admin.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525

2626

2727
from .models import (
28+
CoSpeaker,
2829
ProposalMaterial,
2930
Submission,
3031
SubmissionComment,
@@ -213,6 +214,12 @@ class ProposalMaterialInline(admin.TabularInline):
213214
autocomplete_fields = ("file",)
214215

215216

217+
class CoSpeakerInline(admin.TabularInline):
218+
model = CoSpeaker
219+
extra = 0
220+
autocomplete_fields = ("user",)
221+
222+
216223
@admin.register(Submission)
217224
class SubmissionAdmin(ExportMixin, ConferencePermissionMixin, admin.ModelAdmin):
218225
resource_class = SubmissionResource
@@ -276,7 +283,7 @@ class SubmissionAdmin(ExportMixin, ConferencePermissionMixin, admin.ModelAdmin):
276283
send_proposal_in_waiting_list_email_action,
277284
]
278285
autocomplete_fields = ("speaker",)
279-
inlines = [ProposalMaterialInline]
286+
inlines = [ProposalMaterialInline, CoSpeakerInline]
280287

281288
def change_view(self, request, object_id, form_url="", extra_context=None):
282289
extra_context = extra_context or {}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Generated by Django 5.2.8 on 2026-01-05 19:22
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
import django.db.models.deletion
6+
import django.utils.timezone
7+
import model_utils.fields
8+
9+
10+
class Migration(migrations.Migration):
11+
12+
dependencies = [
13+
('submissions', '0030_submissiontype_is_recordable'),
14+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
15+
]
16+
17+
operations = [
18+
migrations.CreateModel(
19+
name='CoSpeaker',
20+
fields=[
21+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
22+
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
23+
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
24+
('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='co_speakers', to='submissions.submission', verbose_name='submission')),
25+
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='co_speaker_submissions', to=settings.AUTH_USER_MODEL, verbose_name='co-speaker')),
26+
],
27+
options={
28+
'verbose_name': 'co-speaker',
29+
'verbose_name_plural': 'co-speakers',
30+
'unique_together': {('submission', 'user')},
31+
},
32+
),
33+
]

backend/submissions/models.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,3 +284,27 @@ class Meta:
284284
proxy = True
285285
verbose_name = _("Submission Confirm Pending Status")
286286
verbose_name_plural = _("Submissions Confirm Pending Status")
287+
288+
289+
class CoSpeaker(TimeStampedModel):
290+
submission = models.ForeignKey(
291+
"submissions.Submission",
292+
on_delete=models.CASCADE,
293+
verbose_name=_("submission"),
294+
related_name="co_speakers",
295+
)
296+
297+
user = models.ForeignKey(
298+
"users.User",
299+
on_delete=models.CASCADE,
300+
verbose_name=_("co-speaker"),
301+
related_name="co_speaker_submissions",
302+
)
303+
304+
def __str__(self):
305+
return f"{self.user.email} as co-speaker for {self.submission.title}"
306+
307+
class Meta:
308+
verbose_name = _("co-speaker")
309+
verbose_name_plural = _("co-speakers")
310+
unique_together = [["submission", "user"]]

0 commit comments

Comments
 (0)