Skip to content

BREAKING CHANGE: votes are now transmitted live via web sockets.#24

Merged
aniebietafia merged 1 commit intomainfrom
documentation
Nov 30, 2025
Merged

BREAKING CHANGE: votes are now transmitted live via web sockets.#24
aniebietafia merged 1 commit intomainfrom
documentation

Conversation

@aniebietafia
Copy link
Contributor

@aniebietafia aniebietafia commented Nov 30, 2025

Summary by CodeRabbit

  • New Features

    • Poll options now indexed and automatically ordered
    • Duration-based poll creation available
    • Enhanced poll list view showing creator name, category, and voting statistics
  • Improvements

    • Real-time vote update broadcasts optimized
    • Voting workflow error handling enhanced

✏️ Tip: You can customize this high-level summary in your review settings.

Signed-off-by: aniebietafia <aniebietafia87@gmail.com>
@coderabbitai
Copy link

coderabbitai bot commented Nov 30, 2025

Walkthrough

This PR refactors WebSocket routing from polls.routing to core.routing, introduces option indexing via an index field on PollOption, and updates serializers and signal handling to use index-based identifiers instead of primary keys. A CI coverage threshold is also lowered. Migrations enforce unique poll-option index pairs.

Changes

Cohort / File(s) Summary
CI Configuration
.github/workflows/ci.yml
Lowered coverage fail-under threshold from 40 to 30 in test step
WebSocket Routing Reorganization
core/asgi.py, core/routing.py, polls/routing.py
Moved WebSocket URL patterns from polls.routing to new core.routing.websocket_urlpatterns; updated ASGI app import accordingly; removed old patterns from polls module
PollOption Model & Schema
polls/models.py, polls/migrations/0003_...py
Added index PositiveIntegerField (default 1) to PollOption; added ordering by index and unique_together (poll, index) constraint; updated __str__ to display option index; migration applies all changes
Serializer Refactoring
polls/serializers.py
PollOptionSerializer now maps id to index field; VoteSerializer refactored to accept poll_id instead of poll and use option option_id as index lookup; new PollCreateSerializer with duration-based poll creation; PollListSerializer updated with creator_name, category_name, total_votes, has_voted fields and methods
Vote Signal & View Updates
polls/signals.py, polls/views.py
Signal handler now uses poll_id directly, performs atomic vote_count increment, formats option data by index for broadcast; Views add perform_create methods to set creator and updated vote flow to inject poll_id into serializer context

Sequence Diagram

sequenceDiagram
    actor Client
    participant View as PollViewSet.vote()
    participant Serializer as VoteSerializer
    participant DB as Database
    participant Signal as post_save Signal
    participant Cache as Channel Layer
    participant WS as WebSocket Consumer

    Client->>View: POST vote (poll_id, option_id)
    View->>View: Normalize request.data & inject poll_id
    View->>Serializer: Initialize with poll_id + option_id
    Serializer->>DB: Fetch Poll by poll_id
    Serializer->>DB: Fetch PollOption by (poll, index=option_id)
    Serializer->>DB: Validate vote rules & check duplicates
    alt Vote Valid
        Serializer->>DB: Create Vote instance
        DB->>Signal: Trigger post_save signal
        Signal->>DB: Atomic increment PollOption.vote_count
        Signal->>DB: Fetch updated option counts for poll
        Signal->>Signal: Format data: {id: index, vote_count}
        Signal->>Cache: group_send poll_update
        Cache->>WS: Broadcast updated vote counts
        WS->>Client: WebSocket message with new counts
    else Vote Invalid
        Serializer->>Client: Return validation errors
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

  • polls/serializers.py: Major refactoring across multiple serializers with schema changes (poll_id, index-based lookups, new PollCreateSerializer, enhanced PollListSerializer); requires careful validation of field mappings and business logic
  • polls/models.py & migrations: Model constraint additions and migration logic need verification to ensure data integrity
  • polls/signals.py: Refactored broadcast logic with atomic operations and index formatting; verify F() expression and channel message format
  • polls/routing.py & core/routing.py: Confirm WebSocket routing relocation and ASGI import updates are complete and consistent
  • polls/views.py: Vote workflow changes and perform_create additions; ensure poll_id injection and serializer context handling are correct

