Nobody has started this yet — be first.
Business impact
StatusChange is the tracker's permanent record of what happened to an application and when -- the entire value proposition of this app over a spreadsheet is "I can trust this history." apply_transition is the single code path every status change goes through specifically to guarantee that trust. But it was never hardened against two requests landing close together (a user double-clicking "reject," two browser tabs open on the same application, a retried request after a slow network) -- and when that happens, the slower request can silently overwrite a status that a faster, later, perfectly legitimate request already committed, while also permanently writing a StatusChange record describing a transition that never should have been allowed to happen. A user could end up with an application that says interview when they distinctly remember rejecting it -- and a history log that "proves" an impossible sequence of events, with no error, no warning, and nothing to grep for afterward.
Problem
apply_transition() reads from_status = self.status once, at the top of the method, which is whatever status was loaded into this Python object whenever it was fetched -- not the row's actual current status in the database at the moment this method runs. If two independently-fetched in-memory copies of the same Application race, the second one can validate and persist a now-illegal transition against a stale in-memory status, silently clobbering whatever the first one already committed.
Current behavior
A second Application.apply_transition() call, made on a copy of the row fetched before a first, concurrent call already committed a different status, succeeds and silently overwrites that committed status instead of raising ValueError.
Expected behavior
apply_transition must validate against the application's current persisted status at the moment it actually writes, not a possibly-stale in-memory value. If another change has landed in between, the later-arriving call must fail loudly (raise ValueError, consistent with how an ordinarily-invalid transition already fails today) rather than silently overwrite what already happened.
Steps to reproduce
app_copy_1 = Application.objects.get(pk=app.pk) app_copy_2 = Application.objects.get(pk=app.pk) app_copy_1.apply_transition(Application.STATUS_REJECTED) # commits first, succeeds app_copy_2.apply_transition(Application.STATUS_INTERVIEW) # stale self.status == 'applied'
Why this matters
This is a classic check-then-act (time-of-check to time-of-use) race, the same class of bug that causes lost updates in any system where "read, decide, write" isn't atomic against concurrent writers. The fix isn't about threads or timing tricks -- it's about what data the validation check reads from: an in-memory attribute that can go stale the instant another writer commits, versus the row's actual current state at write time.
Suggested approach
Look at how apply_transition currently reads self.status up front and never touches the database again until its own .save(...) at the very end. Consider what it would mean to re-fetch (or lock) the row immediately before validating and writing, inside a single atomic operation -- Django's transaction.atomic() and QuerySet.select_for_update() are the relevant tools here, though the exact mechanism is your call as long as a later-arriving stale transition is rejected rather than silently applied.
Acceptance criteria
Verification
python manage.py test practicetickets.ticket07_concurrent_status_transition_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 feat/concurrent-status-transition-raceFix it and commit
Meet every acceptance criterion, and add a test that would have caught this.
Push the branch
$git push -u origin feat/concurrent-status-transition-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 in.envhttp://127.0.0.1:8000/api/http://127.0.0.1:8000/admin/To run against Postgres via Docker instead:
docker compose up --build # postgres + web, migrations run automatically on startup
Work the tickets in practicetickets/ (TICKET_01 through TICKET_07); each names one dedicated test, addressed by its explicit dotted label since these modules are deliberately named so Django's default test*.py discovery never picks them up:
python manage.py test practicetickets.ticket01_login_credential_swap -v 2 # a single ticket
./practicetickets/run_tickets.sh # all 7, clean pass/fail summary
Two of the seven bugs (tickets 01 and 06) are real enough to also break the project's own pre-existing suite (python manage.py test) -- fixing them correctly makes those pass again too, with zero changes needed inside accounts/tests.py or applications/tests.py.
Level 2
Implement a feature
Extend the system within its own patterns.