# 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 | 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. `` 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. ` ``` 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 `` 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
``` ### HTMX Delete with Confirmation (optional, for plan 03-01) ```html ``` ### 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 `