Deleting an account or a company: export archives and logos stay on disk, and prune_media deletes live exports #355
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#355
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, comparing everyFileFieldin the models with the lists of files that account deletion andprune_medialook after, then deleting a test account that held one of each and runningprune_mediaagainst a fresh export.Checked again on 2026-10-01 against
mainat0a5cad587: still present. The code is now atjobs/models.py:299(Company.logo) andjobs/models.py:204-206(logo_upload_to), and atcore/server_views.py:288(PersonDeleteView.post) and:305(its call todelete_account).Three hand-written lists say which files a row owns:
files_ofandmedia_directories_offor account deletion, and_referencedforprune_media. Two file fields are missing from them:ExportArchive.file(#247) from all three, andCompany.logofrom the first two. Neither model has apost_deletereceiver. So deleting an account leaves the person's export archive, a zip of the whole job search, and their company logos on disk for ever; deleting one company leaves its logo; andprune_media --removedeletes exports that are still waiting to be downloaded.Related to #33 (closed), which designed account deletion as "export offered first, then everything, including the files on disk", named logos among the files, and set the test that matters as "no file under their prefix remains". This issue adds the two file fields that deletion does not cover, one of which is the export #33 tells the person to take first, and the same omission in
prune_media. Also related to #217 (closed), which settled that deleting a row deletes its file and addedprune_media, for documents only.1. Account deletion leaves the export archive and the logos
Where:
accounts/deletion.py:61-81(files_of) collectsUploadedDocument.file,RenderedDocument.file,CapturedPage.sourceand.rendering, and the twoProfilepictures.ExportArchive.file(core/models.py:811, stored underexports/<owner>/YYYY/MM/byarchive_path,core/models.py:788-790) andCompany.logo(jobs/models.py:296, stored underlogos/<owner>/bylogo_upload_to,jobs/models.py:201-203) are not there.accounts/deletion.py:84-91(media_directories_of) prunesdocuments/<pk>,avatars/<pk>andcaptures/<pk>only.core/models.py:830-842(ExportArchive.delete) is the only code that removes an archive's bytes.delete_accountremoves the rows through the cascade fromuser.delete()(accounts/deletion.py:115), and a cascade runs the collector, never a model's owndelete(); the docstring ofjobs/signals.py(lines 10-15) says so.post_deletereceivers that remove files aredocuments/signals.py:38-39(uploads and renders) andjobs/signals.py:57(CapturedPage). There is none forExportArchiveorCompany.core/slow.py:70-83(reap_archives) iteratesExportArchiverows, so once the rows have gone it cannot find the file.What happens:
templates/accounts/delete_account.html:9) and presses the export button (:46).build_an_export(core/slow.py:35) writes anExportArchiveunderexports/<pk>/YYYY/MM/, kept forPOSTULO_EXPORT_KEEP_HOURS(24 by default).MEDIA_ROOT/exports/<pk>/, and every logo underMEDIA_ROOT/logos/<pk>/(onelogo-<company pk>.*per company, which is a list of where they were applying). The deletion report counts neither.manage.py backupcopies it into every later backup with the rest of the media tree.An administrator deleting somebody from Server settings → People (
core/server_views.py:304) andmanage.py delete_accounttake the same path. Checked against the snapshot: with one company logo and one archive,delete_account()reported1 exportarchive,1 companyand0 files removed from disk, and after it and areap_archives()pass both files still existed.Who can trigger it: anyone deleting their own account in the way the page recommends, and any administrator deleting an account that holds a logo or an unexpired export. No special conditions. Nobody can fetch the files through the application afterwards; the exposure is to whoever holds the disk or a backup.
Why it matters: the page says "Every file is removed from the server's disk, not only its record" (
templates/accounts/delete_account.html:35), the wiki says documents "and the files behind them on the disk" go (Accounts-and-invitations.md:206-208), and the module docstring ofaccounts/deletion.pycalls a deletion that leaves files "not a deletion". The file left behind is the most complete copy of the person's data the instance ever writes, and nothing will remove it exceptprune_media --remove, by accident.2. Deleting one company leaves its logo
jobs/views.py:573(CompanyDeleteView); the only code that removes a logo file is replacing or clearing it (jobs/logos.py:169,:180) and the losing side of a merge (jobs/merging.py:505).logos/<owner>/logo-<pk>.*stays, with nothing pointing at it. Checked with a POST tojobs:company_delete: the logo still existed. A company deleted from the admin (jobs/admin.py:18) behaves the same.3.
prune_mediatreats every live export as an orphandocuments/management/commands/prune_media.py:23-42(_referenced) lists seven(model, field)pairs,Company.logoamong them andExportArchive.filenot;handle()unlinks every file underMEDIA_ROOToutside that set (prune_media.py:84-85).core/views_export.py:72(export_archive) decides "expired" fromarchive.file, which is the stored name and not whether the file exists.manage.py prune_media --remove, as the wiki shows (Files-and-what-you-sent.md:154-155,Backups-and-your-data.md:222) and asrestoresuggests (core/management/commands/restore.py:83), while somebody's export is inside its keep window. Run against the snapshot with an archive expiring in 24 hours, the listing printedorphan exports/1/2026/09/x.zipand0 file(s) in use, 1 with no record; with--removethe file was deleted and the row stayed. Your data goes on offering the download, and it answers the bare 404 "File not found." fromcore/files.py:86-87rather than "That export has expired. Ask for a new one.", because the row still has its file name.CapturedPagein_referencedrecords what leaving a model out of the list costs; a list kept by hand will miss the next file field too.4. The files go before the request's transaction commits
accounts/deletion.py:111-127;config/settings/base.py:140(ATOMIC_REQUESTS);accounts/views.py:394(DeleteAccountView.post) andcore/server_views.py:287, neither markednon_atomic_requests.transaction.atomic()indelete_accountis a savepoint inside the request's transaction, and the loop that removes the files runs before that outer transaction commits. A request that fails afterdelete_accountreturns rolls the rows back with their files already gone.Fix
ExportArchive.delete()with apost_deletereceiver that removes the file intransaction.on_commit, asjobs/signals.py:57-84does for kept pages. It then runs for cascades, queryset deletes and the admin as well as forreap_archives.Company.logo, keeping the "only where no other row uses the name" check: a merge hands the other company's logo name to the kept one (jobs/merging.py:489-493) before deleting the other. The explicit removal atjobs/merging.py:505then becomes redundant.exports/<pk>andlogos/<pk>tomedia_directories_of, and count both kinds of file in the deletion report.files_ofandprune_media._referencedwith one helper that walksapps.get_models()for everyFileFieldandImageField, so a model that gains a file field is covered on the day it is added. At the least, add(ExportArchive, "file")to_referenced.delete_accountintotransaction.on_commit(or leave it to the receivers, which already wait for the commit), so section 4 cannot happen.export_archivegive the "expired" message when the bytes are gone.Instances that already hold such files need no migration: once
prune_mediaknows about live exports, its listing shows exactly the archives and logos that deleted accounts and companies left behind.Done when
tests/test_account_deletion.py::test_deleting_removes_every_owned_row_and_every_filealso givesfill()a company with a logo and an unexpiredExportArchive, and asserts afterdelete_account()that no file remains anywhere underMEDIA_ROOTin a<prefix>/<pk>/directory of the deleted person, rather than naming the known directories. It fails today on the archive and the logo.tests/test_logos.pydeletes a company with a logo throughjobs:company_deleteand finds the file gone after commit; another merges two companies and finds the logo the kept company now uses still on disk.ExportArchiverows through a queryset removes their files.tests/test_document_record.py, besidetest_prune_media_lists_orphans_and_removes_them_when_told, writes an unexpiredExportArchive, runsprune_media --remove, and asserts the file is still there and was not listed as an orphan. It fails today.apps.get_models()and fails when a model declares a file field thatfiles_ofor_referenceddoes not read.The maintainer's list of 2026-10-02 says an avatar and a company logo must go with their owner. Checking the avatar half found one more path.
By reading
mainatf09b6010b(not run): deleting a user through Django admin'sUserAdmin(src/postulo/accounts/admin.py:14) removes theProfilerow by cascade but leavesProfile.avatarandProfile.gravatar_imageon disk.files_ofis called only fromdelete_account(accounts/deletion.py:108), and nopost_deletereceiver exists forProfile. The fix should coverProfileas well asCompany.#662 asks whether both pictures should be database rows. If the maintainer chooses that, it supersedes the logo and avatar half here, so do not close that half early.
From the list of improvements of 2026-10-02.