Commit initial
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,54 @@
|
||||
"""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
|
||||
|
||||
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."""
|
||||
return HTMLResponse(
|
||||
content=f"<div id='client-list' class='error'><p>{message}</p></div>",
|
||||
status_code=status_code,
|
||||
)
|
||||
|
||||
|
||||
def _render_client_list(request: Request) -> HTMLResponse:
|
||||
"""Render the client list partial for HTMX swap."""
|
||||
clients = list(Client.select().order_by(Client.name))
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="partials/client_list.html",
|
||||
context={"clients": clients},
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_class=HTMLResponse)
|
||||
def create_client(request: Request, name: str = Form(...)) -> HTMLResponse:
|
||||
"""Create a new client.
|
||||
|
||||
Accepts form-encoded `name`. Validates non-empty. Returns HTMX partial
|
||||
with updated client list on success, or error fragment on failure.
|
||||
"""
|
||||
name = name.strip()
|
||||
if not name:
|
||||
return _error_response("Client name is required.")
|
||||
|
||||
try:
|
||||
Client.create(name=name)
|
||||
except IntegrityError:
|
||||
return _error_response(f"Client '{name}' already exists.", status_code=409)
|
||||
|
||||
return _render_client_list(request)
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Driver upload API — POST /drivers/upload."""
|
||||
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
|
||||
|
||||
import imptune.config as _cfg
|
||||
from imptune.db.models import Driver
|
||||
from imptune.services.inf_parser import _detect_encoding, parse_inf
|
||||
from imptune.storage.driver_store import DriverStore
|
||||
|
||||
router = APIRouter(prefix="/drivers")
|
||||
|
||||
templates = Jinja2Templates(
|
||||
directory=str(Path(__file__).parent.parent / "templates")
|
||||
)
|
||||
|
||||
MAX_UPLOAD_BYTES = 100 * 1024 * 1024 # 100 MB
|
||||
|
||||
|
||||
def _error_response(message: str, status_code: int = 400) -> HTMLResponse:
|
||||
"""Return an HTMX-friendly error fragment swapped into #driver-list."""
|
||||
return HTMLResponse(
|
||||
content=f"<div id='driver-list' class='error'><p>{message}</p></div>",
|
||||
status_code=status_code,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/upload", response_class=HTMLResponse)
|
||||
def upload_driver(
|
||||
request: Request,
|
||||
file: UploadFile,
|
||||
caller: str = Form(""),
|
||||
) -> HTMLResponse:
|
||||
"""Accept a driver ZIP, parse its INF, persist via DriverStore + Peewee ORM.
|
||||
|
||||
Returns an HTMX partial (partials/driver_list.html) on success, or an
|
||||
inline error fragment with HTTP 400 on validation failure.
|
||||
"""
|
||||
data = file.file.read(MAX_UPLOAD_BYTES + 1)
|
||||
if len(data) > MAX_UPLOAD_BYTES:
|
||||
return _error_response("File exceeds 100 MB limit.")
|
||||
|
||||
# Must end with .zip
|
||||
filename = file.filename or ""
|
||||
if not filename.lower().endswith(".zip"):
|
||||
return _error_response("Only .zip files are accepted.")
|
||||
|
||||
# Must be a valid ZIP archive
|
||||
if not zipfile.is_zipfile(io.BytesIO(data)):
|
||||
return _error_response("Uploaded file is not a valid ZIP archive.")
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||
zip_names = zf.namelist()
|
||||
|
||||
# Reject zip-slip paths
|
||||
for name in zip_names:
|
||||
if ".." in name or name.startswith("/"):
|
||||
return _error_response("ZIP contains unsafe paths.")
|
||||
|
||||
# Find .inf files
|
||||
inf_names = [n for n in zip_names if n.lower().endswith(".inf")]
|
||||
if not inf_names:
|
||||
return _error_response("No .inf file found in the uploaded ZIP.")
|
||||
|
||||
# Prefer amd64/x64 INF when multiple exist; fall back to alphabetical first
|
||||
preferred = [
|
||||
n for n in inf_names if "amd64" in n.lower() or "x64" in n.lower()
|
||||
]
|
||||
chosen_inf = preferred[0] if preferred else sorted(inf_names)[0]
|
||||
|
||||
raw_inf = zf.read(chosen_inf)
|
||||
|
||||
# Decode INF
|
||||
encoding = _detect_encoding(raw_inf)
|
||||
inf_text = raw_inf.decode(encoding)
|
||||
|
||||
# Parse INF
|
||||
parsed = parse_inf(inf_text, inf_filename=chosen_inf, zip_names=zip_names)
|
||||
|
||||
# Persist file (content-addressed, dedup automatic)
|
||||
# Read DRIVERS_DIR at call time so tests can monkeypatch imptune.config.DRIVERS_DIR
|
||||
store = DriverStore(_cfg.DRIVERS_DIR)
|
||||
sha256 = store.save(data)
|
||||
|
||||
# Upsert Driver record (no duplicate if same SHA256)
|
||||
new_driver, _created = Driver.get_or_create(
|
||||
sha256=sha256,
|
||||
defaults={
|
||||
"original_filename": filename,
|
||||
"size_bytes": len(data),
|
||||
"driver_desc": json.dumps(parsed.driver_names),
|
||||
"inf_filename": parsed.inf_filename,
|
||||
"architecture": parsed.architecture,
|
||||
"has_cat_file": parsed.has_cat_file,
|
||||
},
|
||||
)
|
||||
|
||||
# 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})
|
||||
|
||||
# When called from the printer form, emit primary fragment + OOB select refresh
|
||||
if caller == "printer_form":
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="partials/driver_upload_with_oob.html",
|
||||
context={
|
||||
"driver_data": driver_data,
|
||||
"new_driver_id": new_driver.id,
|
||||
"parsed": parsed,
|
||||
},
|
||||
)
|
||||
|
||||
# Default: existing behavior — driver list fragment only
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="partials/driver_list.html",
|
||||
context={
|
||||
"driver_data": driver_data,
|
||||
"parsed": parsed,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Icon upload API — POST /printers/{printer_id}/icon."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, UploadFile
|
||||
from fastapi.responses import HTMLResponse
|
||||
from PIL import Image
|
||||
|
||||
import imptune.config as cfg
|
||||
from imptune.db.models import Icon, Printer
|
||||
|
||||
router = APIRouter(prefix="/printers")
|
||||
|
||||
MAX_ICON_BYTES = 750 * 1024 # 750 KB
|
||||
|
||||
|
||||
@router.post("/{printer_id}/icon", response_class=HTMLResponse)
|
||||
def upload_icon(printer_id: int, file: UploadFile) -> HTMLResponse:
|
||||
"""Accept a printer icon PNG, validate it, store it, and update the Icon record.
|
||||
|
||||
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.
|
||||
"""
|
||||
# Check printer exists
|
||||
printer = Printer.get_or_none(Printer.id == printer_id)
|
||||
if printer 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,
|
||||
)
|
||||
|
||||
if img.format != "PNG":
|
||||
return HTMLResponse(
|
||||
content="<p>Icon must be PNG format.</p>",
|
||||
status_code=422,
|
||||
)
|
||||
|
||||
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
|
||||
sha256 = hashlib.sha256(data).hexdigest()
|
||||
icons_dir = Path(cfg.DATA_DIR) / "icons"
|
||||
icons_dir.mkdir(parents=True, exist_ok=True)
|
||||
icon_path = icons_dir / sha256
|
||||
icon_path.write_bytes(data)
|
||||
|
||||
# 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),
|
||||
)
|
||||
|
||||
return HTMLResponse(
|
||||
content="<p>Icon uploaded successfully</p>",
|
||||
status_code=200,
|
||||
)
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Package export endpoints — serves deployment packages for NinjaRMM and Microsoft Intune."""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import PlainTextResponse, Response
|
||||
|
||||
import imptune.config as cfg
|
||||
from imptune.db.models import Icon, Printer
|
||||
from imptune.generators.intunewin_builder import build_intunewin
|
||||
from imptune.generators.script_generator import render_detect, render_install, render_uninstall
|
||||
from imptune.storage.driver_store import DriverStore
|
||||
|
||||
router = APIRouter(prefix="/printers")
|
||||
|
||||
|
||||
def _get_printer_and_driver(printer_id: int):
|
||||
"""Fetch printer and validate driver — returns (printer, driver, driver_name) or PlainTextResponse error."""
|
||||
printer = Printer.get_or_none(Printer.id == printer_id)
|
||||
if printer is None:
|
||||
return None, PlainTextResponse("Printer not found", status_code=404)
|
||||
|
||||
driver = printer.driver
|
||||
if driver is None:
|
||||
return None, PlainTextResponse("No driver assigned", status_code=422)
|
||||
|
||||
if not driver.inf_filename:
|
||||
return None, PlainTextResponse("Driver has no INF file", status_code=422)
|
||||
|
||||
if not driver.driver_desc:
|
||||
return None, PlainTextResponse("Driver has no description", status_code=422)
|
||||
|
||||
try:
|
||||
desc_list = json.loads(driver.driver_desc)
|
||||
driver_name = desc_list[0]
|
||||
except (json.JSONDecodeError, IndexError, TypeError):
|
||||
return None, PlainTextResponse("Driver description is invalid", status_code=422)
|
||||
|
||||
return (printer, driver, driver_name), None
|
||||
|
||||
|
||||
def _get_driver_zip_path(driver) -> str:
|
||||
return str(DriverStore(cfg.DRIVERS_DIR).get_path(driver.sha256))
|
||||
|
||||
|
||||
@router.get("/{printer_id}/packages/ninja")
|
||||
def get_ninja_package(printer_id: int):
|
||||
"""Download a NinjaRMM-ready ZIP containing install.ps1 and the driver files."""
|
||||
result, error = _get_printer_and_driver(printer_id)
|
||||
if error is not None:
|
||||
return error
|
||||
|
||||
printer, driver, driver_name = result
|
||||
|
||||
# Validate driver file exists on disk
|
||||
driver_zip_path = _get_driver_zip_path(driver)
|
||||
if not os.path.isfile(driver_zip_path):
|
||||
return PlainTextResponse("Driver file not found on disk", status_code=422)
|
||||
|
||||
safe_name = printer.name.replace(" ", "_")
|
||||
|
||||
# Render install script
|
||||
install_script = render_install(
|
||||
printer_name=printer.name,
|
||||
ip_address=printer.ip_address,
|
||||
port_name=printer.port_name,
|
||||
driver_name=driver_name,
|
||||
inf_filename=driver.inf_filename,
|
||||
duplex_mode=printer.duplex_mode,
|
||||
color_mode=printer.color_mode,
|
||||
paper_size=printer.paper_size,
|
||||
collate=printer.collate,
|
||||
)
|
||||
|
||||
# Build ZIP in-memory
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
# Add install script
|
||||
zf.writestr(f"{safe_name}/install.ps1", install_script)
|
||||
|
||||
# Extract and re-add driver files from driver ZIP
|
||||
with zipfile.ZipFile(driver_zip_path, "r") as driver_zf:
|
||||
for member in driver_zf.namelist():
|
||||
member_data = driver_zf.read(member)
|
||||
zf.writestr(f"{safe_name}/drivers/{member}", member_data)
|
||||
|
||||
return Response(
|
||||
content=buf.getvalue(),
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="{safe_name}_ninja.zip"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{printer_id}/packages/intunewin")
|
||||
def get_intunewin_package(printer_id: int):
|
||||
"""Download a Microsoft Intune .intunewin deployment package."""
|
||||
result, error = _get_printer_and_driver(printer_id)
|
||||
if error is not None:
|
||||
return error
|
||||
|
||||
printer, driver, driver_name = result
|
||||
|
||||
# Validate driver file exists on disk
|
||||
driver_zip_path = _get_driver_zip_path(driver)
|
||||
if not os.path.isfile(driver_zip_path):
|
||||
return PlainTextResponse("Driver file not found on disk", status_code=422)
|
||||
|
||||
safe_name = printer.name.replace(" ", "_")
|
||||
|
||||
# Render all three scripts
|
||||
install_script = render_install(
|
||||
printer_name=printer.name,
|
||||
ip_address=printer.ip_address,
|
||||
port_name=printer.port_name,
|
||||
driver_name=driver_name,
|
||||
inf_filename=driver.inf_filename,
|
||||
duplex_mode=printer.duplex_mode,
|
||||
color_mode=printer.color_mode,
|
||||
paper_size=printer.paper_size,
|
||||
collate=printer.collate,
|
||||
)
|
||||
uninstall_script = render_uninstall(
|
||||
printer_name=printer.name,
|
||||
driver_name=driver_name,
|
||||
port_name=printer.port_name,
|
||||
)
|
||||
detect_script = render_detect(printer_name=printer.name)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="imptune_") as tmpdir:
|
||||
# Write scripts
|
||||
with open(os.path.join(tmpdir, "install.ps1"), "w", encoding="utf-8") as f:
|
||||
f.write(install_script)
|
||||
with open(os.path.join(tmpdir, "uninstall.ps1"), "w", encoding="utf-8") as f:
|
||||
f.write(uninstall_script)
|
||||
with open(os.path.join(tmpdir, "detect.ps1"), "w", encoding="utf-8") as f:
|
||||
f.write(detect_script)
|
||||
|
||||
# Extract driver ZIP contents into tmpdir/drivers/
|
||||
drivers_subdir = os.path.join(tmpdir, "drivers")
|
||||
os.makedirs(drivers_subdir, exist_ok=True)
|
||||
with zipfile.ZipFile(driver_zip_path, "r") as driver_zf:
|
||||
driver_zf.extractall(drivers_subdir)
|
||||
|
||||
# Copy icon into staging if one exists for this printer
|
||||
icon_record = Icon.get_or_none(Icon.printer == printer.id)
|
||||
if icon_record is not None:
|
||||
icon_src = os.path.join(cfg.ICONS_DIR, icon_record.sha256)
|
||||
if os.path.isfile(icon_src):
|
||||
shutil.copy2(icon_src, os.path.join(tmpdir, "icon.png"))
|
||||
|
||||
# Build .intunewin
|
||||
output_path = os.path.join(tmpdir, "out.intunewin")
|
||||
build_intunewin(tmpdir, "install.ps1", output_path)
|
||||
|
||||
with open(output_path, "rb") as f:
|
||||
content = f.read()
|
||||
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/octet-stream",
|
||||
headers={"Content-Disposition": f'attachment; filename="{safe_name}.intunewin"'},
|
||||
)
|
||||
@@ -0,0 +1,213 @@
|
||||
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
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
def dashboard(request: Request):
|
||||
from imptune.db.models import Printer
|
||||
|
||||
recent_printers = list(
|
||||
Printer.select().order_by(Printer.created_at.desc()).limit(5)
|
||||
)
|
||||
recent_packages = list(
|
||||
Printer.select()
|
||||
.where(Printer.driver.is_null(False))
|
||||
.order_by(Printer.created_at.desc())
|
||||
.limit(5)
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="dashboard.html",
|
||||
context={
|
||||
"recent_printers": recent_printers,
|
||||
"recent_packages": recent_packages,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/drivers", response_class=HTMLResponse)
|
||||
def drivers_page(request: Request):
|
||||
from imptune.db.models import Driver
|
||||
|
||||
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})
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="drivers.html",
|
||||
context={"driver_data": driver_data},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/printers", response_class=HTMLResponse)
|
||||
def printers_page(request: Request):
|
||||
from imptune.db.models import Client, Driver, Printer
|
||||
|
||||
query = (
|
||||
Printer.select(Printer, Client)
|
||||
.join(Client, JOIN.LEFT_OUTER)
|
||||
.order_by(Client.name, Printer.name)
|
||||
)
|
||||
grouped: dict[str, list] = defaultdict(list)
|
||||
for p in query:
|
||||
client_name = p.client.name if p.client_id else "Unassigned"
|
||||
grouped[client_name].append(p)
|
||||
|
||||
clients = list(Client.select().order_by(Client.name))
|
||||
|
||||
all_drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
|
||||
driver_data = []
|
||||
for d in all_drivers:
|
||||
names = json.loads(d.driver_desc) if d.driver_desc else []
|
||||
driver_data.append({"driver": d, "names": names})
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="printers.html",
|
||||
context={
|
||||
"grouped": grouped,
|
||||
"clients": clients,
|
||||
"driver_data": driver_data,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
|
||||
printer = (
|
||||
Printer.select(Printer, Client, Driver)
|
||||
.join(Client, JOIN.LEFT_OUTER)
|
||||
.switch(Printer)
|
||||
.join(Driver, JOIN.LEFT_OUTER)
|
||||
.where(Printer.id == printer_id)
|
||||
.first()
|
||||
)
|
||||
if printer is None:
|
||||
return HTMLResponse(
|
||||
content="<h1>404 Not Found</h1><p>Printer not found.</p>",
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
driver_names: list[str] = []
|
||||
if printer.driver_id and printer.driver.driver_desc:
|
||||
driver_names = json.loads(printer.driver.driver_desc)
|
||||
|
||||
has_driver = printer.driver_id is not None and bool(driver_names)
|
||||
icon = Icon.get_or_none(Icon.printer == printer_id)
|
||||
|
||||
install_cmd = "powershell.exe -ExecutionPolicy Bypass -File install.ps1"
|
||||
uninstall_cmd = "powershell.exe -ExecutionPolicy Bypass -File uninstall.ps1"
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="printer_detail.html",
|
||||
context={
|
||||
"printer": printer,
|
||||
"driver_names": driver_names,
|
||||
"has_driver": has_driver,
|
||||
"has_icon": icon is not None,
|
||||
"install_cmd": install_cmd,
|
||||
"uninstall_cmd": uninstall_cmd,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/clients", response_class=HTMLResponse)
|
||||
def clients_page(request: Request):
|
||||
from imptune.db.models import Client
|
||||
|
||||
clients = list(Client.select().order_by(Client.name))
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="clients.html",
|
||||
context={"clients": clients},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/clients/{client_id}", response_class=HTMLResponse)
|
||||
def client_detail(request: Request, client_id: int):
|
||||
from imptune.db.models import Client, Driver, Printer
|
||||
|
||||
client = Client.get_or_none(Client.id == client_id)
|
||||
if client is None:
|
||||
return HTMLResponse(
|
||||
content="<h1>404 Not Found</h1><p>Client not found.</p>",
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
query = (
|
||||
Printer.select(Printer, Client)
|
||||
.join(Client, JOIN.LEFT_OUTER)
|
||||
.where(Printer.client == client_id)
|
||||
.order_by(Printer.name)
|
||||
)
|
||||
grouped = {client.name: list(query)}
|
||||
|
||||
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="client_detail.html",
|
||||
context={
|
||||
"client": client,
|
||||
"grouped": grouped,
|
||||
"clients": clients,
|
||||
"driver_data": driver_data,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/packages", response_class=HTMLResponse)
|
||||
def packages_page(request: Request):
|
||||
from imptune.db.models import Client, Driver, Printer
|
||||
|
||||
printers = list(
|
||||
Printer.select(Printer, Client, Driver)
|
||||
.join(Client, JOIN.LEFT_OUTER)
|
||||
.switch(Printer)
|
||||
.join(Driver, JOIN.LEFT_OUTER)
|
||||
.where(Printer.driver.is_null(False))
|
||||
.order_by(Printer.name)
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="packages.html",
|
||||
context={"printers": printers},
|
||||
)
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Printer CRUD API — POST /printers, DELETE /printers/{id}, PATCH /printers/{id}."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from peewee import JOIN
|
||||
|
||||
from imptune.db.models import Client, Driver, Printer
|
||||
|
||||
router = APIRouter(prefix="/printers")
|
||||
|
||||
templates = Jinja2Templates(
|
||||
directory=str(Path(__file__).parent.parent / "templates")
|
||||
)
|
||||
|
||||
_VALID_DUPLEX = {"OneSided", "LongEdge", "ShortEdge"}
|
||||
_VALID_PAPER = {"A4", "Letter", "Legal"}
|
||||
|
||||
|
||||
def _error_response(message: str, status_code: int = 400) -> HTMLResponse:
|
||||
"""Return an HTMX-friendly error fragment swapped into #printer-list."""
|
||||
return HTMLResponse(
|
||||
content=f"<div id='printer-list' class='error'><p>{message}</p></div>",
|
||||
status_code=status_code,
|
||||
)
|
||||
|
||||
|
||||
def _render_printer_list(request: Request) -> HTMLResponse:
|
||||
"""Query printers with LEFT JOIN on client and render grouped partial."""
|
||||
import json
|
||||
|
||||
query = (
|
||||
Printer.select(Printer, Client)
|
||||
.join(Client, JOIN.LEFT_OUTER)
|
||||
.order_by(Client.name, Printer.name)
|
||||
)
|
||||
grouped: dict[str, list[Printer]] = defaultdict(list)
|
||||
for p in query:
|
||||
client_name = p.client.name if p.client_id else "Unassigned"
|
||||
grouped[client_name].append(p)
|
||||
|
||||
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="partials/printer_list.html",
|
||||
context={"grouped": grouped, "clients": clients, "driver_data": driver_data},
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_class=HTMLResponse)
|
||||
def create_printer(
|
||||
request: Request,
|
||||
name: str = Form(...),
|
||||
ip_address: str = Form(...),
|
||||
port_name: str = Form(...),
|
||||
duplex_mode: str = Form("OneSided"),
|
||||
color_mode: str = Form(""),
|
||||
paper_size: str = Form("A4"),
|
||||
collate: str = Form(""),
|
||||
client_id: str = Form(""),
|
||||
driver_id: str = Form(""),
|
||||
) -> HTMLResponse:
|
||||
"""Create a new printer configuration.
|
||||
|
||||
Boolean fields (color_mode, collate) use HTML checkbox convention:
|
||||
"on" = True, absent/empty = False.
|
||||
"""
|
||||
name = name.strip()
|
||||
ip_address = ip_address.strip()
|
||||
port_name = port_name.strip()
|
||||
|
||||
if not name:
|
||||
return _error_response("Printer name is required.")
|
||||
if not ip_address:
|
||||
return _error_response("IP address is required.")
|
||||
if not port_name:
|
||||
return _error_response("Port name is required.")
|
||||
if duplex_mode not in _VALID_DUPLEX:
|
||||
return _error_response(f"Invalid duplex mode: {duplex_mode}.")
|
||||
if paper_size not in _VALID_PAPER:
|
||||
return _error_response(f"Invalid paper size: {paper_size}.")
|
||||
|
||||
# Convert checkbox values
|
||||
color_mode_bool = color_mode == "on"
|
||||
collate_bool = collate == "on"
|
||||
|
||||
# Resolve optional FK IDs
|
||||
client_fk = int(client_id) if client_id.strip() else None
|
||||
driver_fk = int(driver_id) if driver_id.strip() else None
|
||||
|
||||
Printer.create(
|
||||
name=name,
|
||||
ip_address=ip_address,
|
||||
port_name=port_name,
|
||||
duplex_mode=duplex_mode,
|
||||
color_mode=color_mode_bool,
|
||||
paper_size=paper_size,
|
||||
collate=collate_bool,
|
||||
client=client_fk,
|
||||
driver=driver_fk,
|
||||
)
|
||||
|
||||
return RedirectResponse(url="/printers", status_code=303)
|
||||
|
||||
|
||||
@router.delete("/{printer_id}", response_class=HTMLResponse)
|
||||
def delete_printer(request: Request, printer_id: int) -> HTMLResponse:
|
||||
"""Delete a printer by ID. Returns updated printer list partial."""
|
||||
deleted = Printer.delete().where(Printer.id == printer_id).execute()
|
||||
if not deleted:
|
||||
return _error_response(f"Printer {printer_id} not found.", status_code=404)
|
||||
|
||||
return _render_printer_list(request)
|
||||
|
||||
|
||||
@router.patch("/{printer_id}", response_class=HTMLResponse)
|
||||
def update_printer(
|
||||
request: Request,
|
||||
printer_id: int,
|
||||
name: str = Form(...),
|
||||
ip_address: str = Form(""),
|
||||
port_name: str = Form(""),
|
||||
duplex_mode: str = Form("OneSided"),
|
||||
color_mode: str = Form(""),
|
||||
paper_size: str = Form("A4"),
|
||||
collate: str = Form(""),
|
||||
client_id: str = Form(""),
|
||||
driver_id: str = Form(""),
|
||||
) -> HTMLResponse:
|
||||
"""Update an existing printer configuration in-place."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
printer = Printer.get_or_none(Printer.id == printer_id)
|
||||
if printer is None:
|
||||
return _error_response(f"Printer {printer_id} not found.", status_code=404)
|
||||
|
||||
name = name.strip()
|
||||
ip_address = ip_address.strip() or printer.ip_address
|
||||
port_name = port_name.strip() or printer.port_name
|
||||
|
||||
if not name:
|
||||
return _error_response("Printer name is required.")
|
||||
if not ip_address:
|
||||
return _error_response("IP address is required.")
|
||||
if not port_name:
|
||||
return _error_response("Port name is required.")
|
||||
if duplex_mode not in _VALID_DUPLEX:
|
||||
return _error_response(f"Invalid duplex mode: {duplex_mode}.")
|
||||
if paper_size not in _VALID_PAPER:
|
||||
return _error_response(f"Invalid paper size: {paper_size}.")
|
||||
|
||||
printer.name = name
|
||||
printer.ip_address = ip_address
|
||||
printer.port_name = port_name
|
||||
printer.duplex_mode = duplex_mode
|
||||
printer.color_mode = color_mode == "on"
|
||||
printer.paper_size = paper_size
|
||||
printer.collate = collate == "on"
|
||||
printer.client = int(client_id) if client_id.strip() else None
|
||||
printer.driver = int(driver_id) if driver_id.strip() else None
|
||||
printer.updated_at = datetime.now(UTC).replace(tzinfo=None)
|
||||
printer.save()
|
||||
|
||||
return _render_printer_list(request)
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Script download endpoints — generates and serves PowerShell scripts for a printer."""
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import PlainTextResponse
|
||||
|
||||
from imptune.db.models import Printer
|
||||
from imptune.generators.script_generator import render_detect, render_install, render_uninstall
|
||||
|
||||
router = APIRouter(prefix="/printers")
|
||||
|
||||
|
||||
def _get_printer_and_driver(printer_id: int):
|
||||
"""Fetch printer and validate driver — returns (printer, driver_name) or PlainTextResponse error."""
|
||||
printer = Printer.get_or_none(Printer.id == printer_id)
|
||||
if printer is None:
|
||||
return None, PlainTextResponse("Printer not found", status_code=404)
|
||||
|
||||
driver = printer.driver
|
||||
if driver is None:
|
||||
return None, PlainTextResponse("No driver assigned", status_code=422)
|
||||
|
||||
if not driver.inf_filename:
|
||||
return None, PlainTextResponse("Driver has no INF file", status_code=422)
|
||||
|
||||
if not driver.driver_desc:
|
||||
return None, PlainTextResponse("Driver has no description", status_code=422)
|
||||
|
||||
try:
|
||||
desc_list = json.loads(driver.driver_desc)
|
||||
driver_name = desc_list[0]
|
||||
except (json.JSONDecodeError, IndexError, TypeError):
|
||||
return None, PlainTextResponse("Driver description is invalid", status_code=422)
|
||||
|
||||
return (printer, driver, driver_name), None
|
||||
|
||||
|
||||
def _install_response(printer_id: int):
|
||||
result, error = _get_printer_and_driver(printer_id)
|
||||
if error is not None:
|
||||
return error
|
||||
printer, driver, driver_name = result
|
||||
rendered = render_install(
|
||||
printer_name=printer.name,
|
||||
ip_address=printer.ip_address,
|
||||
port_name=printer.port_name,
|
||||
driver_name=driver_name,
|
||||
inf_filename=driver.inf_filename,
|
||||
duplex_mode=printer.duplex_mode,
|
||||
color_mode=printer.color_mode,
|
||||
paper_size=printer.paper_size,
|
||||
collate=printer.collate,
|
||||
)
|
||||
return PlainTextResponse(
|
||||
content=rendered,
|
||||
headers={"Content-Disposition": 'attachment; filename="install.ps1"'},
|
||||
)
|
||||
|
||||
|
||||
def _uninstall_response(printer_id: int):
|
||||
result, error = _get_printer_and_driver(printer_id)
|
||||
if error is not None:
|
||||
return error
|
||||
printer, driver, driver_name = result
|
||||
rendered = render_uninstall(
|
||||
printer_name=printer.name,
|
||||
driver_name=driver_name,
|
||||
port_name=printer.port_name,
|
||||
)
|
||||
return PlainTextResponse(
|
||||
content=rendered,
|
||||
headers={"Content-Disposition": 'attachment; filename="uninstall.ps1"'},
|
||||
)
|
||||
|
||||
|
||||
def _detect_response(printer_id: int):
|
||||
result, error = _get_printer_and_driver(printer_id)
|
||||
if error is not None:
|
||||
return error
|
||||
printer, driver, driver_name = result
|
||||
rendered = render_detect(printer_name=printer.name)
|
||||
return PlainTextResponse(
|
||||
content=rendered,
|
||||
headers={"Content-Disposition": 'attachment; filename="detect.ps1"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{printer_id}/scripts/install")
|
||||
def get_install_script(printer_id: int):
|
||||
"""Download the PowerShell install script for a printer."""
|
||||
return _install_response(printer_id)
|
||||
|
||||
|
||||
@router.get("/{printer_id}/scripts/install.ps1")
|
||||
def get_install_script_ps1(printer_id: int):
|
||||
"""Download the PowerShell install script for a printer (.ps1 alias)."""
|
||||
return _install_response(printer_id)
|
||||
|
||||
|
||||
@router.get("/{printer_id}/scripts/uninstall")
|
||||
def get_uninstall_script(printer_id: int):
|
||||
"""Download the PowerShell uninstall script for a printer."""
|
||||
return _uninstall_response(printer_id)
|
||||
|
||||
|
||||
@router.get("/{printer_id}/scripts/uninstall.ps1")
|
||||
def get_uninstall_script_ps1(printer_id: int):
|
||||
"""Download the PowerShell uninstall script for a printer (.ps1 alias)."""
|
||||
return _uninstall_response(printer_id)
|
||||
|
||||
|
||||
@router.get("/{printer_id}/scripts/detect")
|
||||
def get_detect_script(printer_id: int):
|
||||
"""Download the PowerShell detection script for a printer."""
|
||||
return _detect_response(printer_id)
|
||||
|
||||
|
||||
@router.get("/{printer_id}/scripts/detect.ps1")
|
||||
def get_detect_script_ps1(printer_id: int):
|
||||
"""Download the PowerShell detection script for a printer (.ps1 alias)."""
|
||||
return _detect_response(printer_id)
|
||||
Reference in New Issue
Block a user