feat(11-01): UIE-02 — dedicated Add Printer page at GET /printers/new

- Create imptune/templates/printers_new.html: full page extending base.html
  with plain <form action="/printers" method="post"> (no HTMX, browser follows 303)
  and inline driver upload sub-form
- Update imptune/templates/printers.html: remove inline Add Printer form,
  add <a href="/printers/new" role="button">Add Printer</a> link
- Add GET /printers/new route in imptune/api/pages.py (placed before /{printer_id})
- Change POST /printers to always return RedirectResponse(url='/printers', status_code=303)
- Update existing POST /printers tests to assert 303 + follow_redirects=False
- Update test_printer_form.py to check /printers/new instead of /printers (UIE-02 arch)
- UIE-02 tests GREEN; UIE-01/03 scaffolds remain RED (expected — not yet implemented)
This commit is contained in:
2026-04-15 11:03:41 +02:00
parent a02df7d292
commit 3d2cdc4e77
6 changed files with 151 additions and 42 deletions
+17
View File
@@ -85,6 +85,23 @@ def printers_page(request: Request):
) )
@router.get("/printers/new", response_class=HTMLResponse)
def printers_new_page(request: Request):
from imptune.db.models import Client, Driver
clients = list(Client.select().order_by(Client.name))
all_drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
driver_data = [
{"driver": d, "names": json.loads(d.driver_desc) if d.driver_desc else []}
for d in all_drivers
]
return templates.TemplateResponse(
request=request,
name="printers_new.html",
context={"clients": clients, "driver_data": driver_data},
)
@router.get("/printers/{printer_id}", response_class=HTMLResponse) @router.get("/printers/{printer_id}", response_class=HTMLResponse)
def printer_detail(request: Request, printer_id: int): def printer_detail(request: Request, printer_id: int):
from imptune.db.models import Client, Driver, Icon, Printer from imptune.db.models import Client, Driver, Icon, Printer
+2 -2
View File
@@ -5,7 +5,7 @@ from collections import defaultdict
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Form, Request from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from peewee import JOIN from peewee import JOIN
@@ -101,7 +101,7 @@ def create_printer(
driver=driver_fk, driver=driver_fk,
) )
return _render_printer_list(request) return RedirectResponse(url="/printers", status_code=303)
@router.delete("/{printer_id}", response_class=HTMLResponse) @router.delete("/{printer_id}", response_class=HTMLResponse)
+1 -4
View File
@@ -3,10 +3,7 @@
{% block content %} {% block content %}
<h1>Printers</h1> <h1>Printers</h1>
<section> <p><a href="/printers/new" role="button">Add Printer</a></p>
<h2>Add Printer</h2>
{% include "partials/printer_form.html" %}
</section>
<section> <section>
<h2>Printer Library</h2> <h2>Printer Library</h2>
+100
View File
@@ -0,0 +1,100 @@
{% extends "base.html" %}
{% block content %}
<h1>Add Printer</h1>
<p><a href="/printers">&larr; Back to Printer Library</a></p>
<div x-data="{ ip: '', port: '', portEdited: false }">
<form action="/printers" method="post">
<label>
Printer Name
<input type="text" name="name" placeholder="e.g. HP LaserJet 4050" required>
</label>
<label>
IP Address
<input type="text" name="ip_address"
x-model="ip"
@input="if (!portEdited) port = 'IP_' + ip.replaceAll('.', '_')"
placeholder="e.g. 192.168.1.100"
required>
</label>
<label>
Port Name
<input type="text" name="port_name"
x-model="port"
@change="portEdited = true"
@keydown="portEdited = true"
placeholder="e.g. IP_192_168_1_100">
</label>
<label>
Driver
<select name="driver_id" id="printer-form-driver-select">
<option value="">-- No driver --</option>
{% for item in driver_data %}
<option value="{{ item.driver.id }}">
{{ item.driver.original_filename }} ({{ item.names | join(', ') }})
</option>
{% endfor %}
</select>
</label>
<label>
Duplex Mode
<select name="duplex_mode">
<option value="OneSided" selected>One-Sided</option>
<option value="LongEdge">Long Edge</option>
<option value="ShortEdge">Short Edge</option>
</select>
</label>
<label>
<input type="checkbox" name="color_mode" value="on" checked>
Color Mode
</label>
<label>
Paper Size
<select name="paper_size">
<option value="A4" selected>A4</option>
<option value="Letter">Letter</option>
<option value="Legal">Legal</option>
</select>
</label>
<label>
<input type="checkbox" name="collate" value="on" checked>
Collate
</label>
<label>
Client
<select name="client_id">
<option value="">-- Unassigned --</option>
{% for c in clients %}
<option value="{{ c.id }}">{{ c.name }}</option>
{% endfor %}
</select>
</label>
<button type="submit">Save Printer</button>
</form>
<form hx-post="/drivers/upload"
hx-target="#driver-list"
hx-encoding="multipart/form-data"
hx-swap="outerHTML">
<input type="hidden" name="caller" value="printer_form">
<label>
Upload New Driver
<input type="file" name="file" accept=".zip" required>
</label>
<button type="submit" class="secondary">Upload Driver</button>
</form>
<div id="driver-list" style="display:none"></div>
</div>
{% endblock %}
+15 -15
View File
@@ -11,10 +11,9 @@ from fastapi.testclient import TestClient
def test_create_printer_persisted(client: TestClient) -> None: def test_create_printer_persisted(client: TestClient) -> None:
"""POST /printers with required fields returns 200; GET /printers contains printer name.""" """POST /printers with required fields returns 303; GET /printers contains printer name."""
from imptune.db.models import Printer from imptune.db.models import Printer
# FIXME: POST /printers will return 303 after Task 2 — update status_code assertion then
resp = client.post( resp = client.post(
"/printers", "/printers",
data={ data={
@@ -22,8 +21,9 @@ def test_create_printer_persisted(client: TestClient) -> None:
"ip_address": "192.168.1.100", "ip_address": "192.168.1.100",
"port_name": "IP_192_168_1_100", "port_name": "IP_192_168_1_100",
}, },
follow_redirects=False,
) )
assert resp.status_code == 200 assert resp.status_code == 303
# GET /printers page contains the printer name # GET /printers page contains the printer name
page = client.get("/printers") page = client.get("/printers")
@@ -39,7 +39,6 @@ def test_create_printer_duplex(client: TestClient) -> None:
"""POST /printers with duplex_mode=LongEdge persists correctly.""" """POST /printers with duplex_mode=LongEdge persists correctly."""
from imptune.db.models import Printer from imptune.db.models import Printer
# FIXME: POST /printers will return 303 after Task 2 — update status_code assertion then
resp = client.post( resp = client.post(
"/printers", "/printers",
data={ data={
@@ -48,8 +47,9 @@ def test_create_printer_duplex(client: TestClient) -> None:
"port_name": "IP_192_168_1_101", "port_name": "IP_192_168_1_101",
"duplex_mode": "LongEdge", "duplex_mode": "LongEdge",
}, },
follow_redirects=False,
) )
assert resp.status_code == 200 assert resp.status_code == 303
printers = list(Printer.select().where(Printer.name == "Duplex Printer")) printers = list(Printer.select().where(Printer.name == "Duplex Printer"))
assert len(printers) == 1 assert len(printers) == 1
@@ -60,7 +60,6 @@ def test_create_printer_color_mode(client: TestClient) -> None:
"""POST /printers with color_mode not sent (unchecked) sets color_mode=False.""" """POST /printers with color_mode not sent (unchecked) sets color_mode=False."""
from imptune.db.models import Printer from imptune.db.models import Printer
# FIXME: POST /printers will return 303 after Task 2 — update status_code assertion then
resp = client.post( resp = client.post(
"/printers", "/printers",
data={ data={
@@ -69,8 +68,9 @@ def test_create_printer_color_mode(client: TestClient) -> None:
"port_name": "IP_192_168_1_102", "port_name": "IP_192_168_1_102",
# color_mode intentionally omitted (unchecked checkbox) # color_mode intentionally omitted (unchecked checkbox)
}, },
follow_redirects=False,
) )
assert resp.status_code == 200 assert resp.status_code == 303
printers = list(Printer.select().where(Printer.name == "Mono Printer")) printers = list(Printer.select().where(Printer.name == "Mono Printer"))
assert len(printers) == 1 assert len(printers) == 1
@@ -81,7 +81,6 @@ def test_create_printer_paper_size(client: TestClient) -> None:
"""POST /printers with paper_size=Letter persists correctly.""" """POST /printers with paper_size=Letter persists correctly."""
from imptune.db.models import Printer from imptune.db.models import Printer
# FIXME: POST /printers will return 303 after Task 2 — update status_code assertion then
resp = client.post( resp = client.post(
"/printers", "/printers",
data={ data={
@@ -90,8 +89,9 @@ def test_create_printer_paper_size(client: TestClient) -> None:
"port_name": "IP_192_168_1_103", "port_name": "IP_192_168_1_103",
"paper_size": "Letter", "paper_size": "Letter",
}, },
follow_redirects=False,
) )
assert resp.status_code == 200 assert resp.status_code == 303
printers = list(Printer.select().where(Printer.name == "Letter Printer")) printers = list(Printer.select().where(Printer.name == "Letter Printer"))
assert len(printers) == 1 assert len(printers) == 1
@@ -102,7 +102,6 @@ def test_create_printer_collate(client: TestClient) -> None:
"""POST /printers with collate not sent (unchecked) sets collate=False.""" """POST /printers with collate not sent (unchecked) sets collate=False."""
from imptune.db.models import Printer from imptune.db.models import Printer
# FIXME: POST /printers will return 303 after Task 2 — update status_code assertion then
resp = client.post( resp = client.post(
"/printers", "/printers",
data={ data={
@@ -111,8 +110,9 @@ def test_create_printer_collate(client: TestClient) -> None:
"port_name": "IP_192_168_1_104", "port_name": "IP_192_168_1_104",
# collate intentionally omitted (unchecked checkbox) # collate intentionally omitted (unchecked checkbox)
}, },
follow_redirects=False,
) )
assert resp.status_code == 200 assert resp.status_code == 303
printers = list(Printer.select().where(Printer.name == "No Collate Printer")) printers = list(Printer.select().where(Printer.name == "No Collate Printer"))
assert len(printers) == 1 assert len(printers) == 1
@@ -142,7 +142,6 @@ def test_printer_grouped_by_client(client: TestClient) -> None:
contoso = contoso_list[0] contoso = contoso_list[0]
# Create printer assigned to that client # Create printer assigned to that client
# FIXME: POST /printers will return 303 after Task 2 — update status_code assertion then
resp = client.post( resp = client.post(
"/printers", "/printers",
data={ data={
@@ -151,8 +150,9 @@ def test_printer_grouped_by_client(client: TestClient) -> None:
"port_name": "IP_10_0_0_1", "port_name": "IP_10_0_0_1",
"client_id": str(contoso.id), "client_id": str(contoso.id),
}, },
follow_redirects=False,
) )
assert resp.status_code == 200 assert resp.status_code == 303
# GET /printers should show "Contoso" as a group header # GET /printers should show "Contoso" as a group header
page = client.get("/printers") page = client.get("/printers")
@@ -242,7 +242,6 @@ def test_delete_printer(client: TestClient) -> None:
from imptune.db.models import Printer from imptune.db.models import Printer
# Create a printer # Create a printer
# FIXME: POST /printers will return 303 after Task 2 — update status_code assertion then
resp = client.post( resp = client.post(
"/printers", "/printers",
data={ data={
@@ -250,8 +249,9 @@ def test_delete_printer(client: TestClient) -> None:
"ip_address": "192.168.1.200", "ip_address": "192.168.1.200",
"port_name": "IP_192_168_1_200", "port_name": "IP_192_168_1_200",
}, },
follow_redirects=False,
) )
assert resp.status_code == 200 assert resp.status_code == 303
# Find its ID # Find its ID
printers = list(Printer.select().where(Printer.name == "To Delete")) printers = list(Printer.select().where(Printer.name == "To Delete"))
+16 -21
View File
@@ -3,44 +3,39 @@ from fastapi.testclient import TestClient
def test_printer_form_has_inline_driver_upload(client: TestClient) -> None: def test_printer_form_has_inline_driver_upload(client: TestClient) -> None:
"""GET /printers renders printer form with: """GET /printers/new renders printer form with:
- Driver <select> with stable id="printer-form-driver-select" - Driver <select> with stable id="printer-form-driver-select"
- Inline upload form with caller=printer_form hidden field - Inline upload form with caller=printer_form hidden field
- hx-post="/drivers/upload" NOT nested inside the printer <form> - hx-post="/drivers/upload" for the driver upload sub-form
- Plain <form action="/printers" method="post"> for the printer (no HTMX on main form)
Updated in 11-01: form moved from /printers to /printers/new (UIE-02).
""" """
resp = client.get("/printers") resp = client.get("/printers/new")
assert resp.status_code == 200 assert resp.status_code == 200
html = resp.text html = resp.text
# Driver select has stable id for OOB swap target # Driver select has stable id for OOB swap target
assert 'id="printer-form-driver-select"' in html, ( assert 'id="printer-form-driver-select"' in html, (
"Printer form driver <select> must have id=\"printer-form-driver-select\" " 'Printer form driver <select> must have id="printer-form-driver-select" '
"for HTMX OOB swap to work" "for HTMX OOB swap to work"
) )
# Inline upload form posts caller=printer_form sentinel # Inline upload form posts caller=printer_form sentinel
assert 'name="caller"' in html, "Inline upload form missing name=\"caller\" field" assert 'name="caller"' in html, 'Inline upload form missing name="caller" field'
assert 'value="printer_form"' in html, ( assert 'value="printer_form"' in html, (
"Inline upload form missing value=\"printer_form\" sentinel" 'Inline upload form missing value="printer_form" sentinel'
) )
# Inline upload form targets /drivers/upload # Inline upload form targets /drivers/upload
assert 'hx-post="/drivers/upload"' in html, ( assert 'hx-post="/drivers/upload"' in html, (
"Inline upload form missing hx-post=\"/drivers/upload\"" 'Inline upload form missing hx-post="/drivers/upload"'
) )
# CRITICAL: hx-post="/drivers/upload" must NOT be nested inside the printer form. # Main printer form uses plain POST (no HTMX) so browser follows 303 redirect
# Split on the printer form boundary and verify the upload endpoint is not inside it. assert 'action="/printers"' in html, (
# The printer form always starts with hx-post="/printers" and ends at its </form>. 'Printer form must use action="/printers" (plain HTML form, not hx-post)'
printer_form_start = html.find('hx-post="/printers"') )
assert printer_form_start != -1, "Printer form hx-post not found — template changed?" assert 'method="post"' in html, (
'Printer form must use method="post"'
# Find the first </form> after the printer form opening tag
printer_form_end = html.find("</form>", printer_form_start)
assert printer_form_end != -1, "Printer form closing </form> not found"
printer_form_chunk = html[printer_form_start:printer_form_end]
assert 'hx-post="/drivers/upload"' not in printer_form_chunk, (
"hx-post=\"/drivers/upload\" must NOT be nested inside the printer <form> — "
"HTML forbids nested forms and browsers silently ignore the inner one"
) )