Files
ImpTune/.planning/phases/09-ux-tech-debt-closure/09-01-driver-upload-fix-and-inline-oob-PLAN.md
2026-04-15 17:57:12 +02:00

16 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
09-ux-tech-debt-closure 01 execute 1
tests/test_driver_upload.py
imptune/api/drivers.py
imptune/templates/partials/driver_list.html
imptune/templates/partials/driver_upload_with_oob.html
imptune/templates/partials/printer_form.html
true
UX-01
truths artifacts key_links
POST /drivers/upload never returns HTTP 500 for a valid driver ZIP
Uploading a driver from the printer form refreshes the driver <select> via HTMX OOB swap without a page reload
The newly uploaded driver is auto-selected in the refreshed <select>
Uploading from the standalone /drivers page still returns only the #driver-list fragment (no OOB noise)
path provides contains
tests/test_driver_upload.py Regression test for the 500 + OOB contract tests test_upload_500_regression
path provides contains
imptune/api/drivers.py Fixed upload_driver handler with caller-aware OOB branch caller
path provides contains
imptune/templates/partials/driver_upload_with_oob.html Template emitting primary driver_list fragment + OOB <select> hx-swap-oob
path provides contains
imptune/templates/partials/printer_form.html Driver <select> has stable id + separate inline upload form printer-form-driver-select
from to via pattern
imptune/templates/partials/printer_form.html POST /drivers/upload separate <form hx-post=/drivers/upload> with hidden caller=printer_form field name="caller"\s+value="printer_form"
from to via pattern
imptune/api/drivers.py (upload_driver) partials/driver_upload_with_oob.html TemplateResponse when caller == 'printer_form' driver_upload_with_oob.html
from to via pattern
partials/driver_upload_with_oob.html printer_form.html #printer-form-driver-select hx-swap-oob="true" on <select id="printer-form-driver-select"> hx-swap-oob="true"
Fix the HTTP 500 on `POST /drivers/upload` (blocking UX-01), then add an inline driver upload form inside the printer form template that, on success, refreshes the driver `` via HTMX Out-of-Band swap and auto-selects the newly uploaded driver.

Purpose: Closes UX-01 — technicians uploading a driver while creating/editing a printer see it appear in the dropdown and get it auto-selected, no manual reload. Output: Green regression + OOB tests, a working inline upload control, and an OOB-capable upload handler.

<execution_context> @C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md @C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/09-ux-tech-debt-closure/09-CONTEXT.md @.planning/phases/09-ux-tech-debt-closure/09-RESEARCH.md @.planning/phases/09-ux-tech-debt-closure/09-VALIDATION.md @imptune/api/drivers.py @imptune/templates/partials/printer_form.html @imptune/templates/partials/driver_list.html @tests/test_driver_upload.py @tests/conftest.py

From imptune/api/drivers.py:

router = APIRouter(prefix="/drivers")  # mounted at /drivers in main.py
MAX_UPLOAD_BYTES = 100 * 1024 * 1024

def _error_response(message: str, status_code: int = 400) -> HTMLResponse: ...

@router.post("/upload", response_class=HTMLResponse)
def upload_driver(request: Request, file: UploadFile) -> HTMLResponse: ...
# Validates zip, parses INF via parse_inf, persists via DriverStore(_cfg.DRIVERS_DIR),
# upserts Driver via Driver.get_or_create(sha256=..., defaults={...}),
# returns TemplateResponse("partials/driver_list.html", {driver_data, parsed}).
# There is NO try/except around parse_inf / DriverStore.save / Driver.get_or_create —
# any of these can bubble into a FastAPI 500.

From imptune/db/models.Driver: fields include id, sha256, original_filename, driver_desc (JSON list), inf_filename, architecture, has_cat_file, uploaded_at.

From imptune/services/inf_parser: parse_inf(inf_text, inf_filename, zip_names) -> ParsedInf with attributes driver_names: list[str], inf_filename: str, architecture: str | None, has_cat_file: bool.

Current printer_form.html driver select (lines 28-39):

<label>
  Driver
  <select name="driver_id">        <!-- NO id attribute today -->
    <option value="">-- No driver --</option>
    {% for item in driver_data %}
    <option value="{{ item.driver.id }}"
            {% if printer and printer.driver_id == item.driver.id %}selected{% endif %}>
      {{ item.driver.original_filename }} ({{ item.names | join(', ') }})
    </option>
    {% endfor %}
  </select>
</label>

