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:
2026-08-05 10:44:47 +02:00
co-authored by Claude Opus 5
parent 3f9cd1f266
commit 2c06806814
34 changed files with 2516 additions and 162 deletions
+1 -7
View File
@@ -1,21 +1,15 @@
"""Client CRUD API — POST /clients, GET /clients."""
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from peewee import IntegrityError
from imptune.db.models import Client
from imptune.templating import templates
router = APIRouter(prefix="/clients")
templates = Jinja2Templates(
directory=str(Path(__file__).parent.parent / "templates")
)
def _error_response(message: str, status_code: int = 400) -> HTMLResponse:
"""Return an HTMX-friendly error fragment swapped into #client-list."""
+143 -15
View File
@@ -1,27 +1,31 @@
"""Driver upload API — POST /drivers/upload."""
"""Driver API — upload, rename, and driver-library icons."""
from __future__ import annotations
import io
import json
import zipfile
from pathlib import Path
from fastapi import APIRouter, Form, Request, UploadFile
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from fastapi.responses import FileResponse, HTMLResponse, Response
import imptune.config as _cfg
from imptune.db.models import Driver
from imptune.api.pages import build_driver_data, printer_counts_by_driver
from imptune.db.models import Driver, DriverIcon
from imptune.services import websearch
from imptune.services.icons import (
MAX_ICON_BYTES,
ImageError,
IconTooLarge,
icon_path,
set_driver_icon,
)
from imptune.services.inf_parser import _detect_encoding, parse_inf
from imptune.storage.driver_store import DriverStore
from imptune.templating import templates
router = APIRouter(prefix="/drivers")
templates = Jinja2Templates(
directory=str(Path(__file__).parent.parent / "templates")
)
MAX_UPLOAD_BYTES = 100 * 1024 * 1024 # 100 MB
MAX_DISPLAY_NAME = 120
def _error_response(message: str, status_code: int = 400) -> HTMLResponse:
@@ -32,6 +36,19 @@ def _error_response(message: str, status_code: int = 400) -> HTMLResponse:
)
def _render_driver_list(request: Request, **extra) -> HTMLResponse:
"""Re-render the whole `#driver-list` table — the target every form swaps."""
drivers = Driver.select().order_by(Driver.uploaded_at.desc())
context = {
"driver_data": build_driver_data(drivers),
"usage": printer_counts_by_driver(request.state.owner),
}
context.update(extra)
return templates.TemplateResponse(
request=request, name="partials/driver_list.html", context=context
)
@router.post("/upload", response_class=HTMLResponse)
def upload_driver(
request: Request,
@@ -103,11 +120,7 @@ def upload_driver(
)
# Build driver_data for template
drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
driver_data = []
for d in drivers:
names = json.loads(d.driver_desc) if d.driver_desc else []
driver_data.append({"driver": d, "names": names})
driver_data = build_driver_data(Driver.select().order_by(Driver.uploaded_at.desc()))
# When called from the printer form, emit primary fragment + OOB select refresh
if caller == "printer_form":
@@ -128,5 +141,120 @@ def upload_driver(
context={
"driver_data": driver_data,
"parsed": parsed,
"usage": printer_counts_by_driver(request.state.owner),
},
)
@router.patch("/{driver_id}", response_class=HTMLResponse)
def rename_driver(
request: Request, driver_id: int, display_name: str = Form("")
) -> HTMLResponse:
"""Set or clear a driver's display name.
Drivers are global/shared, so this rename is what every Owner sees — the
same as the driver row itself. An empty value clears the rename and the
listing falls back to the ZIP filename.
"""
driver = Driver.get_or_none(Driver.id == driver_id)
if driver is None:
return _error_response(f"Driver {driver_id} not found.", status_code=404)
name = display_name.strip()
if len(name) > MAX_DISPLAY_NAME:
return _error_response(
f"Name must be at most {MAX_DISPLAY_NAME} characters."
)
driver.display_name = name or None
driver.save()
return _render_driver_list(request)
def _driver_icon_status(driver_id: int, sha256: str) -> HTMLResponse:
"""Inline confirmation for the rename/icon dialog, plus an OOB row refresh.
The dialog stays open after picking an icon, so the thumbnail in the table
row behind it is swapped out of band instead of re-rendering the table and
tearing the open `<dialog>` out of the DOM.
"""
src = f"/drivers/{driver_id}/icon?v={sha256[:8]}"
return HTMLResponse(
content=(
'<p class="ok-note" x-data x-text="$store.i18n.t(\'icon_uploaded\')">'
"Icon saved</p>"
f'<div class="icon-preview"><img src="{src}" width="56" height="56" alt="">'
'<span class="meta">256&times;256 PNG</span></div>'
f'<span id="driver-thumb-{driver_id}" class="driver-thumb" hx-swap-oob="true">'
f'<img src="{src}" width="24" height="24" alt=""></span>'
),
status_code=200,
)
def _icon_error(message: str, status_code: int = 422) -> HTMLResponse:
return HTMLResponse(
content=f"<p class='error-note'>{message}</p>", status_code=status_code
)
@router.post("/{driver_id}/icon", response_class=HTMLResponse)
def upload_driver_icon(
request: Request, driver_id: int, file: UploadFile
) -> HTMLResponse:
"""Attach an icon to a driver. Normalized to 256x256 PNG like printer icons."""
if Driver.get_or_none(Driver.id == driver_id) is None:
return _icon_error("Driver not found.", status_code=404)
data = file.file.read(MAX_ICON_BYTES + 1)
try:
icon = set_driver_icon(driver_id, data, file.filename or "icon.png")
except IconTooLarge as exc:
return _icon_error(str(exc))
except ImageError:
return _icon_error("That file is not a readable image.")
return _driver_icon_status(driver_id, icon.sha256)
@router.post("/{driver_id}/icon/from-web", response_class=HTMLResponse)
def driver_icon_from_web(
request: Request, driver_id: int, url: str = Form(...)
) -> HTMLResponse:
"""Download a search-result image server-side and use it as the driver icon."""
if Driver.get_or_none(Driver.id == driver_id) is None:
return _icon_error("Driver not found.", status_code=404)
try:
data = websearch.fetch_image(url)
except websearch.WebSearchError as exc:
return _icon_error(str(exc), status_code=400)
try:
icon = set_driver_icon(
driver_id, data, url.rsplit("/", 1)[-1][:120] or "web.png"
)
except IconTooLarge as exc:
return _icon_error(str(exc))
except ImageError:
return _icon_error("That URL did not return a readable image.")
return _driver_icon_status(driver_id, icon.sha256)
@router.get("/{driver_id}/icon")
def get_driver_icon(driver_id: int) -> Response:
"""Serve a driver icon. Not owner-scoped — the driver library is shared."""
icon = DriverIcon.get_or_none(DriverIcon.driver == driver_id)
if icon is None:
return Response(status_code=404)
path = icon_path(icon.sha256)
if not path.exists():
return Response(status_code=404)
return FileResponse(
path,
media_type="image/png",
headers={"Cache-Control": "private, max-age=300"},
)
+75 -81
View File
@@ -1,99 +1,96 @@
"""Icon upload API — POST /printers/{printer_id}/icon."""
"""Printer icon API — upload, fetch-from-web, and serve."""
from __future__ import annotations
import hashlib
import io
from pathlib import Path
from fastapi import APIRouter, Request, UploadFile
from fastapi import APIRouter, Form, Request, UploadFile
from fastapi.responses import FileResponse, HTMLResponse, Response
from PIL import Image
import imptune.config as cfg
from imptune.db.models import Icon, Printer
from imptune.services import websearch
from imptune.services.icons import (
MAX_ICON_BYTES,
ImageError,
IconTooLarge,
icon_path,
set_printer_icon,
)
router = APIRouter(prefix="/printers")
MAX_ICON_BYTES = 750 * 1024 # 750 KB
def _status_fragment(printer_id: int, sha256: str) -> HTMLResponse:
"""The `#icon-status` block, re-rendered after a successful save.
`?v=` busts the 5-minute private cache the GET route sets — without it the
preview keeps showing the icon that was just replaced.
"""
return HTMLResponse(
content=(
# Swapped-in fragments are normally English-only; this one is on the
# everyday path, so it goes through the i18n store with the English
# text as its fallback body.
'<p class="ok-note" x-data x-text="$store.i18n.t(\'icon_uploaded\')">'
"Icon uploaded successfully</p>"
f'<div class="icon-preview"><img src="/printers/{printer_id}/icon?v={sha256[:8]}"'
' width="56" height="56" alt="">'
'<span class="meta">256&times;256 PNG</span></div>'
),
status_code=200,
)
def _error(message: str, status_code: int = 422) -> HTMLResponse:
return HTMLResponse(content=f"<p class='error-note'>{message}</p>", status_code=status_code)
def _owned_printer(request: Request, printer_id: int) -> Printer | None:
return Printer.get_or_none(
(Printer.id == printer_id) & (Printer.owner == request.state.owner)
)
@router.post("/{printer_id}/icon", response_class=HTMLResponse)
def upload_icon(request: Request, printer_id: int, file: UploadFile) -> HTMLResponse:
"""Accept a printer icon PNG, validate it, store it, and update the Icon record.
"""Accept a printer icon, normalize it to 256x256 PNG, and store it.
Validation rules:
- Format: PNG only
- Dimensions: exactly 256x256 pixels
- Size: at most 750 KB
Replaces any previously uploaded icon for this printer.
Returns an HTMX-friendly HTML fragment.
Any raster Pillow can decode is accepted and letterboxed into the box —
only an unreadable file or one over 750 KB is rejected. Replaces the
printer's previous icon.
"""
# Check printer exists and belongs to this owner
printer = Printer.get_or_none(
(Printer.id == printer_id) & (Printer.owner == request.state.owner)
)
if printer is None:
return HTMLResponse(
content="<p>Printer not found.</p>",
status_code=404,
)
if _owned_printer(request, printer_id) is None:
return HTMLResponse(content="<p>Printer not found.</p>", status_code=404)
# Read file (read one byte extra to detect oversized files)
data = file.file.read(MAX_ICON_BYTES + 1)
if len(data) > MAX_ICON_BYTES:
return HTMLResponse(
content="<p>Icon exceeds 750 KB limit.</p>",
status_code=422,
)
# Validate with Pillow
try:
img = Image.open(io.BytesIO(data))
except Exception:
return HTMLResponse(
content="<p>Icon must be PNG format.</p>",
status_code=422,
)
icon = set_printer_icon(printer_id, data, file.filename or "icon.png")
except IconTooLarge as exc:
return _error(str(exc))
except ImageError:
return _error("That file is not a readable image.")
if img.format != "PNG":
return HTMLResponse(
content="<p>Icon must be PNG format.</p>",
status_code=422,
)
return _status_fragment(printer_id, icon.sha256)
if img.size != (256, 256):
return HTMLResponse(
content=f"<p>Icon must be 256x256 pixels, got {img.size}.</p>",
status_code=422,
)
# Store SHA256-addressed on disk. cfg.ICONS_DIR is the same path the
# .intunewin export reads from — deriving it a second time from DATA_DIR
# would silently diverge if the layout ever changes.
sha256 = hashlib.sha256(data).hexdigest()
icons_dir = Path(cfg.ICONS_DIR)
icons_dir.mkdir(parents=True, exist_ok=True)
icon_path = icons_dir / sha256
icon_path.write_bytes(data)
@router.post("/{printer_id}/icon/from-web", response_class=HTMLResponse)
def upload_icon_from_web(
request: Request, printer_id: int, url: str = Form(...)
) -> HTMLResponse:
"""Download a search-result image server-side and use it as the icon."""
if _owned_printer(request, printer_id) is None:
return HTMLResponse(content="<p>Printer not found.</p>", status_code=404)
# Replace existing Icon record for this printer
Icon.delete().where(Icon.printer == printer_id).execute()
Icon.create(
printer=printer_id,
sha256=sha256,
original_filename=file.filename or "icon.png",
size_bytes=len(data),
)
try:
data = websearch.fetch_image(url)
except websearch.WebSearchError as exc:
return _error(str(exc), status_code=400)
return HTMLResponse(
content=(
'<p class="ok-note">Icon uploaded successfully</p>'
f'<div class="icon-preview"><img src="/printers/{printer_id}/icon?v={sha256[:8]}"'
f' width="56" height="56" alt=""><span class="meta">{img.size[0]}&times;{img.size[1]} PNG</span></div>'
),
status_code=200,
)
try:
icon = set_printer_icon(printer_id, data, url.rsplit("/", 1)[-1][:120] or "web.png")
except IconTooLarge as exc:
return _error(str(exc))
except ImageError:
return _error("That URL did not return a readable image.")
return _status_fragment(printer_id, icon.sha256)
@router.get("/{printer_id}/icon")
@@ -102,22 +99,19 @@ def get_icon(request: Request, printer_id: int) -> Response:
Owner-scoped: a printer belonging to another owner reads as missing.
"""
printer = Printer.get_or_none(
(Printer.id == printer_id) & (Printer.owner == request.state.owner)
)
if printer is None:
if _owned_printer(request, printer_id) is None:
return Response(status_code=404)
icon = Icon.get_or_none(Icon.printer == printer_id)
if icon is None:
return Response(status_code=404)
icon_path = Path(cfg.ICONS_DIR) / icon.sha256
if not icon_path.exists():
path = icon_path(icon.sha256)
if not path.exists():
return Response(status_code=404)
return FileResponse(
icon_path,
path,
media_type="image/png",
headers={"Cache-Control": "private, max-age=300"},
)
+35 -14
View File
@@ -1,17 +1,14 @@
import json
from collections import defaultdict
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from peewee import JOIN, fn
from imptune.templating import templates
router = APIRouter()
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
# Group label for printers with no client — the template translates it.
UNASSIGNED_GROUP = "Unassigned"
@@ -57,12 +54,31 @@ def group_printers_by_client(printers) -> dict[str, list]:
return ordered
def _driver_data(drivers) -> list[dict]:
"""Attach the parsed driver names to each Driver row for template use."""
return [
{"driver": d, "names": json.loads(d.driver_desc) if d.driver_desc else []}
for d in drivers
]
def build_driver_data(drivers) -> list[dict]:
"""Attach parsed driver names + icon presence to each Driver row.
Shared by every template that lists drivers (library table, printer form
select, edit modal) so a rename or a new icon shows up in all of them.
"""
from imptune.services.icons import driver_icon_ids, driver_search_text
from imptune.services.websearch import image_query
drivers = list(drivers)
with_icons = driver_icon_ids(drivers)
data = []
for d in drivers:
names = json.loads(d.driver_desc) if d.driver_desc else []
search_text = driver_search_text(d, names)
data.append(
{
"driver": d,
"names": names,
"has_icon": d.id in with_icons,
"search_text": search_text,
"image_query": image_query(search_text),
}
)
return data
@router.get("/", response_class=HTMLResponse)
@@ -109,13 +125,16 @@ def dashboard(request: Request):
def drivers_page(request: Request):
from imptune.db.models import Driver
from imptune.services.websearch import BRAND_SUGGESTIONS
drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
return templates.TemplateResponse(
request=request,
name="drivers.html",
context={
"driver_data": _driver_data(drivers),
"driver_data": build_driver_data(drivers),
"usage": printer_counts_by_driver(request.state.owner),
"brands": BRAND_SUGGESTIONS,
},
)
@@ -145,7 +164,7 @@ def printers_page(request: Request):
context={
"grouped": grouped,
"clients": clients,
"driver_data": _driver_data(all_drivers),
"driver_data": build_driver_data(all_drivers),
"printer_count": printer_count,
"ready_count": ready_count,
},
@@ -161,13 +180,14 @@ def printers_new_page(request: Request):
return templates.TemplateResponse(
request=request,
name="printers_new.html",
context={"clients": clients, "driver_data": _driver_data(all_drivers)},
context={"clients": clients, "driver_data": build_driver_data(all_drivers)},
)
@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
from imptune.services.websearch import image_query
printer = (
Printer.select(Printer, Client, Driver)
@@ -201,6 +221,7 @@ def printer_detail(request: Request, printer_id: int):
"driver_names": driver_names,
"has_driver": has_driver,
"has_icon": icon is not None,
"image_query": image_query(printer.name),
"install_cmd": install_cmd,
"uninstall_cmd": uninstall_cmd,
},
@@ -251,7 +272,7 @@ def client_detail(request: Request, client_id: int):
"client": client,
"grouped": grouped,
"clients": clients,
"driver_data": _driver_data(all_drivers),
"driver_data": build_driver_data(all_drivers),
"printer_count": len(printers),
"ready_count": sum(1 for p in printers if p.driver_id),
},
+4 -15
View File
@@ -1,22 +1,16 @@
"""Printer CRUD API — POST /printers, DELETE /printers/{id}, PATCH /printers/{id}."""
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
from peewee import JOIN
from imptune.api.pages import group_printers_by_client
from imptune.api.pages import build_driver_data, group_printers_by_client
from imptune.db.models import Client, Driver, Printer
from imptune.templating import templates
router = APIRouter(prefix="/printers")
templates = Jinja2Templates(
directory=str(Path(__file__).parent.parent / "templates")
)
_VALID_DUPLEX = {"OneSided", "LongEdge", "ShortEdge"}
_VALID_PAPER = {"A4", "Letter", "Legal"}
@@ -84,8 +78,6 @@ def _resolve_driver(raw: str) -> tuple[int | None, HTMLResponse | None]:
def _render_printer_list(request: Request) -> HTMLResponse:
"""Query printers with LEFT JOIN on client and render grouped partial."""
import json
owner = request.state.owner
query = (
Printer.select(Printer, Client)
@@ -96,11 +88,8 @@ def _render_printer_list(request: Request) -> HTMLResponse:
grouped = group_printers_by_client(query)
clients = list(Client.select().where(Client.owner == owner).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
]
all_drivers = Driver.select().order_by(Driver.uploaded_at.desc())
driver_data = build_driver_data(all_drivers)
return templates.TemplateResponse(
request=request,
+1 -7
View File
@@ -1,22 +1,16 @@
"""Owner session routes — backup-key download and restore-on-new-browser."""
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
import imptune.config as cfg
from imptune.db.models import Owner
from imptune.services.session import COOKIE_NAME, cookie_kwargs, is_same_origin
from imptune.templating import templates
router = APIRouter(prefix="/session")
templates = Jinja2Templates(
directory=str(Path(__file__).parent.parent / "templates")
)
def _require_cookie_sessions() -> None:
"""404 these routes in single-user mode — keys have no meaning without a cookie.
+113
View File
@@ -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),
},
)