---
phase: 03-printer-configuration
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- tests/test_printer_crud.py
- imptune/api/printers.py
- imptune/api/clients.py
- imptune/api/pages.py
- imptune/main.py
- imptune/templates/printers.html
- imptune/templates/clients.html
- imptune/templates/partials/printer_list.html
- imptune/templates/partials/printer_form.html
autonomous: false
requirements:
- PRNT-01
- PRNT-02
- PRNT-03
- PRNT-04
- PRNT-05
- PRNT-06
- PRNT-07
- PRNT-08
- PRNT-09
must_haves:
truths:
- "User can fill in a printer form with name, IP, port, duplex, color, paper size, collate and save it"
- "Port name auto-populates from IP address (user can still edit it)"
- "User can assign a printer to a client/tenant label"
- "Saved printer appears in a grouped list after page refresh"
- "User can create a new client from the clients page"
artifacts:
- path: "imptune/api/printers.py"
provides: "POST /printers endpoint with Form parsing and validation"
exports: ["router"]
- path: "imptune/api/clients.py"
provides: "POST /clients and GET /clients endpoints"
exports: ["router"]
- path: "imptune/templates/printers.html"
provides: "Printer list page grouped by client"
- path: "imptune/templates/partials/printer_form.html"
provides: "Printer create form with all fields, Alpine.js port derivation"
- path: "imptune/templates/partials/printer_list.html"
provides: "Grouped printer list fragment for HTMX swap"
- path: "tests/test_printer_crud.py"
provides: "Integration tests for PRNT-01 through PRNT-09"
key_links:
- from: "imptune/templates/partials/printer_form.html"
to: "/printers"
via: "hx-post form submission"
pattern: "hx-post.*printers"
- from: "imptune/api/printers.py"
to: "imptune/db/models.py"
via: "Printer.create() and Client.select()"
pattern: "Printer\\.create|Client\\.select"
- from: "imptune/main.py"
to: "imptune/api/printers.py"
via: "app.include_router(printers.router)"
pattern: "include_router.*printers"
---
Implement printer and client CRUD with full form, grouped list display, and persistence.
Purpose: This is the core of Phase 3 — technicians need to configure printer parameters, assign to clients, and see saved configs persist across sessions. All form fields (PRNT-01 through PRNT-07), client assignment (PRNT-08), and persistence (PRNT-09) are covered.
Output: Working /printers and /clients pages with HTMX-powered form submission, Alpine.js port auto-derivation, and grouped printer list.
@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md
@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/03-printer-configuration/03-RESEARCH.md
From imptune/db/models.py:
```python
class Client(BaseModel):
name = CharField(unique=True)
created_at = DateTimeField(default=datetime.utcnow)
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 list of driver names
inf_filename = CharField(null=True)
architecture = CharField(null=True)
has_cat_file = BooleanField(default=False)
class Printer(BaseModel):
name = CharField()
ip_address = CharField()
port_name = CharField()
client = ForeignKeyField(Client, null=True, backref="printers")
driver = ForeignKeyField(Driver, null=True, backref="printers")
duplex_mode = CharField(default="OneSided")
color_mode = BooleanField(default=True)
paper_size = CharField(default="A4")
collate = BooleanField(default=True)
created_at = DateTimeField(default=datetime.utcnow)
updated_at = DateTimeField(default=datetime.utcnow)
```
From imptune/api/drivers.py (established error pattern):
```python
def _error_response(message: str, status_code: int = 400) -> HTMLResponse:
return HTMLResponse(
content=f"
{message}
",
status_code=status_code,
)
```
From tests/conftest.py (test fixtures):
```python
@pytest.fixture
def client(tmp_data_dir):
from imptune.main import app
with TestClient(app) as c:
yield c
@pytest.fixture
def tmp_data_dir(tmp_path, monkeypatch):
data_dir = tmp_path / "data"
data_dir.mkdir()
monkeypatch.setenv("DATA_DIR", str(data_dir))
import imptune.config as cfg
cfg.DATA_DIR = str(data_dir)
cfg.DB_PATH = str(data_dir / "imptune.db")
cfg.DRIVERS_DIR = str(data_dir / "drivers")
return data_dir
```
From imptune/main.py (router registration pattern):
```python
app.include_router(health.router)
app.include_router(pages.router)
app.include_router(drivers.router)
```
Task 1: Write failing integration tests for printer and client CRUDtests/test_printer_crud.py
- test_create_printer_persisted: POST /printers with name="Test Printer", ip_address="192.168.1.100", port_name="IP_192_168_1_100" returns 200; GET /printers contains "Test Printer" (covers PRNT-01, PRNT-02, PRNT-09)
- test_create_printer_duplex: POST /printers with duplex_mode="LongEdge"; verify Printer record has duplex_mode="LongEdge" (covers PRNT-04)
- test_create_printer_color_mode: POST /printers with color_mode=false; verify Printer record has color_mode=False (covers PRNT-05)
- test_create_printer_paper_size: POST /printers with paper_size="Letter"; verify Printer record has paper_size="Letter" (covers PRNT-06)
- test_create_printer_collate: POST /printers with collate=false; verify Printer record has collate=False (covers PRNT-07)
- test_create_client: POST /clients with name="Contoso" returns 200; GET /clients contains "Contoso"
- test_printer_grouped_by_client: Create client "Contoso", POST /printers with client_id=contoso.id; GET /printers HTML contains "Contoso" as group header (covers PRNT-08)
- test_create_printer_missing_name: POST /printers with empty name returns 400 (validation)
- test_create_printer_invalid_ip: POST /printers with ip_address="" returns 400 (validation)
- test_delete_printer: POST /printers to create, then DELETE /printers/{id} returns 200; printer no longer in GET /printers
Create `tests/test_printer_crud.py` with all tests listed above. Use the existing `client` fixture from conftest.py (which provides TestClient with lifespan-triggered init_db). Follow the same pattern as `test_driver_upload.py`:
- Import `pytest` and use `client` fixture
- POST form data via `client.post("/printers", data={...})` (not JSON, form-encoded)
- POST client creation via `client.post("/clients", data={"name": "Contoso"})`
- For verifying DB state, import `Printer` and `Client` from `imptune.db.models` inside each test
- For grouped list test, check that GET /printers response HTML contains the client name in an `
` or `` header
- For boolean fields (color_mode, collate): HTML checkboxes send "on" when checked, nothing when unchecked. Use `data={"color_mode": ""}` for false and `data={"color_mode": "on"}` for true. Design tests accordingly.
- All tests should FAIL initially (routes don't exist yet). Run them to confirm RED state.
cd C:/Users/SebastienQUEROL/Documents/projets/ImpTune && python -m pytest tests/test_printer_crud.py -x -q 2>&1 | head -30All tests exist and fail with connection/404 errors (RED state). No test passes yet.Task 2: Implement printer and client CRUD routes, templates, and wire routersimptune/api/printers.py, imptune/api/clients.py, imptune/api/pages.py, imptune/main.py, imptune/templates/printers.html, imptune/templates/clients.html, imptune/templates/partials/printer_form.html, imptune/templates/partials/printer_list.html
**1. Create `imptune/api/clients.py`:**
- `router = APIRouter(prefix="/clients")`
- `POST /clients`: Accept `name: str = Form(...)`. Validate non-empty. Create `Client.create(name=name)`. Handle IntegrityError (duplicate name) with 400 error. Return redirect or HTMX partial.
- Follow established pattern: sync `def` handlers, Jinja2Templates from same path as drivers.py.
**2. Create `imptune/api/printers.py`:**
- `router = APIRouter(prefix="/printers")`
- `POST /printers`: Accept all form fields via `Form(...)`:
- `name: str = Form(...)` (required)
- `ip_address: str = Form(...)` (required)
- `port_name: str = Form(...)` (required)
- `duplex_mode: str = Form("OneSided")` — validate value in ("OneSided", "LongEdge", "ShortEdge")
- `color_mode: str = Form("")` — checkbox: "on" = True, "" = False. Convert: `bool(color_mode)`
- `paper_size: str = Form("A4")` — validate value in ("A4", "Letter", "Legal")
- `collate: str = Form("")` — same checkbox pattern as color_mode
- `client_id: str = Form("")` — empty string = None, otherwise int FK
- `driver_id: str = Form("")` — empty string = None, otherwise int FK
- Validate: name not empty, ip_address not empty. On failure return `_error_response(msg)` with `
` wrapper (same HTMX pattern as drivers.py).
- On success: `Printer.create(...)` with all fields. Return the updated printer list partial via `_render_printer_list(request)`.
- `DELETE /printers/{printer_id}`: Delete printer by ID. Return updated printer list partial.
- Helper `_render_printer_list(request)`: Query `Printer.select(Printer, Client).join(Client, JOIN.LEFT_OUTER).order_by(Client.name, Printer.name)`, group into `defaultdict(list)` by client name ("Unassigned" for null client_id), pass `grouped` to `partials/printer_list.html`.
- Helper `_error_response(message, status_code=400)`: Return `HTMLResponse(content=f"
{message}
", status_code=status_code)`.
**3. Update `imptune/api/pages.py`:**
Add two new page routes (import Client, Printer, Driver, json, JOIN from peewee):
- `GET /printers`: Render `printers.html` with `grouped` printers (same query as `_render_printer_list`), plus `clients` list and `driver_data` list for form dropdowns.
- `GET /clients`: Render `clients.html` with `clients = list(Client.select().order_by(Client.name))`.
**4. Create `imptune/templates/printers.html`:**
- Extends `base.html`. Contains:
- `
Printers
`
- Section with `
Add Printer
` containing `{% include "partials/printer_form.html" %}`
- Section with `
Printer Library
` containing `{% include "partials/printer_list.html" %}`
**5. Create `imptune/templates/partials/printer_form.html`:**
- Wrap in `
` for Alpine.js reactivity.
- Form with `hx-post="/printers"`, `hx-target="#printer-list"`, `hx-swap="outerHTML"`.
- Fields:
- Printer Name: ``
- IP Address: ``
- Port Name: `` (PRNT-03)
- Driver: ``
- Duplex Mode: ``
- Color Mode: `` (default checked = True)
- Paper Size: ``
- Collate: `` (default checked = True)
- Client: ``
- Submit button: ``
**6. Create `imptune/templates/partials/printer_list.html`:**
- `
`
- If grouped is empty: `
No printers configured yet.
`
- Else: for each `(client_name, printers)` in grouped.items(): `
{{ client_name }}
` then a `
` with columns: Name, IP, Driver, Duplex, Paper, Actions. Each row has a Delete button with `hx-delete="/printers/{{ p.id }}" hx-target="#printer-list" hx-swap="outerHTML" hx-confirm="Delete '{{ p.name }}'?"`.
**7. Create `imptune/templates/clients.html`:**
- Extends `base.html`. `