Skip to content

add bulk forecast and comment api endpoint#4554

Open
lsabor wants to merge 12 commits intomainfrom
issue/4552/bulk-forecast-comment
Open

add bulk forecast and comment api endpoint#4554
lsabor wants to merge 12 commits intomainfrom
issue/4552/bulk-forecast-comment

Conversation

@lsabor
Copy link
Copy Markdown
Contributor

@lsabor lsabor commented Mar 28, 2026

closes #4552

Summary by CodeRabbit

  • New Features
    • Users can now submit multiple forecasts with associated private comments in a single bulk request through a new endpoint. The feature includes comprehensive permission validation for personal accounts, owned bot accounts, and authorized superuser overrides, ensuring all submissions are properly secured and validated.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 28, 2026

Review Change Stack

Warning

Rate limit exceeded

@lsabor has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 48 minutes and 50 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 57ec6cee-2ab7-4395-bdc2-7d149b2282e5

📥 Commits

Reviewing files that changed from the base of the PR and between ee56c6b and 6df7243.

📒 Files selected for processing (3)
  • questions/services/forecasts.py
  • questions/views.py
  • tests/unit/test_questions/test_bulk_forecast_and_comment.py
📝 Walkthrough

Walkthrough

This PR introduces a new authenticated bulk endpoint at questions/bulk-forecast-comment/ that atomically creates multiple forecasts and private comments from a single request. The endpoint validates user permissions (superuser override, self-submission, bot ownership), ensures questions are open for forecasting, checks comment privacy constraints, and persists all changes transactionally.

Changes

Bulk Forecast & Comment Endpoint

Layer / File(s) Summary
Serializer & Validation Contract
questions/views.py
BulkForecastAndCommentSerializer accepts optional user_id/username (at least one required), is_staff_override flag, list of forecasts, and optional comments. Import of create_forecast_bulk and withdraw_forecast_bulk reformatted to multi-line.
Endpoint Implementation
questions/views.py
bulk_forecast_and_comment_api_view POST endpoint resolves the target user, enforces permission rules (superuser-only override, non-superuser limited to self/owned bots), validates referenced questions exist and are open, validates comments are private with no key_factors, accumulates all errors, then transactionally creates forecasts and comments, returning 201 on success.
URL Routing
questions/urls.py
Routes questions/bulk-forecast-comment/ to views.bulk_forecast_and_comment_api_view with name bulk-forecast-comment.
Import Path Cleanup
questions/services/forecasts.py
Normalizes import of run_build_question_forecasts from relative ..tasks to absolute questions.tasks in after_forecast_actions.
Test Setup
tests/unit/test_questions/test_bulk_forecast_and_comment.py
Adds forecast_payload() helper and fixtures for open_question, user_bot (owned), and user_bot_no_owner.
Test Coverage
tests/unit/test_questions/test_bulk_forecast_and_comment.py
16 test cases covering required field validation, authentication, self-submission by user_id and username, cross-user denial, bot ownership rules, superuser override by both identifiers, non-superuser override rejection, unknown user handling (403 non-override, 404 with override), and key_factors rejection.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • Metaculus/metaculus#4200: Refactors bot forecasts/comments handling and modifies imports of create_forecast_bulk — both PRs affect bulk-forecast operations and the same imported function.

Poem

🐰 Hops excitedly with carrots in paw
A bunny's delight—bulk forecasts at last!
No more hopping back-and-forth, one request does it all,
Forecasts and comments unite in a single atomic leap! 🌙✨

🚥 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 'add bulk forecast and comment api endpoint' directly and accurately describes the main change, which adds a new bulk API endpoint for submitting forecasts and comments together.
Linked Issues check ✅ Passed The PR implementation meets all coding requirements: provides a bulk endpoint accepting forecasts and comments, supports staff override for arbitrary users, restricts non-staff to authenticated user/owned bots, validates question accessibility and open status, ensures transactional processing, and includes comprehensive test coverage.
Out of Scope Changes check ✅ Passed All changes are within scope: the bulk endpoint implementation, supporting serializer/views, URL routing, and comprehensive unit tests directly fulfill the feature requirements. The import reorganization in views.py is a minor formatting change necessary for the implementation.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue/4552/bulk-forecast-comment

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.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Mar 28, 2026

