Skip to content

fix: skip questions without forecasts in bulk withdraw#4720

Open
cemreinanc wants to merge 1 commit into
mainfrom
claude/issue-4707-withdraw-all-should-withdraw-from
Open

fix: skip questions without forecasts in bulk withdraw#4720
cemreinanc wants to merge 1 commit into
mainfrom
claude/issue-4707-withdraw-all-should-withdraw-from

Conversation

@cemreinanc
Copy link
Copy Markdown
Contributor

@cemreinanc cemreinanc commented May 13, 2026

Summary

  • withdraw_forecast_bulk now skips questions where the user has no active forecast at withdraw_at instead of raising ValidationError, so "Withdraw all" works on mixed question groups.
  • Updated the corresponding test to assert the new no-op behavior.

Fixes #4707

Test plan

  • pytest tests/unit/test_questions/test_views.py::TestQuestionWithdraw
  • Manual: click "Withdraw all" on a group where at least one question has no user forecast — expect success, remaining forecasts withdrawn.

Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • Bug Fixes
    • Bulk forecast withdrawal requests are now more resilient. When withdrawing forecasts across multiple questions, the system no longer fails if some questions lack an active forecast. These items are automatically skipped, allowing batch withdrawal operations to complete successfully.

Review Change Stack

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 <cemreinanc@users.noreply.github.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 13, 2026

📝 Walkthrough

Walkthrough

This PR removes the ValidationError exception that was raised when a bulk forecast withdrawal request encountered a question without an active forecast at the withdrawal time. The function now skips such questions silently, allowing withdraw-all operations to succeed even when users have not forecasted on all questions. A test verifies the no-op behavior for this case.

Changes

Withdraw-all no-op handling

Layer / File(s) Summary
Core withdraw-all skip behavior
questions/services/forecasts.py
Removed ValidationError import from DRF. Updated withdraw_forecast_bulk to skip questions with no active forecast at withdraw_at by continuing to the next item instead of raising a validation error.
No-op withdrawal test
tests/unit/test_questions/test_views.py
Replaced test expecting a 400 error when withdrawing without an existing forecast. New test test_withdraw_forecast_no_forecast_is_noop asserts HTTP 201 success and verifies no Forecast record exists for the user/question pair.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~8 minutes

Poem

🐰 A rabbit hops through forecasts with glee,
When none exist, just skip happily!
Withdraw-all now flows like a gentle breeze,
No errors to block, just questions to please. 🌿

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix: skip questions without forecasts in bulk withdraw' is concise, clear, and directly describes the main change in the pull request.
Linked Issues check ✅ Passed The pull request successfully addresses the requirement from issue #4707 by modifying bulk withdrawal to skip questions without forecasts instead of raising ValidationError.
Out of Scope Changes check ✅ Passed All changes are directly related to fixing bulk forecast withdrawal behavior and updating corresponding tests; no out-of-scope modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/issue-4707-withdraw-all-should-withdraw-from

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
tests/unit/test_questions/test_views.py (1)

441-444: ⚡ Quick win

Assert no side-effects on other users’ forecasts in the no-op test.

Add an assertion that the pre-existing forecast on this question (e.g., author=user1) still exists after user2’s withdraw request. That makes the no-op guarantee explicit and protects against cross-user regression.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/test_questions/test_views.py` around lines 441 - 444, Add an
assertion to ensure the existing forecast for the original user remains after
user2's withdraw no-op: check that
Forecast.objects.filter(question=question_binary_with_forecast_user_1,
author=user1).exists() is True (or use assert
Forecast.objects.filter(...).exists()). This should be added alongside the
existing assertion that user2's forecast does not exist to explicitly verify no
cross-user side-effects in the test in test_views.py.
questions/services/forecasts.py (1)

188-207: ⚡ Quick win

Only enqueue post recalculation when a withdrawal actually changed data.

post is added to posts before the no-op guard, so skipped questions still trigger run_on_post_forecast. Move posts.add(post) below the exists() check to avoid unnecessary async work on no-op withdrawals.

Proposed diff
     for withdrawal in withdrawals:
         question = cast(Question, withdrawal["question"])
         post = question.get_post()
-        posts.add(post)

         withdraw_at = withdrawal["withdraw_at"]
@@
         if not user_forecasts.exists():
             continue
+
+        posts.add(post)

Also applies to: 226-233

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@questions/services/forecasts.py` around lines 188 - 207, The code adds post
to the posts set before checking whether the withdrawal actually affects any
forecasts, causing no-op withdrawals to still trigger post recalculation; move
the posts.add(post) call so it only runs after confirming
user_forecasts.exists() (i.e., place posts.add(post) below the if not
user_forecasts.exists(): continue guard) and apply the same change to the
analogous block around the other occurrence (the block referenced at lines
226-233) so run_on_post_forecast is only enqueued when data was actually
changed.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@questions/services/forecasts.py`:
- Around line 188-207: The code adds post to the posts set before checking
whether the withdrawal actually affects any forecasts, causing no-op withdrawals
to still trigger post recalculation; move the posts.add(post) call so it only
runs after confirming user_forecasts.exists() (i.e., place posts.add(post) below
the if not user_forecasts.exists(): continue guard) and apply the same change to
the analogous block around the other occurrence (the block referenced at lines
226-233) so run_on_post_forecast is only enqueued when data was actually
changed.

In `@tests/unit/test_questions/test_views.py`:
- Around line 441-444: Add an assertion to ensure the existing forecast for the
original user remains after user2's withdraw no-op: check that
Forecast.objects.filter(question=question_binary_with_forecast_user_1,
author=user1).exists() is True (or use assert
Forecast.objects.filter(...).exists()). This should be added alongside the
existing assertion that user2's forecast does not exist to explicitly verify no
cross-user side-effects in the test in test_views.py.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 4ef23375-4900-4aac-a412-f9fd735b9cdd

📥 Commits

Reviewing files that changed from the base of the PR and between 5793e11 and 0085519.

📒 Files selected for processing (2)
  • questions/services/forecasts.py
  • tests/unit/test_questions/test_views.py

@github-actions
Copy link
Copy Markdown
Contributor

🚀 Preview Environment

Your preview environment is ready!

Resource Details
🌐 Preview URL https://metaculus-pr-4720-claude-issue-4707-withdraw-all-preview.mtcl.cc
📦 Docker Image ghcr.io/metaculus/metaculus:claude-issue-4707-withdraw-all-should-withdraw-from-0085519
🗄️ PostgreSQL NeonDB branch preview/pr-4720-claude-issue-4707-withdraw-all
Redis Fly Redis mtc-redis-pr-4720-claude-issue-4707-withdraw-all

Details

  • Commit: 518acb332d0a7974ee1f55516cf1a814599b49fb
  • Branch: claude/issue-4707-withdraw-all-should-withdraw-from
  • Fly App: metaculus-pr-4720-claude-issue-4707-withdraw-all

ℹ️ Preview Environment Info

Isolation:

  • PostgreSQL and Redis are fully isolated from production
  • Each PR gets its own database branch and Redis instance
  • Changes pushed to this PR will trigger a new deployment

Limitations:

  • Background workers and cron jobs are not deployed in preview environments
  • If you need to test background jobs, use Heroku staging environments

Cleanup:

  • This preview will be automatically destroyed when the PR is closed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Withdraw all should withdraw from any question one has participated in, instead of throwing an error

1 participant