map marker legibility, PDF export blueprint fix, asset list type search
This commit is contained in:
@@ -97,7 +97,7 @@ import L from 'leaflet'
|
|||||||
import 'leaflet/dist/leaflet.css'
|
import 'leaflet/dist/leaflet.css'
|
||||||
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig'
|
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig'
|
||||||
import { assetTypeLabel, assetDetailRoute } from '../utils/assetTypes'
|
import { assetTypeLabel, assetDetailRoute } from '../utils/assetTypes'
|
||||||
import { getSubtypeId } from '../utils/mapColors'
|
import { getSubtypeId, markerRingColor, UNSPECIFIED_COLOR } from '../utils/mapColors'
|
||||||
import api from '../api'
|
import api from '../api'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -451,7 +451,7 @@ function renderMarkers() {
|
|||||||
// Unified asset mode - use subtype colors when a type is selected
|
// Unified asset mode - use subtype colors when a type is selected
|
||||||
if (props.selectedAssetType) {
|
if (props.selectedAssetType) {
|
||||||
const subtypeId = getSubtypeId(item)
|
const subtypeId = getSubtypeId(item)
|
||||||
color = (subtypeId && props.subtypeColors[subtypeId]) || '#BDBDBD'
|
color = (subtypeId && props.subtypeColors[subtypeId]) || UNSPECIFIED_COLOR
|
||||||
typeName = (subtypeId && props.subtypeNames[subtypeId]) || item.assettype || ''
|
typeName = (subtypeId && props.subtypeNames[subtypeId]) || item.assettype || ''
|
||||||
} else {
|
} else {
|
||||||
// Prefer the stored AssetType.color; fall back to the built-in map.
|
// Prefer the stored AssetType.color; fall back to the built-in map.
|
||||||
@@ -477,7 +477,9 @@ function renderMarkers() {
|
|||||||
const marker = L.circleMarker([leafletY, leafletX], {
|
const marker = L.circleMarker([leafletY, leafletX], {
|
||||||
radius: 6,
|
radius: 6,
|
||||||
fillColor: color,
|
fillColor: color,
|
||||||
color: 'rgba(255,255,255,0.8)',
|
// Ring picked from the fill, not fixed white - a white ring on a light
|
||||||
|
// marker vanished against the light (white) blueprint.
|
||||||
|
color: markerRingColor(color),
|
||||||
weight: 2,
|
weight: 2,
|
||||||
fillOpacity: 1,
|
fillOpacity: 1,
|
||||||
renderer: canvasRenderer
|
renderer: canvasRenderer
|
||||||
|
|||||||
@@ -2,17 +2,27 @@
|
|||||||
// the exact same colors the map shows on screen. Mirrors the maps defined in
|
// the exact same colors the map shows on screen. Mirrors the maps defined in
|
||||||
// ShopFloorMap.vue - keep the two in sync if the palette changes.
|
// ShopFloorMap.vue - keep the two in sync if the palette changes.
|
||||||
|
|
||||||
|
// Markers sit on TWO very different surfaces: the light blueprint on white, and
|
||||||
|
// the dark blueprint on the near-black navy card. Every step below clears 3:1
|
||||||
|
// against both (#ffffff and the dark card, ~#060628), so no marker washes out on
|
||||||
|
// either. The hues keep their established meaning - only the step changed. The
|
||||||
|
// old values were far too light to survive white: orange #FF9800 sat at 2.16:1
|
||||||
|
// and the grey default at 1.88:1.
|
||||||
export const assetTypeColorsMap = {
|
export const assetTypeColorsMap = {
|
||||||
machine: '#F44336', // Red
|
machine: '#e03131', // Red
|
||||||
computer: '#2196F3', // Blue
|
computer: '#1976d2', // Blue
|
||||||
printer: '#4CAF50', // Green
|
printer: '#2e7d32', // Green
|
||||||
'network device': '#FF9800', // Orange
|
'network device': '#e65100', // Orange
|
||||||
network_device: '#FF9800', // Orange (alternate key)
|
network_device: '#e65100', // Orange (alternate key)
|
||||||
measuring_tool: '#9C27B0', // Purple
|
measuring_tool: '#9c4dcc', // Purple
|
||||||
'measuring tool': '#9C27B0' // Purple (normalized key)
|
'measuring tool': '#9c4dcc' // Purple (normalized key)
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_COLOR = '#BDBDBD'
|
// Fallback for an asset with no subtype. Reads as neutral without vanishing:
|
||||||
|
// 5.4:1 on white, 3.7:1 on the dark card (the old #BDBDBD was 1.88:1 on white).
|
||||||
|
export const UNSPECIFIED_COLOR = '#546e7a'
|
||||||
|
|
||||||
|
const DEFAULT_COLOR = UNSPECIFIED_COLOR
|
||||||
|
|
||||||
// Canonical form for comparing asset-type strings. The API sends the machine
|
// Canonical form for comparing asset-type strings. The API sends the machine
|
||||||
// type as 'network_device' but subtype keys and labels use 'network device',
|
// type as 'network_device' but subtype keys and labels use 'network device',
|
||||||
@@ -43,8 +53,23 @@ export function getSubtypeId(asset) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ring color for a marker of the given fill. A fixed white ring disappears on
|
||||||
|
// the white blueprint, so pick the ring by the FILL's lightness instead: a light
|
||||||
|
// fill gets a dark ring, a dark fill a light one. The marker then keeps a hard
|
||||||
|
// edge on either surface, and it also rescues a washed-out color a user picked
|
||||||
|
// by hand for a subtype - which the palette below cannot control.
|
||||||
|
export function markerRingColor(fill) {
|
||||||
|
const hex = (fill || '').replace('#', '')
|
||||||
|
if (hex.length !== 6) return '#ffffff'
|
||||||
|
const [r, g, b] = [0, 2, 4].map(i => parseInt(hex.slice(i, i + 2), 16) / 255)
|
||||||
|
// Relative luminance (WCAG), same formula the palette validator uses.
|
||||||
|
const channel = c => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)
|
||||||
|
const luminance = 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
|
||||||
|
return luminance > 0.35 ? '#1b1b1b' : '#ffffff'
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve the marker color for an asset the same way ShopFloorMap does: when a
|
// Resolve the marker color for an asset the same way ShopFloorMap does: when a
|
||||||
// type is selected, color by subtype (grey fallback); otherwise by asset type.
|
// type is selected, color by subtype (neutral fallback); otherwise by asset type.
|
||||||
export function resolveMarkerColor(asset, { selectedType, subtypeColors = {} }) {
|
export function resolveMarkerColor(asset, { selectedType, subtypeColors = {} }) {
|
||||||
if (selectedType) {
|
if (selectedType) {
|
||||||
const id = getSubtypeId(asset)
|
const id = getSubtypeId(asset)
|
||||||
|
|||||||
@@ -1,5 +1,26 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { getSubtypeId, resolveMarkerColor } from './mapColors'
|
import {
|
||||||
|
getSubtypeId, resolveMarkerColor, markerRingColor, assetTypeColorsMap,
|
||||||
|
UNSPECIFIED_COLOR
|
||||||
|
} from './mapColors'
|
||||||
|
|
||||||
|
// WCAG relative luminance / contrast, so the palette assertions below are
|
||||||
|
// computed rather than eyeballed.
|
||||||
|
function contrast(a, b) {
|
||||||
|
const lum = hex => {
|
||||||
|
const h = hex.replace('#', '')
|
||||||
|
const [r, g, b2] = [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16) / 255)
|
||||||
|
const ch = c => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)
|
||||||
|
return 0.2126 * ch(r) + 0.7152 * ch(g) + 0.0722 * ch(b2)
|
||||||
|
}
|
||||||
|
const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x)
|
||||||
|
return (hi + 0.05) / (lo + 0.05)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The two surfaces a marker has to survive: the light blueprint (white) and the
|
||||||
|
// dark blueprint over the dark card (--bg-card over --bg).
|
||||||
|
const WHITE = '#ffffff'
|
||||||
|
const DARK_CARD = '#060628'
|
||||||
|
|
||||||
// One case per asset type that carries a subtype. A missing branch here means
|
// One case per asset type that carries a subtype. A missing branch here means
|
||||||
// the map subtype filter silently drops every asset of that type.
|
// the map subtype filter silently drops every asset of that type.
|
||||||
@@ -26,6 +47,34 @@ describe('getSubtypeId', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('marker legibility', () => {
|
||||||
|
const palette = [...new Set(Object.values(assetTypeColorsMap)), UNSPECIFIED_COLOR]
|
||||||
|
|
||||||
|
it.each(palette)('%s clears 3:1 on the white blueprint', (color) => {
|
||||||
|
expect(contrast(color, WHITE)).toBeGreaterThanOrEqual(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(palette)('%s clears 3:1 on the dark card', (color) => {
|
||||||
|
expect(contrast(color, DARK_CARD)).toBeGreaterThanOrEqual(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rings a light fill dark and a dark fill light', () => {
|
||||||
|
expect(markerRingColor('#FFEB3B')).toBe('#1b1b1b')
|
||||||
|
expect(markerRingColor('#1565c0')).toBe('#ffffff')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gives every ring 3:1 against its own fill', () => {
|
||||||
|
for (const color of palette) {
|
||||||
|
expect(contrast(color, markerRingColor(color))).toBeGreaterThanOrEqual(3)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to a light ring for a malformed color', () => {
|
||||||
|
expect(markerRingColor('')).toBe('#ffffff')
|
||||||
|
expect(markerRingColor('nonsense')).toBe('#ffffff')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('resolveMarkerColor', () => {
|
describe('resolveMarkerColor', () => {
|
||||||
it('colors a measuring tool by its subtype when a type is selected', () => {
|
it('colors a measuring tool by its subtype when a type is selected', () => {
|
||||||
const asset = { assettype: 'Measuring Tool', typedata: { measuringtooltypeid: 7 } }
|
const asset = { assettype: 'Measuring Tool', typedata: { measuringtooltypeid: 7 } }
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
// each visible marker is placed at its scaled coordinate, so the PDF is a crisp
|
// each visible marker is placed at its scaled coordinate, so the PDF is a crisp
|
||||||
// vector-over-raster page rather than a screen capture.
|
// vector-over-raster page rather than a screen capture.
|
||||||
import { jsPDF } from 'jspdf'
|
import { jsPDF } from 'jspdf'
|
||||||
import { resolveMarkerColor, getSubtypeId, getAssetTypeColor } from './mapColors'
|
import {
|
||||||
|
resolveMarkerColor, getSubtypeId, getAssetTypeColor, markerRingColor,
|
||||||
|
UNSPECIFIED_COLOR
|
||||||
|
} from './mapColors'
|
||||||
|
|
||||||
function loadImage(url) {
|
function loadImage(url) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -16,7 +19,7 @@ function loadImage(url) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hexToRgb(hex) {
|
function hexToRgb(hex) {
|
||||||
const h = (hex || '#BDBDBD').replace('#', '')
|
const h = (hex || UNSPECIFIED_COLOR).replace('#', '')
|
||||||
const v = h.length === 3 ? h.split('').map(c => c + c).join('') : h
|
const v = h.length === 3 ? h.split('').map(c => c + c).join('') : h
|
||||||
return [parseInt(v.slice(0, 2), 16), parseInt(v.slice(2, 4), 16), parseInt(v.slice(4, 6), 16)]
|
return [parseInt(v.slice(0, 2), 16), parseInt(v.slice(2, 4), 16), parseInt(v.slice(4, 6), 16)]
|
||||||
}
|
}
|
||||||
@@ -31,7 +34,7 @@ function buildLegend(assets, { selectedType, subtypeColors, subtypeNames }) {
|
|||||||
const id = getSubtypeId(a)
|
const id = getSubtypeId(a)
|
||||||
key = id != null ? String(id) : 'none'
|
key = id != null ? String(id) : 'none'
|
||||||
label = (id != null && subtypeNames[id]) || 'Unspecified'
|
label = (id != null && subtypeNames[id]) || 'Unspecified'
|
||||||
color = (id != null && subtypeColors[id]) || '#BDBDBD'
|
color = (id != null && subtypeColors[id]) || UNSPECIFIED_COLOR
|
||||||
} else {
|
} else {
|
||||||
key = (a.assettype || 'unknown').toLowerCase()
|
key = (a.assettype || 'unknown').toLowerCase()
|
||||||
label = a.assettype || 'Unknown'
|
label = a.assettype || 'Unknown'
|
||||||
@@ -94,8 +97,9 @@ export async function exportMapPdf(opts) {
|
|||||||
if (lx + w > pageW - margin) { lx = margin; ly += lineH }
|
if (lx + w > pageW - margin) { lx = margin; ly += lineH }
|
||||||
const [r, g, b] = hexToRgb(entry.color)
|
const [r, g, b] = hexToRgb(entry.color)
|
||||||
doc.setFillColor(r, g, b)
|
doc.setFillColor(r, g, b)
|
||||||
doc.setDrawColor('#ffffff'); doc.setLineWidth(0.4)
|
// Outline the swatch against the white page, same rule as the markers.
|
||||||
doc.rect(lx, ly - swatch + 1, swatch, swatch, 'F')
|
doc.setDrawColor(markerRingColor(entry.color)); doc.setLineWidth(0.4)
|
||||||
|
doc.rect(lx, ly - swatch + 1, swatch, swatch, 'FD')
|
||||||
doc.setTextColor('#333333')
|
doc.setTextColor('#333333')
|
||||||
doc.text(label, lx + swatch + 4, ly)
|
doc.text(label, lx + swatch + 4, ly)
|
||||||
lx += w
|
lx += w
|
||||||
@@ -124,9 +128,11 @@ export async function exportMapPdf(opts) {
|
|||||||
const x = imgX + (a.mapx / mapWidth) * imgW
|
const x = imgX + (a.mapx / mapWidth) * imgW
|
||||||
const y = imgY + (a.mapy / mapHeight) * imgH
|
const y = imgY + (a.mapy / mapHeight) * imgH
|
||||||
if (x < imgX || x > imgX + imgW || y < imgY || y > imgY + imgH) continue
|
if (x < imgX || x > imgX + imgW || y < imgY || y > imgY + imgH) continue
|
||||||
const [r, g, b] = hexToRgb(resolveMarkerColor(a, { selectedType, subtypeColors }))
|
const fill = resolveMarkerColor(a, { selectedType, subtypeColors })
|
||||||
|
const [r, g, b] = hexToRgb(fill)
|
||||||
doc.setFillColor(r, g, b)
|
doc.setFillColor(r, g, b)
|
||||||
doc.setDrawColor('#ffffff'); doc.setLineWidth(0.6)
|
// The PDF prints on white, where a white ring is invisible - ring by fill.
|
||||||
|
doc.setDrawColor(markerRingColor(fill)); doc.setLineWidth(0.6)
|
||||||
doc.circle(x, y, radius, 'FD')
|
doc.circle(x, y, radius, 'FD')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ import ShopFloorMap from '../components/ShopFloorMap.vue'
|
|||||||
import { assetsApi } from '../api'
|
import { assetsApi } from '../api'
|
||||||
import { currentTheme } from '../stores/theme'
|
import { currentTheme } from '../stores/theme'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { loadMapConfig, state as mapConfig } from '../composables/mapConfig'
|
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig'
|
||||||
import { exportMapPdf } from '../utils/mapPdf'
|
import { exportMapPdf } from '../utils/mapPdf'
|
||||||
import { assetTypeLabel, assetDetailRoute } from '../utils/assetTypes'
|
import { assetTypeLabel, assetDetailRoute } from '../utils/assetTypes'
|
||||||
import { getSubtypeId } from '../utils/mapColors'
|
import { getSubtypeId } from '../utils/mapColors'
|
||||||
@@ -140,12 +140,18 @@ const subtypeLabel = computed(() => {
|
|||||||
return labels[selectedType.value.toLowerCase().replace(/_/g, ' ')] || 'All Subtypes'
|
return labels[selectedType.value.toLowerCase().replace(/_/g, ' ')] || 'All Subtypes'
|
||||||
})
|
})
|
||||||
|
|
||||||
// Generate distinct colors for subtypes
|
// Auto colors for subtypes that have not been given one. Every step clears 3:1
|
||||||
|
// against BOTH map surfaces (white blueprint and the dark navy card), so none
|
||||||
|
// washes out; the old list ran through pale yellows and limes (#FFEB3B was
|
||||||
|
// 1.1:1 on white) that were invisible on the light blueprint. Ordered by
|
||||||
|
// max-min perceptual separation, so the fewer subtypes are on screen the
|
||||||
|
// further apart their colors sit. Past roughly the fifth entry no palette can
|
||||||
|
// keep every pair distinguishable under color-blind simulation - the legend and
|
||||||
|
// the hover tooltip carry identity from there.
|
||||||
const subtypeColorPalette = [
|
const subtypeColorPalette = [
|
||||||
'#F44336', '#E91E63', '#9C27B0', '#673AB7', '#3F51B5',
|
'#7c4dff', '#ef6c00', '#0097a7', '#c2185b', '#1565c0',
|
||||||
'#2196F3', '#03A9F4', '#00BCD4', '#009688', '#4CAF50',
|
'#e03131', '#e0479e', '#00897b', '#827717', '#1976d2',
|
||||||
'#8BC34A', '#CDDC39', '#FFEB3B', '#FFC107', '#FF9800',
|
'#d81b60', '#388e3c', '#9c4dcc', '#2e7d32'
|
||||||
'#FF5722', '#795548', '#607D8B', '#00ACC1', '#5C6BC0'
|
|
||||||
]
|
]
|
||||||
|
|
||||||
// Map subtype IDs to colors: prefer each subtype's stored color; fall back to
|
// Map subtype IDs to colors: prefer each subtype's stored color; fall back to
|
||||||
@@ -236,7 +242,10 @@ async function exportPdf() {
|
|||||||
await loadMapConfig()
|
await loadMapConfig()
|
||||||
await exportMapPdf({
|
await exportMapPdf({
|
||||||
assets: filteredAssets.value,
|
assets: filteredAssets.value,
|
||||||
blueprintUrl: mapConfig.blueprintLight,
|
// blueprintUrlFor applies withBase - the raw setting value is a
|
||||||
|
// root-relative /api path, which 404s under a subpath mount like /ops.
|
||||||
|
// The PDF always uses the light blueprint: it prints on white paper.
|
||||||
|
blueprintUrl: blueprintUrlFor('light'),
|
||||||
mapWidth: mapConfig.width,
|
mapWidth: mapConfig.width,
|
||||||
mapHeight: mapConfig.height,
|
mapHeight: mapConfig.height,
|
||||||
selectedType: selectedType.value,
|
selectedType: selectedType.value,
|
||||||
|
|||||||
@@ -363,14 +363,20 @@ def list_computers():
|
|||||||
if exactassetnumber := request.args.get('assetnumber'):
|
if exactassetnumber := request.args.get('assetnumber'):
|
||||||
query = query.filter(Asset.assetnumber == exactassetnumber)
|
query = query.filter(Asset.assetnumber == exactassetnumber)
|
||||||
|
|
||||||
# Search filter
|
# Search filter. Covers the type name too - it is a column in the list, so
|
||||||
|
# searching 'Standard' must find the PCs of that type. Outer join so a PC
|
||||||
|
# with no type still matches on its own fields.
|
||||||
if search := request.args.get('search'):
|
if search := request.args.get('search'):
|
||||||
query = query.filter(
|
pattern = f'%{search}%'
|
||||||
|
query = query.outerjoin(
|
||||||
|
ComputerType, Computer.computertypeid == ComputerType.computertypeid
|
||||||
|
).filter(
|
||||||
db.or_(
|
db.or_(
|
||||||
Asset.assetnumber.ilike(f'%{search}%'),
|
Asset.assetnumber.ilike(pattern),
|
||||||
Asset.name.ilike(f'%{search}%'),
|
Asset.name.ilike(pattern),
|
||||||
Asset.serialnumber.ilike(f'%{search}%'),
|
Asset.serialnumber.ilike(pattern),
|
||||||
Computer.hostname.ilike(f'%{search}%')
|
Computer.hostname.ilike(pattern),
|
||||||
|
ComputerType.computertype.ilike(pattern)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from flask import Blueprint, request
|
from flask import Blueprint, request
|
||||||
from flask_jwt_extended import jwt_required
|
from flask_jwt_extended import jwt_required
|
||||||
|
|
||||||
from shopdb.api import db, Asset, AssetType, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query, resolve_dualpath_pairs, dualpath_single_machine_enabled
|
from shopdb.api import db, Asset, AssetType, AuditLog, Vendor, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query, resolve_dualpath_pairs, dualpath_single_machine_enabled
|
||||||
|
|
||||||
from ..models import Machine, MachineType
|
from ..models import Machine, MachineType
|
||||||
|
|
||||||
@@ -174,13 +174,23 @@ def list_machines():
|
|||||||
if exactassetnumber := request.args.get('assetnumber'):
|
if exactassetnumber := request.args.get('assetnumber'):
|
||||||
query = query.filter(Asset.assetnumber == exactassetnumber)
|
query = query.filter(Asset.assetnumber == exactassetnumber)
|
||||||
|
|
||||||
# Search filter
|
# Search filter. Covers what the list actually SHOWS - the asset fields plus
|
||||||
|
# the type and vendor names, which are columns in the table. Searching a type
|
||||||
|
# like 'Part Washer' used to return nothing. Outer joins so a machine with no
|
||||||
|
# type or vendor still matches on its own fields.
|
||||||
if search := request.args.get('search'):
|
if search := request.args.get('search'):
|
||||||
query = query.filter(
|
pattern = f'%{search}%'
|
||||||
|
query = query.outerjoin(
|
||||||
|
MachineType, Machine.machinetypeid == MachineType.machinetypeid
|
||||||
|
).outerjoin(
|
||||||
|
Vendor, Machine.vendorid == Vendor.vendorid
|
||||||
|
).filter(
|
||||||
db.or_(
|
db.or_(
|
||||||
Asset.assetnumber.ilike(f'%{search}%'),
|
Asset.assetnumber.ilike(pattern),
|
||||||
Asset.name.ilike(f'%{search}%'),
|
Asset.name.ilike(pattern),
|
||||||
Asset.serialnumber.ilike(f'%{search}%')
|
Asset.serialnumber.ilike(pattern),
|
||||||
|
MachineType.machinetype.ilike(pattern),
|
||||||
|
Vendor.vendor.ilike(pattern)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -168,11 +168,18 @@ def list_tools():
|
|||||||
# Exact-match natural-key lookup for idempotent import (asset number).
|
# Exact-match natural-key lookup for idempotent import (asset number).
|
||||||
if exactassetnumber := request.args.get('assetnumber'):
|
if exactassetnumber := request.args.get('assetnumber'):
|
||||||
query = query.filter(Asset.assetnumber == exactassetnumber)
|
query = query.filter(Asset.assetnumber == exactassetnumber)
|
||||||
|
# Type is a column in the list, so it has to be searchable. Outer join so a
|
||||||
|
# tool with no type still matches on its own fields.
|
||||||
if search := request.args.get('search'):
|
if search := request.args.get('search'):
|
||||||
query = query.filter(db.or_(
|
pattern = f'%{search}%'
|
||||||
Asset.assetnumber.ilike(f'%{search}%'),
|
query = query.outerjoin(
|
||||||
Asset.name.ilike(f'%{search}%'),
|
MeasuringToolType,
|
||||||
Asset.serialnumber.ilike(f'%{search}%'),
|
MeasuringTool.measuringtooltypeid == MeasuringToolType.measuringtooltypeid
|
||||||
|
).filter(db.or_(
|
||||||
|
Asset.assetnumber.ilike(pattern),
|
||||||
|
Asset.name.ilike(pattern),
|
||||||
|
Asset.serialnumber.ilike(pattern),
|
||||||
|
MeasuringToolType.name.ilike(pattern),
|
||||||
))
|
))
|
||||||
if type_id := request.args.get('typeid', type=int):
|
if type_id := request.args.get('typeid', type=int):
|
||||||
query = query.filter(MeasuringTool.measuringtooltypeid == type_id)
|
query = query.filter(MeasuringTool.measuringtooltypeid == type_id)
|
||||||
|
|||||||
@@ -201,14 +201,24 @@ def list_network_devices():
|
|||||||
if exactassetnumber := request.args.get('assetnumber'):
|
if exactassetnumber := request.args.get('assetnumber'):
|
||||||
query = query.filter(Asset.assetnumber == exactassetnumber)
|
query = query.filter(Asset.assetnumber == exactassetnumber)
|
||||||
|
|
||||||
# Search filter
|
# Search filter. Type and vendor are columns in the list, so both must be
|
||||||
|
# searchable. Outer joins so a device missing either still matches on its
|
||||||
|
# own fields.
|
||||||
if search := request.args.get('search'):
|
if search := request.args.get('search'):
|
||||||
query = query.filter(
|
pattern = f'%{search}%'
|
||||||
|
query = query.outerjoin(
|
||||||
|
NetworkDeviceType,
|
||||||
|
NetworkDevice.networkdevicetypeid == NetworkDeviceType.networkdevicetypeid
|
||||||
|
).outerjoin(
|
||||||
|
Vendor, NetworkDevice.vendorid == Vendor.vendorid
|
||||||
|
).filter(
|
||||||
db.or_(
|
db.or_(
|
||||||
Asset.assetnumber.ilike(f'%{search}%'),
|
Asset.assetnumber.ilike(pattern),
|
||||||
Asset.name.ilike(f'%{search}%'),
|
Asset.name.ilike(pattern),
|
||||||
Asset.serialnumber.ilike(f'%{search}%'),
|
Asset.serialnumber.ilike(pattern),
|
||||||
NetworkDevice.hostname.ilike(f'%{search}%')
|
NetworkDevice.hostname.ilike(pattern),
|
||||||
|
NetworkDeviceType.networkdevicetype.ilike(pattern),
|
||||||
|
Vendor.vendor.ilike(pattern)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -240,15 +240,24 @@ def list_printers():
|
|||||||
if exactassetnumber := request.args.get('assetnumber'):
|
if exactassetnumber := request.args.get('assetnumber'):
|
||||||
query = query.filter(Asset.assetnumber == exactassetnumber)
|
query = query.filter(Asset.assetnumber == exactassetnumber)
|
||||||
|
|
||||||
# Search filter
|
# Search filter. Type and model are columns in the list, so searching
|
||||||
|
# 'Thermal' must find the thermal printers. Outer joins so a printer missing
|
||||||
|
# either still matches on its own fields.
|
||||||
if search := request.args.get('search'):
|
if search := request.args.get('search'):
|
||||||
query = query.filter(
|
pattern = f'%{search}%'
|
||||||
|
query = query.outerjoin(
|
||||||
|
PrinterType, Printer.printertypeid == PrinterType.printertypeid
|
||||||
|
).outerjoin(
|
||||||
|
Model, Printer.modelnumberid == Model.modelnumberid
|
||||||
|
).filter(
|
||||||
db.or_(
|
db.or_(
|
||||||
Asset.assetnumber.ilike(f'%{search}%'),
|
Asset.assetnumber.ilike(pattern),
|
||||||
Asset.name.ilike(f'%{search}%'),
|
Asset.name.ilike(pattern),
|
||||||
Asset.serialnumber.ilike(f'%{search}%'),
|
Asset.serialnumber.ilike(pattern),
|
||||||
Printer.hostname.ilike(f'%{search}%'),
|
Printer.hostname.ilike(pattern),
|
||||||
Printer.windowsname.ilike(f'%{search}%')
|
Printer.windowsname.ilike(pattern),
|
||||||
|
PrinterType.printertype.ilike(pattern),
|
||||||
|
Model.modelnumber.ilike(pattern)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -354,13 +354,17 @@ def list_assets():
|
|||||||
if request.args.get('active', 'true').lower() != 'false':
|
if request.args.get('active', 'true').lower() != 'false':
|
||||||
query = query.filter(Asset.isactive == True)
|
query = query.filter(Asset.isactive == True)
|
||||||
|
|
||||||
# Search filter
|
# Search filter. The asset type is a column in the list, so it has to be
|
||||||
|
# searchable. Matched via .has() (a correlated EXISTS) rather than a join,
|
||||||
|
# because the type-name filter below joins AssetType itself.
|
||||||
if search := request.args.get('search'):
|
if search := request.args.get('search'):
|
||||||
|
pattern = f'%{search}%'
|
||||||
query = query.filter(
|
query = query.filter(
|
||||||
db.or_(
|
db.or_(
|
||||||
Asset.assetnumber.ilike(f'%{search}%'),
|
Asset.assetnumber.ilike(pattern),
|
||||||
Asset.name.ilike(f'%{search}%'),
|
Asset.name.ilike(pattern),
|
||||||
Asset.serialnumber.ilike(f'%{search}%')
|
Asset.serialnumber.ilike(pattern),
|
||||||
|
Asset.assettype.has(AssetType.assettype.ilike(pattern))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
166
tests/test_plugins/test_list_search_type_columns.py
Normal file
166
tests/test_plugins/test_list_search_type_columns.py
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
"""Every asset list searches the type (and vendor/model) columns it displays.
|
||||||
|
|
||||||
|
Each list shows a Type column, and printers/network also show Model/Vendor, but
|
||||||
|
the search filters only looked at the asset fields - so searching 'Standard' on
|
||||||
|
PCs or 'Thermal' on printers returned nothing. One test per list, each also
|
||||||
|
proving the outer joins did not turn into inner joins.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from shopdb.extensions import db as _db
|
||||||
|
from shopdb.core.models import Asset, AssetType
|
||||||
|
from shopdb.core.models.model import Model
|
||||||
|
from shopdb.core.models.vendor import Vendor
|
||||||
|
|
||||||
|
from plugins.computers.models import Computer, ComputerType
|
||||||
|
from plugins.printers.models import Printer, PrinterType
|
||||||
|
from plugins.network.models import NetworkDevice, NetworkDeviceType
|
||||||
|
from plugins.measuringtools.models import MeasuringTool, MeasuringToolType
|
||||||
|
|
||||||
|
|
||||||
|
def _assettype(name):
|
||||||
|
assettype = AssetType(assettype=name)
|
||||||
|
_db.session.add(assettype)
|
||||||
|
_db.session.flush()
|
||||||
|
return assettype
|
||||||
|
|
||||||
|
|
||||||
|
def _numbers(resp):
|
||||||
|
return sorted(item['assetnumber'] for item in resp.get_json()['data'])
|
||||||
|
|
||||||
|
|
||||||
|
def test_pcs_search_matches_computer_type(client, db):
|
||||||
|
assettype = _assettype('computer')
|
||||||
|
standard = ComputerType(computertype='Standard')
|
||||||
|
_db.session.add(standard)
|
||||||
|
_db.session.flush()
|
||||||
|
|
||||||
|
typed = Asset(assetnumber='PC-1', assettypeid=assettype.assettypeid)
|
||||||
|
untyped = Asset(assetnumber='PC-2', name='spare bench box',
|
||||||
|
assettypeid=assettype.assettypeid)
|
||||||
|
_db.session.add_all([typed, untyped])
|
||||||
|
_db.session.flush()
|
||||||
|
_db.session.add_all([
|
||||||
|
Computer(assetid=typed.assetid, computertypeid=standard.computertypeid),
|
||||||
|
Computer(assetid=untyped.assetid),
|
||||||
|
])
|
||||||
|
_db.session.commit()
|
||||||
|
|
||||||
|
resp = client.get('/api/computers?search=Standard')
|
||||||
|
assert resp.status_code == 200, resp.get_json()
|
||||||
|
assert _numbers(resp) == ['PC-1']
|
||||||
|
|
||||||
|
# the typeless PC is still findable on its own fields
|
||||||
|
resp = client.get('/api/computers?search=bench')
|
||||||
|
assert _numbers(resp) == ['PC-2']
|
||||||
|
|
||||||
|
|
||||||
|
def test_printers_search_matches_type_and_model(client, db):
|
||||||
|
assettype = _assettype('printer')
|
||||||
|
thermal = PrinterType(printertype='Thermal')
|
||||||
|
model = Model(modelnumber='ZT411')
|
||||||
|
_db.session.add_all([thermal, model])
|
||||||
|
_db.session.flush()
|
||||||
|
|
||||||
|
labeler = Asset(assetnumber='PR-1', assettypeid=assettype.assettypeid)
|
||||||
|
plain = Asset(assetnumber='PR-2', name='old plotter',
|
||||||
|
assettypeid=assettype.assettypeid)
|
||||||
|
_db.session.add_all([labeler, plain])
|
||||||
|
_db.session.flush()
|
||||||
|
_db.session.add_all([
|
||||||
|
Printer(assetid=labeler.assetid, printertypeid=thermal.printertypeid,
|
||||||
|
modelnumberid=model.modelnumberid),
|
||||||
|
Printer(assetid=plain.assetid),
|
||||||
|
])
|
||||||
|
_db.session.commit()
|
||||||
|
|
||||||
|
for term in ['Thermal', 'ZT411']:
|
||||||
|
resp = client.get(f'/api/printers?search={term}')
|
||||||
|
assert resp.status_code == 200, resp.get_json()
|
||||||
|
assert _numbers(resp) == ['PR-1'], term
|
||||||
|
|
||||||
|
resp = client.get('/api/printers?search=plotter')
|
||||||
|
assert _numbers(resp) == ['PR-2']
|
||||||
|
|
||||||
|
|
||||||
|
def test_network_search_matches_type_and_vendor(client, db):
|
||||||
|
assettype = _assettype('network_device')
|
||||||
|
switchtype = NetworkDeviceType(networkdevicetype='Switch')
|
||||||
|
vendor = Vendor(vendor='Cisco')
|
||||||
|
_db.session.add_all([switchtype, vendor])
|
||||||
|
_db.session.flush()
|
||||||
|
|
||||||
|
switch = Asset(assetnumber='NET-1', assettypeid=assettype.assettypeid)
|
||||||
|
plain = Asset(assetnumber='NET-2', name='unmanaged hub',
|
||||||
|
assettypeid=assettype.assettypeid)
|
||||||
|
_db.session.add_all([switch, plain])
|
||||||
|
_db.session.flush()
|
||||||
|
_db.session.add_all([
|
||||||
|
NetworkDevice(assetid=switch.assetid,
|
||||||
|
networkdevicetypeid=switchtype.networkdevicetypeid,
|
||||||
|
vendorid=vendor.vendorid),
|
||||||
|
NetworkDevice(assetid=plain.assetid),
|
||||||
|
])
|
||||||
|
_db.session.commit()
|
||||||
|
|
||||||
|
for term in ['Switch', 'Cisco']:
|
||||||
|
resp = client.get(f'/api/network?search={term}')
|
||||||
|
assert resp.status_code == 200, resp.get_json()
|
||||||
|
assert _numbers(resp) == ['NET-1'], term
|
||||||
|
|
||||||
|
resp = client.get('/api/network?search=hub')
|
||||||
|
assert _numbers(resp) == ['NET-2']
|
||||||
|
|
||||||
|
|
||||||
|
def test_measuringtools_search_matches_type(client, db):
|
||||||
|
assettype = _assettype('measuring_tool')
|
||||||
|
caliper = MeasuringToolType(name='Caliper')
|
||||||
|
_db.session.add(caliper)
|
||||||
|
_db.session.flush()
|
||||||
|
|
||||||
|
typed = Asset(assetnumber='MT-1', assettypeid=assettype.assettypeid)
|
||||||
|
untyped = Asset(assetnumber='MT-2', name='height gage',
|
||||||
|
assettypeid=assettype.assettypeid)
|
||||||
|
_db.session.add_all([typed, untyped])
|
||||||
|
_db.session.flush()
|
||||||
|
_db.session.add_all([
|
||||||
|
MeasuringTool(assetid=typed.assetid,
|
||||||
|
measuringtooltypeid=caliper.measuringtooltypeid),
|
||||||
|
MeasuringTool(assetid=untyped.assetid),
|
||||||
|
])
|
||||||
|
_db.session.commit()
|
||||||
|
|
||||||
|
resp = client.get('/api/measuringtools?search=Caliper')
|
||||||
|
assert resp.status_code == 200, resp.get_json()
|
||||||
|
assert _numbers(resp) == ['MT-1']
|
||||||
|
|
||||||
|
resp = client.get('/api/measuringtools?search=height')
|
||||||
|
assert _numbers(resp) == ['MT-2']
|
||||||
|
|
||||||
|
|
||||||
|
def test_core_assets_search_matches_asset_type(client, db):
|
||||||
|
"""The unified asset list shows a Type column too."""
|
||||||
|
machine_type = _assettype('machine')
|
||||||
|
printer_type = _assettype('printer')
|
||||||
|
_db.session.add_all([
|
||||||
|
Asset(assetnumber='A-1', assettypeid=machine_type.assettypeid),
|
||||||
|
Asset(assetnumber='A-2', assettypeid=printer_type.assettypeid),
|
||||||
|
])
|
||||||
|
_db.session.commit()
|
||||||
|
|
||||||
|
resp = client.get('/api/assets?search=machine')
|
||||||
|
assert resp.status_code == 200, resp.get_json()
|
||||||
|
assert _numbers(resp) == ['A-1']
|
||||||
|
|
||||||
|
|
||||||
|
def test_core_assets_search_type_filter_still_works(client, db):
|
||||||
|
"""The type-name filter joins AssetType; search uses EXISTS, so both apply."""
|
||||||
|
machine_type = _assettype('machine')
|
||||||
|
_db.session.add_all([
|
||||||
|
Asset(assetnumber='A-1', name='mill', assettypeid=machine_type.assettypeid),
|
||||||
|
Asset(assetnumber='A-2', name='lathe', assettypeid=machine_type.assettypeid),
|
||||||
|
])
|
||||||
|
_db.session.commit()
|
||||||
|
|
||||||
|
resp = client.get('/api/assets?type=machine&search=mill')
|
||||||
|
assert resp.status_code == 200, resp.get_json()
|
||||||
|
assert _numbers(resp) == ['A-1']
|
||||||
95
tests/test_plugins/test_machines_search.py
Normal file
95
tests/test_plugins/test_machines_search.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"""Machines list search covers the columns the list actually shows.
|
||||||
|
|
||||||
|
The table displays Machine #, Name, Serial Number, Type and Vendor, so all of
|
||||||
|
them must be searchable. Searching a type name ('Part Washer') used to return
|
||||||
|
zero rows because the filter only looked at the asset fields.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from shopdb.extensions import db as _db
|
||||||
|
from shopdb.core.models import Asset, AssetType
|
||||||
|
from shopdb.core.models.vendor import Vendor
|
||||||
|
|
||||||
|
from plugins.machines.models import Machine, MachineType
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_machines():
|
||||||
|
"""Two machines: one Part Washer by Acme, one Lathe with no type or vendor.
|
||||||
|
|
||||||
|
The typeless machine guards the outer joins - it must still be findable by
|
||||||
|
its own asset fields.
|
||||||
|
"""
|
||||||
|
assettype = AssetType(assettype='machine')
|
||||||
|
_db.session.add(assettype)
|
||||||
|
_db.session.flush()
|
||||||
|
|
||||||
|
washertype = MachineType(machinetype='Part Washer')
|
||||||
|
lathe_vendor = Vendor(vendor='Acme Industrial')
|
||||||
|
_db.session.add_all([washertype, lathe_vendor])
|
||||||
|
_db.session.flush()
|
||||||
|
|
||||||
|
washer_asset = Asset(assetnumber='0410', name='Cell 4 washer',
|
||||||
|
serialnumber='SN-WASH-1', assettypeid=assettype.assettypeid)
|
||||||
|
plain_asset = Asset(assetnumber='0999', name='Old lathe',
|
||||||
|
serialnumber='SN-LATHE-9', assettypeid=assettype.assettypeid)
|
||||||
|
_db.session.add_all([washer_asset, plain_asset])
|
||||||
|
_db.session.flush()
|
||||||
|
|
||||||
|
_db.session.add_all([
|
||||||
|
Machine(assetid=washer_asset.assetid,
|
||||||
|
machinetypeid=washertype.machinetypeid,
|
||||||
|
vendorid=lathe_vendor.vendorid),
|
||||||
|
Machine(assetid=plain_asset.assetid),
|
||||||
|
])
|
||||||
|
_db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _numbers(resp):
|
||||||
|
return sorted(item['assetnumber'] for item in resp.get_json()['data'])
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_matches_machine_type_name(client, db):
|
||||||
|
_seed_machines()
|
||||||
|
|
||||||
|
resp = client.get('/api/machines?search=Part Washer')
|
||||||
|
|
||||||
|
assert resp.status_code == 200, resp.get_json()
|
||||||
|
assert _numbers(resp) == ['0410']
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_matches_vendor_name(client, db):
|
||||||
|
_seed_machines()
|
||||||
|
|
||||||
|
resp = client.get('/api/machines?search=Acme')
|
||||||
|
|
||||||
|
assert resp.status_code == 200, resp.get_json()
|
||||||
|
assert _numbers(resp) == ['0410']
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_still_matches_asset_fields(client, db):
|
||||||
|
_seed_machines()
|
||||||
|
|
||||||
|
for term, expected in [('0999', ['0999']),
|
||||||
|
('Old lathe', ['0999']),
|
||||||
|
('SN-WASH', ['0410'])]:
|
||||||
|
resp = client.get(f'/api/machines?search={term}')
|
||||||
|
assert resp.status_code == 200, resp.get_json()
|
||||||
|
assert _numbers(resp) == expected, term
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_does_not_drop_machines_without_type_or_vendor(client, db):
|
||||||
|
"""The outer joins must not turn into inner joins."""
|
||||||
|
_seed_machines()
|
||||||
|
|
||||||
|
resp = client.get('/api/machines?search=lathe')
|
||||||
|
|
||||||
|
assert resp.status_code == 200, resp.get_json()
|
||||||
|
assert _numbers(resp) == ['0999']
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_miss_returns_empty(client, db):
|
||||||
|
_seed_machines()
|
||||||
|
|
||||||
|
resp = client.get('/api/machines?search=zzzznotathing')
|
||||||
|
|
||||||
|
assert resp.status_code == 200, resp.get_json()
|
||||||
|
assert _numbers(resp) == []
|
||||||
Reference in New Issue
Block a user