🚀 Preview Environment

Your preview environment is ready!

Resource Details
🌐 Preview URL https://metaculus-pr-4554-issue-4552-bulk-forecast-comme-preview.mtcl.cc
📦 Docker Image ghcr.io/metaculus/metaculus:issue-4552-bulk-forecast-comment-6df7243
🗄️ PostgreSQL NeonDB branch preview/pr-4554-issue-4552-bulk-forecast-comme
Redis Fly Redis mtc-redis-pr-4554-issue-4552-bulk-forecast-comme

Details

  • Commit: 6df72432cddfadb009529f06ce29622925ea1d02
  • Branch: issue/4552/bulk-forecast-comment
  • Fly App: metaculus-pr-4554-issue-4552-bulk-forecast-comme

ℹ️ 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

@lsabor lsabor temporarily deployed to testing_env May 7, 2026 17:38 — with GitHub Actions Inactive
@lsabor lsabor marked this pull request as ready for review May 7, 2026 17:38
@lsabor
Copy link
Copy Markdown
Contributor Author

lsabor commented May 7, 2026

@claude please scan this PR for security concerns

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 7, 2026

Claude finished @lsabor's task in 2m 44s —— View job


Security Review: bulk-forecast-comment endpoint

I reviewed questions/views.py, questions/urls.py, and the test file. Findings below, ordered by severity.


🟠 HIGH — User enumeration via response-code differential

File: questions/views.py:287-325

The endpoint discloses the existence of arbitrary user_id / username values to any authenticated user. The response code differs by case:

  • 404 — user does not exist
  • 403 — user exists but is not the requester or one of their bots
  • 201 — user is the requester or their bot (and forecasts/comments lists may be empty)

Because forecasts and comments both have default=list, an attacker can iterate over IDs/usernames using minimal payloads ({"user_id": N}) and harvest the full user-ID space or test for the existence of specific usernames/emails of interest.

Recommended fix: unify the responses to a single 403 (PermissionDenied) for both "user not found" and "user not yours" cases. Concretely, replace get_object_or_404(...) with a .first() lookup and combine the missing-user branch with the not-self/not-bot branch.

Fix this →


🟡 MEDIUM — No bulk-size limit (DoS vector)

File: questions/views.py:287 (and pre-existing in bulk_create_forecasts_api_view, bulk_withdraw_forecasts_api_view)

There is no upper bound on len(forecasts) or len(comments). A single authenticated request can carry thousands of items; each comment triggers full_clean(), spam check, and translation hooks (trigger_update_comment_translations). This is amplified because the entire batch runs inside transaction.atomic() and pre-validation does N permission checks plus a Question fetch.

Recommended fix: validate len(forecasts) + len(comments) against a server-side maximum (e.g. 500) and reject oversize payloads early with 413/400.


🟡 MEDIUM — Side effects run outside the atomic transaction

File: questions/views.py:395-400

update_forecast_notification, after_forecast_actions, and run_on_post_forecast.send_with_options execute after the with transaction.atomic(): block exits. If the loop raises partway through (e.g., notification backend failure), the DB state is already committed and the remaining notifications/scoring jobs are silently dropped. Worse, if the loop fails on item #5 of 100, items 1–4 got notifications, items 5–100 did not, and there's no retry.

Recommended fix: use transaction.on_commit(...) to enqueue post-commit hooks atomically; or capture failures and retry.


🟡 MEDIUM — Missing audit log for is_staff_override

File: questions/views.py:307-308

A staff user can submit forecasts and comments as any user, and the only signal in the data is Forecast.author = user / Comment.author = user — there is no record that a different staff actor performed the write. For a feature that lets staff act on others' behalf, an audit trail (who triggered the override, when, on which user) is strongly advisable for compliance and incident investigation.

