Nobody has started this yet — be first.
Business impact
A poll creator (or a moderator) deactivates a poll expecting that to immediately stop new votes -- that's the entire point of is_active, and it's how a live prediction market gets finalized, how a moderator takes down a poll that's being abused, or how an interview panel closes scoring before tallying results. Right now, a vote that was already "in flight" (its Poll object was fetched by the web process a moment earlier, before the deactivation happened) can still be recorded after the poll was closed, because the code checking "is this poll open?" never looks at the database again once it has that Python object in hand. To anyone watching the results, this looks like the deactivation silently didn't work -- a vote appears on a poll that's supposed to be closed, with no error, no log entry a normal admin would notice, nothing to point at.
Problem
cast_vote(user, poll, option) in polls/services.py checks poll.is_active on the Poll object it was handed. PollViewSet.vote() fetches that object once, near the start of the request, via self.get_object(). Everything between that fetch and the cast_vote() call (serializer validation, looking up the chosen Option, etc.) is real, non-zero time during which a different request can run PollViewSet.update() and flip is_active to False in the database. The first request's poll object never observes that change -- its is_active attribute is still whatever it was read as when the object was fetched -- so cast_vote happily proceeds to record the vote.
Current behavior
A vote fired against a Poll object that was fetched while the poll was still active gets recorded successfully even though the poll was deactivated in the database moments earlier, before cast_vote() actually ran -- no error, no rejection, just a vote landing on a poll that is supposed to be closed.
Expected behavior
cast_vote rejects a vote (raising PollInactiveError, same as it does for a poll that was already inactive when fetched) if the poll has been deactivated in the database by the time the vote is actually written -- regardless of what the in-memory Poll object it was handed says.
Steps to reproduce
cd django/poll_app source .venv/bin/activate python manage.py shell
from django.contrib.auth import get_user_model from polls.models import Option, Poll from polls.services import cast_vote
User = get_user_model() creator = User.objects.create_user(username="creator", password="x") voter = User.objects.create_user(username="voter", password="x") poll = Poll.objects.create(question="Best editor?", created_by=creator) option = Option.objects.create(poll=poll, text="vim")
poll = Poll.objects.get(pk=poll.pk)
Why this matters
This is the "hard" ticket in this set for the same reason test_race.py's duplicate-vote scenario is the app's headline lesson: a check performed against application-held state, rather than the database at write time, cannot be trusted once concurrent requests are possible. The difference here is that the fix for the duplicate-vote case (a UniqueConstraint + catching IntegrityError) doesn't transfer directly -- is_active isn't a uniqueness constraint, so closing this hole needs a different technique: re-reading (and ideally locking) the poll's current state as part of the same write, not relying on a constraint to reject the impossible after the fact.
Suggested approach
Think about what "read the current state, as of right now, as part of the write" means for a single row you're about to insert against -- this is exactly the kind of situation select_for_update() (inside transaction.atomic(), which cast_vote already uses for the Vote insert) exists for: it re-fetches a row from the database, locking it so no concurrent transaction can change it out from under you until you're done. Compare that to just re-querying Poll.objects.get(pk=poll.pk) without a lock, and think about whether that alone actually closes the window or just narrows it.
Acceptance criteria
Verification
.venv/bin/python manage.py test practicetickets.test_ticket06_stale_poll_is_active_race -v 2
Hints (0/2)
Try it without hints first — the reading is the exercise.
Working on this ticket
Work on a branch named for the ticket — that's what you'll submit.
Branch off your fork
$git checkout -b fix/stale-poll-is-active-raceFix it and commit
Meet every acceptance criterion, and add a test that would have caught this.
Push the branch
$git push -u origin fix/stale-poll-is-active-raceSubmit it below
Paste your fork URL and the branch name, with a short write-up of the root cause.
Questions
Ask about anything unclear in the ticket — the maintainer and anyone who has solved it can answer. Please don't post full solutions.
Sign in to ask a question or reply.
Sign inpoll object above.Poll.objects.filter(pk=poll.pk).update(is_active=False)
cast_vote(user=voter, poll=poll, option=option)
http://127.0.0.1:8000/api/http://127.0.0.1:8000/admin/.env.example.envSECRET_KEYDEBUGDB_*CELERY_BROKER_URLmanage.py testOr via Docker (Postgres, Redis, Django with hot reload, and a Celery worker, all wired together, migrations run automatically on startup):
docker compose up --build
Work the tickets in practicetickets/ (ticket01 through ticket07); each names one dedicated Django test module:
./practicetickets/run_tickets.sh # all 7, clean pass/fail summary
./practicetickets/run_tickets.sh -v # summary + each test's full output
.venv/bin/python manage.py test practicetickets.test_ticket01_permission_check_inverted -v 2 # a single ticket
Three of the seven tickets (01, 02, 03) also collaterally break pre-existing tests in polls/tests/; run .venv/bin/python manage.py test polls -v 2 to confirm the main suite is back to fully green once those are fixed. No Redis or Celery worker is required for any of this -- settings.py forces CELERY_TASK_ALWAYS_EAGER = True whenever "test" appears in sys.argv.
Level 1
Fix a bug
Read existing behaviour, correct it.