Files
ImpTune/imptune/api/web.py
T
kawaandClaude Opus 5 2c06806814 feat: driver rename, driver icons, web image + driver search
Driver rename and icons: `Driver.display_name` plus a `DriverIcon` table, both
global/shared like the `Driver` row they hang off, so a rename or an icon is
what every Owner sees. The rename/icon dialog keeps its forms as siblings
(nested forms are invalid HTML) and the icon routes return an `hx-swap-oob`
thumbnail refresh rather than re-rendering the table, which would tear the open
`<dialog>` out of the DOM.

Web image picker: `GET /web/images` renders a pickable grid for a printer or a
driver icon, with the search term prefilled from the entity name and editable.
Picking one downloads it server-side and normalizes it.

Driver download search: `GET /web/drivers` searches for a vendor-wide driver
(the term is rewritten into the vendor's real product name for 15 brands) or for
the exact model as typed. Links only — nothing is downloaded, and the fragment
says the results are unvetted.

Icon uploads no longer reject off-size or non-PNG files: `normalize_icon()`
letterboxes any decodable raster into a 256x256 PNG. An already-exact 256x256
PNG is returned byte-identical, because icon storage is content-addressed and
re-encoding would move the file on every save.

`fetch_image()` makes the request from the server, so `assert_fetchable()`
refuses any URL resolving to a private, loopback, or link-local address, and
re-runs on every redirect. ImpTune sits on the same LAN as the printers it
configures; an unguarded fetcher would be a port scanner for anyone who can
reach the UI.

DuckDuckGo is scraped, not called through an API — no key needed, but fragile,
so both search functions swallow parse failures and return [] instead of 500ing
a page. `WEB_SEARCH=false` disables every outbound request and hides the
controls, for air-gapped installs.

Also: one shared `Jinja2Templates` in `templating.py` instead of five per-router
instances, so a template global is declared once; `_add_missing_columns()` in
`database.py` adds new nullable columns to a pre-existing table, which
`create_tables(safe=True)` skips; `db_env` in test_db.py now closes its
connection on teardown, or the next test's ORM writes land in the previous
test's DB file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 10:44:47 +02:00

114 lines
4.1 KiB
Python

"""Web lookup routes — image picker and driver-download search.
Both return HTML fragments for HTMX, never JSON: the results are only ever
rendered into a page, and keeping the shaping server-side means the templates
own the "unverified source" warnings.
"""
from __future__ import annotations
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from imptune.db.models import Driver, Printer
from imptune.services import websearch
from imptune.templating import templates
router = APIRouter(prefix="/web")
MAX_RESULTS = 12
_TARGETS = ("printer", "driver")
def _notice(key: str, fallback: str, status_code: int = 200) -> HTMLResponse:
"""A one-line status fragment that still speaks French.
Server-rendered fragments are normally English-only, but these are the
everyday path (empty search box, disabled feature), so they go through the
i18n store like the rest of the picker — Alpine initializes the swapped-in
node, and the English body is the fallback if it does not.
"""
return HTMLResponse(
content=f"<p class='dim' x-data x-text=\"$store.i18n.t('{key}')\">{fallback}</p>",
status_code=status_code,
)
@router.get("/images", response_class=HTMLResponse)
def search_images(
request: Request,
q: str = "",
target: str = "printer",
id: int = 0,
) -> HTMLResponse:
"""Image results for `q`, each pickable as the icon of `target`/`id`.
`target` decides which POST endpoint the result buttons hit, so the picker
is the same partial for a printer icon and a driver icon.
"""
if not websearch.enabled():
return _notice("web_search_off", "Web lookups are disabled on this server.")
if target not in _TARGETS:
return _notice("unknown_target", "Unknown search target.", status_code=400)
# Confirm the caller may write to this entity before spending a search on
# it — an unowned printer id must not even reveal that it exists.
if target == "printer":
owned = Printer.get_or_none(
(Printer.id == id) & (Printer.owner == request.state.owner)
)
if owned is None:
return _notice("printer_not_found", "Printer not found.", status_code=404)
post_url = f"/printers/{id}/icon/from-web"
status_target = "#icon-status"
else:
if Driver.get_or_none(Driver.id == id) is None:
return _notice("driver_not_found", "Driver not found.", status_code=404)
post_url = f"/drivers/{id}/icon/from-web"
status_target = f"#driver-icon-status-{id}"
query = q.strip()
if not query:
return _notice("type_to_search_image", "Type something to search for.")
hits = websearch.search_images(query, limit=MAX_RESULTS)
return templates.TemplateResponse(
request=request,
name="partials/image_results.html",
context={
"hits": hits,
"query": query,
"post_url": post_url,
"status_target": status_target,
},
)
@router.get("/drivers", response_class=HTMLResponse)
def search_driver_pages(request: Request, q: str = "", mode: str = "generic") -> HTMLResponse:
"""Search the web for a driver download page.
`mode=generic` rewrites the term into the vendor's universal-driver product
name (HP UPD, Xerox Global, …); `mode=exact` searches the text as typed.
Results are plain links — nothing is downloaded, and the partial says so.
"""
if not websearch.enabled():
return _notice("web_search_off", "Web lookups are disabled on this server.")
typed = q.strip()
if not typed:
return _notice("type_to_search_driver", "Type a printer model or brand to search for.")
query = websearch.generic_driver_query(typed) if mode == "generic" else typed
hits = websearch.search_pages(query, limit=MAX_RESULTS)
return templates.TemplateResponse(
request=request,
name="partials/driver_search_results.html",
context={
"hits": hits,
"query": query,
"typed": typed,
"mode": mode,
"brand": websearch.detect_brand(typed),
},
)