Status: BUILT, DEPLOYED AND VERIFIED LIVE 2026-09-28 — see §9. Written and authorised by Matthew 2026-09-28 ("let's do it and then it's out the way
once given to OpenCode"). Hermes has written no application code. This is the smallest job on the list, and §2 is the
one thing in it that is not mechanical.
One screen gains a drop zone. On the price-file import step — Stock → Prices → Import a price file
(/stock/prices/import/) — the Business Manager can drop the wholesaler's file onto the page instead of working
the file picker.
Nothing else moves. The same form, the same POST, the same "confirm which of the file's own columns is the code
and which is the price" step, the same server-side parse and validation, the same SupplierPriceFile record
(supplier, price date, counts, mapping, unmatched and rejected lines), the same audit and the same roles (Business
Manager only). The file picker stays and keeps working. This is a control added in front of an unchanged pipeline.
Measured 2026-09-28: the pharmacy app contains no JavaScript at all. Zero <script> tags across every
pharmacy*/templates/ directory; no JS bundle, no static JS, one hand-written <style> in
pharmacy/templates/pharmacy/base.html. The app's forms are deliberately written to work without a client: a
dependent select is solved by a server-side clean() with the comment "both are on the same form, so the list cannot
be filtered before submit without JavaScript" (pharmacy_stock/forms.py).
Dragging a file is a browser behaviour, so this change introduces the app's first script. That makes the following
the review test for the whole job, not a footnote:
| Thing | Today | Where |
|---|---|---|
| The screen | A plain form: supplier, price date, file, then Upload and map | pharmacy_stock/templates/pharmacy_stock/price_file_import.html |
| The entry point | Import a price file on the price-file list | price_file_list.html; URL pharmacy_stock:price_import |
| The file field | forms.FileField, help text "The wholesaler's own file: .csv, .txt or .tsv" — no accept attribute at all |
pharmacy_stock/forms.py (PriceFileUploadForm) |
| The pipeline | POST → _upload → temp file → price_import.read_file → parse failure recorded as a FAILED file → session → PriceFileMappingForm → _commit |
pharmacy_stock/views.PriceFileImportView |
| Drop-zone patterns in this repo | Two: the core import dashboard ("Drag & drop CSV file here, or click to browse") and the dental image upload | templates/core/import_dashboard.html, templates/clinical_data/patient_image_upload.html — both Bootstrap; neither is the pharmacy app's idiom |
| The pharmacy app's idiom | Its own inline CSS: .card, .muted, .warning, .btn / .btn.secondary, green #0b5d4a, max-width: 880px |
pharmacy/templates/pharmacy/base.html |
The drop zone appears only where the file is chosen — never on the confirm-and-map step (re-dropping there would
mean a second file after a mapping has been read). Wording, in the app's plain voice:
Drag the wholesaler's price file here, or choose it.
One file at a time — .csv, .txt or .tsv.
The existing picker sits inside the zone (and is still reachable by click and by keyboard). The accept attribute
must list .csv,.txt,.tsv — the form's own help text — and must never be narrowed to .csv: a .txt wholesaler
file is accepted today and must not become undroppable. accept is a hint to the picker only; it changes no rule.
Inline, vanilla, at the end of the content block:
dragover/dragenter: preventDefault() (otherwise the browser opens the dropped file instead of letting thedragleave/drop: clear the mark.drop: take event.dataTransfer.files.
DataTransfer, and show the file's nameFileReader, no client-side parsing, no preview — the file is not read in the browser at all. The preview andA .drop-zone block added to the existing <style> in pharmacy/templates/pharmacy/base.html, beside the other
components: dashed 1px border in the card's own border colour (#e2e5e9), radius 8px, padding, the .muted class
for the hint line, and an active state using the app's green (#0b5d4a) — a tinted background, nothing loud. No
Bootstrap classes and no icon library: the two existing drop zones in this repository are the GP app's, and copying
their markup into the pharmacy app would drag a second styling system in with it.
Today form.cleaned_data['file'] yields one value, and a POST carrying two parts named file silently imports
one of them (Django's MultiValueDict returns the last). Since a multi-file drop is now a thing a person can
plausibly attempt, the refusal has to exist server-side, where a script cannot be bypassed:
request.FILES.getlist('file') holds more than one entry;SupplierPriceFile row is created, and nothing is parsed.The form's fields, the parser and its file-type rules, the mapping step, the FAILED record for a refused file, the
SupplierPriceFile rows, the audit, and the role check (PharmacyPriceImportRequiredMixin — Business Manager only,
and the view remains the authority whatever the template does). No new URL, no AJAX, no endpoint, no JSON.
FileReader, no client-side row count, no client-side type judgement).Unchanged and green — every existing price-import test: they are the proof that the POST path, the mapping step and
the records did not move.
New:
file is refused with the W4 sentence, no SupplierPriceFile is created,accept attribute equals the form's own help-text list (.csv, .txt, .tsv), so the two cannot drift..txt/.tsv file still uploads (the parser's existing behaviour, asserted through the same POST path) —.csv-only drop zone narrowing what the app accepts.The full pharmacy suite must be green, and the template's script must remain the only script in the app.
Logged in as the Business Manager, on the live test client, using the synthetic example files the price seed
already carries (synthetic-wholesaler-demo-price-list-01.csv and friends) — never a real price list, and no patient
data is involved anywhere in this feature:
.txt or .tsv → accepted;The Windows rig follows the satellite order (Linux first, then the PC) — drag-and-drop is a browser behaviour, so it
gets one pass on each. Hermes writes the dated as-built note.
Documentation, after it ships (Hermes's job, not OpenCode's): the pricing walkthrough's Stop 1 and the pricing
page's wording describe choosing the file; each gains a sentence about the drop zone. No guide has to be rewritten —
the picker path they describe stays true.
Status: BUILT, DEPLOYED AND VERIFIED LIVE. OpenCode built it on feature/price-import-drop-zone (522a85a),
merged into feature/pharmacy-session-20260928 @ ee45617 and deployed to the app host. Three verifiers are
committed to veripath-ops: verify-price-drop-zone-live.py (HTTPS, Business Manager — all checks passed),
verify-price-drop-zone-browser.py (a real browser on the live-rendered page — all checks passed) and the
satellite probe in /root/work/satellite-stage2-probe/.
The screen, as served. The drop zone is there with the spec's wording; the picker is inside it and keeps
name="file"; accept=".csv,.txt,.tsv" — the form's own help text, built from the same constant in forms.py, so
a .txt wholesaler file is still accepted; the multi-file sentence is carried to the browser in
data-multi-file-message. The script is the only script on the page (one block), and it contains no submit, no
FileReader, no fetch/XMLHttpRequest/sendBeacon — it refuses anything that is not exactly one file with the
shared sentence.
The browser half, exercised for real (the part no HTTP test can show). The live-rendered page was loaded in
headless Chrome and driven over the DevTools protocol: a dropped file lands in the input, is named on screen, and
does not submit the form; a two-file drop shows the refusal sentence and changes nothing; and with
JavaScript disabled the picker, the Upload and map button and accept are all still there — the page works
exactly as before, as §2 required.
The pipeline did not move. One file went through the same confirm-the-columns step, showing the file's own
headers and the rows as read, and completed with counts Read 2 · Matched 2 · Unmatched 0 · Rejected 0; the same
file imported a second time produced byte-identical counts — the drop changes no figure. Both imports appear on the
price-file list. A .txt file was accepted and read.
The refusals are the server's. A single POST carrying two file parts is refused with the shared sentence,
reaches no confirm step and creates no record (this is the case the pre-existing code silently accepted one of).
The Counter Assistant gets HTTP 403 on both the page and the POST, and the refused post created nothing.
Demo data used, and what was cleaned up afterwards — corrected 2026-09-28. Every imported file is synthetic,
priced deliberately high (£9.99), so no demo figure moved: the Amoxicillin comparison still reads buy £2.10
against £1.04. The verifier imports against the supplier the form lists first — Example Specials Ltd (DEMO)
(pk 13), not a synthetic wholesaler as this note first said — on two dates (2026-09-26 and 2026-09-19), and each
pass supersedes its own earlier pair rather than deleting it (the app's own rule), so five passes left ten rows
on the tenant. They were then removed, leaving the tenant with exactly its five seeded price files again, because
the walkthrough's Stop 1 asks the reader to look at this list and ten verification rows are noise. The app has no
delete path for a price file, so this was a deliberate direct removal of exactly the verifier's own filename
(synthetic-verification-drop-01.csv) and nothing else, recorded here because a bulk delete on a client tenant should
never be silent. If the verifier is re-run, it leaves its pair behind again — worth knowing before a demo.