--- 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 CRUD tests/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 -30 All 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 routers 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_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`. `

Clients

`. - Form: `` with name input and submit button. - `
`: Table of clients (Name, Created, Printer Count). Printer count via `Client.printers` backref — pass pre-computed count from route. **8. Update `imptune/main.py`:** - Add imports: `from imptune.api import clients, printers` - Add: `app.include_router(printers.router)` and `app.include_router(clients.router)` After all files are created, run the full test suite to confirm GREEN state. cd C:/Users/SebastienQUEROL/Documents/projets/ImpTune && python -m pytest tests/test_printer_crud.py -x -q && python -m pytest tests/ -v All test_printer_crud.py tests pass (GREEN). Full test suite passes. GET /printers shows form with all fields. POST /printers creates and persists printer. Printers grouped by client name in list. GET /clients shows client list with creation form. Task 3: Verify printer form and Alpine.js port auto-derivation in browser imptune/templates/partials/printer_form.html Human verifies the complete printer configuration flow in a browser, especially the Alpine.js port auto-derivation (PRNT-03) which cannot be tested via pytest. What was built: Complete printer configuration form with Alpine.js port auto-derivation (PRNT-03), all form fields (PRNT-01 through PRNT-07), client assignment (PRNT-08), and persistence (PRNT-09). Also a /clients page for client management. Steps to verify: 1. Start app: `docker compose up` (or `uvicorn imptune.main:app --reload`) 2. Navigate to /clients — create a client "Contoso" 3. Navigate to /printers — verify empty state message 4. Fill in printer form: - Name: "HP LaserJet 4050" - IP: "192.168.1.100" — verify port name auto-fills to "IP_192_168_1_100" - Manually edit port name to "CUSTOM_PORT" — change IP to "10.0.0.1" — verify port stays "CUSTOM_PORT" (not overwritten) - Select duplex "Long Edge", uncheck Color, paper "Letter", check Collate - Select client "Contoso" - Click Save 5. Verify printer appears under "Contoso" group heading 6. Refresh page — verify printer still appears (persistence) 7. Click Delete on the printer — confirm deletion dialog — verify it disappears Human confirms all 7 steps pass in browser Alpine.js port auto-derivation works correctly: auto-fills from IP, preserves manual edits. Full CRUD flow verified visually. - `pytest tests/test_printer_crud.py -x -q` — all printer CRUD tests pass - `pytest tests/ -v` — full suite green (no regressions) - GET /printers renders form with all required fields - POST /printers persists to SQLite and returns updated list - Printers are grouped by client name in the list display - Alpine.js port derivation works in browser (manual checkpoint) - All PRNT-01 through PRNT-09 requirements verified by tests or manual check - Printer form has: name, IP, port (auto-derived), duplex select, color checkbox, paper select, collate checkbox, client select, driver select - Printer list groups by client with "Unassigned" fallback - Client CRUD works on /clients page - No N+1 queries (LEFT_OUTER JOIN used) - Full test suite green After completion, create `.planning/phases/03-printer-configuration/03-01-SUMMARY.md`