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:
@@ -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)
|
||||
def printer_detail(request: Request, printer_id: int):
|
||||
from imptune.db.models import Client, Driver, Icon, Printer
|
||||
|
||||
@@ -5,7 +5,7 @@ from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from peewee import JOIN
|
||||
|
||||
@@ -101,7 +101,7 @@ def create_printer(
|
||||
driver=driver_fk,
|
||||
)
|
||||
|
||||
return _render_printer_list(request)
|
||||
return RedirectResponse(url="/printers", status_code=303)
|
||||
|
||||
|
||||
@router.delete("/{printer_id}", response_class=HTMLResponse)
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
{% block content %}
|
||||
<h1>Printers</h1>
|
||||
|
||||
<section>
|
||||
<h2>Add Printer</h2>
|
||||
{% include "partials/printer_form.html" %}
|
||||
</section>
|
||||
<p><a href="/printers/new" role="button">Add Printer</a></p>
|
||||
|
||||
<section>
|
||||
<h2>Printer Library</h2>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Add Printer</h1>
|
||||
|
||||
<p><a href="/printers">← 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
@@ -11,10 +11,9 @@ from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
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
|
||||
|
||||
# FIXME: POST /printers will return 303 after Task 2 — update status_code assertion then
|
||||
resp = client.post(
|
||||
"/printers",
|
||||
data={
|
||||
@@ -22,8 +21,9 @@ def test_create_printer_persisted(client: TestClient) -> None:
|
||||
"ip_address": "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
|
||||
page = client.get("/printers")
|
||||
@@ -39,7 +39,6 @@ def test_create_printer_duplex(client: TestClient) -> None:
|
||||
"""POST /printers with duplex_mode=LongEdge persists correctly."""
|
||||
from imptune.db.models import Printer
|
||||
|
||||
# FIXME: POST /printers will return 303 after Task 2 — update status_code assertion then
|
||||
resp = client.post(
|
||||
"/printers",
|
||||
data={
|
||||
@@ -48,8 +47,9 @@ def test_create_printer_duplex(client: TestClient) -> None:
|
||||
"port_name": "IP_192_168_1_101",
|
||||
"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"))
|
||||
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."""
|
||||
from imptune.db.models import Printer
|
||||
|
||||
# FIXME: POST /printers will return 303 after Task 2 — update status_code assertion then
|
||||
resp = client.post(
|
||||
"/printers",
|
||||
data={
|
||||
@@ -69,8 +68,9 @@ def test_create_printer_color_mode(client: TestClient) -> None:
|
||||
"port_name": "IP_192_168_1_102",
|
||||
# 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"))
|
||||
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."""
|
||||
from imptune.db.models import Printer
|
||||
|
||||
# FIXME: POST /printers will return 303 after Task 2 — update status_code assertion then
|
||||
resp = client.post(
|
||||
"/printers",
|
||||
data={
|
||||
@@ -90,8 +89,9 @@ def test_create_printer_paper_size(client: TestClient) -> None:
|
||||
"port_name": "IP_192_168_1_103",
|
||||
"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"))
|
||||
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."""
|
||||
from imptune.db.models import Printer
|
||||
|
||||
# FIXME: POST /printers will return 303 after Task 2 — update status_code assertion then
|
||||
resp = client.post(
|
||||
"/printers",
|
||||
data={
|
||||
@@ -111,8 +110,9 @@ def test_create_printer_collate(client: TestClient) -> None:
|
||||
"port_name": "IP_192_168_1_104",
|
||||
# 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"))
|
||||
assert len(printers) == 1
|
||||
@@ -142,7 +142,6 @@ def test_printer_grouped_by_client(client: TestClient) -> None:
|
||||
contoso = contoso_list[0]
|
||||
|
||||
# Create printer assigned to that client
|
||||
# FIXME: POST /printers will return 303 after Task 2 — update status_code assertion then
|
||||
resp = client.post(
|
||||
"/printers",
|
||||
data={
|
||||
@@ -151,8 +150,9 @@ def test_printer_grouped_by_client(client: TestClient) -> None:
|
||||
"port_name": "IP_10_0_0_1",
|
||||
"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
|
||||
page = client.get("/printers")
|
||||
@@ -242,7 +242,6 @@ def test_delete_printer(client: TestClient) -> None:
|
||||
from imptune.db.models import Printer
|
||||
|
||||
# Create a printer
|
||||
# FIXME: POST /printers will return 303 after Task 2 — update status_code assertion then
|
||||
resp = client.post(
|
||||
"/printers",
|
||||
data={
|
||||
@@ -250,8 +249,9 @@ def test_delete_printer(client: TestClient) -> None:
|
||||
"ip_address": "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
|
||||
printers = list(Printer.select().where(Printer.name == "To Delete"))
|
||||
|
||||
+16
-21
@@ -3,44 +3,39 @@ from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
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"
|
||||
- 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
|
||||
html = resp.text
|
||||
|
||||
# Driver select has stable id for OOB swap target
|
||||
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"
|
||||
)
|
||||
|
||||
# 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, (
|
||||
"Inline upload form missing value=\"printer_form\" sentinel"
|
||||
'Inline upload form missing value="printer_form" sentinel'
|
||||
)
|
||||
|
||||
# Inline upload form targets /drivers/upload
|
||||
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.
|
||||
# Split on the printer form boundary and verify the upload endpoint is not inside it.
|
||||
# The printer form always starts with hx-post="/printers" and ends at its </form>.
|
||||
printer_form_start = html.find('hx-post="/printers"')
|
||||
assert printer_form_start != -1, "Printer form hx-post not found — template changed?"
|
||||
|
||||
# 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"
|
||||
# Main printer form uses plain POST (no HTMX) so browser follows 303 redirect
|
||||
assert 'action="/printers"' in html, (
|
||||
'Printer form must use action="/printers" (plain HTML form, not hx-post)'
|
||||
)
|
||||
assert 'method="post"' in html, (
|
||||
'Printer form must use method="post"'
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user