API: a refused company or interview write still keeps the changes it made before the refusal #435
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
performance
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo#435
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found on 2026-09-29 in a read-only review of postulo at
95348908d, reading the order of saves and checks in the API's write calls and sending refused writes through the test client.Checked again on 2026-10-01 against
mainat0a5cad587: still present.Three API calls save first and check the rest afterwards. A refusal is an
HttpError, which django-ninja's exception handler (api/problems.py:209-222) turns into a response inside the view, soATOMIC_REQUESTSsees a view that returned normally and commits. The client is told 422, and half of what it sent is kept. The applications router states the rule these three break: "Looked up before anything is made, so a refusal leaves no company behind it" (api/routers/applications.py:94).api/routers/companies.py:54-70,add_company: finds or makes the company, setswebsite,careers_url,location,notesandkind, saves, adds industries, and only then callsidentifiers_or_422.api/routers/companies.py:89-102,patch_company: saves every changed field and replaces the industries beforeidentifiers_or_422(..., replace=True).api/routers/interviews.py:136-154,change_interview: saveskind,locationandnotes(interview.save(), line 150) before_contacts(line 152) refuses a contact who is not at the application's company.POST /api/v1/companieswithname: "Mesa", awebsiteand an identifier{"scheme": "lei", "value": "not-a-lei"}answers 422, and the companyMesawith that website exists afterwards. When the name matched an existing company, that company's fields have been overwritten instead.PATCH /api/v1/companies/{id}withname: "Renamed"and a malformed identifier answers 422, and the company is calledRenamed; industries sent in the same call have replaced the old ones.PATCH /api/v1/interviews/{id}withlocation: "Moved"and acontact_idsentry that is not at the company answers 422, and the location isMoved.POSTis a stray record the person never asked for.jobs/services.py:set_identifiersinto a function that returns the cleaned list or raises, and call it at the top of both company calls), and resolve_contactsbeforeinterview.save(). Where reordering is awkward, wrap the body inwith transaction.atomic():, so theHttpErrorrolls back the savepoint before ninja renders it. Rolling back the whole request from the error handler instead would be simpler but wrong: the rate limits count in the database cache inside the same transaction, and a refused request must still be counted.Done when
tests/test_api.pysends each of the three refused writes above and asserts a 422 and that the company or interview is exactly as it was before (no new company, same name, same industries, same location).