Commit initial

This commit is contained in:
2026-04-15 17:57:12 +02:00
parent 005d8e797e
commit 55516ee10f
269 changed files with 26854 additions and 0 deletions
@@ -0,0 +1,311 @@
---
phase: 02-driver-management
plan: "02"
type: execute
wave: 2
depends_on: ["02-01"]
files_modified:
- imptune/api/drivers.py
- imptune/api/pages.py
- imptune/main.py
- imptune/templates/drivers.html
- imptune/templates/partials/driver_list.html
- tests/test_driver_upload.py
autonomous: true
requirements: [DRV-01, DRV-03, DRV-04, DRV-05]
must_haves:
truths:
- "User can upload a ZIP file via the /drivers page and receive a success response"
- "After upload, the response contains a populated select dropdown with driver names from the INF"
- "Uploading a non-ZIP file or a ZIP with no INF returns a 400 error displayed in-page"
- "Uploaded driver file is persisted to DRIVERS_DIR via DriverStore (survives restart)"
- "Re-uploading the same ZIP does not create a duplicate Driver record (SHA256 dedup)"
- "Upload response shows count of unused files not referenced by the INF"
- "GET /drivers renders the drivers page with upload form and existing driver list"
artifacts:
- path: "imptune/api/drivers.py"
provides: "POST /drivers/upload endpoint returning HTMX partial"
exports: ["router"]
- path: "imptune/templates/drivers.html"
provides: "Drivers page with upload form and driver list container"
contains: "hx-post"
- path: "imptune/templates/partials/driver_list.html"
provides: "HTMX partial fragment with driver table and select dropdown"
contains: "<select"
- path: "tests/test_driver_upload.py"
provides: "Integration tests for upload endpoint and drivers page"
min_lines: 80
key_links:
- from: "imptune/api/drivers.py"
to: "imptune/services/inf_parser.py"
via: "import parse_inf, _detect_encoding"
pattern: "from imptune\\.services\\.inf_parser import"
- from: "imptune/api/drivers.py"
to: "imptune/storage/driver_store.py"
via: "DriverStore(DRIVERS_DIR).save(data)"
pattern: "DriverStore.*save"
- from: "imptune/api/drivers.py"
to: "imptune/db/models.py"
via: "Driver.get_or_create(sha256=...)"
pattern: "Driver\\.get_or_create"
- from: "imptune/templates/drivers.html"
to: "/drivers/upload"
via: "hx-post with multipart/form-data"
pattern: "hx-post.*drivers/upload"
- from: "imptune/main.py"
to: "imptune/api/drivers.py"
via: "app.include_router(drivers.router)"
pattern: "include_router.*drivers"
---
<objective>
Create the driver upload endpoint, drivers page, and HTMX-driven UI that lets technicians upload driver ZIPs, see parsed driver names in a dropdown, and view unused-file hints.
Purpose: This wires the INF parser (from plan 02-01) into a working upload flow with persistence and UI feedback. After this plan, the full DRV-01 through DRV-05 feature set is functional.
Output: Upload API endpoint, drivers page template, HTMX partial for driver list, integration tests.
</objective>
<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>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/02-driver-management/02-RESEARCH.md
@.planning/phases/02-driver-management/02-01-SUMMARY.md
<interfaces>
<!-- From Plan 02-01 (INF parser) -->
From imptune/services/inf_parser.py:
```python
from dataclasses import dataclass
@dataclass
class ParsedInf:
driver_names: list[str] # resolved DriverDesc values, deduplicated, sorted
inf_filename: str # which .inf file inside the ZIP
architecture: str | None # 'x64', 'x86', 'arm64', or None
has_cat_file: bool # whether a .cat file exists in the ZIP
unused_files: list[str] # ZIP members not referenced by the INF
def _detect_encoding(raw: bytes) -> str: ...
def parse_inf(inf_text: str, inf_filename: str, zip_names: list[str]) -> ParsedInf: ...
```
<!-- Existing Phase 1 interfaces -->
From imptune/config.py:
```python
DATA_DIR = os.environ.get("DATA_DIR", "/data")
DRIVERS_DIR = str(Path(DATA_DIR) / "drivers")
```
From imptune/storage/driver_store.py:
```python
class DriverStore:
def __init__(self, base_dir: str) -> None: ...
def save(self, data: bytes) -> str: ... # returns SHA256 hex
```
From imptune/db/models.py:
```python
class Driver(BaseModel):
sha256 = CharField(unique=True, index=True)
original_filename = CharField()
size_bytes = IntegerField()
uploaded_at = DateTimeField(default=datetime.utcnow)
driver_desc = CharField(null=True) # json.dumps(list) for multi-model
inf_filename = CharField(null=True)
architecture = CharField(null=True)
has_cat_file = BooleanField(default=False)
```
From imptune/main.py:
```python
app = FastAPI(title="ImpTune", lifespan=lifespan)
app.include_router(health.router)
app.include_router(pages.router)
# Add: app.include_router(drivers.router)
```
From imptune/api/pages.py:
```python
router = APIRouter()
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
```
From imptune/templates/base.html:
```html
<!-- Sidebar already has /drivers link -->
<li><a href="/drivers" ...>Drivers</a></li>
<!-- Content block: {% block content %}{% endblock %} -->
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Upload endpoint, drivers page route, and integration tests</name>
<files>imptune/api/drivers.py, imptune/api/pages.py, imptune/main.py, tests/test_driver_upload.py</files>
<behavior>
- test_drivers_page: GET /drivers returns 200 with HTML containing upload form (input type="file", hx-post="/drivers/upload")
- test_upload_valid_zip: POST /drivers/upload with a valid ZIP containing sample.inf returns 200, HTML contains driver name from INF
- test_upload_non_zip: POST /drivers/upload with a .txt file returns 400
- test_upload_no_inf: POST /drivers/upload with a ZIP containing no .inf returns 400
- test_upload_returns_select: POST /drivers/upload with valid ZIP returns HTML containing a select element with driver names as options
- test_driver_persisted: After upload, Driver.select().where(Driver.sha256==expected).count() == 1, and DriverStore file exists on disk
- test_dedup_upload: Uploading same ZIP twice creates only one Driver record
- test_unused_files_in_response: Upload a ZIP with an extra file not in INF text; response HTML contains "unused" or the count
</behavior>
<action>
**RED phase first:**
1. Create `tests/test_driver_upload.py` with all 8 integration tests. Tests use the `client` fixture from conftest.py. For test fixtures, create valid ZIP bytes in-memory using `zipfile.ZipFile(io.BytesIO(), 'w')`:
- Build a helper `_make_driver_zip(inf_content: str, extra_files: dict[str, bytes] = None) -> bytes` that creates a ZIP with the INF and optional extra files
- Use the same sample INF content from tests/fixtures/sample.inf (read it or inline it)
- For `test_upload_non_zip`, send raw text bytes with filename="test.zip"
- For `test_upload_no_inf`, create a ZIP with only a .txt file
- For `test_unused_files_in_response`, add a "readme.txt" to the ZIP that the INF does not reference
- All tests use `client.post("/drivers/upload", files={"file": ("driver.zip", zip_bytes, "application/zip")})`
- Import `Driver` from `imptune.db.models` and `init_db` from `imptune.db.database` for persistence checks. Call `init_db()` in tests that check DB state (the `client` fixture triggers lifespan which calls init_db).
2. Run `pytest tests/test_driver_upload.py -x` — all MUST FAIL. Commit: `test(02-02): add failing integration tests for driver upload`
**GREEN phase:**
3. Create `imptune/api/drivers.py`:
- `router = APIRouter(prefix="/drivers")`
- `templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))`
- `MAX_UPLOAD_BYTES = 100 * 1024 * 1024`
- `POST /upload` endpoint (sync def, not async — Peewee is sync):
- Read file bytes, validate size <= 100MB
- Validate filename ends with `.zip`
- Validate `zipfile.is_zipfile(io.BytesIO(data))`
- Open ZIP, validate no zip-slip paths (reject `..` or absolute paths)
- Find `.inf` files in namelist; raise 400 if none
- Prefer INF whose path contains `amd64`/`x64` if multiple exist; else first alphabetically
- Read INF bytes, detect encoding with `_detect_encoding()`, decode
- Call `parse_inf(inf_text, inf_filename, zip_names)`
- Save via `DriverStore(DRIVERS_DIR).save(data)`
- Upsert `Driver.get_or_create(sha256=sha256, defaults={...})` — store `json.dumps(parsed.driver_names)` in `driver_desc`
- Query all drivers: `Driver.select().order_by(Driver.uploaded_at.desc())`
- Return `templates.TemplateResponse(request=request, name="partials/driver_list.html", context={...})`
- On validation errors, return HTMX-friendly error: `HTMLResponse(content="<div id='driver-list' class='error'>Error message</div>", status_code=400)` — so HTMX can swap the error into the target area
4. Add GET /drivers route to `imptune/api/pages.py`:
```python
@router.get("/drivers", response_class=HTMLResponse)
def drivers_page(request: Request):
from imptune.db.models import Driver
drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
return templates.TemplateResponse(
request=request, name="drivers.html",
context={"drivers": drivers}
)
```
5. Register the drivers router in `imptune/main.py`:
- Add `from imptune.api import drivers` to imports
- Add `app.include_router(drivers.router)` after the pages router
6. Run `pytest tests/test_driver_upload.py -x` — all MUST PASS. Commit: `feat(02-02): add driver upload endpoint with INF parsing and dedup`
</action>
<verify>
<automated>pytest tests/test_driver_upload.py -v</automated>
</verify>
<done>All 8 integration tests pass. POST /drivers/upload accepts ZIPs, parses INFs, persists via DriverStore + Peewee, returns HTMX partial. GET /drivers renders the page. Error cases return 400.</done>
</task>
<task type="auto">
<name>Task 2: Drivers page template and HTMX partial</name>
<files>imptune/templates/drivers.html, imptune/templates/partials/driver_list.html</files>
<action>
1. Create `imptune/templates/partials/` directory (if not exists).
2. Create `imptune/templates/drivers.html` extending base.html:
```html
{% extends "base.html" %}
{% block content %}
<h1>Drivers</h1>
<section>
<h2>Upload Driver Package</h2>
<form
hx-post="/drivers/upload"
hx-encoding="multipart/form-data"
hx-target="#driver-list"
hx-swap="outerHTML"
hx-indicator="#upload-spinner"
>
<label for="driver-file">Driver Package (ZIP containing .inf + driver files)</label>
<input type="file" id="driver-file" name="file" accept=".zip" required>
<button type="submit">Upload</button>
<span id="upload-spinner" class="htmx-indicator" aria-busy="true">Uploading...</span>
</form>
</section>
<section>
<h2>Driver Library</h2>
<div id="driver-list">
{% include "partials/driver_list.html" %}
</div>
</section>
{% endblock %}
```
3. Create `imptune/templates/partials/driver_list.html`:
- Wrap everything in `<div id="driver-list">` (for HTMX outerHTML swap)
- If `drivers` list is empty, show "No drivers uploaded yet."
- If `drivers` exist, render a table with columns: Filename, Driver Name(s), Architecture, Uploaded, Unused Files
- For each driver, parse `driver.driver_desc` as JSON to get the list of driver names. Display as a `<select>` dropdown if multiple names, or plain text if single name. Use Jinja2: `{% set names = driver.driver_desc | tojson | default('[]') %}` — actually, since driver_desc is already a JSON string, parse it in template or pass parsed data from the route.
- Show unused files count if `parsed` context variable is available (on fresh upload): "N files may be unused" with a details/summary for the list
- For the "new_driver" highlight (if present in context), add a CSS class to indicate success
The partial must work both as an include (initial page load, no `parsed` variable) and as a standalone HTMX response (after upload, `parsed` available).
Template approach for driver names: In the route, pass `driver_names_map` — a dict mapping driver.id to the parsed list. Or simpler: add a property/method. Simplest approach for Jinja2: use a custom filter or pass a helper. Actually, simplest: in the route handler, build a list of dicts with pre-parsed data:
```python
import json
driver_data = []
for d in drivers:
names = json.loads(d.driver_desc) if d.driver_desc else []
driver_data.append({"driver": d, "names": names})
```
Pass `driver_data` to template. Template iterates `driver_data` and renders `item.names` as select options.
Update both the drivers.py upload endpoint AND the pages.py GET /drivers route to pass `driver_data` in this format.
4. Run full test suite to verify no regressions: `pytest tests/ -x -q`
</action>
<verify>
<automated>pytest tests/ -v</automated>
</verify>
<done>GET /drivers renders a page with upload form and driver table. After upload, HTMX swaps in updated driver list with select dropdown containing parsed driver names. Unused file count visible. All tests green.</done>
</task>
</tasks>
<verification>
```bash
pytest tests/ -v # Full suite green
pytest tests/test_driver_upload.py -v # All upload integration tests
pytest tests/test_inf_parser.py -v # All parser unit tests
```
</verification>
<success_criteria>
- POST /drivers/upload with valid driver ZIP returns 200 with HTML containing driver name dropdown
- POST /drivers/upload with invalid input returns 400 with clear error
- Driver records persisted in SQLite with json.dumps(driver_names) in driver_desc
- Driver files persisted in DRIVERS_DIR via SHA256 content-addressed storage
- Duplicate uploads produce no duplicate records
- GET /drivers renders upload form and existing driver list
- Unused files flagged in upload response
- All integration + unit tests pass, zero regressions
</success_criteria>
<output>
After completion, create `.planning/phases/02-driver-management/02-02-SUMMARY.md`
</output>