HTMX OOB contract: response body contains the primary swap fragment (targets #driver-list) PLUS one or more sibling elements with hx-swap-oob="true" whose id matches an element in the current page DOM. Out-of-band elements MUST be top-level in the response body (not nested inside the primary fragment).

Task 1: Wave 0 — write failing driver-upload regression + OOB contract tests tests/test_driver_upload.py - test_upload_500_regression: POST /drivers/upload with a valid driver ZIP fixture MUST return status_code != 500. Use the existing test fixture pattern from tests/conftest.py (tmp_data_dir) and the same synthetic driver ZIP builder already used in this test module if present; otherwise create `_make_driver_zip()` helper that writes a minimal valid INF + `.cat` file into a ZIP. Assertion: `assert resp.status_code == 200, resp.text`. - test_upload_returns_oob_when_called_from_form: POST /drivers/upload with multipart fields `{file: valid_zip, caller: "printer_form"}` MUST return 200 AND the response body MUST contain `hx-swap-oob="true"` AND `id="printer-form-driver-select"`. - test_upload_oob_autoselects_new_driver: Same call as above — response body MUST contain the newly created driver's `` inside the OOB ``. Parse the new id from the response or query the DB post-upload and assert the selected marker appears on that option. - test_upload_no_oob_from_standalone_drivers_page: POST /drivers/upload with `{file: valid_zip}` and NO `caller` field MUST return the existing `#driver-list` fragment and MUST NOT contain `hx-swap-oob`. This guards against OOB junk leaking into the standalone /drivers page. Open `tests/test_driver_upload.py`. Add the four test functions above following the existing httpx TestClient pattern (see `tests/conftest.py` for `client` fixture). Reuse any existing driver-zip helper in the module; if none exists, create `_make_driver_zip() -> bytes` that builds a minimal ZIP with a real `.inf` body (encoding utf-8) whose `[Version]` section declares `Signature="$Windows NT$"` and a single `[Strings]` entry so `parse_inf` returns at least one driver name. Include a `.cat` sibling so `has_cat_file` is True.
Run the tests. At least `test_upload_500_regression` MAY pass or fail depending on synthetic fixture vs. real-world root cause — if it still passes with a synthetic ZIP, ALSO add a parametrized variant that feeds a ZIP containing an INF with a BOM + Windows-1252 encoded `[Strings]` section (the most likely real-world repro per 09-RESEARCH.md pitfall 1). At least one variant MUST go red before proceeding to Task 2.

The other three OOB tests MUST go red — the current handler has no `caller` support and no OOB template.

Commit: `test(09-01): add failing driver upload 500 regression + OOB contract tests`
pytest tests/test_driver_upload.py::test_upload_500_regression tests/test_driver_upload.py::test_upload_returns_oob_when_called_from_form tests/test_driver_upload.py::test_upload_oob_autoselects_new_driver -x All four new tests exist in tests/test_driver_upload.py. At least one test is RED (the intended failure). Failing test output captured in commit message or task notes so Task 2 has the traceback. Task 2: Fix the 500 + extend upload_driver with caller-aware OOB branch imptune/api/drivers.py, imptune/templates/partials/driver_upload_with_oob.html, imptune/templates/partials/driver_list.html - All four tests from Task 1 MUST go GREEN. - `pytest tests/test_driver_upload.py -x` passes fully (no regressions on existing tests). - `pytest tests/ -x -q --ignore=tests/e2e` passes. Step 1 — Diagnose the 500 from the Task 1 red test traceback. Likely candidates per 09-RESEARCH.md pitfall 1: `parse_inf()` choking on encoding, `DriverStore.save()` on missing DRIVERS_DIR, or `Driver.get_or_create()` on constraint. Fix the SPECIFIC root cause only — do NOT wrap the whole handler in `try/except Exception`. If it's `parse_inf`, fix the parser; if it's `DriverStore.save`, ensure the dir exists before writing; if it's ORM, fix the field.
Step 2 — Add `caller: str = Form("")` parameter to `upload_driver(request, file, caller="")` (import `Form` from fastapi). FastAPI handles mixed multipart `UploadFile` + `Form` fields natively.

Step 3 — After the existing success path builds `driver_data`, capture `new_driver` from the `get_or_create` return tuple: `new_driver, _created = Driver.get_or_create(...)`. Currently the code discards this — fix it.

Step 4 — Branch on `caller`:
```python
if caller == "printer_form":
    return templates.TemplateResponse(
        request=request,
        name="partials/driver_upload_with_oob.html",
        context={"driver_data": driver_data, "new_driver_id": new_driver.id, "parsed": parsed},
    )
# else: existing behavior unchanged
return templates.TemplateResponse(
    request=request,
    name="partials/driver_list.html",
    context={"driver_data": driver_data, "parsed": parsed},
)
```

Step 5 — Create `imptune/templates/partials/driver_upload_with_oob.html`:
```jinja
{% include "partials/driver_list.html" %}

<select name="driver_id" id="printer-form-driver-select" hx-swap-oob="true">
  <option value="">-- No driver --</option>
  {% for item in driver_data %}
  <option value="{{ item.driver.id }}"
          {% if item.driver.id == new_driver_id %}selected{% endif %}>
    {{ item.driver.original_filename }} ({{ item.names | join(', ') }})
  </option>
  {% endfor %}
</select>
```

Step 6 — Inspect `partials/driver_list.html`. Confirm its root element has `id="driver-list"` (hx-target from the inline upload form will swap it). If the partial currently wraps itself differently, leave as-is; the OOB template simply includes it. Do NOT refactor driver_list.html unless necessary.

Step 7 — Run the failing tests. Iterate until GREEN. Then run full non-e2e suite.

Commit: `fix(09-01): resolve driver upload 500 and add HTMX OOB refresh path`
pytest tests/test_driver_upload.py -x -v && pytest tests/ -x -q --ignore=tests/e2e All Task 1 tests green. Full non-e2e suite green. `upload_driver` accepts a `caller` form field and returns OOB-enabled response only when caller=="printer_form". New partial file exists. Task 3: Wire inline driver upload form into printer_form.html imptune/templates/partials/printer_form.html, tests/test_printer_form.py - printer_form.html renders a separate inline `` OUTSIDE the main printer `` but inside the Alpine x-data wrapper div. - The driver `` has `id="printer-form-driver-select"` (required OOB target). - The inline upload form posts `caller=printer_form` as a hidden field and `file` as the upload. - `pytest tests/test_printer_form.py` passes (add one assertion: rendered HTML contains `id="printer-form-driver-select"` and contains `name="caller" value="printer_form"` and an inline `hx-post="/drivers/upload"` NOT nested inside ` Step 1 — Edit `imptune/templates/partials/printer_form.html`: - Add `id="printer-form-driver-select"` attribute to the existing `` (line 30). - AFTER the closing `` of the printer form (line 85) but BEFORE the closing `` of the x-data wrapper (line 86), add a separate inline upload form: ```html Upload New Driver Upload Driver ``` - CRITICAL: Do NOT nest this form inside the printer `` — HTML forbids nested forms and browsers silently drop the inner one. Place it as a sibling, still within the outer `
` so visual grouping and Alpine scope are preserved. - Also ensure `partials/driver_list.html` (or wherever the `#driver-list` anchor lives) is reachable from the page that renders printer_form.html. If the printer form page doesn't currently include a `
` anchor, add a hidden one next to the upload form: `
` so the primary HTMX swap target exists even on the printer form page. Alternatively render the full driver_list partial for visibility (preferred if space allows — shows technician the uploaded driver landed).
Step 2 — Extend `tests/test_printer_form.py` with `test_printer_form_has_inline_driver_upload`:
- GET the printer form route (`/printers/new` or the HTMX partial route used by the existing tests — match the existing pattern in this test file).
- Assert response body contains `id="printer-form-driver-select"`.
- Assert response body contains `name="caller"` with value `printer_form`.
- Assert response body contains `hx-post="/drivers/upload"`.
- Assert nested form check: the substring between `<form hx-post="/printers"` and its matching `</form>` does NOT contain `hx-post="/drivers/upload"` (naive check is fine: split on `</form>` and verify the printer form chunk is clean).

Step 3 — Run the test. GREEN.

Commit: `feat(09-01): add inline driver upload to printer form with OOB refresh`
pytest tests/test_printer_form.py -x -v && pytest tests/ -x -q --ignore=tests/e2e Printer form template contains stable-id driver select + sibling inline upload form with caller sentinel. test_printer_form.py guards the wiring. Full non-e2e suite green. - `pytest tests/ -x -q --ignore=tests/e2e` passes - Manual eye check (captured in 09-VALIDATION.md manual section): start app, open printer form, upload a real driver ZIP, confirm driver list refreshes AND the new driver becomes the selected option in the dropdown without a page reload

<success_criteria>

  • UX-01 observable truth #1 achieved: technician uploading a driver on the printer form sees new DriverDesc in the dropdown and auto-selected, no reload
  • No HTTP 500 from POST /drivers/upload for the captured repro case
  • All 4 new tests (500 regression, OOB contract, auto-select, no-oob-on-standalone) green </success_criteria>
After completion, create `.planning/phases/09-ux-tech-debt-closure/09-01-SUMMARY.md` documenting: actual root cause of the 500, files changed, test results, and link to commits.