Possibly related PRs

Poem

🐰 Options indexed, votes flow anew,
WebSocket routing shuffled through,
From polls to core, our paths align,
Atomic updates, signals shine,
Poll after poll, democracy hops!

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ 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 accurately describes the main feature addition: implementing real-time vote transmission via WebSockets, which is the primary focus of the changeset across multiple files including routing, consumers, serializers, signals, and views.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch documentation

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

@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: 5

🧹 Nitpick comments (3)
polls/models.py (1)

82-85: Consider using constraints instead of deprecated unique_together.

unique_together is soft-deprecated in favor of UniqueConstraint which provides more flexibility.

     class Meta:
         ordering = ["index"]
-        # Ensures we can't have same index in the same poll
-        unique_together = ["poll", "index"]
+        constraints = [
+            models.UniqueConstraint(fields=["poll", "index"], name="unique_poll_option_index")
+        ]
polls/views.py (1)

168-173: Redundant validation—serializer already ensures option belongs to poll.

The VoteSerializer.validate() method (line 56 in serializers.py) already fetches the option via PollOption.objects.get(poll=poll, index=option_index), guaranteeing option.poll == poll. This check will always pass after serializer validation succeeds.

Consider removing the redundant check:

         serializer.is_valid(raise_exception=True)

-        # Ensure the option belongs to the poll in the URL
-        if serializer.validated_data["option"].poll != poll:
-            return Response(
-                {"error": "Invalid option for this poll."},
-                status=status.HTTP_400_BAD_REQUEST,
-            )
-
         serializer.save()
polls/serializers.py (1)

49-58: Consider exception chaining for better debugging.

When re-raising as ValidationError, chaining with from None or from err improves traceability.

         try:
             poll = Poll.objects.get(pk=poll_id)
         except Poll.DoesNotExist:
-            raise serializers.ValidationError("Poll not found.")
+            raise serializers.ValidationError("Poll not found.") from None

         try:
             option = PollOption.objects.get(poll=poll, index=option_index)
         except PollOption.DoesNotExist:
-            raise serializers.ValidationError("Invalid option for this poll.")
+            raise serializers.ValidationError("Invalid option for this poll.") from None
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5f8108e and 4e7ff7e.

⛔ Files ignored due to path filters (2)
  • core/__pycache__/asgi.cpython-313.pyc is excluded by !**/*.pyc
  • polls/__pycache__/routing.cpython-313.pyc is excluded by !**/*.pyc
📒 Files selected for processing (9)
  • .github/workflows/ci.yml (1 hunks)
  • core/asgi.py (2 hunks)
  • core/routing.py (1 hunks)
  • polls/migrations/0003_alter_polloption_options_polloption_index_and_more.py (1 hunks)
  • polls/models.py (1 hunks)
  • polls/routing.py (0 hunks)
  • polls/serializers.py (3 hunks)
  • polls/signals.py (2 hunks)
  • polls/views.py (2 hunks)
💤 Files with no reviewable changes (1)
  • polls/routing.py
🧰 Additional context used
🧬 Code graph analysis (4)
core/routing.py (1)
polls/consumers.py (1)
  • PollConsumer (9-50)
polls/signals.py (1)
polls/models.py (2)
  • Vote (91-107)
  • PollOption (71-88)
polls/serializers.py (2)
polls/models.py (7)
  • Poll (26-68)
  • PollCategory (8-23)
  • PollOption (71-88)
  • Vote (91-107)
  • Meta (82-85)
  • Meta (103-107)
  • is_expired (67-68)
polls/utils.py (2)
  • get_client_ip (8-15)
  • get_country_from_ip (18-44)
polls/views.py (1)
polls/serializers.py (1)
  • VoteSerializer (31-119)
