Editing an application: saving a stale form moves the status back and logs a move nobody made #545

Closed
opened 2026-10-01 10:47:32 +00:00 by tiagoagueda · 0 comments
Owner

Found on 2026-09-29 in a read-only review of postulo at 95348908d, by reading the edit views that route a field through a service.

Checked again on 2026-10-01 against main at 0a5cad587: still present. The code is now at applications/views.py:960-987 for InterviewUpdateView.form_valid (the reschedule_interview call at :985); ApplicationUpdateView.form_valid, the form and the cell editor have not moved.

Since #231, ApplicationUpdateView has written the status read under a lock rather than the one the form was drawn with. It then asks change_status for the status the form posted, and the form always posts the status it was drawn with. So a move made after the page was opened is still undone, now with a timeline entry for it. InterviewUpdateView does the same with an interview's times.

  • Where:

    • applications/views.py:435-472, ApplicationUpdateView.form_valid: requested_status = form.cleaned_data["status"] (:460), then change_status(self.object, requested_status) (:470). status is in ApplicationForm.Meta.fields (applications/forms.py:318-346).
    • applications/views.py:920-947, InterviewUpdateView.form_valid: reschedule_interview(interview, starts_at=…, ends_at=…) (:945) with the times the form was drawn with.
    • The table's cell editor already refuses a stale save by comparing updated_at (core/cells.py:170-195, moved_on); the full forms have no such check.
  • What happens:

    1. An application stands at Applied. Its edit page is opened.
    2. It is moved to Screening elsewhere: the board, another tab, or the API and MCP.
    3. On the old page only the priority is changed, and the form is saved.
    4. The status is Applied again, and the timeline reads Applied → Screening, then Screening → Applied.

    An interview moved by the API or a calendar sync is moved back in the same way when an older edit page is saved to change its location, and a "moved from … to …" entry is written.

  • Why it matters: the event log is meant to be the truth about the search. A stale page silently reverses a real change and records a transition the person never chose; the view's docstring describes this case as fixed.

  • Fix: carry what the form was drawn with (a hidden updated_at stamp, as core/cells.py does, or the drawn status and times). Call change_status only when the posted status differs from the drawn one, and reschedule_interview only when the posted times differ from the drawn ones. When the row has moved since, either refuse with the cells' STALE message or keep the current value.

Related to #231 (closed), whose item 11 described the race; the lock it added does not cover a stale form.

Done when

  • A test in tests/test_applications.py draws the edit form, moves the application with change_status, posts the form with only the priority changed, and asserts the status is unchanged and no new timeline entry exists.
  • A test in tests/test_interviews.py does the same for an interview rescheduled after its edit form was drawn.
Found on 2026-09-29 in a read-only review of postulo at 95348908d, by reading the edit views that route a field through a service. Checked again on 2026-10-01 against `main` at `0a5cad587`: still present. The code is now at `applications/views.py:960-987` for `InterviewUpdateView.form_valid` (the `reschedule_interview` call at `:985`); `ApplicationUpdateView.form_valid`, the form and the cell editor have not moved. Since #231, `ApplicationUpdateView` has written the status read under a lock rather than the one the form was drawn with. It then asks `change_status` for the status the form posted, and the form always posts the status it was drawn with. So a move made after the page was opened is still undone, now with a timeline entry for it. `InterviewUpdateView` does the same with an interview's times. - **Where:** - `applications/views.py:435-472`, `ApplicationUpdateView.form_valid`: `requested_status = form.cleaned_data["status"]` (`:460`), then `change_status(self.object, requested_status)` (`:470`). `status` is in `ApplicationForm.Meta.fields` (`applications/forms.py:318-346`). - `applications/views.py:920-947`, `InterviewUpdateView.form_valid`: `reschedule_interview(interview, starts_at=…, ends_at=…)` (`:945`) with the times the form was drawn with. - The table's cell editor already refuses a stale save by comparing `updated_at` (`core/cells.py:170-195`, `moved_on`); the full forms have no such check. - **What happens:** 1. An application stands at *Applied*. Its edit page is opened. 2. It is moved to *Screening* elsewhere: the board, another tab, or the API and MCP. 3. On the old page only the priority is changed, and the form is saved. 4. The status is *Applied* again, and the timeline reads *Applied → Screening*, then *Screening → Applied*. An interview moved by the API or a calendar sync is moved back in the same way when an older edit page is saved to change its location, and a "moved from … to …" entry is written. - **Why it matters:** the event log is meant to be the truth about the search. A stale page silently reverses a real change and records a transition the person never chose; the view's docstring describes this case as fixed. - **Fix:** carry what the form was drawn with (a hidden `updated_at` stamp, as `core/cells.py` does, or the drawn status and times). Call `change_status` only when the posted status differs from the drawn one, and `reschedule_interview` only when the posted times differ from the drawn ones. When the row has moved since, either refuse with the cells' `STALE` message or keep the current value. Related to #231 (closed), whose item 11 described the race; the lock it added does not cover a stale form. ## Done when - A test in `tests/test_applications.py` draws the edit form, moves the application with `change_status`, posts the form with only the priority changed, and asserts the status is unchanged and no new timeline entry exists. - A test in `tests/test_interviews.py` does the same for an interview rescheduled after its edit form was drawn.
tiagoagueda added this to the 0.5.0 milestone 2026-10-01 10:47:32 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo#545
No description provided.