diff --git a/backend/api/submissions/mutations.py b/backend/api/submissions/mutations.py index 02c9c14f12..94bc82c055 100644 --- a/backend/api/submissions/mutations.py +++ b/backend/api/submissions/mutations.py @@ -412,16 +412,18 @@ def send_submission( if not conference.is_cfp_open: errors.add_error("non_field_errors", "The call for paper is not open!") - if ( - SubmissionModel.objects.of_user(request.user) - .for_conference(conference) - .non_cancelled() - .count() - >= 3 - ): - errors.add_error( - "non_field_errors", "You can only submit up to 3 proposals" + if conference.max_proposals_per_user is not None: + user_submissions_count = ( + SubmissionModel.objects.of_user(request.user) + .for_conference(conference) + .non_cancelled() + .count() ) + if user_submissions_count >= conference.max_proposals_per_user: + errors.add_error( + "non_field_errors", + f"You can only submit up to {conference.max_proposals_per_user} proposals", + ) if errors.has_errors: return errors diff --git a/backend/api/submissions/tests/test_send_submission.py b/backend/api/submissions/tests/test_send_submission.py index 0a8156cb29..0f3bfeef16 100644 --- a/backend/api/submissions/tests/test_send_submission.py +++ b/backend/api/submissions/tests/test_send_submission.py @@ -1249,6 +1249,7 @@ def test_cannot_submit_more_than_3_proposals(graphql_client, user): active_cfp=True, durations=("50",), audience_levels=("Beginner",), + max_proposals_per_user=3, ) SubmissionFactory( @@ -1267,7 +1268,9 @@ def test_cannot_submit_more_than_3_proposals(graphql_client, user): status=Submission.STATUS.proposed, ) - resp, _ = _submit_talk(graphql_client, conference, title={"en": "My first talk"}) + resp, _ = _submit_talk( + graphql_client, conference, title={"en": "My first talk"}, languages=["en"] + ) assert resp["data"]["sendSubmission"]["__typename"] == "SendSubmissionErrors" assert resp["data"]["sendSubmission"]["errors"]["nonFieldErrors"] == [ @@ -1275,6 +1278,43 @@ def test_cannot_submit_more_than_3_proposals(graphql_client, user): ] +def test_can_submit_unlimited_proposals_when_max_proposals_is_none( + graphql_client, user +): + graphql_client.force_login(user) + + conference = ConferenceFactory( + topics=("my-topic",), + languages=("en", "it"), + submission_types=("talk",), + active_cfp=True, + durations=("50",), + audience_levels=("Beginner",), + # max_proposals_per_user defaults to None (no limit) + ) + + EmailTemplateFactory( + conference=conference, + identifier=EmailTemplateIdentifier.proposal_received_confirmation, + ) + + # Create 3 existing submissions + for _ in range(3): + SubmissionFactory( + speaker_id=user.id, + conference=conference, + status=Submission.STATUS.proposed, + ) + + # Should be able to submit a 4th proposal + resp, _ = _submit_talk( + graphql_client, conference, title={"en": "My fourth talk"}, languages=["en"] + ) + + assert resp["data"]["sendSubmission"]["__typename"] == "Submission" + assert resp["data"]["sendSubmission"]["title"] == "My fourth talk" + + def test_submit_talk_with_do_not_record_true(graphql_client, user): graphql_client.force_login(user) diff --git a/backend/conferences/admin/conference.py b/backend/conferences/admin/conference.py index a833b8af9a..4c637bc86b 100644 --- a/backend/conferences/admin/conference.py +++ b/backend/conferences/admin/conference.py @@ -181,6 +181,7 @@ class ConferenceAdmin( "audience_levels", "languages", "proposal_tags", + "max_proposals_per_user", ) }, ), diff --git a/backend/conferences/migrations/0056_conference_max_proposals.py b/backend/conferences/migrations/0056_conference_max_proposals.py new file mode 100644 index 0000000000..c0d9fa7a51 --- /dev/null +++ b/backend/conferences/migrations/0056_conference_max_proposals.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.4 on 2026-01-07 01:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('conferences', '0055_remove_conference_grants_default_accommodation_amount_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='conference', + name='max_proposals_per_user', + field=models.PositiveIntegerField(blank=True, help_text='Maximum number of proposals a user can submit. Leave empty for no limit.', null=True, verbose_name='max proposals per user'), + ), + ] diff --git a/backend/conferences/models/conference.py b/backend/conferences/models/conference.py index 1388becd07..397724f190 100644 --- a/backend/conferences/models/conference.py +++ b/backend/conferences/models/conference.py @@ -117,6 +117,13 @@ class Conference(GeoLocalizedModel, TimeFramedModel, TimeStampedModel): max_length=32224, ) + max_proposals_per_user = models.PositiveIntegerField( + _("max proposals per user"), + null=True, + blank=True, + help_text=_("Maximum number of proposals a user can submit. Leave empty for no limit."), + ) + def get_slack_oauth_token(self): return self.organizer.slack_oauth_bot_token