Recommended fix: emit a structured log entry (or persist to an audit table) whenever is_staff_override=True, including request_user.id, target user.id, counts, and post IDs touched.


🟡 MEDIUM — Bot-owner permission elevation path

File: questions/views.py:315-325, 343-344, 361-364

When the request_user impersonates their bot, only the bot's permissions are checked (get_post_permission_for_user(..., user=user) where user is the bot). If a bot has been granted explicit access on a private/restricted project that its owner does not have, the owner can post forecasts and comments there via the bot. Legitimate bot ownership semantics may make this acceptable, but it is worth confirming this is the intended threat model — and ideally also asserting request_user has at least viewer permission on the post.


🟢 LOW — validate_parent raises 500 on missing comment ID

File: comments/serializers/common.py:153-157 (used here, pre-existing)

def validate_parent(self, value):
    if not value:
        return value
    return Comment.objects.get(pk=value)

Comment.DoesNotExist is uncaught → 500. The new endpoint exposes this serializer to bulk input where a single bad parent ID will 500 the entire request. Should use get_object_or_404 or convert to ValidationError.


🟢 LOW — included_forecast lookup assumes single-question post

File: questions/views.py:381-387

on_post.question.user_forecasts.filter(...) assumes on_post has a single .question. For group questions this attribute does not represent the user's per-subquestion forecasts. Not a security issue per se, but an unhandled AttributeError / wrong-data path.


🟢 LOW — Withdrawal-time check ordering in bulk_withdraw_forecasts_api_view

File: questions/views.py:178-191

Pre-existing in this PR's diff: withdrawal["question"] = question is assigned before the if not question check. Harmless because the ValidationError is raised before the value is used, but reordering (if not question first) makes the intent clearer.


✅ Things that look good

  • IsAuthenticated is explicit on the new endpoint.
  • is_staff_override correctly rejects non-staff before any user lookup or DB write.
  • Permission check uses get_post_permission_for_user with ObjectPermission.can_forecast / can_comment — appropriate.
  • Forecasts and comments are committed in a single atomic transaction, preventing partial writes.
  • Validation pre-loop runs before any mutations, so a single bad item rejects the whole batch (good for atomicity).
  • Test coverage for the impersonation matrix (self / own-bot / other / orphan-bot / staff override / non-staff override attempt) is solid.

Todo

  • Read changed files
  • Analyze auth/permissions on bulk endpoints
  • Check input validation, rate limiting, IDOR risks
  • Look for injection / data exposure / DoS risks
  • Post final security review
    • Branch: issue/4552/bulk-forecast-comment

Comment thread questions/views.py Outdated
Comment on lines +307 to +308
if is_staff_override and not request_user.is_staff:
raise PermissionDenied("Non-staff users cannot use the is_staff_override flag.")
Copy link
Copy Markdown
Contributor

@hlbmtc hlbmtc May 8, 2026

Choose a reason for hiding this comment

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

Why do we need an explicit is_staff_override flag? Can we simplify it by checking bot-belonging/user permissions when the user explicitly passes user_id or username params?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I could see a scenario where I'm a staff user and want to have my personal bot forecast, in which case id be forecasting without the "is_staff_override" flag. This way, if I mistype the bot ID or Username, I get bounced appropriately. Since being able to forecast on behalf of anyone is a "risky" feature to add, I want to make sure the one submitting the forecast is being really explicit in their knowledge that they're using a staff-override.

Do you feel like the additional parameter would be hard to maintain or confusing? I think it makes it easier!

Comment thread questions/views.py
username = serializers.CharField(required=False, allow_null=True)
is_staff_override = serializers.BooleanField(required=False, default=False)
forecasts = ForecastWriteSerializer(many=True, required=False, default=list)
comments = CommentWriteSerializer(many=True, required=False, default=list)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A small nit: I don’t like that we have forecasts and comments objects acting independently from their target post, so you could create a forecast for post A and a comment on unrelated post B at the same time.

