Nobody has started this yet — be first.
Business impact
Once a user votes on a poll today, that choice is permanent, which is a real usability gap for anything resembling a genuine survey or prediction market -- a voter who changes their mind, or fat-fingered their first tap, has no way to correct it short of contacting a moderator. Any fix has to hold up under the same kind of concurrency this app is built to take seriously: a slow UI where someone double-clicks a new choice, or a retried request after a flaky connection, must never leave a voter with zero votes or two votes on the same poll, even briefly.
Problem
Once a user votes on a poll, that choice is permanent -- there is no way to change it. polls/models.py's UniqueConstraint(fields=["user", "poll"]) guarantees at most one Vote row per user per poll, and PollViewSet.vote (POST) is the only way to write one; a second POST from the same user is rejected with 409 (see polls/services.py::cast_vote). /api/polls/{id}/vote/ currently only routes POST -- there is no PATCH handler at all, so a PATCH request today returns 405 Method Not Allowed. No stub is provided for this ticket -- the feature is entirely absent, and both tests exercise the endpoint directly over HTTP.
Current behavior
PATCH /api/polls/{id}/vote/ returns 405 Method Not Allowed unconditionally, for every user, whether or not they have already voted.
Expected behavior
PATCH /api/polls/{id}/vote/, authenticated, for a user who has already voted on the poll: updates their existing Vote to point at the new option and returns 200 with the updated vote. This must hold even when many change-requests for the same voter arrive at (approximately) the same instant -- e.g. a slow UI where someone double-clicks a new choice, or a retried request after a flaky connection. At every instant, exactly one Vote row must exist for that user/poll -- never zero (e.g. transiently deleted before the replacement is inserted), and never two (e.g. a duplicate insert slipping past a check that ran just before another request's write landed).
Steps to reproduce
cd django/poll_app source .venv/bin/activate python manage.py runserver &
curl -s -o /dev/null -w "%{http_code}\n" -X PATCH http://127.0.0.1:8000/api/polls/1/vote/
-H "Authorization: Token $TOKEN" -H "Content-Type: application/json" -d '{"option": 2}'
Why this matters
This is the concurrency-safe counterpart to polls/tests/test_race.py's lesson, applied to an update instead of a first-time insert. The naive approach -- "delete the old Vote, then create a new one" -- is exactly the kind of thing that reads cleanly and passes a quick manual check, and is wrong under concurrency: two near-simultaneous change requests can interleave their delete/create pairs so that, at some point during the race, either zero Vote rows exist (a small window where the user "looks like" they haven't voted at all) or two Vote rows briefly or permanently exist (violating the very constraint the rest of this app is built around). A single atomic UPDATE-style operation on the existing row avoids that window entirely -- but you have to reach for it deliberately, since "delete, then create" is the more obvious first instinct.
Suggested approach
Look at how cast_vote in polls/services.py currently handles a new vote (checking is_active, then relying on the database to reject a duplicate). A vote change isn't a new insert at all -- it's a modification of the row that the UniqueConstraint guarantees already exists exactly once. Think about what a single, atomic write against that existing row looks like (something that changes option in place, in one statement, rather than two separate delete-then-create statements with a window in between), versus what would need to happen if you insisted on a check-then-act shape. For the view side, PollViewSet.vote is currently declared with methods=["post"] only -- the same @action route-sharing mechanism that lets a GET handler share /vote/'s URL (see the my-vote-lookup ticket) applies here for PATCH too.
Acceptance criteria
Verification
.venv/bin/python manage.py test practicetickets.test_ticket07_change_vote_atomic_upsert -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 feat/change-vote-atomic-upsertFix it and commit
Meet every acceptance criterion, and add a test that would have caught this.
Push the branch
$git push -u origin feat/change-vote-atomic-upsertSubmit 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 inhttp://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 2
Implement a feature
Extend the system within its own patterns.