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 = ({ + + + +