diff --git a/front_end/src/types/projects.ts b/front_end/src/types/projects.ts index e97689858..523b2f80b 100644 --- a/front_end/src/types/projects.ts +++ b/front_end/src/types/projects.ts @@ -98,6 +98,7 @@ export type Tournament = TournamentPreview & { timeline: TournamentTimeline; forecasts_flow_enabled: boolean; index_data?: IndexData | null; + allow_forecast_resubmission: boolean; }; export type ProjectIndexWeights = { diff --git a/front_end/src/utils/questions/predictions.ts b/front_end/src/utils/questions/predictions.ts index ec7336909..c27ce9939 100644 --- a/front_end/src/utils/questions/predictions.ts +++ b/front_end/src/utils/questions/predictions.ts @@ -18,6 +18,7 @@ type CanPredictParams = Pick< | "question" | "group_of_questions" | "conditional" + | "projects" >; export function canPredictQuestion({ @@ -26,6 +27,7 @@ export function canPredictQuestion({ question, group_of_questions, conditional, + projects, }: CanPredictParams) { // post level checks if ( @@ -34,6 +36,11 @@ export function canPredictQuestion({ ) { return false; } + if (!projects.default_project.allow_forecast_resubmission) { + if (!!question?.my_forecasts?.latest) { + return false; + } + } // question-specific checks if (question) { diff --git a/projects/migrations/0022_project_allow_forecast_resubmission.py b/projects/migrations/0022_project_allow_forecast_resubmission.py new file mode 100644 index 000000000..747913d94 --- /dev/null +++ b/projects/migrations/0022_project_allow_forecast_resubmission.py @@ -0,0 +1,21 @@ +# Generated by Django 5.1.11 on 2025-09-17 20:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("projects", "0021_projectindex_project_index_projectindexpost"), + ] + + operations = [ + migrations.AddField( + model_name="project", + name="allow_forecast_resubmission", + field=models.BooleanField( + default=True, + help_text="Turning this off prevents users from submitting multiple forecasts on any question where this is it's default_project. Forecasts submitted on those questions will always have infinite duration, ignoring auto-withdrawal.", + ), + ), + ] diff --git a/projects/models.py b/projects/models.py index 62f8adda8..1554bbb1f 100644 --- a/projects/models.py +++ b/projects/models.py @@ -280,6 +280,14 @@ class BotLeaderboardStatus(models.TextChoices): sign_up_fields = models.JSONField( default=list, blank=True, help_text="Used during tournament onboarding." ) + allow_forecast_resubmission = models.BooleanField( + default=True, + help_text=( + "Turning this off prevents users from submitting multiple forecasts on any " + "question where this is it's default_project. Forecasts submitted on those " + "questions will always have infinite duration, ignoring auto-withdrawal." + ), + ) # SEO html_metadata_json = models.JSONField( diff --git a/projects/serializers/common.py b/projects/serializers/common.py index b8b21b2ef..982a3a1f8 100644 --- a/projects/serializers/common.py +++ b/projects/serializers/common.py @@ -38,6 +38,7 @@ class Meta: "show_on_homepage", "show_on_services_page", "forecasts_flow_enabled", + "allow_forecast_resubmission", ) read_only_fields = ( "created_at", @@ -96,6 +97,7 @@ class Meta: "visibility", "is_current_content_translated", "bot_leaderboard_status", + "allow_forecast_resubmission", ) def get_score_type(self, project: Project) -> str | None: diff --git a/questions/views.py b/questions/views.py index c1f6cead2..487b7c871 100644 --- a/questions/views.py +++ b/questions/views.py @@ -113,14 +113,25 @@ def bulk_create_forecasts_api_view(request): if not question: raise ValidationError(f"Wrong question id {forecast["question"]}") - forecast["question"] = question # used in create_foreacst_bulk + forecast["question"] = question # used in create_forecast_bulk # Check permissions - permission = get_post_permission_for_user( - question.get_post(), user=request.user - ) + post = question.get_post() + permission = get_post_permission_for_user(post, user=request.user) ObjectPermission.can_forecast(permission, raise_exception=True) + if not post.default_project.allow_forecast_resubmission: + if question.user_forecasts.filter(author=request.user).exists(): + return Response( + { + "error": f"Question {question.id}'s Project does not allow " + "resubmission of forecasts !" + }, + status=status.HTTP_405_METHOD_NOT_ALLOWED, + ) + # If resubmission is not allowed, forecast duration is set to infinite + forecast["end_time"] = None + if not question.open_time or question.open_time > now: return Response( {"error": f"Question {question.id} is not open for forecasting yet !"}, @@ -162,6 +173,16 @@ def bulk_withdraw_forecasts_api_view(request): # Replacing prefetched optimized questions for withdrawal in validated_data: question = questions_map.get(withdrawal["question"]) + + if not question.get_post().default_project.allow_forecast_resubmission: + return Response( + { + "error": f"Question {question.id}'s Project does not allow " + "withdrawal of forecasts !" + }, + status=status.HTTP_405_METHOD_NOT_ALLOWED, + ) + withdrawal["question"] = question # used in withdraw_foreacst_bulk withdraw_at = withdrawal.get("withdraw_at", now) withdrawal["withdraw_at"] = withdraw_at # used in withdraw_foreacst_bulk diff --git a/tests/unit/test_questions/test_views.py b/tests/unit/test_questions/test_views.py index 2f009b145..3b60c32a0 100644 --- a/tests/unit/test_questions/test_views.py +++ b/tests/unit/test_questions/test_views.py @@ -261,6 +261,76 @@ def test_forecast_numeric_boundary_test( ) assert response.status_code == status_code + @pytest.mark.parametrize( + "allow_foreacst_resubmission, status_code", + [ + (True, 201), + (False, 405), + ], + ) + def test_allow_forecast_resubmission( + self, + post_binary_public: Post, + user1, + user1_client, + allow_foreacst_resubmission, + status_code, + ): + # explicitly set field + project = post_binary_public.default_project + project.allow_forecast_resubmission = allow_foreacst_resubmission + project.save() + + # predict once + response = user1_client.post( + self.url, + data=json.dumps( + [{"question": post_binary_public.question.id, "probability_yes": 0.5}] + ), + content_type="application/json", + ) + assert response.status_code == 201 + # predict again + response = user1_client.post( + self.url, + data=json.dumps( + [{"question": post_binary_public.question.id, "probability_yes": 0.5}] + ), + content_type="application/json", + ) + assert response.status_code == status_code + + def test_allow_forecast_resubmission_enforces_infinite_forecast( + self, + post_binary_public: Post, + user1, + user1_client, + ): + # explicitly set field + project = post_binary_public.default_project + project.allow_forecast_resubmission = False + project.save() + + question = post_binary_public.question + response = user1_client.post( + self.url, + data=json.dumps( + [ + { + "question": question.id, + "probability_yes": 0.5, + "end_time": question.scheduled_close_time.strftime( + "%Y-%m-%dT%H:%M:%S" + ), + } + ] + ), + content_type="application/json", + ) + assert response.status_code == 201 + user_forecast = Forecast.objects.filter(question=question, author=user1).first() + assert user_forecast.end_time is None + class TestQuestionWithdraw: url = reverse("create-withdraw") @@ -285,6 +355,35 @@ def test_cant_withdraw_forecast_if_no_forecast( ) assert response.status_code == 400 + @pytest.mark.parametrize( + "allow_foreacst_resubmission, status_code", + [ + (True, 201), + (False, 405), + ], + ) + def test_allow_forecast_resubmission( + self, + question_binary_with_forecast_user_1: Question, + user1, + user1_client, + allow_foreacst_resubmission, + status_code, + ): + # explicitly set field + post = question_binary_with_forecast_user_1.get_post() + project = post.default_project + project.allow_forecast_resubmission = allow_foreacst_resubmission + project.save() + + # withdraw + response = user1_client.post( + self.url, + data=json.dumps([{"question": question_binary_with_forecast_user_1.id}]), + content_type="application/json", + ) + assert response.status_code == status_code + class TestQuestionResolve: