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

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 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)

# 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 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)