I’m wondering if there’s a more sophisticated way to shape the schema so it explicitly says “this forecast comes together with this question,” instead of looking almost the same as just making two separate requests.

On the other hand, we still need to keep these things isolated and avoid mixing responsibilities. We currently have a similar case with Key Factors: comments.views.common.comment_create_api_view creates both a comment and a key factor if present.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I think it should be acceptable for this to be a bulk endpoint for forecasts and comments without restricting it such that you always need to pair comments with forecasts in the submission.

I'd be willing to change it if you insist, but I think it's simpler to allow for independent sets. I guess the risk here is that the forecast needs to attach to a Question while the comment attaches to a Post. I'll make sure that we validate all forecast and comment attachment points before saving any of them, but I still think it's good this way.

Comment thread questions/views.py
Comment on lines +277 to +282
def validate(self, attrs):
if not attrs.get("user_id") and not attrs.get("username"):
raise serializers.ValidationError(
"Either user_id or username must be provided."
)
return attrs
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hm, I’m confused. What if the user wants to forecast on behalf of themselves? Should they also pass their own user ID?

If so, maybe we can simplify it to this flow:

  • No user params → assume current authenticated user
  • User param → check whether:
    • it’s the current user
    • the bot belongs to the current user
    • the user is an admin and can impersonate another user

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I think the adage of "explicit over implicit" is worth it here. What's the downside to making sure you are definitely trying to forecast for yourself vs the bot? It just forces the user to be explicit and I think that's a value add.

Comment thread questions/views.py Outdated
@lsabor lsabor force-pushed the issue/4552/bulk-forecast-comment branch from ee56c6b to ad12105 Compare May 8, 2026 16:53
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.

Actionable comments posted: 1

🧹 Nitpick comments (5)
questions/views.py (3)

291-298: ⚡ Quick win

Empty payload silently returns 201.

If forecasts and comments are both empty (or both omitted, since they default to list), the endpoint completes with no work done and returns 201. That is potentially confusing for bot authors and obscures buggy clients. Consider rejecting payloads with no forecasts and no comments.

♻️ Require at least one item
     def validate(self, attrs):
         if not attrs.get("user_id") and not attrs.get("username"):
             raise serializers.ValidationError(
                 "Either user_id or username must be provided."
             )
+        if not attrs.get("forecasts") and not attrs.get("comments"):
+            raise serializers.ValidationError(
+                "At least one forecast or comment must be provided."
+            )
         return attrs
🤖 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/views.py` around lines 291 - 298, The view currently accepts empty
payloads because when forecasts_data and comments_data are both empty it
proceeds and returns 201; add a validation step that rejects such requests:
after deserializing with BulkForecastAndCommentSerializer and extracting
forecasts_data and comments_data, if both are empty (e.g., not forecasts_data
and not comments_data) raise a serializers.ValidationError (or return
Response(..., status=400)) with a clear message like "payload must include at
least one forecast or comment"; alternatively implement the same check in
BulkForecastAndCommentSerializer.validate(...) to enforce at-least-one-item and
surface a 400 to callers.

305-315: ⚡ Quick win

Silent preference of user_id when both user_id and username are provided.

The serializer requires at least one of user_id/username, but if a caller provides both with mismatched values, username is silently ignored (both in the staff-override branch and in the non-override branch). Given the explicit-by-design philosophy from earlier discussion, consider rejecting payloads where both are present, or validating that they refer to the same User row.

♻️ Reject ambiguous payloads at the serializer level
     def validate(self, attrs):
-        if not attrs.get("user_id") and not attrs.get("username"):
+        user_id = attrs.get("user_id")
+        username = attrs.get("username")
+        if not user_id and not username:
             raise serializers.ValidationError(
                 "Either user_id or username must be provided."
             )
+        if user_id and username:
+            raise serializers.ValidationError(
+                "Provide either user_id or username, not both."
+            )
         return attrs
🤖 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/views.py` around lines 305 - 315, The code silently prefers user_id
over username (in both the is_staff_override branch using
get_object_or_404(User, id=user_id) / get_object_or_404(User, username=username)
and the non-override branch using User.objects.filter(...).first()), which
allows ambiguous or conflicting payloads; update validation to reject or
reconcile dual fields: modify the serializer (or add a clean/validate method) to
error if both user_id and username are provided but refer to different users, or
to reject payloads that include both, and then adjust the view to assume exactly
one identifier is present (remove the silent preference logic) so the view uses
the serializer-validated single user identifier instead of choosing between
get_object_or_404(User, id=...) and get_object_or_404(User, username=...).

