Files
2026-04-15 17:57:12 +02:00

417 lines
24 KiB
Markdown

# Phase 3: Printer Configuration - Research
**Researched:** 2026-04-10
**Domain:** FastAPI + Peewee ORM + HTMX + Alpine.js — form CRUD, client grouping, saved config retrieval
**Confidence:** HIGH
## Summary
Phase 3 builds on a fully functional Phase 2 stack: FastAPI 0.115, Peewee 3.17, Jinja2 3.1, HTMX, Alpine.js, Pico CSS. The Printer and Client ORM models are already created (from the Phase 1 schema spike) with every field the requirements specify: `name`, `ip_address`, `port_name`, `client` (FK), `driver` (FK), `duplex_mode`, `color_mode`, `paper_size`, `collate`. No schema changes are needed in Phase 3 — it is purely route + template + service work.
The key interaction patterns are already proven in Phase 2: HTMX `hx-post` / `hx-get` with `outerHTML` swaps for partial re-renders, Jinja2 partials for list fragments, Peewee sync queries in sync FastAPI route handlers (`def`, not `async def`), and the `client` fixture + `monkeypatch` pattern for integration tests.
The three planned sub-plans map cleanly to separable concerns: (1) printer CRUD with form validation, (2) Client CRUD and grouped display, (3) regeneration flow that re-uses the saved `driver` FK. No new libraries are required.
**Primary recommendation:** Re-use every established Phase 2 pattern exactly — HTMX partial swaps, Peewee `get_or_create`/`save()`, `json.dumps` for multi-value fields, sync route handlers, and the conftest `client` fixture.
<phase_requirements>
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|-----------------|
| PRNT-01 | User can set printer display name | `Printer.name = CharField()` already in models.py. Route validates non-empty. |
| PRNT-02 | User can set printer IP address or hostname | `Printer.ip_address = CharField()` already in models.py. Server-side regex validates format. |
| PRNT-03 | System auto-suggests port name from IP (user can override) | Alpine.js `x-model` / `@input` on IP field drives port field in-browser; field remains editable. |
| PRNT-04 | User can set duplex mode (one-sided, long-edge, short-edge) | `Printer.duplex_mode = CharField(default="OneSided")` already in models.py. `<select>` with three fixed options. |
| PRNT-05 | User can set color vs. grayscale default | `Printer.color_mode = BooleanField(default=True)` already in models.py. Radio buttons or checkbox. |
| PRNT-06 | User can set paper size (A4, Letter, Legal at minimum) | `Printer.paper_size = CharField(default="A4")` already in models.py. `<select>` with fixed options. |
| PRNT-07 | User can set collate on/off | `Printer.collate = BooleanField(default=True)` already in models.py. Checkbox. |
| PRNT-08 | User can assign printer to a client/tenant label | `Printer.client = ForeignKeyField(Client, null=True)` already in models.py. `<select>` of existing clients or inline creation. |
| PRNT-09 | Printer configurations are persisted in SQLite across sessions | Peewee `Printer.create()` / `Printer.save()` to existing DB. init_db() already creates table. |
| PRNT-10 | User can regenerate a package from saved config without re-uploading drivers | `Printer.driver` FK stores the original `Driver` record. Regeneration reads `DriverStore(sha256)` path. (Actual script generation is Phase 4; Phase 3 only ensures the association is persisted and displayable.) |
</phase_requirements>
## Standard Stack
### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| FastAPI | 0.115.* | HTTP routing, request parsing, response | Already in use; same patterns as Phase 2 |
| Peewee | 3.17.* | ORM for SQLite CRUD | Already in use; Printer/Client models pre-created |
| Jinja2 | 3.1.* | Server-side HTML templating | Already in use; base.html + partials pattern established |
| HTMX | baked-in static | Async partial HTML swaps without JS | Already in use; proven with driver upload flow |
| Alpine.js | baked-in static | Reactive in-browser logic (auto-port derivation) | Already in use; needed for PRNT-03 live field derivation |
| Pico CSS | baked-in static | Semantic HTML styling | Already in use |
### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| python-multipart | 0.0.9 | Form data parsing for FastAPI | Required for `Form(...)` parameters (already in requirements.txt) |
### Alternatives Considered
None — all decisions are locked by prior phases. Do not introduce new libraries.
**Installation:** No new packages required.
## Architecture Patterns
### Project Structure (additions only)
```
imptune/
├── api/
│ ├── drivers.py # existing
│ ├── pages.py # extend with /printers, /clients routes
│ └── printers.py # NEW — POST /printers, PUT /printers/{id}, DELETE /printers/{id}
│ └── clients.py # NEW — POST /clients (inline create for PRNT-08)
├── templates/
│ ├── printers.html # NEW — printer list page grouped by client
│ ├── partials/
│ │ ├── driver_list.html # existing
│ │ ├── printer_form.html # NEW — create/edit form fragment
│ │ ├── printer_list.html # NEW — grouped printer list fragment (HTMX target)
│ │ └── printer_row.html # NEW — single printer row (optional, for inline edit)
tests/
└── test_printer_crud.py # NEW — integration tests for printer CRUD
└── test_client_crud.py # NEW (or merged into test_printer_crud) — client creation tests
```
### Pattern 1: Sync Peewee Route Handler (established in Phase 2)
**What:** All Peewee queries run in `def` (sync) route handlers, never `async def`. FastAPI runs sync handlers in a threadpool automatically.
**When to use:** Every route that touches the DB.
**Example:**
```python
# Source: imptune/api/drivers.py (Phase 2 established pattern)
@router.post("/upload", response_class=HTMLResponse)
def upload_driver(request: Request, file: UploadFile) -> HTMLResponse:
# Peewee calls here — all sync
Driver.get_or_create(sha256=sha256, defaults={...})
```
### Pattern 2: HTMX Partial Swap (established in Phase 2)
**What:** Form submits to an API endpoint via `hx-post`; endpoint returns an HTML fragment that HTMX swaps into the target div using `outerHTML`.
**When to use:** Printer form submission (PRNT-09), client assignment, list refresh.
**Example:**
```html
<!-- Source: imptune/templates/drivers.html (Phase 2 established) -->
<form
hx-post="/printers"
hx-target="#printer-list"
hx-swap="outerHTML"
>
...
</form>
```
### Pattern 3: Error Fragment Response (established in Phase 2)
**What:** Validation errors return an `HTMLResponse` whose content is a div with `id="[target-id]"` so HTMX replaces the target area with the error message.
**When to use:** Any form validation failure in printer or client routes.
**Example:**
```python
# Source: imptune/api/drivers.py (Phase 2 established)
def _error_response(message: str, status_code: int = 400) -> HTMLResponse:
return HTMLResponse(
content=f"<div id='printer-list' class='error'><p>{message}</p></div>",
status_code=status_code,
)
```
### Pattern 4: Alpine.js Reactive Field (new in Phase 3, PRNT-03)
**What:** Alpine.js `x-data` component on the printer form watches the IP address field and derives a default port name. The port field remains user-editable (not disabled).
**When to use:** PRNT-03 auto-suggest port name from IP.
**Example:**
```html
<!-- Alpine.js already available in base.html as /static/alpine.min.js -->
<div x-data="{ ip: '', port: '' }" @input.debounce="port = port || ('IP_' + ip.replaceAll('.', '_'))">
<input type="text" name="ip_address" x-model="ip" placeholder="192.168.1.100" required>
<input type="text" name="port_name" x-model="port" placeholder="IP_192_168_1_100">
</div>
```
Note: The exact derivation logic should be `IP_` + IP with dots replaced by underscores — this matches the Windows standard `pnputil` port name format used in Phase 4 scripts.
### Pattern 5: Peewee FK Population for Dropdown (new in Phase 3)
**What:** Pass a list of all Client records to the printer form template. Render as `<select name="client_id">` with an "Unassigned" option (value="").
**When to use:** PRNT-08 client assignment dropdown.
**Example:**
```python
# In GET /printers/new or GET /printers/{id}/edit route
clients = list(Client.select().order_by(Client.name))
drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
return templates.TemplateResponse(
request=request,
name="partials/printer_form.html",
context={"clients": clients, "drivers": drivers, "printer": None},
)
```
### Pattern 6: Grouping Printers by Client in Template (new in Phase 3, PRNT-08)
**What:** Query all printers with their client FK resolved. In the route handler, group into a dict `{client_name: [printer, ...]}` before passing to template. Avoids N+1 queries.
**When to use:** Printers page list view.
**Example:**
```python
from collections import defaultdict
printers = list(Printer.select(Printer, Client).join(Client, JOIN.LEFT_OUTER).order_by(Client.name, Printer.name))
grouped = defaultdict(list)
for p in printers:
label = p.client.name if p.client_id else "Unassigned"
grouped[label].append(p)
```
### Anti-Patterns to Avoid
- **`async def` route with Peewee calls:** Peewee is synchronous; calling it in an async context blocks the event loop. Use `def` handlers. (Established decision from Phase 2.)
- **Schema changes in Phase 3:** The full 4-table schema was created in Phase 1. Do not call `db.create_tables()` for any new table — it does not exist in the schema.
- **Free-text driver name entry:** The driver selection on the printer form must use the `Driver` FK dropdown (parsed from stored `driver_desc`), not a text input. This preserves the Phase 2 guarantee (DRV-03).
- **Disabling the port name field:** PRNT-03 says "user can override" — keep the field editable. Alpine.js only pre-fills when the field is empty or when IP changes and port hasn't been manually set.
- **N+1 queries for printer list:** Use `Printer.select(Printer, Client).join(Client, JOIN.LEFT_OUTER)` for the grouped list, not a loop with individual `.client` accesses.
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Form field validation | Custom regex validator class | Peewee model constraints + FastAPI `Form(...)` validation | Peewee unique/null constraints enforce DB-level; FastAPI Form gives HTTP 422 on missing required fields |
| Client auto-create on printer save | Complex upsert logic | Separate POST /clients endpoint or inline create in printer route | Simpler and testable in isolation |
| Port name auto-derivation | Server-side computation on every request | Alpine.js `x-model` in-browser reactivity | PRNT-03 is a UX hint, not a business rule — belongs in the browser |
| Driver file re-upload for regeneration | New upload flow | `DriverStore.get_path(driver.sha256)` with stored FK | The sha256 FK already points to the persisted file on disk |
**Key insight:** Every data model needed for Phase 3 is already in SQLite. Phase 3 is purely UI wiring and route handlers — no new storage logic.
## Common Pitfalls
### Pitfall 1: Peewee ForeignKeyField Access Triggers N+1
**What goes wrong:** Template or route iterates printers and accesses `p.client.name` — each access fires a separate SELECT query.
**Why it happens:** Peewee lazy-loads FK relations by default.
**How to avoid:** Use `Printer.select(Printer, Client).join(Client, JOIN.LEFT_OUTER)` to fetch all data in one query. Pre-build a grouping dict in Python before passing to template.
**Warning signs:** Slow printer list page; SQL log shows many small SELECT queries per request.
### Pitfall 2: Alpine.js Port Derivation Overwrites User Edits
**What goes wrong:** User edits the port name field, then touches the IP field — Alpine.js replaces the edited port name.
**Why it happens:** If `x-model` on port always derives from IP, any IP input event overwrites.
**How to avoid:** Track whether the user has manually edited the port field. One approach: use a `portEdited` flag in `x-data`; set it on `@change` of the port input; only auto-derive when `!portEdited`.
**Warning signs:** PRNT-03 success criterion says "user can edit it" — test this explicitly.
### Pitfall 3: HTMX Target ID Mismatch on Error Response
**What goes wrong:** Error HTML fragment has a different `id` than the HTMX `hx-target`, so HTMX cannot swap it.
**Why it happens:** Error helper function uses a hardcoded ID that doesn't match the form's target.
**How to avoid:** Either pass the target ID as a parameter to `_error_response()`, or use a consistent convention (`printer-list` is always the target for the printer page).
**Warning signs:** Error messages not appearing after form submission; HTMX console warnings about missing targets.
### Pitfall 4: Printer Form Missing `driver_id` Dropdown Population
**What goes wrong:** Driver `<select>` on printer form shows no options because drivers list was not passed to template context.
**Why it happens:** GET /printers/new handler omits `drivers` from context.
**How to avoid:** Always pass `drivers` list to both create and edit form contexts. Build `driver_data` the same way as in `drivers.py` (parse `driver_desc` JSON into a list per driver).
**Warning signs:** Driver dropdown empty on new/edit printer form.
### Pitfall 5: PRNT-10 Scope Creep into Phase 4
**What goes wrong:** Implementing actual script/package regeneration (PowerShell generation) in Phase 3.
**Why it happens:** PRNT-10 says "regenerate its package" — but Phase 4 is the script generation phase.
**How to avoid:** Phase 3's scope for PRNT-10 is: (a) display saved printer config with all fields pre-populated, (b) show associated driver info, (c) provide a "Regenerate" button that will call the Phase 4 endpoint. The button can be disabled/placeholder in Phase 3. The plan 03-03 "Saved config retrieval and regeneration flow" is about navigation and form pre-population, not script generation.
**Warning signs:** Trying to write PowerShell templates in Phase 3.
### Pitfall 6: TestClient Lifespan Not Triggered
**What goes wrong:** Tests fail with "table does not exist" errors.
**Why it happens:** TestClient must be used as a context manager to trigger the FastAPI `lifespan` (which calls `init_db()`). Using `TestClient(app)` without `with` does not trigger lifespan.
**How to avoid:** Always use `with TestClient(app) as c:` — established and enforced in conftest.py's `client` fixture. All new tests should use the `client` fixture, not create their own TestClient.
**Warning signs:** `OperationalError: no such table: printer` in test output.
## Code Examples
### Create Printer (Peewee)
```python
# Pattern: use keyword args matching Printer model field names
# Source: Peewee 3.17 docs + established Phase 2 Driver.get_or_create pattern
from imptune.db.models import Printer, Client, Driver
printer = Printer.create(
name=name,
ip_address=ip,
port_name=port,
duplex_mode=duplex, # "OneSided" | "LongEdge" | "ShortEdge"
color_mode=color_mode, # bool
paper_size=paper_size, # "A4" | "Letter" | "Legal"
collate=collate, # bool
client=client_obj_or_none,
driver=driver_obj_or_none,
)
```
### Update Printer (Peewee)
```python
# Source: Peewee 3.17 — Model.save() with only_fields for efficiency
printer = Printer.get_by_id(printer_id)
printer.name = new_name
printer.ip_address = new_ip
# ... set other fields ...
printer.updated_at = datetime.utcnow()
printer.save()
```
### Query Printers Grouped by Client (Peewee)
```python
# Source: Peewee 3.17 JOIN pattern — avoids N+1
from collections import defaultdict
from peewee import JOIN
printers = list(
Printer.select(Printer, Client)
.join(Client, JOIN.LEFT_OUTER)
.order_by(Client.name.nulls_last(), Printer.name)
)
grouped: dict[str, list] = defaultdict(list)
for p in printers:
label = p.client.name if p.client_id else "Unassigned"
grouped[label].append(p)
```
### FastAPI Form Parsing (python-multipart)
```python
# Source: FastAPI docs — Form parameters
from fastapi import Form
@router.post("/printers", response_class=HTMLResponse)
def create_printer(
request: Request,
name: str = Form(...),
ip_address: str = Form(...),
port_name: str = Form(...),
duplex_mode: str = Form("OneSided"),
color_mode: bool = Form(True),
paper_size: str = Form("A4"),
collate: bool = Form(True),
client_id: int | None = Form(None),
driver_id: int | None = Form(None),
) -> HTMLResponse:
...
```
### Alpine.js Port Auto-Derivation (PRNT-03)
```html
<!-- Alpine.js available from /static/alpine.min.js (already in base.html) -->
<div x-data="{ ip: '', port: '', portEdited: false }">
<label>IP Address</label>
<input type="text" name="ip_address" x-model="ip"
@input="if (!portEdited) port = 'IP_' + ip.replaceAll('.', '_')"
required>
<label>Port Name</label>
<input type="text" name="port_name" x-model="port"
@change="portEdited = true"
@keydown="portEdited = true">
</div>
```
### HTMX Delete with Confirmation (optional, for plan 03-01)
```html
<!-- HTMX hx-confirm attribute prevents accidental deletes -->
<button
hx-delete="/printers/{{ printer.id }}"
hx-target="#printer-list"
hx-swap="outerHTML"
hx-confirm="Delete printer '{{ printer.name }}'?"
>
Delete
</button>
```
### Retrieve Driver Path for Regeneration (PRNT-10)
```python
# Source: imptune/storage/driver_store.py (Phase 2)
from imptune.storage.driver_store import DriverStore
import imptune.config as cfg
printer = Printer.get_by_id(printer_id)
if printer.driver_id:
store = DriverStore(cfg.DRIVERS_DIR)
driver_zip_path = store.get_path(printer.driver.sha256)
# driver_zip_path is the Path to the stored ZIP, available for Phase 4 script generation
```
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| `@app.on_event("startup")` | `asynccontextmanager lifespan` | FastAPI 0.93+ / Starlette 0.40+ | Use lifespan pattern, never on_event |
| `TemplateResponse("name", {"request": req})` positional dict | `TemplateResponse(request=req, name="name", context={})` kwargs | Starlette 0.40+ | Must use kwargs form |
| `async def` with Peewee | `def` (sync) handlers | Phase 2 decision | Peewee is sync; async would block event loop |
**Deprecated/outdated:**
- `@app.on_event`: Replaced by lifespan context manager (established in Plan 01-01).
- Schema changes in Phase 3: Full schema was created in Phase 1. No `CREATE TABLE` calls needed.
## Open Questions
1. **How many paper sizes beyond A4/Letter/Legal?**
- What we know: PRNT-06 says "A4, Letter, Legal at minimum"
- What's unclear: Should the `<select>` include A3, A5, Executive, etc.?
- Recommendation: Implement exactly A4, Letter, Legal for Phase 3 to match the requirement. More sizes can be added in Phase 4 or later without schema changes (it's a CharField).
2. **Inline client creation vs. separate /clients page**
- What we know: PRNT-08 requires client/tenant label assignment. Plan 03-02 covers "Client/tenant organization".
- What's unclear: Should creating a new client require navigating away from the printer form?
- Recommendation: Plan 03-02 creates both a /clients page (GET, POST for CRUD) and ensures the printer form's client dropdown refreshes. An HTMX-powered inline "Add new client" flow is a nice-to-have but not required — a separate /clients page first keeps the plan atomic.
3. **PRNT-10 exact scope boundary between Phase 3 and Phase 4**
- What we know: PRNT-10 says "regenerate its package without re-uploading drivers". Phase 4 generates the scripts. Phase 5 assembles the package.
- What's unclear: What should the "Regenerate" button do in Phase 3 before Phase 4 exists?
- Recommendation: Phase 3 implements the complete printer detail/edit page with a "Regenerate Package" button that is disabled (or links to a placeholder). The button will become functional when Phase 4's script generation endpoint exists. The Phase 3 obligation is that the printer config is fully retrievable and the driver FK is intact.
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Framework | pytest 8.x |
| Config file | none — discovered via standard pytest directory scan |
| Quick run command | `pytest tests/test_printer_crud.py -x -q` |
| Full suite command | `pytest tests/ -v` |
### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| PRNT-01 | POST /printers with valid name creates printer record | integration | `pytest tests/test_printer_crud.py::test_create_printer_persisted -x` | Wave 0 |
| PRNT-02 | POST /printers with valid IP creates printer record | integration | `pytest tests/test_printer_crud.py::test_create_printer_persisted -x` | Wave 0 |
| PRNT-03 | Alpine.js auto-populates port from IP in-browser | manual | Manual browser test: type IP, verify port auto-fills, verify it remains editable | n/a |
| PRNT-04 | POST /printers with duplex_mode stores correct value | integration | `pytest tests/test_printer_crud.py::test_create_printer_duplex -x` | Wave 0 |
| PRNT-05 | POST /printers with color_mode stores correct bool | integration | `pytest tests/test_printer_crud.py::test_create_printer_color_mode -x` | Wave 0 |
| PRNT-06 | POST /printers with paper_size stores correct value | integration | `pytest tests/test_printer_crud.py::test_create_printer_paper_size -x` | Wave 0 |
| PRNT-07 | POST /printers with collate stores correct bool | integration | `pytest tests/test_printer_crud.py::test_create_printer_collate -x` | Wave 0 |
| PRNT-08 | POST /printers with client_id assigns printer to client; GET /printers groups by client | integration | `pytest tests/test_printer_crud.py::test_printer_grouped_by_client -x` | Wave 0 |
| PRNT-09 | Created printer appears on GET /printers after creation | integration | `pytest tests/test_printer_crud.py::test_printer_survives_page_refresh -x` | Wave 0 |
| PRNT-10 | GET /printers/{id} shows driver info; driver file still accessible via DriverStore | integration | `pytest tests/test_printer_crud.py::test_printer_detail_shows_driver -x` | Wave 0 |
### Sampling Rate
- **Per task commit:** `pytest tests/test_printer_crud.py -x -q`
- **Per wave merge:** `pytest tests/ -v`
- **Phase gate:** Full suite green before `/gsd:verify-work`
### Wave 0 Gaps
- [ ] `tests/test_printer_crud.py` — covers PRNT-01 through PRNT-10
- [ ] `tests/test_client_crud.py` — covers Client creation, listing (can be merged into test_printer_crud.py if small)
*(conftest.py and framework already exist — no new infrastructure needed)*
## Sources
### Primary (HIGH confidence)
- `imptune/db/models.py` — Full Printer and Client schema already defined; field names and types verified by direct inspection
- `imptune/db/database.py` — init_db pattern, Peewee deferred init, WAL + foreign_keys verified
- `imptune/api/drivers.py` — HTMX partial swap, error fragment, sync handler, Jinja2 context patterns
- `imptune/templates/` — base.html, partials, Alpine.js availability verified
- `requirements.txt` — Exact library versions verified by direct inspection
### Secondary (MEDIUM confidence)
- Peewee 3.17 JOIN.LEFT_OUTER pattern — verified against Peewee changelog notes from known behavior in Phase 2 integration tests
- Alpine.js `x-data` / `x-model` / `@input` pattern for port derivation — standard Alpine.js 3.x reactive pattern; Alpine.js is already baked into static assets
### Tertiary (LOW confidence)
- `nulls_last()` availability on Peewee `order_by` — common Peewee pattern but not directly tested in Phase 2; fallback is Python-side sort if needed
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH — all libraries verified from requirements.txt and existing code
- Architecture: HIGH — all patterns verified from Phase 2 working code
- Pitfalls: HIGH — most derived from documented Phase 2 decisions in STATE.md and direct code inspection
- Validation architecture: HIGH — test framework and conftest already exist; only test file creation is needed
**Research date:** 2026-04-10
**Valid until:** 2026-05-10 (stack is stable; no fast-moving dependencies)