Skip to content

add bulk forecast and comment api endpoint - #4554

Open
lsabor wants to merge 13 commits into
mainfrom
issue/4552/bulk-forecast-comment
Open

add bulk forecast and comment api endpoint#4554
lsabor wants to merge 13 commits into
mainfrom
issue/4552/bulk-forecast-comment

Conversation

@lsabor

@lsabor lsabor commented Mar 28, 2026

Copy link
Copy Markdown
Contributor

closes #4552

Summary by CodeRabbit

  • New Features
    • Users can now submit multiple forecasts with associated private comments in a single request through a new endpoint.
    • Submissions support personal accounts and owned bot accounts, with authorized superuser overrides and comprehensive permission validation.
    • Forecasts and comments are processed together, with validation for timing, permissions, and unsupported comment fields.

@coderabbitai

coderabbitai Bot commented Mar 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds an authenticated bulk endpoint for forecasts and private comments. It validates target-user permissions, question state, and comment constraints. It creates valid forecasts and comments in one transaction.

Changes

Bulk Forecast & Comment Endpoint

Layer / File(s) Summary
Request validation and endpoint processing
questions/views.py
Adds the request serializer and authenticated POST view. The endpoint validates target users, permissions, questions, comments, and atomic persistence.
Endpoint wiring and forecast task import
questions/urls.py, questions/services/forecasts.py
Registers the questions/bulk-forecast-comment/ route and uses the absolute questions.tasks import path.
Test data and request helpers
tests/unit/test_questions/test_bulk_forecast_and_comment.py
Adds helpers and fixtures for open questions and bot ownership cases.
Authorization and validation tests
tests/unit/test_questions/test_bulk_forecast_and_comment.py
Tests authentication, self-submission, bot permissions, superuser overrides, unknown users, and key_factors rejection.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Suggested reviewers: codexveritas, hlbmtc

Poem

A rabbit sends forecasts in one careful hop,
Private comments follow and never stop.
Owners and superusers pass each gate,
One transaction keeps the records straight.
Sniff! The bulk endpoint is ready to launch.

