diff --git a/backend/api/conferences/types.py b/backend/api/conferences/types.py
index 2f1076cd67..76810a586d 100644
--- a/backend/api/conferences/types.py
+++ b/backend/api/conferences/types.py
@@ -335,6 +335,8 @@ def days(self, info: Info) -> list[Day]:
"submission__speaker",
"submission__languages",
"submission__schedule_items",
+ "submission__co_speakers",
+ "submission__co_speakers__user",
"keynote",
"keynote__schedule_items",
"keynote__schedule_items__rooms",
diff --git a/backend/schedule/models.py b/backend/schedule/models.py
index 514f29e8ce..2e502a1cf4 100644
--- a/backend/schedule/models.py
+++ b/backend/schedule/models.py
@@ -316,6 +316,16 @@ def speakers(self):
speakers.extend(
[speaker.user for speaker in self.additional_speakers.order_by("id").all()]
)
+
+ if self.submission_id:
+ speakers.extend(
+ [
+ co_speaker.user
+ for co_speaker in self.submission.co_speakers.accepted()
+ .order_by("id")
+ .all()
+ ]
+ )
return speakers
def clean(self):
diff --git a/backend/submissions/admin.py b/backend/submissions/admin.py
index 72944c9a74..66760bc90f 100644
--- a/backend/submissions/admin.py
+++ b/backend/submissions/admin.py
@@ -1,3 +1,7 @@
+from ordered_model.admin import (
+ OrderedInlineModelAdminMixin,
+ OrderedTabularInline,
+)
from django.urls import reverse
from grants.tasks import get_name
from notifications.models import EmailTemplate, EmailTemplateIdentifier
@@ -26,6 +30,7 @@
from .models import (
+ ProposalCoSpeaker,
ProposalMaterial,
Submission,
SubmissionComment,
@@ -214,8 +219,21 @@ class ProposalMaterialInline(admin.TabularInline):
autocomplete_fields = ("file",)
+class ProposalCoSpeakerInline(OrderedTabularInline):
+ model = ProposalCoSpeaker
+ extra = 0
+ autocomplete_fields = ("user",)
+ fields = ("user", "status", "order", "move_up_down_links")
+ readonly_fields = ("order", "move_up_down_links")
+
+
@admin.register(Submission)
-class SubmissionAdmin(ExportMixin, ConferencePermissionMixin, admin.ModelAdmin):
+class SubmissionAdmin(
+ ExportMixin,
+ ConferencePermissionMixin,
+ OrderedInlineModelAdminMixin,
+ admin.ModelAdmin,
+):
resource_class = SubmissionResource
form = SubmissionAdminForm
list_display = (
@@ -276,7 +294,7 @@ class SubmissionAdmin(ExportMixin, ConferencePermissionMixin, admin.ModelAdmin):
send_proposal_in_waiting_list_email_action,
]
autocomplete_fields = ("speaker",)
- inlines = [ProposalMaterialInline]
+ inlines = [ProposalMaterialInline, ProposalCoSpeakerInline]
def change_view(self, request, object_id, form_url="", extra_context=None):
extra_context = extra_context or {}
diff --git a/backend/submissions/migrations/0028_proposalcospeaker.py b/backend/submissions/migrations/0028_proposalcospeaker.py
new file mode 100644
index 0000000000..aa06fccb50
--- /dev/null
+++ b/backend/submissions/migrations/0028_proposalcospeaker.py
@@ -0,0 +1,33 @@
+# Generated by Django 5.1.4 on 2025-02-23 18:08
+
+import django.db.models.deletion
+import django.utils.timezone
+import model_utils.fields
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('submissions', '0027_submissionconfirmpendingstatusproxy'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ProposalCoSpeaker',
+ 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')),
+ ('order', models.PositiveIntegerField(db_index=True, editable=False, verbose_name='order')),
+ ('status', models.CharField(choices=[('pending', 'Pending'), ('accepted', 'Accepted'), ('rejected', 'Rejected')], default='pending', max_length=30, verbose_name='status')),
+ ('proposal', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='co_speakers', to='submissions.submission', verbose_name='proposal')),
+ ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='user')),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ ]
diff --git a/backend/submissions/models.py b/backend/submissions/models.py
index a3e4e6c80b..097b03095b 100644
--- a/backend/submissions/models.py
+++ b/backend/submissions/models.py
@@ -1,3 +1,4 @@
+from ordered_model.models import OrderedModel
from django.core import exceptions
from django.db import models
from django.urls import reverse
@@ -9,7 +10,7 @@
from api.helpers.ids import encode_hashid
from i18n.fields import I18nCharField, I18nTextField
-from .querysets import SubmissionQuerySet
+from .querysets import ProposalCoSpeakerQuerySet, SubmissionQuerySet
class SubmissionTag(models.Model):
@@ -212,6 +213,44 @@ def __str__(self):
)
+class ProposalCoSpeakerStatus(models.TextChoices):
+ pending = "pending", _("Pending")
+ accepted = "accepted", _("Accepted")
+ rejected = "rejected", _("Rejected")
+
+
+class ProposalCoSpeaker(TimeStampedModel, OrderedModel):
+ conference_reference = "proposal__conference"
+
+ status = models.CharField(
+ _("status"),
+ choices=ProposalCoSpeakerStatus.choices,
+ max_length=30,
+ default=ProposalCoSpeakerStatus.pending,
+ )
+ proposal = models.ForeignKey(
+ "submissions.Submission",
+ on_delete=models.CASCADE,
+ verbose_name=_("proposal"),
+ related_name="co_speakers",
+ )
+
+ user = models.ForeignKey(
+ "users.User",
+ on_delete=models.CASCADE,
+ null=False,
+ blank=False,
+ verbose_name=_("user"),
+ related_name="+",
+ )
+
+ order_with_respect_to = "proposal"
+ objects = ProposalCoSpeakerQuerySet().as_manager()
+
+ def __str__(self):
+ return f"{self.user_id} {self.proposal.title}"
+
+
class ProposalMaterial(TimeStampedModel):
proposal = models.ForeignKey(
"submissions.Submission",
diff --git a/backend/submissions/querysets.py b/backend/submissions/querysets.py
index 54d54c0b34..6c4b7bfb56 100644
--- a/backend/submissions/querysets.py
+++ b/backend/submissions/querysets.py
@@ -1,6 +1,7 @@
from api.helpers.ids import decode_hashid
from conferences.querysets import ConferenceQuerySetMixin
from django.db import models
+from ordered_model.models import OrderedModelQuerySet
class SubmissionQuerySet(ConferenceQuerySetMixin, models.QuerySet):
@@ -15,3 +16,12 @@ def accepted(self):
def of_user(self, user):
return self.filter(speaker=user)
+
+
+class ProposalCoSpeakerQuerySet(
+ ConferenceQuerySetMixin, OrderedModelQuerySet, models.QuerySet
+):
+ def accepted(self):
+ from submissions.models import ProposalCoSpeakerStatus
+
+ return self.filter(status=ProposalCoSpeakerStatus.accepted)
diff --git a/frontend/src/components/cfp-form/co-speakers-section.tsx b/frontend/src/components/cfp-form/co-speakers-section.tsx
new file mode 100644
index 0000000000..2ab561a100
--- /dev/null
+++ b/frontend/src/components/cfp-form/co-speakers-section.tsx
@@ -0,0 +1,42 @@
+import {
+ CardPart,
+ Grid,
+ Heading,
+ Input,
+ InputWrapper,
+ MultiplePartsCard,
+ Spacer,
+ Text,
+} from "@python-italia/pycon-styleguide";
+import { FormattedMessage } from "react-intl";
+import { useTranslatedMessage } from "~/helpers/use-translated-message";
+
+export const CoSpeakersSection = () => {
+ const inputPlaceholder = useTranslatedMessage("input.placeholder");
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ description={
+
+ }
+ >
+
+
+
+
+ );
+};
diff --git a/frontend/src/components/cfp-form/index.tsx b/frontend/src/components/cfp-form/index.tsx
index 122f63aa94..686a4b597a 100644
--- a/frontend/src/components/cfp-form/index.tsx
+++ b/frontend/src/components/cfp-form/index.tsx
@@ -29,6 +29,7 @@ import {
} from "../public-profile-card";
import { AboutYouSection } from "./about-you-section";
import { AvailabilitySection } from "./availability-section";
+import { CoSpeakersSection } from "./co-speakers-section";
import { ProposalSection } from "./proposal-section";
export type CfpFormFields = ParticipantFormFields & {
@@ -331,6 +332,10 @@ export const CfpForm = ({
+
+
+
+