docs(02-driver-management): create phase plan
Two plans: INF parser TDD (wave 1), upload endpoint + UI (wave 2). Covers DRV-01 through DRV-05. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user