From 0085519392ee0f8f99a6aa5f9750f678a204e304 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 09:30:15 +0000 Subject: [PATCH] fix: skip questions without forecasts in bulk withdraw When a user clicks "Withdraw all" on a question group, the frontend sends every question it considers predicted (including resolved siblings the user never forecasted on). The backend then raised a ValidationError on the first such question, aborting the entire operation. Make `withdraw_forecast_bulk` skip questions where the user has no active forecast at the withdrawal time instead, so bulk withdrawals succeed across mixed groups. Fixes #4707 Co-authored-by: Cemre Inanc --- questions/services/forecasts.py | 11 +++++------ tests/unit/test_questions/test_views.py | 13 ++++++++++--- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/questions/services/forecasts.py b/questions/services/forecasts.py index c5caa6abd2..16d078d623 100644 --- a/questions/services/forecasts.py +++ b/questions/services/forecasts.py @@ -7,8 +7,6 @@ from django.db import IntegrityError, transaction from django.db.models import F, Q, QuerySet, Subquery, OuterRef, Count from django.utils import timezone -from rest_framework.exceptions import ValidationError - from notifications.constants import MailingTags from posts.models import PostUserSnapshot, PostSubscription from posts.services.subscriptions import ( @@ -201,11 +199,12 @@ def withdraw_forecast_bulk(user: User = None, withdrawals: list[dict] = None): author=user, ).order_by("start_time") + # Skip questions where the user has no active forecast at withdraw_at. + # This allows bulk "withdraw all" requests to succeed even when some + # questions in a group have no forecast from the user (e.g. resolved + # questions the user never forecasted on). if not user_forecasts.exists(): - raise ValidationError( - f"User {user.id} has no forecast at {withdraw_at} to " - f"withdraw for question {question.id}" - ) + continue forecast_to_terminate = user_forecasts.first() forecast_to_terminate.end_time = withdraw_at diff --git a/tests/unit/test_questions/test_views.py b/tests/unit/test_questions/test_views.py index 2551ee1434..1f243e9f24 100644 --- a/tests/unit/test_questions/test_views.py +++ b/tests/unit/test_questions/test_views.py @@ -426,15 +426,22 @@ def test_withdraw_forecast( ) assert response.status_code == 201 - def test_cant_withdraw_forecast_if_no_forecast( - self, question_binary_with_forecast_user_1, user2_client + def test_withdraw_forecast_no_forecast_is_noop( + self, question_binary_with_forecast_user_1, user2, user2_client ): + # Withdrawing for a question the user never forecasted on is a no-op + # so that bulk "withdraw all" works across mixed groups containing + # questions the user did not forecast on (e.g. resolved siblings). response = user2_client.post( self.url, data=json.dumps([{"question": question_binary_with_forecast_user_1.id}]), content_type="application/json", ) - assert response.status_code == 400 + assert response.status_code == 201 + assert not Forecast.objects.filter( + question=question_binary_with_forecast_user_1, + author=user2, + ).exists() class TestQuestionResolve: