feat: memory-only sessions on HTTP, streamed exports, UI refresh
Session - COOKIE_SECURE=false no longer persists the owner key for ten years. services/session.cookie_kwargs() drops max_age in that mode, so the browser holds the key in memory and the session ends with the window. Everything still persists server-side; only the browser link is temporary. base.html shows a warning banner (FR/EN) and an extra paragraph in the onboarding modal, and the README explains the trade-off and the backup-key escape hatch. - Both cookie writers (middleware, POST /session/restore) go through cookie_kwargs() so the policy cannot drift between them. - The CSRF guard on /session/restore compared request.url.scheme against the Origin header. Behind a TLS-terminating proxy uvicorn sees http while the browser sends https, so every legitimate restore was rejected with 403. It now compares hosts only, including X-Forwarded-Host. - /static/*, /favicon.ico and /robots.txt skip the middleware. Each cookieless hit was inserting an Owner row no browser could ever use. Reliability - Malformed printer-form FK fields no longer escape as HTTP 500: a non-numeric client_id/driver_id raised ValueError and an unknown driver_id hit a FOREIGN KEY constraint. Both are now 400/404 HTMX fragments, and the duplicated field checks moved into _validate_fields(). - Package exports stream. build_intunewin() encrypts the inner ZIP in 1 MB chunks against temp files with a streaming HMAC and SHA256, and both endpoints serve the result with FileResponse plus a background cleanup task. A 100 MB driver used to be held in memory three or four times over per concurrent download. The byte layout is unchanged. - FileResponse also escapes the download filename, which was previously interpolated raw into Content-Disposition. - python-multipart >= 0.0.18 (CVE-2024-53981, reachable from /drivers/upload) and Pillow >= 10.3 (CVE-2024-28219, reachable from icon upload). - icons.py reads cfg.ICONS_DIR instead of re-deriving the path from DATA_DIR, matching the .intunewin export. UI - Sidebar/topbar shell, inline SVG icon macros (partials/icons.html), card and data-table components, grouped printer list, and the dedicated /printers/new page replacing partials/printer_form.html. Tests - 194 pass with a bare `pytest tests/`: tests/conftest.py now forces cfg.COOKIE_SECURE = False like the e2e conftest already did, so the Secure cookie is no longer dropped over http://testserver. - New coverage for the malformed-FK guards, the chunk-boundary cases in the encrypt loop (every residue mod _CHUNK plus a multi-megabyte payload), temp-dir cleanup after both exports, and the whole COOKIE_SECURE matrix. - test_printer_edit.py located the Edit button by its translated label, so it only passed on English-locale machines. It now targets the showModal() hook, which also cuts the e2e run from 84s to 15s. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,22 +1,45 @@
|
||||
{% import "partials/icons.html" as ico %}
|
||||
{% set counts = counts | default({}, true) %}
|
||||
<div id="client-list">
|
||||
{% if not clients %}
|
||||
<p x-data x-text="$store.i18n.t('no_clients')">No clients configured yet.</p>
|
||||
<div class="card">
|
||||
<div class="empty">
|
||||
<span class="empty-ico">{{ ico.i('group', 18) }}</span>
|
||||
<strong x-data x-text="$store.i18n.t('no_clients')">No clients configured yet.</strong>
|
||||
<p x-data x-text="$store.i18n.t('clients_intro')">Clients group printers by site or organization.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th x-data x-text="$store.i18n.t('th_name')">Name</th>
|
||||
<th x-data x-text="$store.i18n.t('created')">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for c in clients %}
|
||||
<tr>
|
||||
<td><a href="/clients/{{ c.id }}">{{ c.name }}</a></td>
|
||||
<td>{{ c.created_at.strftime('%Y-%m-%d') if c.created_at else '—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="card card-flush">
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th x-data x-text="$store.i18n.t('th_name')">Name</th>
|
||||
<th x-data x-text="$store.i18n.t('th_printers')">Printers</th>
|
||||
<th x-data x-text="$store.i18n.t('created')">Created</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for c in clients %}
|
||||
<tr>
|
||||
<td><a class="cell-name" href="/clients/{{ c.id }}">{{ c.name }}</a></td>
|
||||
<td class="num">
|
||||
{% set n = counts.get(c.id, 0) %}
|
||||
{% if n %}{{ n }}{% else %}<span class="faint">0</span>{% endif %}
|
||||
</td>
|
||||
<td class="num">{{ c.created_at.strftime('%Y-%m-%d') if c.created_at else '—' }}</td>
|
||||
<td class="actions">
|
||||
<a class="btn quiet sm" href="/clients/{{ c.id }}" x-data>
|
||||
<span x-text="$store.i18n.t('open_client')">Open</span>{{ ico.i('chevron', 13) }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -1,50 +1,95 @@
|
||||
{% import "partials/icons.html" as ico %}
|
||||
{% set usage = usage | default({}, true) %}
|
||||
{% set new_driver_id = new_driver_id | default(None, true) %}
|
||||
<div id="driver-list">
|
||||
{% if parsed is defined and parsed.unused_files %}
|
||||
<p class="notice">
|
||||
{{ parsed.unused_files | length }} file(s) may be unused (not referenced by the INF):
|
||||
<div class="notice">
|
||||
<p>
|
||||
{{ parsed.unused_files | length }}
|
||||
<span x-data x-text="$store.i18n.t('unused_files')">file(s) in the ZIP are unused — the .inf never references them.</span>
|
||||
</p>
|
||||
<details>
|
||||
<summary>Show unused files</summary>
|
||||
<summary x-data x-text="$store.i18n.t('show_unused')">Show unused files</summary>
|
||||
<ul>
|
||||
{% for f in parsed.unused_files %}
|
||||
<li>{{ f }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if driver_data %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th x-data x-text="$store.i18n.t('driver_filename')">Filename</th>
|
||||
<th x-data x-text="$store.i18n.t('driver_names_col')">Driver Name(s)</th>
|
||||
<th x-data x-text="$store.i18n.t('architecture')">Architecture</th>
|
||||
<th x-data x-text="$store.i18n.t('uploaded_at')">Uploaded</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in driver_data %}
|
||||
<tr>
|
||||
<td>{{ item.driver.original_filename }}</td>
|
||||
<td>
|
||||
{% if item.names %}
|
||||
<select aria-label="Driver names">
|
||||
{% for name in item.names %}
|
||||
<option>{{ name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<em x-data x-text="$store.i18n.t('unknown')">Unknown</em>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{% if item.driver.architecture %}{{ item.driver.architecture }}{% else %}<span x-data x-text="$store.i18n.t('unknown')">Unknown</span>{% endif %}</td>
|
||||
<td>{{ item.driver.uploaded_at }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="card card-flush">
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th x-data x-text="$store.i18n.t('driver_filename')">File</th>
|
||||
<th x-data x-text="$store.i18n.t('driver_names_col')">Driver name(s)</th>
|
||||
<th class="col-arch" x-data x-text="$store.i18n.t('architecture')">Architecture</th>
|
||||
<th class="col-used" x-data x-text="$store.i18n.t('th_used_by')">Used by</th>
|
||||
<th class="col-added" x-data x-text="$store.i18n.t('uploaded_at')">Added</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in driver_data %}
|
||||
<tr>
|
||||
<td>
|
||||
<span class="cell-name mono">{{ item.driver.original_filename }}</span>
|
||||
<span class="cell-sub">
|
||||
{% if item.driver.size_bytes >= 1048576 %}{{ (item.driver.size_bytes / 1048576) | round(1) }} MB{% elif item.driver.size_bytes >= 1024 %}{{ (item.driver.size_bytes / 1024) | round(0) | int }} KB{% else %}{{ item.driver.size_bytes }} B{% endif %}
|
||||
{% if item.driver.inf_filename %} · {{ item.driver.inf_filename }}{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if item.names %}
|
||||
{# Multi-model INFs can carry a dozen names — show three, fold the rest. #}
|
||||
<span class="pill-row">
|
||||
{% for name in item.names[:3] %}<span class="pill">{{ name }}</span>{% endfor %}
|
||||
{% if item.names | length > 3 %}
|
||||
<details class="more-pills">
|
||||
<summary class="pill">+{{ item.names | length - 3 }}</summary>
|
||||
<span class="pill-row">
|
||||
{% for name in item.names[3:] %}<span class="pill">{{ name }}</span>{% endfor %}
|
||||
</span>
|
||||
</details>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="faint" x-data x-text="$store.i18n.t('unknown')">Unknown</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="col-arch">
|
||||
{% if item.driver.architecture %}
|
||||
<span class="badge">{{ item.driver.architecture }}</span>
|
||||
{% else %}
|
||||
<span class="faint" x-data x-text="$store.i18n.t('unknown')">Unknown</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="num col-used">
|
||||
{% set n = usage.get(item.driver.id, 0) %}
|
||||
{% if n %}<a href="/printers">{{ n }}</a>{% else %}<span class="faint">0</span>{% endif %}
|
||||
</td>
|
||||
<td class="num col-added">
|
||||
{{ item.driver.uploaded_at.strftime('%Y-%m-%d %H:%M') if item.driver.uploaded_at is not string else item.driver.uploaded_at }}
|
||||
{% if new_driver_id and item.driver.id == new_driver_id %}
|
||||
<span class="badge ok">{{ ico.i('check', 12) }}new</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p x-data x-text="$store.i18n.t('no_drivers')">No drivers uploaded yet.</p>
|
||||
<div class="card">
|
||||
<div class="empty">
|
||||
<span class="empty-ico">{{ ico.i('driver', 18) }}</span>
|
||||
<strong x-data x-text="$store.i18n.t('no_drivers')">No drivers uploaded yet.</strong>
|
||||
<p x-data x-text="$store.i18n.t('hint_driver_zip')">The ZIP must contain the .inf file and every file it references.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{% include "partials/driver_list.html" %}
|
||||
|
||||
{# Out-of-band: refresh the printer form's driver picker with the new driver chosen. #}
|
||||
<select name="driver_id" id="printer-form-driver-select" hx-swap-oob="true">
|
||||
<option value="">-- No driver --</option>
|
||||
<option value="" x-data x-text="$store.i18n.t('no_driver_option')">No driver</option>
|
||||
{% for item in driver_data %}
|
||||
<option value="{{ item.driver.id }}"
|
||||
{% if item.driver.id == new_driver_id %}selected{% endif %}>
|
||||
{{ item.driver.original_filename }} ({{ item.names | join(', ') }})
|
||||
{{ item.driver.original_filename }}{% if item.names %} — {{ item.names | join(', ') }}{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
{#
|
||||
Inline SVG icon set — no external files, no text nodes.
|
||||
|
||||
Text-node-free on purpose: E2E tests read `textContent` of nav links to assert
|
||||
the translated label, so icons must contribute no characters.
|
||||
|
||||
Usage: {% import "partials/icons.html" as ico %} → {{ ico.i('printer') }}
|
||||
#}
|
||||
{% macro i(name, size=16) -%}
|
||||
{%- set s = size -%}
|
||||
<svg class="ico" width="{{ s }}" height="{{ s }}" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="1.6" stroke-linecap="round"
|
||||
stroke-linejoin="round" aria-hidden="true" focusable="false">
|
||||
{%- if name == 'gauge' -%}
|
||||
<path d="M12 21a9 9 0 1 0-9-9"/><path d="M3 12h2"/><path d="M19 12h2"/><path d="M12 3v2"/><path d="m12 12 4.5-3.5"/><circle cx="12" cy="12" r="1.6"/>
|
||||
{%- elif name == 'driver' -%}
|
||||
<path d="M3 8.5 12 4l9 4.5-9 4.5-9-4.5Z"/><path d="M3 8.5v7L12 20l9-4.5v-7"/><path d="M12 13v7"/>
|
||||
{%- elif name == 'printer' -%}
|
||||
<path d="M6 9V4h12v5"/><rect x="3" y="9" width="18" height="7" rx="1.5"/><path d="M7 16h10v4H7z"/><circle cx="17.5" cy="12" r=".8" fill="currentColor" stroke="none"/>
|
||||
{%- elif name == 'group' -%}
|
||||
<path d="M4 20v-1.5A3.5 3.5 0 0 1 7.5 15h3A3.5 3.5 0 0 1 14 18.5V20"/><circle cx="9" cy="8.5" r="3"/><path d="M16 15.5h.5A3.5 3.5 0 0 1 20 19v1"/><circle cx="17" cy="9.5" r="2.2"/>
|
||||
{%- elif name == 'package' -%}
|
||||
<path d="M4 7.5 12 4l8 3.5v9L12 20l-8-3.5v-9Z"/><path d="M4 7.5 12 11l8-3.5"/><path d="M12 11v9"/><path d="M8 5.7 16 9.2"/>
|
||||
{%- elif name == 'key' -%}
|
||||
<circle cx="8" cy="15" r="3.5"/><path d="m10.5 12.5 8-8"/><path d="m15.5 7.5 2 2"/><path d="m18 5 2 2"/>
|
||||
{%- elif name == 'upload' -%}
|
||||
<path d="M12 16V4"/><path d="m7.5 8.5 4.5-4.5 4.5 4.5"/><path d="M4 15v3.5A1.5 1.5 0 0 0 5.5 20h13a1.5 1.5 0 0 0 1.5-1.5V15"/>
|
||||
{%- elif name == 'download' -%}
|
||||
<path d="M12 4v12"/><path d="m7.5 11.5 4.5 4.5 4.5-4.5"/><path d="M4 15v3.5A1.5 1.5 0 0 0 5.5 20h13a1.5 1.5 0 0 0 1.5-1.5V15"/>
|
||||
{%- elif name == 'plus' -%}
|
||||
<path d="M12 5v14"/><path d="M5 12h14"/>
|
||||
{%- elif name == 'search' -%}
|
||||
<circle cx="11" cy="11" r="6"/><path d="m15.5 15.5 4 4"/>
|
||||
{%- elif name == 'pencil' -%}
|
||||
<path d="M4 20h4l10-10-4-4L4 16v4Z"/><path d="m13.5 6.5 4 4"/>
|
||||
{%- elif name == 'trash' -%}
|
||||
<path d="M4 7h16"/><path d="M9 7V4.5h6V7"/><path d="M6 7v11.5A1.5 1.5 0 0 0 7.5 20h9a1.5 1.5 0 0 0 1.5-1.5V7"/><path d="M10 11v5"/><path d="M14 11v5"/>
|
||||
{%- elif name == 'check' -%}
|
||||
<path d="m5 12.5 4.5 4.5L19 7.5"/>
|
||||
{%- elif name == 'alert' -%}
|
||||
<path d="M12 4.5 21 19.5H3L12 4.5Z"/><path d="M12 10v4"/><circle cx="12" cy="16.8" r=".9" fill="currentColor" stroke="none"/>
|
||||
{%- elif name == 'copy' -%}
|
||||
<rect x="9" y="9" width="11" height="11" rx="1.5"/><path d="M15 6.5V5.5A1.5 1.5 0 0 0 13.5 4h-8A1.5 1.5 0 0 0 4 5.5v8A1.5 1.5 0 0 0 5.5 15h1"/>
|
||||
{%- elif name == 'terminal' -%}
|
||||
<rect x="3" y="4.5" width="18" height="15" rx="2"/><path d="m7.5 10 2.5 2.5-2.5 2.5"/><path d="M12.5 15h4"/>
|
||||
{%- elif name == 'script' -%}
|
||||
<path d="M7 3.5h7l4 4v13H7z"/><path d="M14 3.5v4h4"/><path d="M10 12h5"/><path d="M10 16h5"/>
|
||||
{%- elif name == 'image' -%}
|
||||
<rect x="3.5" y="4.5" width="17" height="15" rx="2"/><circle cx="9" cy="10" r="1.8"/><path d="m4.5 18 4.5-4.5 3.5 3.5 3-2.5 4 3.5"/>
|
||||
{%- elif name == 'menu' -%}
|
||||
<path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h16"/>
|
||||
{%- elif name == 'chevron' -%}
|
||||
<path d="m8.5 5.5 6 6.5-6 6.5"/>
|
||||
{%- elif name == 'back' -%}
|
||||
<path d="M19 12H5"/><path d="m11 6-6 6 6 6"/>
|
||||
{%- elif name == 'link' -%}
|
||||
<path d="M10 13.5a3.5 3.5 0 0 0 5 0l3-3a3.5 3.5 0 0 0-5-5l-1 1"/><path d="M14 10.5a3.5 3.5 0 0 0-5 0l-3 3a3.5 3.5 0 0 0 5 5l1-1"/>
|
||||
{%- elif name == 'network' -%}
|
||||
<rect x="3.5" y="14.5" width="6" height="5" rx="1"/><rect x="14.5" y="14.5" width="6" height="5" rx="1"/><rect x="9" y="4.5" width="6" height="5" rx="1"/><path d="M12 9.5v3H6.5v2"/><path d="M12 12.5h5.5v2"/>
|
||||
{%- endif -%}
|
||||
</svg>
|
||||
{%- endmacro %}
|
||||
@@ -1,102 +1,122 @@
|
||||
<!-- Edit trigger button — placed in Actions column -->
|
||||
<button class="secondary outline"
|
||||
{% import "partials/icons.html" as ico %}
|
||||
<!-- Edit trigger — sits in the row's Actions cell -->
|
||||
<button class="btn ghost sm"
|
||||
onclick="document.getElementById('edit-modal-{{ p.id }}').showModal()"
|
||||
x-data x-text="$store.i18n.t('edit')">
|
||||
Edit
|
||||
x-data>
|
||||
{{ ico.i('pencil', 14) }}<span x-text="$store.i18n.t('edit')">Edit</span>
|
||||
</button>
|
||||
|
||||
<!-- Edit dialog — Pico CSS native dialog, no extra library -->
|
||||
<!-- Edit dialog — native <dialog>, no extra library -->
|
||||
<dialog id="edit-modal-{{ p.id }}">
|
||||
<article>
|
||||
<header>
|
||||
<button aria-label="Close" rel="prev"
|
||||
onclick="document.getElementById('edit-modal-{{ p.id }}').close()"></button>
|
||||
<h3 x-data x-text="$store.i18n.t('edit_printer_title')">Edit Printer</h3>
|
||||
</header>
|
||||
<div x-data="{ ip: '{{ p.ip_address }}', port: '{{ p.port_name }}', portEdited: true }">
|
||||
<form hx-patch="/printers/{{ p.id }}"
|
||||
hx-target="#printer-list"
|
||||
hx-swap="outerHTML"
|
||||
hx-on::after-request="document.getElementById('edit-modal-{{ p.id }}').close()">
|
||||
|
||||
<label><span x-text="$store.i18n.t('printer_name')">Printer Name</span>
|
||||
<input type="text" name="name" value="{{ p.name }}" required>
|
||||
</label>
|
||||
<div class="dialog-head">
|
||||
<h3 x-text="$store.i18n.t('edit_printer_title')">Edit printer</h3>
|
||||
{# No aria-label="Close": Pico hooks that attribute to draw its own
|
||||
floated X icon, which fights this button's glyph and placement. #}
|
||||
<button type="button" class="icon-btn close"
|
||||
:aria-label="$store.i18n.t('cancel')"
|
||||
onclick="document.getElementById('edit-modal-{{ p.id }}').close()">×</button>
|
||||
</div>
|
||||
|
||||
<label><span x-text="$store.i18n.t('ip_address')">IP Address</span>
|
||||
<input type="text" name="ip_address"
|
||||
x-model="ip"
|
||||
@input="if (!portEdited) port = 'IP_' + ip.replaceAll('.', '_')"
|
||||
required>
|
||||
</label>
|
||||
<div class="dialog-body">
|
||||
<fieldset class="form-section">
|
||||
<legend x-text="$store.i18n.t('section_identity')">Identity</legend>
|
||||
<label>
|
||||
<span class="label-text" x-text="$store.i18n.t('printer_name')">Printer name</span>
|
||||
<input type="text" name="name" value="{{ p.name }}" required>
|
||||
</label>
|
||||
<label>
|
||||
<span class="label-text" x-text="$store.i18n.t('client')">Client</span>
|
||||
<select name="client_id">
|
||||
<option value="" x-text="$store.i18n.t('unassigned_option')">Unassigned</option>
|
||||
{% for c in clients %}
|
||||
<option value="{{ c.id }}" {% if p.client_id == c.id %}selected{% endif %}>{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<label><span x-text="$store.i18n.t('port_name')">Port Name</span>
|
||||
<input type="text" name="port_name"
|
||||
x-model="port"
|
||||
@change="portEdited = true"
|
||||
@keydown="portEdited = true">
|
||||
</label>
|
||||
<fieldset class="form-section">
|
||||
<legend x-text="$store.i18n.t('section_connection')">Network connection</legend>
|
||||
<div class="form-row">
|
||||
<label>
|
||||
<span class="label-text" x-text="$store.i18n.t('ip_address')">IP address</span>
|
||||
<input type="text" name="ip_address" class="mono"
|
||||
x-model="ip"
|
||||
@input="if (!portEdited) port = 'IP_' + ip.replaceAll('.', '_')"
|
||||
required>
|
||||
</label>
|
||||
<label>
|
||||
<span class="label-text" x-text="$store.i18n.t('port_name')">Port name</span>
|
||||
<input type="text" name="port_name" class="mono"
|
||||
x-model="port"
|
||||
@change="portEdited = true"
|
||||
@keydown="portEdited = true">
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<label><span x-text="$store.i18n.t('driver')">Driver</span>
|
||||
<select name="driver_id">
|
||||
<option value="" x-text="$store.i18n.t('no_driver_option')">-- No driver --</option>
|
||||
{% for item in driver_data %}
|
||||
<option value="{{ item.driver.id }}"
|
||||
{% if p.driver_id == item.driver.id %}selected{% endif %}>
|
||||
{{ item.driver.original_filename }} ({{ item.names | join(', ') }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<fieldset class="form-section">
|
||||
<legend x-text="$store.i18n.t('section_driver')">Driver</legend>
|
||||
<label>
|
||||
<select name="driver_id">
|
||||
<option value="" x-text="$store.i18n.t('no_driver_option')">No driver</option>
|
||||
{% for item in driver_data %}
|
||||
<option value="{{ item.driver.id }}"
|
||||
{% if p.driver_id == item.driver.id %}selected{% endif %}>
|
||||
{{ item.driver.original_filename }}{% if item.names %} — {{ item.names | join(', ') }}{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<span class="hint" x-text="$store.i18n.t('hint_driver')">Without a driver the printer is saved, but export stays locked.</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<label><span x-text="$store.i18n.t('duplex_mode')">Duplex Mode</span>
|
||||
<select name="duplex_mode">
|
||||
<option value="OneSided" {% if p.duplex_mode == 'OneSided' %}selected{% endif %} x-text="$store.i18n.t('one_sided')">One-Sided</option>
|
||||
<option value="LongEdge" {% if p.duplex_mode == 'LongEdge' %}selected{% endif %} x-text="$store.i18n.t('long_edge')">Long Edge</option>
|
||||
<option value="ShortEdge" {% if p.duplex_mode == 'ShortEdge' %}selected{% endif %} x-text="$store.i18n.t('short_edge')">Short Edge</option>
|
||||
</select>
|
||||
</label>
|
||||
<fieldset class="form-section">
|
||||
<legend x-text="$store.i18n.t('section_defaults')">Print defaults</legend>
|
||||
<div class="form-row">
|
||||
<label>
|
||||
<span class="label-text" x-text="$store.i18n.t('duplex_mode')">Duplex mode</span>
|
||||
<select name="duplex_mode">
|
||||
<option value="OneSided" {% if p.duplex_mode == 'OneSided' %}selected{% endif %} x-text="$store.i18n.t('one_sided')">One-sided</option>
|
||||
<option value="LongEdge" {% if p.duplex_mode == 'LongEdge' %}selected{% endif %} x-text="$store.i18n.t('long_edge')">Long edge</option>
|
||||
<option value="ShortEdge" {% if p.duplex_mode == 'ShortEdge' %}selected{% endif %} x-text="$store.i18n.t('short_edge')">Short edge</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span class="label-text" x-text="$store.i18n.t('paper_size')">Paper size</span>
|
||||
<select name="paper_size">
|
||||
<option value="A4" {% if p.paper_size == 'A4' %}selected{% endif %}>A4</option>
|
||||
<option value="Letter" {% if p.paper_size == 'Letter' %}selected{% endif %}>Letter</option>
|
||||
<option value="Legal" {% if p.paper_size == 'Legal' %}selected{% endif %}>Legal</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="check-row">
|
||||
<label>
|
||||
<input type="checkbox" name="color_mode" value="on" {% if p.color_mode %}checked{% endif %}>
|
||||
<span x-text="$store.i18n.t('color_mode')">Color</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" name="collate" value="on" {% if p.collate %}checked{% endif %}>
|
||||
<span x-text="$store.i18n.t('collate')">Collate</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
<input type="checkbox" name="color_mode" value="on"
|
||||
{% if p.color_mode %}checked{% endif %}>
|
||||
<span x-text="$store.i18n.t('color_mode')">Color Mode</span>
|
||||
</label>
|
||||
|
||||
<label><span x-text="$store.i18n.t('paper_size')">Paper Size</span>
|
||||
<select name="paper_size">
|
||||
<option value="A4" {% if p.paper_size == 'A4' %}selected{% endif %}>A4</option>
|
||||
<option value="Letter" {% if p.paper_size == 'Letter' %}selected{% endif %}>Letter</option>
|
||||
<option value="Legal" {% if p.paper_size == 'Legal' %}selected{% endif %}>Legal</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<input type="checkbox" name="collate" value="on"
|
||||
{% if p.collate %}checked{% endif %}>
|
||||
<span x-text="$store.i18n.t('collate')">Collate</span>
|
||||
</label>
|
||||
|
||||
<label><span x-text="$store.i18n.t('client')">Client</span>
|
||||
<select name="client_id">
|
||||
<option value="" x-text="$store.i18n.t('unassigned_option')">-- Unassigned --</option>
|
||||
{% for c in clients %}
|
||||
<option value="{{ c.id }}"
|
||||
{% if p.client_id == c.id %}selected{% endif %}>
|
||||
{{ c.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<footer>
|
||||
<button type="submit" x-text="$store.i18n.t('save')">Save</button>
|
||||
<button type="button" class="secondary"
|
||||
<div class="dialog-foot">
|
||||
<button type="button" class="btn ghost"
|
||||
onclick="document.getElementById('edit-modal-{{ p.id }}').close()"
|
||||
x-text="$store.i18n.t('cancel')">
|
||||
Cancel
|
||||
</button>
|
||||
</footer>
|
||||
x-text="$store.i18n.t('cancel')">Cancel</button>
|
||||
<button type="submit" class="btn" x-text="$store.i18n.t('save')">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
<div x-data="{ ip: '{{ printer.ip_address if printer else '' }}', port: '{{ printer.port_name if printer else '' }}', portEdited: {{ 'true' if printer else 'false' }} }">
|
||||
<form hx-post="/printers" hx-target="#printer-list" hx-swap="outerHTML">
|
||||
|
||||
<label>
|
||||
Printer Name
|
||||
<input type="text" name="name" placeholder="e.g. HP LaserJet 4050" required
|
||||
value="{{ printer.name if printer else '' }}">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
IP Address
|
||||
<input type="text" name="ip_address"
|
||||
x-model="ip"
|
||||
@input="if (!portEdited) port = 'IP_' + ip.replaceAll('.', '_')"
|
||||
placeholder="e.g. 192.168.1.100"
|
||||
required>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Port Name
|
||||
<input type="text" name="port_name"
|
||||
x-model="port"
|
||||
@change="portEdited = true"
|
||||
@keydown="portEdited = true"
|
||||
placeholder="e.g. IP_192_168_1_100">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Driver
|
||||
<select name="driver_id" id="printer-form-driver-select">
|
||||
<option value="">-- No driver --</option>
|
||||
{% for item in driver_data %}
|
||||
<option value="{{ item.driver.id }}"
|
||||
{% if printer and printer.driver_id == item.driver.id %}selected{% endif %}>
|
||||
{{ item.driver.original_filename }} ({{ item.names | join(', ') }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Duplex Mode
|
||||
<select name="duplex_mode">
|
||||
<option value="OneSided" {% if not printer or printer.duplex_mode == 'OneSided' %}selected{% endif %}>One-Sided</option>
|
||||
<option value="LongEdge" {% if printer and printer.duplex_mode == 'LongEdge' %}selected{% endif %}>Long Edge</option>
|
||||
<option value="ShortEdge" {% if printer and printer.duplex_mode == 'ShortEdge' %}selected{% endif %}>Short Edge</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<input type="checkbox" name="color_mode" value="on"
|
||||
{% if not printer or printer.color_mode %}checked{% endif %}>
|
||||
Color Mode
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Paper Size
|
||||
<select name="paper_size">
|
||||
<option value="A4" {% if not printer or printer.paper_size == 'A4' %}selected{% endif %}>A4</option>
|
||||
<option value="Letter" {% if printer and printer.paper_size == 'Letter' %}selected{% endif %}>Letter</option>
|
||||
<option value="Legal" {% if printer and printer.paper_size == 'Legal' %}selected{% endif %}>Legal</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<input type="checkbox" name="collate" value="on"
|
||||
{% if not printer or printer.collate %}checked{% endif %}>
|
||||
Collate
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Client
|
||||
<select name="client_id">
|
||||
<option value="">-- Unassigned --</option>
|
||||
{% for c in clients %}
|
||||
<option value="{{ c.id }}"
|
||||
{% if printer and printer.client_id == c.id %}selected{% endif %}>
|
||||
{{ c.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button type="submit">Save Printer</button>
|
||||
</form>
|
||||
|
||||
<form hx-post="/drivers/upload"
|
||||
hx-target="#driver-list"
|
||||
hx-encoding="multipart/form-data"
|
||||
hx-swap="outerHTML">
|
||||
<input type="hidden" name="caller" value="printer_form">
|
||||
<label>
|
||||
Upload New Driver
|
||||
<input type="file" name="file" accept=".zip" required>
|
||||
</label>
|
||||
<button type="submit" class="secondary">Upload Driver</button>
|
||||
</form>
|
||||
<div id="driver-list" style="display:none"></div>
|
||||
</div>
|
||||
@@ -1,57 +1,98 @@
|
||||
{% import "partials/icons.html" as ico %}
|
||||
<div id="printer-list">
|
||||
{% if not grouped %}
|
||||
<p x-data x-text="$store.i18n.t('no_printers')">No printers configured yet.</p>
|
||||
<div class="card">
|
||||
<div class="empty">
|
||||
<span class="empty-ico">{{ ico.i('printer', 18) }}</span>
|
||||
<strong x-data x-text="$store.i18n.t('no_printers')">No printers configured yet.</strong>
|
||||
<p x-data x-text="$store.i18n.t('step_printer_desc')">Name, IP address, driver, and the defaults the workstation gets.</p>
|
||||
<a href="/printers/new" class="btn sm" x-data>{{ ico.i('plus', 14) }}<span x-text="$store.i18n.t('add_printer')">Add printer</span></a>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{% for client_name, printers in grouped.items() %}
|
||||
<section>
|
||||
{% set group_client_id = printers[0].client_id if printers else None %}
|
||||
{% if group_client_id %}
|
||||
<h3><a href="/clients/{{ group_client_id }}">{{ client_name }}</a></h3>
|
||||
{% else %}
|
||||
<h3>{{ client_name }}</h3>
|
||||
{% endif %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th x-data x-text="$store.i18n.t('th_name')">Name</th>
|
||||
<th x-data x-text="$store.i18n.t('th_ip')">IP Address</th>
|
||||
<th x-data x-text="$store.i18n.t('th_port')">Port</th>
|
||||
<th x-data x-text="$store.i18n.t('th_driver')">Driver</th>
|
||||
<th x-data x-text="$store.i18n.t('th_duplex')">Duplex</th>
|
||||
<th x-data x-text="$store.i18n.t('th_color')">Color</th>
|
||||
<th x-data x-text="$store.i18n.t('th_paper')">Paper</th>
|
||||
<th x-data x-text="$store.i18n.t('th_collate')">Collate</th>
|
||||
<th x-data x-text="$store.i18n.t('th_actions')">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in printers %}
|
||||
<tr>
|
||||
<td><a href="/printers/{{ p.id }}">{{ p.name }}</a></td>
|
||||
<td>{{ p.ip_address }}</td>
|
||||
<td>{{ p.port_name }}</td>
|
||||
<td>{{ p.driver.original_filename if p.driver_id else '—' }}</td>
|
||||
<td>{{ p.duplex_mode }}</td>
|
||||
<td x-data="{ val: {{ 'true' if p.color_mode else 'false' }} }"
|
||||
x-text="val ? $store.i18n.t('yes') : $store.i18n.t('no')">{{ 'Yes' if p.color_mode else 'No' }}</td>
|
||||
<td>{{ p.paper_size }}</td>
|
||||
<td x-data="{ val: {{ 'true' if p.collate else 'false' }} }"
|
||||
x-text="val ? $store.i18n.t('yes') : $store.i18n.t('no')">{{ 'Yes' if p.collate else 'No' }}</td>
|
||||
<td>
|
||||
{% include "partials/printer_edit_modal.html" %}
|
||||
<button
|
||||
hx-delete="/printers/{{ p.id }}"
|
||||
hx-target="#printer-list"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Delete '{{ p.name }}'?"
|
||||
x-data x-text="$store.i18n.t('delete')">
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% set group_client_id = printers[0].client_id if printers else None %}
|
||||
{# Group search text must cover everything its rows match on, or a row can
|
||||
match while its group stays hidden. #}
|
||||
{% set gsearch = (printers | map(attribute='name') | join(' ')) ~ ' '
|
||||
~ (printers | map(attribute='ip_address') | join(' ')) ~ ' '
|
||||
~ (printers | map(attribute='port_name') | join(' ')) ~ ' '
|
||||
~ (printers | selectattr('driver_id') | map(attribute='driver.original_filename') | join(' ')) ~ ' '
|
||||
~ client_name %}
|
||||
<section class="card card-flush"
|
||||
data-search="{{ gsearch | lower }}"
|
||||
x-data
|
||||
x-show="!$store.filter.q.trim() || $el.dataset.search.includes($store.filter.q.trim().toLowerCase())">
|
||||
<div class="group-head">
|
||||
{{ ico.i('group') }}
|
||||
{% if group_client_id %}
|
||||
<h3><a href="/clients/{{ group_client_id }}">{{ client_name }}</a></h3>
|
||||
{% else %}
|
||||
<h3 x-data x-text="$store.i18n.t('unassigned')">{{ client_name }}</h3>
|
||||
{% endif %}
|
||||
<span class="count">{{ printers | length }}</span>
|
||||
</div>
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th x-data x-text="$store.i18n.t('th_name')">Name</th>
|
||||
<th x-data x-text="$store.i18n.t('th_status')">Status</th>
|
||||
<th class="col-defaults" x-data x-text="$store.i18n.t('th_config')">Defaults</th>
|
||||
<th x-data x-text="$store.i18n.t('th_actions')">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in printers %}
|
||||
<tr data-search="{{ (p.name ~ ' ' ~ p.ip_address ~ ' ' ~ p.port_name ~ ' ' ~ client_name ~ ' ' ~ (p.driver.original_filename if p.driver_id else '')) | lower }}"
|
||||
x-data
|
||||
x-show="!$store.filter.q.trim() || $el.dataset.search.includes($store.filter.q.trim().toLowerCase())">
|
||||
<td>
|
||||
<a class="cell-name" href="/printers/{{ p.id }}">{{ p.name }}</a>
|
||||
<span class="cell-sub">{{ p.ip_address }} · {{ p.port_name }}</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if p.driver_id %}
|
||||
<span class="badge ok">{{ ico.i('check', 12) }}<span x-data x-text="$store.i18n.t('ready_to_export')">Ready</span></span>
|
||||
<span class="cell-sub">{{ p.driver.original_filename }}</span>
|
||||
{% else %}
|
||||
<span class="badge warn">{{ ico.i('alert', 12) }}<span x-data x-text="$store.i18n.t('needs_driver')">Driver needed</span></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="col-defaults">
|
||||
<span class="pill-row">
|
||||
<span class="pill" x-data
|
||||
x-text="$store.i18n.t('{{ 'one_sided' if p.duplex_mode == 'OneSided' else ('long_edge' if p.duplex_mode == 'LongEdge' else 'short_edge') }}')">{{ p.duplex_mode }}</span>
|
||||
<span class="pill" x-data
|
||||
x-text="$store.i18n.t('{{ 'color_value' if p.color_mode else 'grayscale_value' }}')">{{ 'Color' if p.color_mode else 'Grayscale' }}</span>
|
||||
<span class="pill">{{ p.paper_size }}</span>
|
||||
{% if p.collate %}
|
||||
<span class="pill" x-data x-text="$store.i18n.t('collate')">Collate</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<span class="btn-row">
|
||||
{% if p.driver_id %}
|
||||
<a class="btn quiet sm" href="/printers/{{ p.id }}/packages/intunewin"
|
||||
title=".intunewin">{{ ico.i('download', 14) }}</a>
|
||||
{% endif %}
|
||||
{% include "partials/printer_edit_modal.html" %}
|
||||
<button class="btn danger sm"
|
||||
hx-delete="/printers/{{ p.id }}"
|
||||
hx-target="#printer-list"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Delete '{{ p.name }}'?"
|
||||
x-data :title="$store.i18n.t('delete')">
|
||||
{{ ico.i('trash', 14) }}
|
||||
</button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user