280-282: 💤 Low value

Remove the redundant IsAuthenticated decorator on line 281.

IsAuthenticated is configured as the default permission class in Django REST Framework settings, making the explicit @permission_classes([IsAuthenticated]) decorator unnecessary here and inconsistent with neighboring bulk endpoints.

Suggested change
 `@api_view`(["POST"])
-@permission_classes([IsAuthenticated])
 def bulk_forecast_and_comment_api_view(request):
🤖 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/views.py` around lines 280 - 282, Remove the redundant
`@permission_classes`([IsAuthenticated]) decorator from the
bulk_forecast_and_comment_api_view function: delete the decorator line that
directly precedes the def bulk_forecast_and_comment_api_view(request) so the
view relies on the global DEFAULT_PERMISSION_CLASSES setting (and matches
neighboring bulk endpoints) while leaving the `@api_view`(["POST"]) decorator and
function body unchanged.
tests/unit/test_questions/test_bulk_forecast_and_comment.py (2)

53-272: ⚡ Quick win

Add tests for the public-comment ban and the comment happy path.

The PR explicitly “blocks public commenting” and supports an included_forecast flag, but the only comment-related coverage here is the key_factors → 400 case. Consider adding:

  • A test that submitting a comment with is_private=False (or omitted) returns 400.
  • A successful path that creates a private comment (with and without forecasts) and asserts the Comment row exists.
  • An atomicity test where a comment validation/creation failure also prevents the forecasts from being persisted (confirms the transaction.atomic() block actually behaves as intended).
  • A test for the included_forecast=True branch in bulk_forecast_and_comment_api_view (lines 393-399 of questions/views.py), since that is the most non-trivial code path.

These would substantially strengthen the contract this endpoint advertises.

🤖 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_bulk_forecast_and_comment.py` around lines 53
- 272, Add tests inside TestBulkForecastAndComment to cover public-comment ban
and happy paths: (1) submit a comment with is_private=False (or omit is_private)
and assert the bulk endpoint (bulk_forecast_and_comment_api_view) returns 400 to
enforce the public-comment ban; (2) create a successful private comment request
(with and without forecasts) and assert a Comment row exists and associated
Forecast rows are created (use Comment and Forecast model checks); (3) add an
atomicity test where you force comment validation/creation to fail (e.g.,
invalid comment payload) while supplying valid forecasts and assert that no
Forecast rows persist, verifying transaction.atomic behavior; and (4) add a test
exercising the included_forecast=True branch in
bulk_forecast_and_comment_api_view to assert it follows the alternate code path
and persists expected Forecast/Comment results.

14-14: 💤 Low value

Module-level reverse() runs at import time.

This works under pytest-django because ROOT_URLCONF is loaded when the test module is imported, but it ties test collection to URL resolution. If a future test ever wants to override ROOT_URLCONF (e.g., via @pytest.mark.urls), this constant won't reflect that. A simple fixture or inline call inside each test avoids the foot-gun.

