Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions front_end/src/types/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
7 changes: 7 additions & 0 deletions front_end/src/utils/questions/predictions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type CanPredictParams = Pick<
| "question"
| "group_of_questions"
| "conditional"
| "projects"
>;

export function canPredictQuestion({
Expand All @@ -26,6 +27,7 @@ export function canPredictQuestion({
question,
group_of_questions,
conditional,
projects,
}: CanPredictParams) {
// post level checks
if (
Expand All @@ -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) {
Expand Down
21 changes: 21 additions & 0 deletions projects/migrations/0022_project_allow_forecast_resubmission.py
Original file line number Diff line number Diff line change
@@ -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.",
),
),
]
8 changes: 8 additions & 0 deletions projects/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions projects/serializers/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class Meta:
"show_on_homepage",
"show_on_services_page",
"forecasts_flow_enabled",
"allow_forecast_resubmission",
)
read_only_fields = (
"created_at",
Expand Down Expand Up @@ -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:
Expand Down
29 changes: 25 additions & 4 deletions questions/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 !"},
Expand Down Expand Up @@ -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
Expand Down
99 changes: 99 additions & 0 deletions tests/unit/test_questions/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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:

Expand Down