24 KiB
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:
# 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:
<!-- 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:
# 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:
<!-- 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:
# 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:
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 defroute with Peewee calls: Peewee is synchronous; calling it in an async context blocks the event loop. Usedefhandlers. (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
DriverFK dropdown (parsed from storeddriver_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.clientaccesses.
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)
# 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)
# 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)
# 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)
# 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)
<!-- 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)
<!-- 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)
# 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 TABLEcalls needed.
Open Questions
-
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).
-
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.
-
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-10tests/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 inspectionimptune/db/database.py— init_db pattern, Peewee deferred init, WAL + foreign_keys verifiedimptune/api/drivers.py— HTMX partial swap, error fragment, sync handler, Jinja2 context patternsimptune/templates/— base.html, partials, Alpine.js availability verifiedrequirements.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/@inputpattern 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 Peeweeorder_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)