🪛 Ruff (0.14.6)
polls/migrations/0003_alter_polloption_options_polloption_index_and_more.py

8-10: Mutable class attributes should be annotated with typing.ClassVar

(RUF012)


12-26: Mutable class attributes should be annotated with typing.ClassVar

(RUF012)

polls/models.py

83-83: Mutable class attributes should be annotated with typing.ClassVar

(RUF012)


85-85: Mutable class attributes should be annotated with typing.ClassVar

(RUF012)

polls/serializers.py

27-27: Mutable class attributes should be annotated with typing.ClassVar

(RUF012)


28-28: Mutable class attributes should be annotated with typing.ClassVar

(RUF012)


38-38: Mutable class attributes should be annotated with typing.ClassVar

(RUF012)


39-39: Mutable class attributes should be annotated with typing.ClassVar

(RUF012)


52-52: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)


52-52: Avoid specifying long messages outside the exception class

(TRY003)


58-58: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)


58-58: Avoid specifying long messages outside the exception class

(TRY003)


62-62: Avoid specifying long messages outside the exception class

(TRY003)


65-65: Avoid specifying long messages outside the exception class

(TRY003)


69-71: Avoid specifying long messages outside the exception class

(TRY003)


81-83: Avoid specifying long messages outside the exception class

(TRY003)


149-162: Mutable class attributes should be annotated with typing.ClassVar

(RUF012)


163-163: Mutable class attributes should be annotated with typing.ClassVar

(RUF012)


190-192: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)


217-229: Mutable class attributes should be annotated with typing.ClassVar

(RUF012)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Run Tests & Lint
🔇 Additional comments (8)
core/routing.py (1)

1-7: LGTM! WebSocket routing correctly configured.

The routing setup follows Django Channels conventions. Consider using a stricter UUID regex pattern like (?P<poll_id>[0-9a-f-]{36}) to reject malformed poll IDs at the routing layer, though current validation can occur in the consumer.

core/asgi.py (1)

8-8: LGTM! Routing correctly migrated to core module.

The WebSocket routing now correctly references core.routing.websocket_urlpatterns, and security middleware (AllowedHostsOriginValidator, AuthMiddlewareStack) remains properly applied.

Also applies to: 21-21

polls/models.py (1)

76-77: LGTM! Index field correctly implemented.

The index field with default and the updated __str__ representation are well-implemented for the index-based option identification system.

Also applies to: 87-88

polls/signals.py (1)

49-53: Good error handling for broadcast failures.

Catching exceptions and logging without crashing the request is the right approach—the vote is already persisted, so a broadcast failure shouldn't return a 500 to the user.

polls/views.py (2)

82-87: LGTM! Creator injection correctly implemented.

The perform_create method properly injects the authenticated user as the poll creator.


157-165: Data normalization is correct but could be simplified.

The conditional handles both dict and QueryDict types, though dict(request.data) on a QueryDict may flatten multi-value keys. For this use case (single values), it works correctly.

polls/serializers.py (2)

21-28: LGTM! Index-based ID mapping correctly implemented.

Mapping id to index provides a stable, user-friendly identifier for poll options instead of exposing database PKs.


165-202: LGTM! Atomic poll creation with index assignment.

The transaction ensures poll and options are created together, and indices are correctly assigned starting from 1. The duration calculation using relativedelta correctly handles months/years.

pip install coverage
coverage run manage.py test
coverage report --fail-under=40
coverage report --fail-under=30
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Do not lower coverage threshold for a breaking change PR.

Reducing the coverage threshold from 40% to 30% during a BREAKING CHANGE PR with substantial refactoring (WebSocket routing, model changes, serializer updates, signal handling) is a risky trade-off that masks potential gaps in test coverage for critical changes.

Breaking changes require higher—not lower—test coverage to ensure correctness and prevent regressions. If coverage is dropping, the solution is to improve test coverage for the new/refactored code, not to lower the bar.

