diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 21d4f13..18a6ce9 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -60,12 +60,11 @@ Plans:
2. Saved printer appears under its client/tenant label after page refresh
3. System auto-populates the port name field from the entered IP address (user can edit it)
4. User can open a saved printer config and regenerate its package without uploading the driver again
-**Plans**: TBD
+**Plans**: 2 plans
Plans:
-- [ ] 03-01: Printer config form and CRUD API (all fields, validation, SQLite persistence)
-- [ ] 03-02: Client/tenant organization (label assignment, printer list grouped by client)
-- [ ] 03-03: Saved config retrieval and regeneration flow
+- [ ] 03-01-PLAN.md — Printer + client CRUD, form with all fields, Alpine.js port derivation, grouped list, integration tests
+- [ ] 03-02-PLAN.md — Printer detail page, saved config retrieval, driver association display, regeneration placeholder
### Phase 4: Script Generation
**Goal**: The system produces correct, production-ready PowerShell scripts that handle all Intune and RMM execution contexts
@@ -109,6 +108,6 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5
|-------|----------------|--------|-----------|
| 1. Foundation | 3/3 | Complete | 2026-04-10 |
| 2. Driver Management | 1/2 | In Progress| |
-| 3. Printer Configuration | 0/3 | Not started | - |
+| 3. Printer Configuration | 0/2 | Not started | - |
| 4. Script Generation | 0/3 | Not started | - |
| 5. Package Export | 0/3 | Not started | - |
diff --git a/.planning/phases/03-printer-configuration/03-01-PLAN.md b/.planning/phases/03-printer-configuration/03-01-PLAN.md
new file mode 100644
index 0000000..a24f4eb
--- /dev/null
+++ b/.planning/phases/03-printer-configuration/03-01-PLAN.md
@@ -0,0 +1,313 @@
+---
+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 `