Remediate the 2026-09-03 feature-test findings

This commit is contained in:
2026-09-03 17:15:03 -04:00
parent cf95daf3bb
commit fd513c08eb
112 changed files with 5182 additions and 857 deletions

View File

@@ -6,7 +6,7 @@ import re
from flask import Blueprint, request, Response, current_app
from flask_jwt_extended import jwt_required
from shopdb.api import db, cache, Setting, Asset, AssetType, Vendor, Model, Communication, CommunicationType, AssetRelationship, RelationshipType, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
from shopdb.api import db, cache, Setting, Asset, AssetType, Vendor, Model, Communication, CommunicationType, AssetRelationship, RelationshipType, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query, intarg
from ..models import Printer, PrinterType, ModelSupply, PrinterDriver, PrinterObservedQueue
from ..models.model_supply import SUPPLY_TYPES, SUPPLY_COLORS, CAPACITY_TIERS
@@ -163,10 +163,20 @@ def create_driver():
data = request.get_json() or {}
if not (data.get('name') and data.get('location')):
return error_response(ErrorCodes.VALIDATION_ERROR, 'name and location are required')
# drivername and vendorid are accepted here because they are the two
# fields the converger actually uses. drivername is the exact string from
# the INF that Add-PrinterDriver matches on - `name` is ours to choose and
# means nothing to Windows - and vendorid attaches a universal driver to a
# whole make. Neither was read from the payload, so both came back null on
# a 201 and there was no way to set them through any interface: the
# converger had nothing to match and installed nothing, and driver-name
# drift could never be detected because the expected value was never there.
d = PrinterDriver(
name=data['name'],
location=data['location'],
description=data.get('description'),
drivername=(data.get('drivername') or '').strip() or None,
vendorid=data.get('vendorid') or None,
modelnumberid=data.get('modelnumberid') or None,
isactive=data.get('isactive', True),
)
@@ -185,8 +195,11 @@ def update_driver(driver_id):
for key in ('name', 'location', 'description', 'isactive'):
if key in data:
setattr(d, key, data[key])
if 'modelnumberid' in data:
d.modelnumberid = data['modelnumberid'] or None
if 'drivername' in data:
d.drivername = (data['drivername'] or '').strip() or None
for key in ('vendorid', 'modelnumberid'):
if key in data:
setattr(d, key, data[key] or None)
db.session.commit()
return success_response(d.to_dict(), message='Driver updated')
@@ -261,20 +274,20 @@ def list_printers():
)
# Type filter
if typeid := request.args.get('typeid', request.args.get('type_id')):
query = query.filter(Printer.printertypeid == int(typeid))
if (typeid := intarg('typeid', intarg('type_id'))) is not None:
query = query.filter(Printer.printertypeid == typeid)
# Vendor filter
if vendor_id := request.args.get('vendorid', request.args.get('vendor_id')):
query = query.filter(Printer.vendorid == int(vendor_id))
if (vendor_id := intarg('vendorid', intarg('vendor_id'))) is not None:
query = query.filter(Printer.vendorid == vendor_id)
# Location filter
if location_id := request.args.get('locationid', request.args.get('location_id')):
query = query.filter(Asset.locationid == int(location_id))
if (location_id := intarg('locationid', intarg('location_id'))) is not None:
query = query.filter(Asset.locationid == location_id)
# Business unit filter
if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')):
query = query.filter(Asset.businessunitid == int(bu_id))
if (bu_id := intarg('businessunitid', intarg('businessunit_id'))) is not None:
query = query.filter(Asset.businessunitid == bu_id)
# Sorting
sort_by = request.args.get('sort', 'hostname')
@@ -2316,15 +2329,28 @@ def printer_lookup():
'Provide ip or fqdn'
)
# match the IP against any active printer communication
row = (
# An IP matches a communication row; an FQDN matches the printer's
# hostname. Both were compared against Communication.ipaddress, so
# ?fqdn=<hostname> never found anything - it 404d on printers whose
# hostname was right there on the record. Zabbix uses this to jump to a
# printer, and it knows names as often as addresses.
query = (
db.session.query(Printer, Asset)
.join(Asset, Asset.assetid == Printer.assetid)
.join(Communication, Communication.assetid == Asset.assetid)
.filter(Asset.isactive == True)
.filter(Communication.ipaddress == lookup_value)
.first()
)
if ip:
row = (query
.join(Communication, Communication.assetid == Asset.assetid)
.filter(Communication.ipaddress == ip)
.first())
else:
row = query.filter(Printer.hostname == fqdn).first()
if row is None:
# A caller holding a fully-qualified name against a site that
# stores the short one, or the reverse.
shortname = fqdn.split('.', 1)[0]
row = query.filter(Printer.hostname == shortname).first()
if not row:
return error_response(
@@ -2481,8 +2507,8 @@ def list_supply_models():
if search := request.args.get('search'):
query = query.filter(Model.modelnumber.ilike(f'%{search}%'))
if vendor_id := request.args.get('vendorid', request.args.get('vendor_id')):
query = query.filter(Model.vendorid == int(vendor_id))
if (vendor_id := intarg('vendorid', intarg('vendor_id'))) is not None:
query = query.filter(Model.vendorid == vendor_id)
if request.args.get('withsupplies', '').lower() == 'true':
query = query.having(supplycount > 0)
@@ -2755,7 +2781,7 @@ def supplies_forecast():
)
try:
days = max(1, min(365, int(request.args.get('days', 90))))
days = max(1, min(365, intarg('days', 90)))
except (TypeError, ValueError):
days = 90

View File

@@ -10,11 +10,21 @@
Print Label
</router-link>
<router-link :to="`/printers/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
<button @click="confirmRetire" class="btn btn-danger">Retire</button>
<button v-if="!isRetired" @click="confirmRetire" class="btn btn-danger">Retire</button>
<!-- A retired record offered Edit and Retire exactly like a live
one, and nothing said it was retired. Restoring it was possible
through the API and nowhere in the product. -->
<button v-if="isRetired" @click="restore" class="btn btn-primary">Restore</button>
<router-link to="/printers" class="btn btn-secondary">Back to List</router-link>
</div>
</div>
<div v-if="printer && printer.isactive === false" class="retired-banner card">
<strong>This printer is retired.</strong>
It stays out of every list and report, and its history and relationships
are intact. Restore puts it back.
</div>
<div v-if="loading" class="loading">Loading...</div>
<template v-else-if="printer">
@@ -306,7 +316,7 @@
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { printersApi } from '@/api'
import { withBase } from '@/utils/basePath'
@@ -406,6 +416,21 @@ function formatDate(dateStr) {
// Retire, not delete: the server deactivates the asset, so the printer
// leaves every list and report while its history and relationships stay put.
// Reversible by an admin setting isactive back to true.
const isRetired = computed(() => printer.value?.isactive === false)
async function restore() {
const label = printer.value?.assetnumber || printer.value?.name || 'this printer'
try {
await printersApi.update(route.params.id, { isactive: true })
toast.success(`Restored ${label}`)
const response = await printersApi.get(route.params.id)
printer.value = response.data.data
} catch (error) {
console.error('Error restoring:', error)
toast.error(apiError(error, 'Failed to restore'))
}
}
async function confirmRetire() {
const label = displayTitle.value || 'this printer'
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
@@ -422,6 +447,10 @@ async function confirmRetire() {
</script>
<style scoped>
.retired-banner {
border-left: 3px solid var(--warning);
margin-bottom: 1rem;
}
/* Printer-specific styles - shared styles are in global style.css */
.empty-state {

View File

@@ -61,6 +61,26 @@
<label>Name *</label>
<input v-model="form.name" type="text" class="form-control" maxlength="150" required />
</div>
<div class="form-group">
<label>
INF Driver Name
<span class="hint">(the exact string the INF declares - what
Add-PrinterDriver matches on. Blank means the converger
cannot install this driver.)</span>
</label>
<input v-model="form.drivername" type="text" class="form-control"
maxlength="255"
placeholder="HP Universal Printing PCL 6" />
</div>
<div class="form-group">
<label>Vendor <span class="hint">(a universal driver serves every printer of that make; a model below wins over it)</span></label>
<select v-model="form.vendorid" class="form-control">
<option :value="null">-- none --</option>
<option v-for="v in vendors" :key="v.vendorid" :value="v.vendorid">
{{ v.vendor }}
</option>
</select>
</div>
<div class="form-group">
<label>Printer Model <span class="hint">(links driver to a model so it shows on matching printers)</span></label>
<select v-model="form.modelnumberid" class="form-control">
@@ -96,13 +116,14 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { printersApi } from '@/api'
import { printersApi, vendorsApi } from '@/api'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
const toast = useToast()
const items = ref([])
const models = ref([])
const vendors = ref([])
const showInactive = ref(false)
const visibleItems = computed(() => showInactive.value ? items.value : items.value.filter(x => x.isactive !== false))
const loading = ref(true)
@@ -110,13 +131,13 @@ const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref({ name: '', location: '', description: '', modelnumberid: null, isactive: true })
const form = ref({ name: '', location: '', description: '', drivername: '', vendorid: null, modelnumberid: null, isactive: true })
function isHttp(loc) {
return typeof loc === 'string' && /^https?:\/\//i.test(loc)
}
onMounted(() => { loadData(); loadModels() })
onMounted(() => { loadData(); loadModels(); loadVendors() })
async function loadData() {
loading.value = true
@@ -139,11 +160,23 @@ async function loadModels() {
}
}
async function loadVendors() {
try {
// A universal driver (HP UPD, Xerox GPD) attaches to the whole make rather
// than one model, which is what vendorid is for. listAll, because the
// picker must show every vendor - perpage is clamped at 100.
const response = await vendorsApi.list({ perpage: 100 })
vendors.value = response.data.data || []
} catch (err) {
console.error('Error loading vendors:', err)
}
}
function openModal(item = null) {
editing.value = item
form.value = item
? { name: item.name || '', location: item.location || '', description: item.description || '', modelnumberid: item.modelnumberid || null, isactive: item.isactive !== false }
: { name: '', location: '', description: '', modelnumberid: null, isactive: true }
? { name: item.name || '', location: item.location || '', description: item.description || '', drivername: item.drivername || '', vendorid: item.vendorid || null, modelnumberid: item.modelnumberid || null, isactive: item.isactive !== false }
: { name: '', location: '', description: '', drivername: '', vendorid: null, modelnumberid: null, isactive: true }
error.value = ''
showModal.value = true
}

View File

@@ -336,6 +336,7 @@ import Modal from '@/components/Modal.vue'
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
import { currentTheme } from '@/stores/theme'
import { useIdentifierFlags } from '@/composables/identifierSettings'
import { loadAssetTypeIds, assetTypeId } from '@/composables/assetTypeIds'
import { apiError } from '@/utils/apiError'
import { getPrinterHostnameTemplate } from '@/utils/siteSettings'
@@ -346,8 +347,11 @@ const router = useRouter()
const isEdit = computed(() => !!route.params.id)
// Seeded asset-type id for printers (see /api/assets/types).
const PRINTER_ASSETTYPEID = 4
// Resolved by NAME at load, never hardcoded: the id comes from seed
// order, so the constant that used to live here pointed at a different
// asset type on a differently-seeded site - the form then rendered THAT
// type's custom fields and dropped whatever was typed into them.
const PRINTER_ASSETTYPEID = computed(() => assetTypeId('printer'))
const customFieldsRef = ref(null)
const currentAssetId = ref(null)
const manualHostname = ref(false)
@@ -542,6 +546,7 @@ function onWindowsNameInput() {
}
onMounted(async () => {
await loadAssetTypeIds()
try {
// Load reference data (models paged in full via listAll, see api/index.js)
// perpage 100 so dropdowns aren't truncated to the default 20-row page
@@ -686,7 +691,13 @@ async function savePrinter() {
isduplex: !!form.value.isduplex,
isnetwork: !!form.value.isnetwork,
mapx: form.value.mapx,
mapy: form.value.mapy
mapy: form.value.mapy,
// ADR-017: a position without its level cannot be drawn on the right
// floor plan. The picker sets all three and this payload sent two, so a
// picked pin saved a levelid of null and then rendered nowhere - the
// form even showed "Position: x, y" back, because it kept the value it
// had never sent.
levelid: form.value.levelid
}
// only set the display name when an alias is given, so we don't clobber it
if (form.value.alias) {

View File

@@ -95,7 +95,7 @@ onMounted(loadData)
async function loadData() {
loading.value = true
try {
const response = await printersApi.types.list({ perpage: 200, active: false })
const response = await printersApi.types.list({ perpage: 100, active: false })
items.value = response.data.data || []
} catch (err) {
console.error('Error loading printer types:', err)

View File

@@ -25,7 +25,14 @@
{{ pt.printertype }}
</option>
</select>
</div>
<!-- Retire is a soft delete and the confirm dialog promises the record
"can be restored". The API kept that promise (?active=false lists
them, PUT isactive restores); nothing in the UI could reach it. -->
<label class="checkbox-label">
<input type="checkbox" v-model="showRetired" @change="loadPrinters" />
Show retired
</label>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
@@ -111,6 +118,7 @@ function applySuggestion(word) {
// search page documents. Load directly.
loadPrinters()
}
const showRetired = ref(false)
const totalPages = ref(1)
const perPage = ref(20)
@@ -134,6 +142,8 @@ async function loadPrinters() {
perpage: perPage.value
}
if (search.value) params.search = search.value
// active=false means "include retired", matching the API.
if (showRetired.value) params.active = 'false'
if (typeFilter.value) params.typeid = typeFilter.value
const response = await printersApi.list(params)

View File

@@ -17,8 +17,18 @@
</div>
<template v-else>
<!-- Cannot see the supplies: say so instead of reporting zeros. -->
<div v-if="unavailable" class="card blind-notice">
<strong>Supply levels are not being read.</strong>
<p>{{ unavailable }}</p>
<p class="muted">
The counts below are not a result - nothing was measured. Check the
supply monitoring configuration in Settings.
</p>
</div>
<!-- Summary Bar -->
<div class="summary-bar">
<div v-if="!unavailable" class="summary-bar">
<div class="summary-stat card">
<div class="stat-value">{{ summary.total_checked }}</div>
<div class="stat-label">Printers Checked</div>
@@ -122,6 +132,9 @@
</tr>
</tbody>
</table>
<p v-else-if="unavailable" class="empty-state">
No supply data was read, so there is nothing to list.
</p>
<p v-else class="empty-state">No printers match the selected filter.</p>
</div>
</div>
@@ -156,6 +169,7 @@ const loading = ref(true)
const error = ref(null)
const printers = ref([])
const summary = ref({ total_checked: 0, low: 0, critical: 0 })
const unavailable = ref('')
const filter = ref('all')
const filterOptions = [
@@ -239,6 +253,11 @@ onMounted(async () => {
const data = response.data.data
printers.value = data.printers || []
summary.value = data.summary || { total_checked: 0, low: 0, critical: 0 }
// The API says so explicitly when it cannot see supplies at all. Without
// this the page rendered the zeros it was given - "0 Printers Checked,
// 0 Low, 0 Critical" and "No printers match the selected filter" - which
// reads as a clean bill of health for a fleet nobody has looked at.
unavailable.value = data.unavailable || ''
} catch (err) {
console.error('Error loading toner report:', err)
error.value = 'Failed to load supply data. Zabbix may not be configured or reachable.'
@@ -249,6 +268,13 @@ onMounted(async () => {
</script>
<style scoped>
.blind-notice {
border-left: 3px solid var(--warning);
margin-bottom: 1rem;
}
.blind-notice p {
margin: 0.4rem 0 0 0;
}
.summary-bar {
display: flex;
gap: 1.5rem;