Recommendation: Revert the threshold to 40% (or higher) and ensure comprehensive test coverage for all breaking changes, especially:

  • WebSocket routing refactoring
  • PollOption.index field introduction
  • Index-based identifier changes in serializers
  • Signal handling updates

If the current code cannot meet the 40% threshold, this indicates inadequate test coverage that should be addressed before merging a breaking change.

🤖 Prompt for AI Agents
.github/workflows/ci.yml around line 72: the CI coverage threshold was lowered
to 30% for a breaking-change PR; revert the change by restoring the threshold to
40% (replace the current line with "coverage report --fail-under=40") and do not
merge with a reduced threshold — instead add/adjust tests to cover the
refactored areas (WebSocket routing, PollOption.index, serializer index-based
IDs, signal handling) so the pipeline passes at the 40% minimum.

Comment on lines +17 to +25
migrations.AddField(
model_name="polloption",
name="index",
field=models.PositiveIntegerField(default=1),
),
migrations.AlterUniqueTogether(
name="polloption",
unique_together={("poll", "index")},
),
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Migration will fail if existing PollOptions share the same poll.

Adding index with default=1 and then applying unique_together on (poll, index) will cause an IntegrityError if any poll already has multiple options—they'll all have index=1.

Split this into two migrations:

  1. Add the index field (without the constraint)
  2. Run a data migration to assign unique indices per poll
  3. Apply unique_together

Example data migration:

from django.db import migrations

def assign_option_indices(apps, schema_editor):
    PollOption = apps.get_model('polls', 'PollOption')
    from django.db.models import Window, F
    from django.db.models.functions import RowNumber
    
    # Group by poll and assign sequential indices
    for poll_id in PollOption.objects.values_list('poll_id', flat=True).distinct():
        options = PollOption.objects.filter(poll_id=poll_id).order_by('id')
        for idx, option in enumerate(options, start=1):
            option.index = idx
            option.save(update_fields=['index'])

class Migration(migrations.Migration):
    dependencies = [("polls", "0003_add_index_field")]
    operations = [migrations.RunPython(assign_option_indices, migrations.RunPython.noop)]
🤖 Prompt for AI Agents
polls/migrations/0003_alter_polloption_options_polloption_index_and_more.py
lines 17-25: the current migration adds an index field with default=1 and
immediately applies unique_together on (poll, index), which will produce
IntegrityError for polls with multiple options; fix by splitting into three
steps: (1) create a migration that adds the PositiveIntegerField index (keep a
harmless default or allow null temporarily) without any unique constraint, (2)
add a data migration (RunPython) that iterates PollOption grouped by poll_id,
orders each group deterministically (e.g., by id), assigns sequential index
values and saves with update_fields=['index'], and (3) create a follow-up schema
migration that applies AlterUniqueTogether/UniqueConstraint on (poll, index)
after the data migration has ensured uniqueness.

Comment on lines 41 to 44
def validate(self, attrs):
request = self.context["request"]
user = request.user if request.user.is_authenticated else None
request = self.context.get("request")
user = request.user if request and request.user.is_authenticated else None
ip_address = get_client_ip(request)
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Potential AttributeError if request context is missing.

Line 44 calls get_client_ip(request) before verifying request is not None. If serializer is instantiated without context, this will raise AttributeError.

     def validate(self, attrs):
         request = self.context.get("request")
+        if not request:
+            raise serializers.ValidationError("Request context is required.")
         user = request.user if request and request.user.is_authenticated else None
         ip_address = get_client_ip(request)
📝 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
def validate(self, attrs):
request = self.context["request"]
user = request.user if request.user.is_authenticated else None
request = self.context.get("request")
user = request.user if request and request.user.is_authenticated else None
ip_address = get_client_ip(request)
def validate(self, attrs):
request = self.context.get("request")
if not request:
raise serializers.ValidationError("Request context is required.")
user = request.user if request and request.user.is_authenticated else None
ip_address = get_client_ip(request)
🤖 Prompt for AI Agents
In polls/serializers.py around lines 41 to 44, the code calls
get_client_ip(request) without ensuring request is present, which can raise
AttributeError when context is missing; modify the validate method to first
check if request is truthy (and optionally request.user.is_authenticated) and
only call get_client_ip(request) when request exists, otherwise set ip_address
to None (or an appropriate default), then proceed using that safe value.