♻️ Optional move into a fixture
-URL = reverse("bulk-forecast-comment")
-
-
+@pytest.fixture()
+def url():
+    return reverse("bulk-forecast-comment")
🤖 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_bulk_forecast_and_comment.py` at line 14, The
module-level URL = reverse("bulk-forecast-comment") call runs at import time and
prevents tests from seeing runtime URL overrides; change it so
reverse("bulk-forecast-comment") is invoked at test runtime instead—either
replace the module-level URL with a small fixture (e.g., def
bulk_forecast_comment_url(): return reverse("bulk-forecast-comment")) and use
that fixture in tests, or call reverse("bulk-forecast-comment") directly inside
each test that needs it; update references to the module-level URL variable in
test_bulk_forecast_and_comment.py accordingly.
🤖 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.

Inline comments:
In `@questions/views.py`:
- Around line 305-327: The view enqueues Dramatiq messages inside a transaction
(see create_forecast_bulk and after_forecast_actions) which can be rolled back,
producing orphaned async jobs; change all places that call
run_build_question_forecasts.send(...) and
run_on_post_forecast.send_with_options(...) so they are invoked inside
transaction.on_commit(...) lambdas instead of directly, including the callsite
in after_forecast_actions and the send at line 183 of create_forecast_bulk;
ensure any other callers from non-bulk paths (e.g., the service-layer uses
around lines 155 and 241) use the same transaction.on_commit wrapping so
messages are only published after the DB transaction successfully commits.

---

Nitpick comments:
In `@questions/views.py`:
- Around line 291-298: The view currently accepts empty payloads because when
forecasts_data and comments_data are both empty it proceeds and returns 201; add
a validation step that rejects such requests: after deserializing with
BulkForecastAndCommentSerializer and extracting forecasts_data and
comments_data, if both are empty (e.g., not forecasts_data and not
comments_data) raise a serializers.ValidationError (or return Response(...,
status=400)) with a clear message like "payload must include at least one
forecast or comment"; alternatively implement the same check in
BulkForecastAndCommentSerializer.validate(...) to enforce at-least-one-item and
surface a 400 to callers.
- Around line 305-315: The code silently prefers user_id over username (in both
the is_staff_override branch using get_object_or_404(User, id=user_id) /
get_object_or_404(User, username=username) and the non-override branch using
User.objects.filter(...).first()), which allows ambiguous or conflicting
payloads; update validation to reject or reconcile dual fields: modify the
serializer (or add a clean/validate method) to error if both user_id and
username are provided but refer to different users, or to reject payloads that
include both, and then adjust the view to assume exactly one identifier is
present (remove the silent preference logic) so the view uses the
serializer-validated single user identifier instead of choosing between
get_object_or_404(User, id=...) and get_object_or_404(User, username=...).
- Around line 280-282: Remove the redundant
`@permission_classes`([IsAuthenticated]) decorator from the
bulk_forecast_and_comment_api_view function: delete the decorator line that
directly precedes the def bulk_forecast_and_comment_api_view(request) so the
view relies on the global DEFAULT_PERMISSION_CLASSES setting (and matches
neighboring bulk endpoints) while leaving the `@api_view`(["POST"]) decorator and
function body unchanged.

In `@tests/unit/test_questions/test_bulk_forecast_and_comment.py`:
- Around line 53-272: Add tests inside TestBulkForecastAndComment to cover
public-comment ban and happy paths: (1) submit a comment with is_private=False
(or omit is_private) and assert the bulk endpoint
(bulk_forecast_and_comment_api_view) returns 400 to enforce the public-comment
ban; (2) create a successful private comment request (with and without
forecasts) and assert a Comment row exists and associated Forecast rows are
created (use Comment and Forecast model checks); (3) add an atomicity test where
you force comment validation/creation to fail (e.g., invalid comment payload)
while supplying valid forecasts and assert that no Forecast rows persist,
verifying transaction.atomic behavior; and (4) add a test exercising the
included_forecast=True branch in bulk_forecast_and_comment_api_view to assert it
follows the alternate code path and persists expected Forecast/Comment results.
- Line 14: The module-level URL = reverse("bulk-forecast-comment") call runs at
import time and prevents tests from seeing runtime URL overrides; change it so
reverse("bulk-forecast-comment") is invoked at test runtime instead—either
replace the module-level URL with a small fixture (e.g., def
bulk_forecast_comment_url(): return reverse("bulk-forecast-comment")) and use
that fixture in tests, or call reverse("bulk-forecast-comment") directly inside
each test that needs it; update references to the module-level URL variable in
test_bulk_forecast_and_comment.py accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c153829e-0036-41df-b417-0faf08f48c42

📥 Commits

Reviewing files that changed from the base of the PR and between 005a617 and ee56c6b.

📒 Files selected for processing (4)
  • questions/services/forecasts.py
  • questions/urls.py
  • questions/views.py
  • tests/unit/test_questions/test_bulk_forecast_and_comment.py

Comment thread questions/views.py
Comment on lines +305 to +327
if is_staff_override:
if user_id:
user = get_object_or_404(User, id=user_id)
else:
user = get_object_or_404(User, username=username)
else:
user = (
User.objects.filter(id=user_id).first()
if user_id
else User.objects.filter(username=username).first()
)
is_self = user is not None and user.id == request_user.id
is_own_bot = (
user is not None
and user.is_bot
and user.bot_owner_id is not None
and user.bot_owner_id == request_user.id
)
if not is_self and not is_own_bot:
raise PermissionDenied(
"Non-superusers can only submit forecasts and comments as themselves "
"or their bots."
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm task.send invocations on the forecast hot path are not already wrapped with transaction.on_commit.
rg -nP -C3 "run_build_question_forecasts\.send|run_on_post_forecast\.send" --type=py
rg -nP -C2 "transaction\.on_commit" --type=py

Repository: Metaculus/metaculus

Length of output: 1597


🏁 Script executed:

# Find the create_forecast_bulk function and its surrounding context
sed -n '160,190p' questions/services/forecasts.py

# Check the context around line 385 in views.py
sed -n '380,395p' questions/views.py

# Look for the after_forecast_actions function mentioned in the comment
rg -n "after_forecast_actions" --type=py -B2 -A10

Repository: Metaculus/metaculus

Length of output: 4794


🏁 Script executed:

# Get the full after_forecast_actions function
sed -n '137,160p' questions/services/forecasts.py

# Check line 385 in views.py and surrounding context
sed -n '375,395p' questions/views.py

# Check where create_forecast_bulk is called
rg -n "create_forecast_bulk" --type=py -B3 -A3

Repository: Metaculus/metaculus

Length of output: 6866


Defer task enqueueing until transaction commit to avoid orphaned async jobs.

create_forecast_bulk enqueues run_build_question_forecasts.send(...) (via after_forecast_actions at line 160) and run_on_post_forecast.send_with_options(...) (at line 183) inside transaction.atomic() on lines 404-405 in views.py. Dramatiq messages are published immediately, not bound to the transaction. If any subsequent create_comment call raises (line 407+), the forecasts and comments get rolled back but the workers will still pick up those messages and try to rebuild aggregates / fire post-forecast actions for objects that no longer exist, leading to noisy errors and potentially stale aggregate state.

Wrap each task send in transaction.on_commit(lambda: task.send(...)) in after_forecast_actions and line 183 of create_forecast_bulk. Doing this in the service layer also benefits the existing non-bulk callers (lines 155, 241).

🤖 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/views.py` around lines 305 - 327, The view enqueues Dramatiq
messages inside a transaction (see create_forecast_bulk and
after_forecast_actions) which can be rolled back, producing orphaned async jobs;
change all places that call run_build_question_forecasts.send(...) and
run_on_post_forecast.send_with_options(...) so they are invoked inside
transaction.on_commit(...) lambdas instead of directly, including the callsite
in after_forecast_actions and the send at line 183 of create_forecast_bulk;
ensure any other callers from non-bulk paths (e.g., the service-layer uses
around lines 155 and 241) use the same transaction.on_commit wrapping so
messages are only published after the DB transaction successfully commits.

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.

bulk forecast & comment endpoint

2 participants