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>
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
"""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),
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user