🚥 Pre-merge checks | ✅ 2 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The endpoint covers bulk atomic submissions and bot permissions, but override access is limited to superusers instead of staff users [#4552]. Permit authenticated staff users to use the override by user ID, or update issue #4552 if superuser-only access is intentional.
Out of Scope Changes check ⚠️ Warning The questions.services.forecasts import-path change is unrelated to the bulk endpoint requirements and does not alter task invocation [#4552]. Remove the unrelated import-path change, or document why the endpoint requires it.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the primary change: a bulk forecast and comment API endpoint.
✨ 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 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.

@github-actions

github-actions Bot commented Mar 28, 2026

Copy link
Copy Markdown
Contributor

Cleanup: Preview Environment Removed (Stale)

This preview environment was fully removed because the PR was marked as Stale (no activity detected).

Resource Status
🌐 Preview App Deleted
🗄️ PostgreSQL Branch Deleted
⚡ Redis Database Deleted
🔧 GitHub Deployments Removed
📦 Docker Image Retained (auto-cleanup via GHCR policies)

Push a new commit to this PR to recreate the preview environment.


Automated cleanup by weekly maintenance

@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

lsabor commented May 7, 2026

Copy link
Copy Markdown
Contributor Author

@claude please scan this PR for security concerns

@claude

claude Bot commented May 7, 2026

Copy link
Copy Markdown
Contributor

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

@lsabor
lsabor temporarily deployed to testing_env May 7, 2026 17:51 — with GitHub Actions Inactive
@lsabor
lsabor temporarily deployed to testing_env May 7, 2026 17:52 — with GitHub Actions Inactive
@lsabor
lsabor requested review from CodexVeritas and hlbmtc May 7, 2026 18:29
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.")

@hlbmtc hlbmtc May 8, 2026

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.

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

@coderabbitai coderabbitai Bot left a comment

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.

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.

@github-actions

Copy link
Copy Markdown
Contributor

This pull request has been automatically marked as stale because it has not had recent activity. It will not be closed automatically, but please consider updating it or closing it if it is no longer relevant.

@github-actions github-actions Bot added the Stale label Jul 23, 2026
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 2

🧹 Nitpick comments (3)
tests/unit/test_questions/test_bulk_forecast_and_comment.py (2)

159-193: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Test staff submission for an arbitrary bot user.

These tests use user2, which is not a bot. Add a case where a staff user submits for user_bot_no_owner by user ID. This verifies the required staff override path without bot-owner authorization.

Based on PR objectives: staff users must submit forecasts for arbitrary bot user IDs.

🤖 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 159
- 193, Add a test alongside test_superuser_override_by_user_id that has
user_admin submit with is_staff_override for user_bot_no_owner.id, using the
existing forecast payload and request setup. Assert a 201 response and verify
the created Forecast is authored by user_bot_no_owner, covering staff submission
for a bot without an owner.

251-273: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Test successful comments and transaction rollback.

This test only checks that key_factors is rejected. Add a valid private-comment test that verifies persistence and author. Add a mixed invalid request test that verifies no forecast or comment persists after rejection.

Based on PR objectives: forecasts and comments must be created in one bulk transaction.

🤖 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 251
- 273, Extend the bulk comment tests around
test_key_factors_in_bulk_comment_returns_400 with a valid private-comment case
asserting successful response, persisted comment, and correct author, plus a
mixed valid/invalid request case asserting rejection rolls back all forecast and
comment creation. Reuse the existing bulk endpoint fixtures and query
mechanisms, and verify both records remain absent after the invalid request.
questions/views.py (1)

261-263: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the redundant permission decorator.

DRF uses IsAuthenticated as the default permission class in this repository. Remove this decorator unless this view must override that default.

Based on learnings, DRF is configured with IsAuthenticated as the default permission class.

🤖 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 261 - 263, Remove the redundant
`@permission_classes`([IsAuthenticated]) decorator from
bulk_forecast_and_comment_api_view, leaving the existing POST route and default
IsAuthenticated permission behavior unchanged.

Source: Learnings

🤖 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 374-385: Update the included forecast lookup in the comments loop
to use the persisted comment post: when a parent exists, resolve the question
through parent.on_post rather than the submitted on_post, while retaining the
current submitted-post behavior for top-level comments. Optionally validate and
reject any submitted on_post that differs from the parent post.
- Around line 371-372: Update the create_forecast_bulk call in the atomic
transaction to pass Forecast.SourceChoices.API as the forecast source, ensuring
API entitlement validation runs and created forecasts are stored with the API
source.

---

Nitpick comments:
In `@questions/views.py`:
- Around line 261-263: Remove the redundant
`@permission_classes`([IsAuthenticated]) decorator from
bulk_forecast_and_comment_api_view, leaving the existing POST route and default
IsAuthenticated permission behavior unchanged.

In `@tests/unit/test_questions/test_bulk_forecast_and_comment.py`:
- Around line 159-193: Add a test alongside test_superuser_override_by_user_id
that has user_admin submit with is_staff_override for user_bot_no_owner.id,
using the existing forecast payload and request setup. Assert a 201 response and
verify the created Forecast is authored by user_bot_no_owner, covering staff
submission for a bot without an owner.
- Around line 251-273: Extend the bulk comment tests around
test_key_factors_in_bulk_comment_returns_400 with a valid private-comment case
asserting successful response, persisted comment, and correct author, plus a
mixed valid/invalid request case asserting rejection rolls back all forecast and
comment creation. Reuse the existing bulk endpoint fixtures and query
mechanisms, and verify both records remain absent after the invalid request.
🪄 Autofix

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 Plus

Run ID: 13b1599c-d17b-48f7-bd73-2dc3b077608d

📥 Commits

Reviewing files that changed from the base of the PR and between 5b9935b and d04c304.

📒 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
🚧 Files skipped from review as they are similar to previous changes (2)
  • questions/services/forecasts.py
  • questions/urls.py

Comment thread questions/views.py
Comment on lines +371 to +372
with transaction.atomic():
create_forecast_bulk(user=user, forecasts=forecasts_data)

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.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Set the forecast source to API.

create_forecast_bulk checks API access only when source == Forecast.SourceChoices.API. This call omits source, so restricted accounts can use this API endpoint without the normal API forecast entitlement check. It also stores no API source value.

Proposed fix
-        create_forecast_bulk(user=user, forecasts=forecasts_data)
+        create_forecast_bulk(
+            user=user,
+            forecasts=forecasts_data,
+            source=Forecast.SourceChoices.API,
+        )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
with transaction.atomic():
create_forecast_bulk(user=user, forecasts=forecasts_data)
with transaction.atomic():
create_forecast_bulk(
user=user,
forecasts=forecasts_data,
source=Forecast.SourceChoices.API,
)
🤖 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 371 - 372, Update the create_forecast_bulk
call in the atomic transaction to pass Forecast.SourceChoices.API as the
forecast source, ensuring API entitlement validation runs and created forecasts
are stored with the API source.

Comment thread questions/views.py
Comment on lines +374 to +385
for comment_data in comments_data:
on_post = comment_data["on_post"]
included_forecast_flag = comment_data.pop("included_forecast", False)
comment_data.pop("key_factors", None)

included_forecast = (
on_post.question.user_forecasts.filter(author_id=user.id)
.order_by("-start_time")
.first()
if included_forecast_flag and on_post.question_id
else None
)

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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Select the included forecast from the persisted comment post.

When parent is present, create_comment persists the reply on parent.on_post. This lookup instead uses the submitted on_post. A reply on post A can then include the target user's forecast from unrelated post B.

Use parent.on_post when a parent exists. Optionally reject a submitted on_post that differs from the parent post.

🤖 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 374 - 385, Update the included forecast
lookup in the comments loop to use the persisted comment post: when a parent
exists, resolve the question through parent.on_post rather than the submitted
on_post, while retaining the current submitted-post behavior for top-level
comments. Optionally validate and reject any submitted on_post that differs from
the parent post.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bulk forecast & comment endpoint

2 participants