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:
@@ -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
@@ -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×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
@@ -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×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]}×{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
@@ -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
@@ -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,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.
|
||||
|
||||
@@ -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