Comment on lines +231 to +239
def get_total_votes(self, obj):
# Sum the pre-calculated vote_counts from options
return sum(opt.vote_count for opt in obj.options.all())

def get_has_voted(self, obj):
request = self.context.get("request")
if request and request.user.is_authenticated:
return Vote.objects.filter(poll=obj, user=request.user).exists()
return False
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Potential N+1 queries in list serializer.

get_total_votes iterates obj.options.all() and get_has_voted queries Vote.objects for each poll in the list. Without prefetching, this causes N+1 queries.

Ensure the viewset prefetches the related data:

# In PollViewSet.get_queryset()
return Poll.objects.prefetch_related('options').select_related(...)

For has_voted, consider annotating at the queryset level:

from django.db.models import Exists, OuterRef

user_vote_subquery = Vote.objects.filter(poll=OuterRef('pk'), user=request.user)
queryset = queryset.annotate(has_voted=Exists(user_vote_subquery))

Then expose via a SerializerMethodField that reads the annotation instead of issuing a query.

🤖 Prompt for AI Agents
In polls/serializers.py around lines 231-239, the serializer methods cause N+1
queries: get_total_votes iterates obj.options.all() without guaranteed prefetch
and get_has_voted issues a Vote query per poll; update PollViewSet.get_queryset
to prefetch_related('options') (and select_related for any FK) so options are
fetched once, and annotate the queryset with a has_voted boolean using
Exists/OuterRef tied to request.user so the serializer can read the annotation
instead of querying; then change get_total_votes to sum the already-prefetched
option.vote_count and get_has_voted to return the annotated field (or fall back
safely if annotation missing).

Comment on lines 23 to +42
PollOption.objects.filter(id=instance.option.id).update(
vote_count=F("vote_count") + 1
)
)

# Real-time Broadcast using WebSockets
channel_layer = get_channel_layer()
poll_id = str(instance.poll.poll_id)
room_group_name = f"poll_{poll_id}"
# 2. Real-time Broadcast
channel_layer = get_channel_layer()
# Use raw poll_id to avoid extra DB lookup for the Poll object
poll_id = str(instance.poll_id)
room_group_name = f"poll_{poll_id}"

# Fetch fresh counts
# We fetch all options for this poll to ensure the frontend is fully synced
options = PollOption.objects.filter(poll_id=poll_id).only(
"index", "vote_count"
)

# Format data: Map 'index' to 'id' for the frontend
formatted_data = [
{"id": opt.index, "vote_count": opt.vote_count} for opt in options
]
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Potential stale read after atomic increment.

The atomic update() on line 23-25 doesn't refresh the queryset. The subsequent query on line 35 may return stale vote_count values due to Django's query caching or if another concurrent vote is processed between these two operations.

Consider refreshing from the database or using select_for_update() for stronger consistency:

             PollOption.objects.filter(id=instance.option.id).update(
                 vote_count=F("vote_count") + 1
             )

             # 2. Real-time Broadcast
             channel_layer = get_channel_layer()
             poll_id = str(instance.poll_id)
             room_group_name = f"poll_{poll_id}"

-            # Fetch fresh counts
-            options = PollOption.objects.filter(poll_id=poll_id).only(
-                "index", "vote_count"
-            )
+            # Fetch fresh counts - force DB read
+            options = list(PollOption.objects.filter(poll_id=poll_id).only(
+                "index", "vote_count"
+            ))

The list() forces evaluation, though in high-concurrency scenarios consider whether eventual consistency is acceptable for the real-time display.

@aniebietafia aniebietafia merged commit 3df78e3 into main Nov 30, 2025
2 checks passed
@coderabbitai coderabbitai bot mentioned this pull request Dec 14, 2025
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.

1 participant