Carry the level everywhere a position is drawn, and gate it per occurrence
The hover mini-map said "This asset has a position (2835, 1410) but no level" for every asset in the product. When 0.11.0 gave LocationMapTooltip a levelid prop, NONE of its seven call sites were taught to pass one - printer, machine and PC detail pages, the toner report, enforcement reports, the warranty chip and the dashboard cards - so the component correctly reported a missing level and the preview never drew. Two payloads behind those views also emitted mapx/mapy with no level: the toner report and the enforcement report. The map PDF export had the ORIGINAL bug still in it: it plotted every filtered asset onto the sheet, so exporting the ground floor printed second-floor markers on it. Worse than on screen, because nobody can correct a sheet once it has been printed and carried onto the floor. It now exports only the level being viewed. The legacy import loader sent mapleft/maptop with no level at three call sites. That loader is the one still to run against production, and every marker it created would have been undrawable. It now resolves the site's default level - the legacy schema predates levels and has one floor plan, so that is what its coordinates mean. THE GATE MISSED ALL OF THIS because it asked whether a FILE mentions 'levelid', not whether each position does: one module emitted 'mapx' six times and 'levelid' once and passed. It now checks per occurrence, covers scripts/ as well as shopdb/ and plugins/, and fails any Vue file that binds tooltip coordinates without :levelid. Both new rules were confirmed to fail the build against planted violations before being relied on. Printer QR labels: the asset number is no longer printed. A label now reads name (8201-HPLaserJetPro), QR, FQDN, then IP. The name falls back to the assetnumber because that is where sites actually keep it - every printer here has an empty name field, so preferring the Windows queue name alone would have printed a blank line on every label.
This commit is contained in:
@@ -22,7 +22,7 @@
|
||||
<!-- With coordinates, the name carries the same floor-plan preview
|
||||
the asset's own page uses. Without them, a plain link. -->
|
||||
<LocationMapTooltip v-if="row.link && row.maphover"
|
||||
:left="row.maphover.x" :top="row.maphover.y"
|
||||
:left="row.maphover.x" :top="row.maphover.y" :levelid="row.maphover.levelid"
|
||||
:machineName="row.maphover.label">
|
||||
<router-link :to="row.link" class="dc-row-title"
|
||||
:title="row.titletip || undefined">
|
||||
|
||||
@@ -127,7 +127,15 @@ export function mapHover(card, item) {
|
||||
const x = item[spec.x]
|
||||
const y = item[spec.y]
|
||||
if (x === null || x === undefined || y === null || y === undefined) return null
|
||||
return { x, y, label: spec.label ? (item[spec.label] || '') : '' }
|
||||
// The level travels with the coordinates (ADR-017) - they are pixels of ONE
|
||||
// drawing. Read `levelid` unless the card names another field, so a card that
|
||||
// predates levels still previews on the right floor instead of none.
|
||||
const levelid = item[spec.level || 'levelid']
|
||||
return {
|
||||
x, y,
|
||||
levelid: levelid === undefined ? null : levelid,
|
||||
label: spec.label ? (item[spec.label] || '') : '',
|
||||
}
|
||||
}
|
||||
|
||||
export function cardRows(card) {
|
||||
|
||||
@@ -289,7 +289,12 @@ async function exportPdf() {
|
||||
try {
|
||||
await loadMapConfig()
|
||||
await exportMapPdf({
|
||||
assets: filteredAssets.value,
|
||||
// Only this level's markers. The sheet is one drawing, so a marker
|
||||
// positioned against another level would be printed on the wrong floor
|
||||
// plan - the same failure the on-screen map had, in a form nobody can
|
||||
// correct after it is printed and carried onto the floor.
|
||||
assets: filteredAssets.value.filter(
|
||||
asset => (asset.levelid ?? null) === shownLevelId.value),
|
||||
// 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.
|
||||
|
||||
@@ -185,6 +185,7 @@
|
||||
v-if="computer.mapx != null && computer.mapy != null"
|
||||
:left="computer.mapx"
|
||||
:top="computer.mapy"
|
||||
:levelid="computer.levelid"
|
||||
:machineName="computer.assetnumber"
|
||||
>
|
||||
<span class="location-link">{{ computer.locationname || 'On Map' }}</span>
|
||||
|
||||
@@ -1223,6 +1223,9 @@ def list_reports():
|
||||
'location': known.get('location'),
|
||||
'mapx': known.get('mapx'),
|
||||
'mapy': known.get('mapy'),
|
||||
# Coordinates are pixels of ONE level (ADR-017), so the level goes
|
||||
# with them or the hover preview has nothing to draw on.
|
||||
'levelid': known.get('levelid'),
|
||||
'machinenumber': known.get('machinenumber'),
|
||||
'machineassetid': known.get('machineassetid'),
|
||||
'machinepluginid': known.get('machinepluginid'),
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
empty map would be worse than none. -->
|
||||
<LocationMapTooltip
|
||||
v-if="report.mapx != null && report.mapy != null"
|
||||
:left="report.mapx" :top="report.mapy"
|
||||
:left="report.mapx" :top="report.mapy" :levelid="report.levelid"
|
||||
:machineName="report.hostname"
|
||||
>
|
||||
<span class="map-pin" :title="report.location || 'On the floor plan'">◎</span>
|
||||
|
||||
@@ -167,6 +167,7 @@
|
||||
v-if="machine.mapx != null && machine.mapy != null"
|
||||
:left="machine.mapx"
|
||||
:top="machine.mapy"
|
||||
:levelid="machine.levelid"
|
||||
:machineName="machine.assetnumber"
|
||||
>
|
||||
<span class="location-link">{{ machine.locationname || 'On Map' }}</span>
|
||||
|
||||
@@ -1042,7 +1042,7 @@ def _get_low_supplies_data():
|
||||
'model': model_number,
|
||||
'location': location_name,
|
||||
'mapx': asset.mapx,
|
||||
'levelid': asset.levelid,
|
||||
'levelid': asset.levelid,
|
||||
'mapy': asset.mapy,
|
||||
'supplies': annotated
|
||||
})
|
||||
@@ -1479,6 +1479,10 @@ def dashboard_supplies():
|
||||
# card, it just has nothing to preview.
|
||||
'mapx': printer.get('mapx'),
|
||||
'mapy': printer.get('mapy'),
|
||||
# The level those pixels belong to (ADR-017). Without it the hover
|
||||
# preview cannot draw the marker and says so, which is what the
|
||||
# dashboard card and the toner report were both doing.
|
||||
'levelid': printer.get('levelid'),
|
||||
'iscritical': any(s['status'] == 'critical' for s in depleted),
|
||||
'supplies': [{
|
||||
'text': '{} {}%'.format(_shortsupplyname(supply.get('name')),
|
||||
|
||||
@@ -169,6 +169,7 @@
|
||||
v-if="printer.mapx != null && printer.mapy != null"
|
||||
:left="printer.mapx"
|
||||
:top="printer.mapy"
|
||||
:levelid="printer.levelid"
|
||||
:machineName="printer.name || printer.assetnumber"
|
||||
>
|
||||
<span class="location-link">View on Map</span>
|
||||
|
||||
@@ -43,14 +43,13 @@
|
||||
:class="[`pos-${pos}`, page[pos - 1] ? 'filled' : 'empty']"
|
||||
>
|
||||
<template v-if="page[pos - 1]">
|
||||
<div class="model-name">{{ page[pos - 1].printer?.modelname || '' }}</div>
|
||||
<div class="csf-name">{{ labelName(page[pos - 1]) }}</div>
|
||||
<div class="qr-container">
|
||||
<img v-if="qrImages[`${pageIdx}-${pos}`]" :src="qrImages[`${pageIdx}-${pos}`]" class="qr-img" alt="QR" />
|
||||
</div>
|
||||
<div class="info-section">
|
||||
<div class="csf-name">{{ page[pos - 1].assetnumber }}</div>
|
||||
<div class="info-inner">
|
||||
<div v-if="page[pos - 1].printer?.windowsname" class="info-row">{{ page[pos - 1].printer.windowsname }}</div>
|
||||
<div v-if="fqdnFor(page[pos - 1])" class="info-row">{{ fqdnFor(page[pos - 1]) }}</div>
|
||||
<div v-if="getIp(page[pos - 1])" class="info-row">{{ getIp(page[pos - 1]) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -67,6 +66,7 @@ import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import { printersApi } from '@/api'
|
||||
import { renderQrDataUrl } from '@/utils/codes'
|
||||
import { buildQrUrl } from '@/utils/qrTarget'
|
||||
import { getPrinterHostnameTemplate } from '@/utils/siteSettings'
|
||||
|
||||
const printers = ref([])
|
||||
const selectedPrinters = ref([])
|
||||
@@ -91,6 +91,7 @@ const pages = computed(() => {
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
hostnameTemplate.value = await getPrinterHostnameTemplate()
|
||||
// listAll: perpage is clamped to 100, and a batch sheet must cover every
|
||||
// printer, not the first page of them.
|
||||
printers.value = await printersApi.listAll()
|
||||
@@ -129,6 +130,29 @@ async function generateQRCodes() {
|
||||
qrImages.value = next
|
||||
}
|
||||
|
||||
// The printer's FQDN. A stored hostname wins; otherwise the site builds one
|
||||
// from the IP through the printer_hostname_template setting (ADR-015), which is
|
||||
// how PrinterForm and the toner report derive it.
|
||||
const hostnameTemplate = ref('')
|
||||
|
||||
function fqdnFor(item) {
|
||||
const stored = item?.printer?.hostname
|
||||
if (stored) return stored
|
||||
const ip = getIp(item)
|
||||
if (!ip || !hostnameTemplate.value) return ''
|
||||
return hostnameTemplate.value.replace('{ip}', ip.replace(/\./g, '-'))
|
||||
}
|
||||
|
||||
// What goes on the label's prominent top line: the printer's NAME, e.g.
|
||||
// 8201-HPLaserJetPro. Sites keep that name in different places - the Windows
|
||||
// queue name when one is set, otherwise the asset's name, and in practice most
|
||||
// printers carry it as the assetnumber and nothing else, so that is the last
|
||||
// fallback rather than a blank label. This is a name, not an identifier line:
|
||||
// the label deliberately carries no separate "Asset #".
|
||||
function labelName(item) {
|
||||
return item?.printer?.windowsname || item?.name || item?.assetnumber || ''
|
||||
}
|
||||
|
||||
function displayName(printer) {
|
||||
return printer.assetnumber || printer.name || `Printer-${printer.assetid}`
|
||||
}
|
||||
@@ -286,7 +310,7 @@ function print() {
|
||||
.info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; }
|
||||
.info-inner { text-align: left; }
|
||||
.info-row { font-size: 9pt; color: #333; margin: 1px 0; white-space: nowrap; }
|
||||
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 2px; color: #000; }
|
||||
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 0.06in; color: #000; }
|
||||
.empty-label { color: #999; font-size: 14px; }
|
||||
|
||||
@media print {
|
||||
|
||||
@@ -24,14 +24,13 @@
|
||||
:class="[`pos-${pos}`, pos === parseInt(position) ? 'active' : 'inactive']"
|
||||
>
|
||||
<template v-if="pos === parseInt(position)">
|
||||
<div class="model-name">{{ printer.printer?.modelname || '' }}</div>
|
||||
<div class="csf-name">{{ labelName }}</div>
|
||||
<div class="qr-container">
|
||||
<img v-if="qrImage" :src="qrImage" class="qr-img" alt="QR" />
|
||||
</div>
|
||||
<div class="info-section">
|
||||
<div class="csf-name">{{ printer.assetnumber }}</div>
|
||||
<div class="info-inner">
|
||||
<div v-if="printer.printer?.windowsname" class="info-row">{{ printer.printer.windowsname }}</div>
|
||||
<div v-if="fqdn" class="info-row">{{ fqdn }}</div>
|
||||
<div v-if="ipAddress" class="info-row">{{ ipAddress }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -49,6 +48,7 @@ import { useRoute } from 'vue-router'
|
||||
import { printersApi } from '@/api'
|
||||
import { renderQrDataUrl } from '@/utils/codes'
|
||||
import { buildQrUrl } from '@/utils/qrTarget'
|
||||
import { getPrinterHostnameTemplate } from '@/utils/siteSettings'
|
||||
|
||||
const route = useRoute()
|
||||
const loading = ref(true)
|
||||
@@ -58,6 +58,18 @@ const position = ref('1')
|
||||
// in print output, images print every time.
|
||||
const qrImage = ref('')
|
||||
|
||||
// What goes on the label's prominent top line: the printer's NAME, e.g.
|
||||
// 8201-HPLaserJetPro. Sites keep that name in different places - the Windows
|
||||
// queue name when one is set, otherwise the asset's name, and in practice most
|
||||
// printers carry it as the assetnumber and nothing else, so that is the last
|
||||
// fallback rather than a blank label. This is a name, not an identifier line:
|
||||
// the label deliberately carries no separate "Asset #".
|
||||
const labelName = computed(() =>
|
||||
printer.value?.printer?.windowsname
|
||||
|| printer.value?.name
|
||||
|| printer.value?.assetnumber
|
||||
|| '')
|
||||
|
||||
const ipAddress = computed(() => {
|
||||
// Check direct ipaddress field first (from list API)
|
||||
if (printer.value?.ipaddress && printer.value.ipaddress !== 'USB') return printer.value.ipaddress
|
||||
@@ -67,8 +79,21 @@ const ipAddress = computed(() => {
|
||||
return primary?.ipaddress || primary?.address || null
|
||||
})
|
||||
|
||||
// The printer's FQDN. A stored hostname wins; otherwise the site builds one from
|
||||
// the IP through the printer_hostname_template setting (ADR-015), the same way
|
||||
// PrinterForm and the toner report derive it.
|
||||
const hostnameTemplate = ref('')
|
||||
|
||||
const fqdn = computed(() => {
|
||||
const stored = printer.value?.printer?.hostname
|
||||
if (stored) return stored
|
||||
if (!ipAddress.value || !hostnameTemplate.value) return ''
|
||||
return hostnameTemplate.value.replace('{ip}', ipAddress.value.replace(/\./g, '-'))
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
hostnameTemplate.value = await getPrinterHostnameTemplate()
|
||||
const response = await printersApi.get(route.params.id)
|
||||
printer.value = response.data.data
|
||||
} catch (error) {
|
||||
@@ -169,7 +194,7 @@ function print() {
|
||||
.info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; }
|
||||
.info-inner { text-align: left; }
|
||||
.info-row { font-size: 9pt; color: #333; margin: 1px 0; white-space: nowrap; }
|
||||
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 2px; color: #000; }
|
||||
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 0.06in; color: #000; }
|
||||
|
||||
@media print {
|
||||
/* Force rendered images/colors to print even when "Background graphics" is
|
||||
|
||||
@@ -1,378 +1,379 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h1>Toner Report</h1>
|
||||
<div class="header-actions">
|
||||
<button v-if="!loading && !error" class="btn btn-secondary" @click="exportCSV">Export CSV</button>
|
||||
<EmailReportButton v-if="!loading && !error" subject="Toner / Supply Report"
|
||||
:columns="emailColumns" :rows="emailRows" />
|
||||
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading supply data...</div>
|
||||
|
||||
<div v-else-if="error" class="card">
|
||||
<p class="text-danger">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Summary Bar -->
|
||||
<div class="summary-bar">
|
||||
<div class="summary-stat card">
|
||||
<div class="stat-value">{{ summary.total_checked }}</div>
|
||||
<div class="stat-label">Printers Checked</div>
|
||||
</div>
|
||||
<div class="summary-stat card stat-low">
|
||||
<div class="stat-value">{{ summary.low }}</div>
|
||||
<div class="stat-label">Low Supply</div>
|
||||
</div>
|
||||
<div class="summary-stat card stat-critical">
|
||||
<div class="stat-value">{{ summary.critical }}</div>
|
||||
<div class="stat-label">Critical Supply</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Buttons -->
|
||||
<div class="filters">
|
||||
<button
|
||||
v-for="f in filterOptions"
|
||||
:key="f.value"
|
||||
class="btn"
|
||||
:class="filter === f.value ? 'btn-primary' : 'btn-secondary'"
|
||||
@click="filter = f.value"
|
||||
>
|
||||
{{ f.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Printers Table -->
|
||||
<div class="card">
|
||||
<div class="table-container">
|
||||
<table v-if="filteredPrinters.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Printer Name</th>
|
||||
<th>Hostname</th>
|
||||
<th>Supplies</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="printer in filteredPrinters" :key="printer.printerid">
|
||||
<td>
|
||||
<!-- Coordinates give the name the same floor-plan preview the
|
||||
printer's own page uses; it replaces the Location column. -->
|
||||
<LocationMapTooltip
|
||||
v-if="printer.mapx != null && printer.mapy != null"
|
||||
:left="printer.mapx"
|
||||
:top="printer.mapy"
|
||||
:machineName="printer.printername || printer.assetnumber"
|
||||
>
|
||||
<router-link :to="`/printers/${printer.printerid}`">
|
||||
{{ printer.printername || 'Unknown' }}
|
||||
</router-link>
|
||||
</LocationMapTooltip>
|
||||
<router-link v-else :to="`/printers/${printer.printerid}`">
|
||||
{{ printer.printername || 'Unknown' }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td>
|
||||
<!-- Opens the printer's own web page. New tab: the report is a
|
||||
worklist, and losing your place in it to visit one printer
|
||||
means finding your row again. -->
|
||||
<a
|
||||
v-if="fqdnFor(printer)"
|
||||
:href="`http://${fqdnFor(printer)}`"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:title="printer.ipaddress"
|
||||
>{{ fqdnFor(printer) }}</a>
|
||||
<span v-else>{{ printer.ipaddress || '-' }}</span>
|
||||
</td>
|
||||
<td class="supplies-cell">
|
||||
<div
|
||||
v-for="(supply, idx) in printer.supplies"
|
||||
:key="idx"
|
||||
class="supply-row"
|
||||
>
|
||||
<span class="supply-name">{{ supply.name }}</span>
|
||||
<div class="supply-bar-track">
|
||||
<div
|
||||
class="supply-bar-fill"
|
||||
:class="'supply-' + supply.status"
|
||||
:style="{ width: Math.max(supply.level, 2) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="supply-level" :class="'supply-text-' + supply.status">
|
||||
{{ supply.level }}%
|
||||
</span>
|
||||
<span class="supply-parts">
|
||||
<span
|
||||
v-for="part in supply.partnumbers"
|
||||
:key="part.partnumber"
|
||||
class="part-chip"
|
||||
:title="partTooltip(part)"
|
||||
>{{ part.partnumber }}</span>
|
||||
<span v-if="!supply.partnumbers || !supply.partnumbers.length"
|
||||
class="part-none" title="No part number on file for this model and color">-</span>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="empty-state">No printers match the selected filter.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { printersApi } from '@/api'
|
||||
import EmailReportButton from '@/components/EmailReportButton.vue'
|
||||
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
||||
import { getPrinterHostnameTemplate } from '@/utils/siteSettings'
|
||||
|
||||
const emailColumns = [
|
||||
{ key: 'printer', label: 'Printer' },
|
||||
{ key: 'hostname', label: 'Hostname' },
|
||||
{ key: 'supply', label: 'Supply' },
|
||||
{ key: 'partnumber', label: 'Part Number' },
|
||||
{ key: 'level', label: 'Level' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
]
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
const printers = ref([])
|
||||
const summary = ref({ total_checked: 0, low: 0, critical: 0 })
|
||||
const filter = ref('all')
|
||||
|
||||
const filterOptions = [
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Critical', value: 'critical' },
|
||||
{ label: 'Low', value: 'low' }
|
||||
]
|
||||
|
||||
// Printers have no stored FQDN; the site builds one from the IP via the
|
||||
// printer_hostname_template setting, the same way PrinterForm does.
|
||||
const hostnameTemplate = ref('')
|
||||
|
||||
function fqdnFor(printer) {
|
||||
if (!printer.ipaddress || !hostnameTemplate.value) return ''
|
||||
return hostnameTemplate.value.replace('{ip}', printer.ipaddress.replace(/\./g, '-'))
|
||||
}
|
||||
|
||||
// A model can list several capacity tiers for one color, so the chip shows the
|
||||
// part number and the tooltip says which one it is.
|
||||
function partTooltip(part) {
|
||||
const bits = [part.marketingname, part.capacitytier]
|
||||
if (part.pageyield) bits.push(part.pageyield + ' pages')
|
||||
return bits.filter(Boolean).join(' - ')
|
||||
}
|
||||
|
||||
// One flat row per part number so an ordering list is copy-pasteable; supplies
|
||||
// with no part on file still get a row.
|
||||
function reportRows() {
|
||||
const rows = []
|
||||
for (const printer of filteredPrinters.value) {
|
||||
for (const supply of printer.supplies || []) {
|
||||
const parts = supply.partnumbers && supply.partnumbers.length
|
||||
? supply.partnumbers.map(p => p.partnumber)
|
||||
: ['']
|
||||
for (const partnumber of parts) {
|
||||
rows.push({
|
||||
printer: printer.printername || '',
|
||||
hostname: fqdnFor(printer) || printer.ipaddress || '',
|
||||
supply: supply.name || '',
|
||||
partnumber: partnumber,
|
||||
level: supply.level,
|
||||
status: supply.status || '',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
const filteredPrinters = computed(() => {
|
||||
if (filter.value === 'all') return printers.value
|
||||
return printers.value.filter(p =>
|
||||
p.supplies.some(s => s.status === filter.value)
|
||||
)
|
||||
})
|
||||
|
||||
// Emailed table, honoring the active filter.
|
||||
const emailRows = computed(() =>
|
||||
reportRows().map(row => ({ ...row, level: row.level + '%' }))
|
||||
)
|
||||
|
||||
function exportCSV() {
|
||||
const quote = value => `"${String(value ?? '').replace(/"/g, '""')}"`
|
||||
const header = ['printer', 'hostname', 'supply', 'partnumber', 'level', 'status']
|
||||
const rows = [header]
|
||||
for (const row of reportRows()) {
|
||||
rows.push(header.map(key => row[key]))
|
||||
}
|
||||
const csv = rows.map(row => row.map(quote).join(',')).join('\n')
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }))
|
||||
link.download = 'toner_report.csv'
|
||||
link.click()
|
||||
URL.revokeObjectURL(link.href)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
hostnameTemplate.value = await getPrinterHostnameTemplate()
|
||||
const response = await printersApi.lowSupplies()
|
||||
const data = response.data.data
|
||||
printers.value = data.printers || []
|
||||
summary.value = data.summary || { total_checked: 0, low: 0, critical: 0 }
|
||||
} catch (err) {
|
||||
console.error('Error loading toner report:', err)
|
||||
error.value = 'Failed to load supply data. Zabbix may not be configured or reachable.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.summary-bar {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.summary-stat {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-light);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-low .stat-value {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.stat-critical .stat-value {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* One grid for the whole cell, with each supply row contributing its cells
|
||||
directly (display: contents). Cartridge names were being ellipsised inside a
|
||||
fixed 120px column, which hid the very thing being reordered; a max-content
|
||||
column sizes to the longest name instead, and the columns still line up
|
||||
across the rows of one printer. */
|
||||
.supplies-cell {
|
||||
min-width: 460px;
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(80px, 1fr) auto max-content;
|
||||
column-gap: 0.5rem;
|
||||
row-gap: 0.35rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.supply-row {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.supply-name {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-light);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.supply-bar-track {
|
||||
height: 8px;
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.supply-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.supply-ok {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.supply-low {
|
||||
background: var(--warning);
|
||||
}
|
||||
|
||||
.supply-critical {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.supply-level {
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.supply-parts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.part-chip {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.05rem 0.35rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.part-none {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.supply-text-ok {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.supply-text-low {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.supply-text-critical {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h1>Toner Report</h1>
|
||||
<div class="header-actions">
|
||||
<button v-if="!loading && !error" class="btn btn-secondary" @click="exportCSV">Export CSV</button>
|
||||
<EmailReportButton v-if="!loading && !error" subject="Toner / Supply Report"
|
||||
:columns="emailColumns" :rows="emailRows" />
|
||||
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading supply data...</div>
|
||||
|
||||
<div v-else-if="error" class="card">
|
||||
<p class="text-danger">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Summary Bar -->
|
||||
<div class="summary-bar">
|
||||
<div class="summary-stat card">
|
||||
<div class="stat-value">{{ summary.total_checked }}</div>
|
||||
<div class="stat-label">Printers Checked</div>
|
||||
</div>
|
||||
<div class="summary-stat card stat-low">
|
||||
<div class="stat-value">{{ summary.low }}</div>
|
||||
<div class="stat-label">Low Supply</div>
|
||||
</div>
|
||||
<div class="summary-stat card stat-critical">
|
||||
<div class="stat-value">{{ summary.critical }}</div>
|
||||
<div class="stat-label">Critical Supply</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Buttons -->
|
||||
<div class="filters">
|
||||
<button
|
||||
v-for="f in filterOptions"
|
||||
:key="f.value"
|
||||
class="btn"
|
||||
:class="filter === f.value ? 'btn-primary' : 'btn-secondary'"
|
||||
@click="filter = f.value"
|
||||
>
|
||||
{{ f.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Printers Table -->
|
||||
<div class="card">
|
||||
<div class="table-container">
|
||||
<table v-if="filteredPrinters.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Printer Name</th>
|
||||
<th>Hostname</th>
|
||||
<th>Supplies</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="printer in filteredPrinters" :key="printer.printerid">
|
||||
<td>
|
||||
<!-- Coordinates give the name the same floor-plan preview the
|
||||
printer's own page uses; it replaces the Location column. -->
|
||||
<LocationMapTooltip
|
||||
v-if="printer.mapx != null && printer.mapy != null"
|
||||
:left="printer.mapx"
|
||||
:top="printer.mapy"
|
||||
:levelid="printer.levelid"
|
||||
:machineName="printer.printername || printer.assetnumber"
|
||||
>
|
||||
<router-link :to="`/printers/${printer.printerid}`">
|
||||
{{ printer.printername || 'Unknown' }}
|
||||
</router-link>
|
||||
</LocationMapTooltip>
|
||||
<router-link v-else :to="`/printers/${printer.printerid}`">
|
||||
{{ printer.printername || 'Unknown' }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td>
|
||||
<!-- Opens the printer's own web page. New tab: the report is a
|
||||
worklist, and losing your place in it to visit one printer
|
||||
means finding your row again. -->
|
||||
<a
|
||||
v-if="fqdnFor(printer)"
|
||||
:href="`http://${fqdnFor(printer)}`"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:title="printer.ipaddress"
|
||||
>{{ fqdnFor(printer) }}</a>
|
||||
<span v-else>{{ printer.ipaddress || '-' }}</span>
|
||||
</td>
|
||||
<td class="supplies-cell">
|
||||
<div
|
||||
v-for="(supply, idx) in printer.supplies"
|
||||
:key="idx"
|
||||
class="supply-row"
|
||||
>
|
||||
<span class="supply-name">{{ supply.name }}</span>
|
||||
<div class="supply-bar-track">
|
||||
<div
|
||||
class="supply-bar-fill"
|
||||
:class="'supply-' + supply.status"
|
||||
:style="{ width: Math.max(supply.level, 2) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="supply-level" :class="'supply-text-' + supply.status">
|
||||
{{ supply.level }}%
|
||||
</span>
|
||||
<span class="supply-parts">
|
||||
<span
|
||||
v-for="part in supply.partnumbers"
|
||||
:key="part.partnumber"
|
||||
class="part-chip"
|
||||
:title="partTooltip(part)"
|
||||
>{{ part.partnumber }}</span>
|
||||
<span v-if="!supply.partnumbers || !supply.partnumbers.length"
|
||||
class="part-none" title="No part number on file for this model and color">-</span>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="empty-state">No printers match the selected filter.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { printersApi } from '@/api'
|
||||
import EmailReportButton from '@/components/EmailReportButton.vue'
|
||||
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
||||
import { getPrinterHostnameTemplate } from '@/utils/siteSettings'
|
||||
|
||||
const emailColumns = [
|
||||
{ key: 'printer', label: 'Printer' },
|
||||
{ key: 'hostname', label: 'Hostname' },
|
||||
{ key: 'supply', label: 'Supply' },
|
||||
{ key: 'partnumber', label: 'Part Number' },
|
||||
{ key: 'level', label: 'Level' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
]
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
const printers = ref([])
|
||||
const summary = ref({ total_checked: 0, low: 0, critical: 0 })
|
||||
const filter = ref('all')
|
||||
|
||||
const filterOptions = [
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Critical', value: 'critical' },
|
||||
{ label: 'Low', value: 'low' }
|
||||
]
|
||||
|
||||
// Printers have no stored FQDN; the site builds one from the IP via the
|
||||
// printer_hostname_template setting, the same way PrinterForm does.
|
||||
const hostnameTemplate = ref('')
|
||||
|
||||
function fqdnFor(printer) {
|
||||
if (!printer.ipaddress || !hostnameTemplate.value) return ''
|
||||
return hostnameTemplate.value.replace('{ip}', printer.ipaddress.replace(/\./g, '-'))
|
||||
}
|
||||
|
||||
// A model can list several capacity tiers for one color, so the chip shows the
|
||||
// part number and the tooltip says which one it is.
|
||||
function partTooltip(part) {
|
||||
const bits = [part.marketingname, part.capacitytier]
|
||||
if (part.pageyield) bits.push(part.pageyield + ' pages')
|
||||
return bits.filter(Boolean).join(' - ')
|
||||
}
|
||||
|
||||
// One flat row per part number so an ordering list is copy-pasteable; supplies
|
||||
// with no part on file still get a row.
|
||||
function reportRows() {
|
||||
const rows = []
|
||||
for (const printer of filteredPrinters.value) {
|
||||
for (const supply of printer.supplies || []) {
|
||||
const parts = supply.partnumbers && supply.partnumbers.length
|
||||
? supply.partnumbers.map(p => p.partnumber)
|
||||
: ['']
|
||||
for (const partnumber of parts) {
|
||||
rows.push({
|
||||
printer: printer.printername || '',
|
||||
hostname: fqdnFor(printer) || printer.ipaddress || '',
|
||||
supply: supply.name || '',
|
||||
partnumber: partnumber,
|
||||
level: supply.level,
|
||||
status: supply.status || '',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
const filteredPrinters = computed(() => {
|
||||
if (filter.value === 'all') return printers.value
|
||||
return printers.value.filter(p =>
|
||||
p.supplies.some(s => s.status === filter.value)
|
||||
)
|
||||
})
|
||||
|
||||
// Emailed table, honoring the active filter.
|
||||
const emailRows = computed(() =>
|
||||
reportRows().map(row => ({ ...row, level: row.level + '%' }))
|
||||
)
|
||||
|
||||
function exportCSV() {
|
||||
const quote = value => `"${String(value ?? '').replace(/"/g, '""')}"`
|
||||
const header = ['printer', 'hostname', 'supply', 'partnumber', 'level', 'status']
|
||||
const rows = [header]
|
||||
for (const row of reportRows()) {
|
||||
rows.push(header.map(key => row[key]))
|
||||
}
|
||||
const csv = rows.map(row => row.map(quote).join(',')).join('\n')
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }))
|
||||
link.download = 'toner_report.csv'
|
||||
link.click()
|
||||
URL.revokeObjectURL(link.href)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
hostnameTemplate.value = await getPrinterHostnameTemplate()
|
||||
const response = await printersApi.lowSupplies()
|
||||
const data = response.data.data
|
||||
printers.value = data.printers || []
|
||||
summary.value = data.summary || { total_checked: 0, low: 0, critical: 0 }
|
||||
} catch (err) {
|
||||
console.error('Error loading toner report:', err)
|
||||
error.value = 'Failed to load supply data. Zabbix may not be configured or reachable.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.summary-bar {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.summary-stat {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-light);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-low .stat-value {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.stat-critical .stat-value {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* One grid for the whole cell, with each supply row contributing its cells
|
||||
directly (display: contents). Cartridge names were being ellipsised inside a
|
||||
fixed 120px column, which hid the very thing being reordered; a max-content
|
||||
column sizes to the longest name instead, and the columns still line up
|
||||
across the rows of one printer. */
|
||||
.supplies-cell {
|
||||
min-width: 460px;
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(80px, 1fr) auto max-content;
|
||||
column-gap: 0.5rem;
|
||||
row-gap: 0.35rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.supply-row {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.supply-name {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-light);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.supply-bar-track {
|
||||
height: 8px;
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.supply-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.supply-ok {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.supply-low {
|
||||
background: var(--warning);
|
||||
}
|
||||
|
||||
.supply-critical {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.supply-level {
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.supply-parts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.part-chip {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.05rem 0.35rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.part-none {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.supply-text-ok {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.supply-text-low {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.supply-text-critical {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
v-if="machine && hasPosition"
|
||||
:left="machine.mapx"
|
||||
:top="machine.mapy"
|
||||
:levelid="machine.levelid"
|
||||
:machineName="machine.machinenumber || machine.name || ''"
|
||||
>
|
||||
<span class="mmc-chip" :title="hoverTitle">
|
||||
|
||||
@@ -169,21 +169,44 @@ fi
|
||||
# whether all of them travel with their level, because reading them by eye is
|
||||
# how the twentieth gets missed.
|
||||
echo "==> Checking that emitted map positions carry their level (ADR-017)..."
|
||||
POSITION_FILES=$(grep -rln "'mapx':" --include='*.py' shopdb/ plugins/ 2>/dev/null \
|
||||
| grep -v '/tests\?/' || true)
|
||||
# PER OCCURRENCE, not per file. The file-level form passed a module that emitted
|
||||
# 'mapx' six times and 'levelid' once, and two payloads shipped without a level:
|
||||
# the toner report and the enforcement report, both feeding a hover preview that
|
||||
# then said "no level". Every 'mapx' must have a 'levelid' in the same literal -
|
||||
# the window is wide enough for a comment between them, and no wider.
|
||||
MISSING_LEVEL=""
|
||||
for candidate in $POSITION_FILES; do
|
||||
if ! grep -q "'levelid'" "$candidate"; then
|
||||
MISSING_LEVEL="$MISSING_LEVEL$candidate"$'\n'
|
||||
while IFS= read -r hit; do
|
||||
[ -z "$hit" ] && continue
|
||||
file=${hit%%:*}
|
||||
line=${hit#*:}; line=${line%%:*}
|
||||
if ! sed -n "${line},$((line + 8))p" "$file" | grep -q "'levelid'"; then
|
||||
MISSING_LEVEL="$MISSING_LEVEL$file:$line"$'\n'
|
||||
fi
|
||||
done
|
||||
done <<EOF
|
||||
$(grep -rn "'mapx':" --include='*.py' shopdb/ plugins/ scripts/ 2>/dev/null | grep -v '/tests\?/' || true)
|
||||
EOF
|
||||
if [ -n "$MISSING_LEVEL" ]; then
|
||||
echo "FAIL: these emit 'mapx' but never 'levelid' - a position with no level"
|
||||
echo " cannot be rendered on the right drawing:"
|
||||
echo "FAIL: these emit 'mapx' with no 'levelid' beside it - a position with no"
|
||||
echo " level cannot be drawn on the right floor plan (ADR-017):"
|
||||
echo "$MISSING_LEVEL" | sed 's/^/ /'
|
||||
VIOLATIONS=$((VIOLATIONS + 1))
|
||||
fi
|
||||
|
||||
# The same rule for the hover preview: binding coordinates into
|
||||
# LocationMapTooltip without a level makes it report "no level" for every asset,
|
||||
# which is exactly what shipped in 0.11.0 - all seven call sites missed it.
|
||||
TOOLTIP_MISSING=""
|
||||
for candidate in $(grep -rl "LocationMapTooltip" --include='*.vue' frontend/src plugins/ 2>/dev/null | grep -v plugins-staged || true); do
|
||||
grep -q ':left=' "$candidate" || continue
|
||||
grep -q ':levelid=' "$candidate" || TOOLTIP_MISSING="$TOOLTIP_MISSING$candidate"$'\n'
|
||||
done
|
||||
if [ -n "$TOOLTIP_MISSING" ]; then
|
||||
echo "FAIL: these bind LocationMapTooltip coordinates without :levelid, so the"
|
||||
echo " preview cannot know which drawing to use (ADR-017):"
|
||||
echo "$TOOLTIP_MISSING" | sed 's/^/ /'
|
||||
VIOLATIONS=$((VIOLATIONS + 1))
|
||||
fi
|
||||
|
||||
# ENFORCING. It was report-only while the backlog was worked off, and the hit
|
||||
# count then did not move for weeks - a rule that only prints is read as no rule.
|
||||
# Set SITE_LITERALS_ENFORCE=0 to drop back to reporting for a local run.
|
||||
|
||||
@@ -100,6 +100,22 @@ class Harness:
|
||||
self.ids = IdMap(idmap_path or default)
|
||||
self.source = Source()
|
||||
self.errors = []
|
||||
self._defaultlevelid = None
|
||||
|
||||
@property
|
||||
def defaultlevelid(self):
|
||||
"""The level imported map positions belong to (ADR-017).
|
||||
|
||||
The legacy schema predates levels: it has ONE floor plan, so every
|
||||
mapleft/maptop it carries is a coordinate on this site's default level.
|
||||
Importing them without a level produces markers the map refuses to draw
|
||||
- it will not guess a drawing for coordinates that do not name one.
|
||||
"""
|
||||
if self._defaultlevelid is None:
|
||||
from shopdb.core.models import MapLevel
|
||||
level = MapLevel.default_level()
|
||||
self._defaultlevelid = level.levelid if level else None
|
||||
return self._defaultlevelid
|
||||
|
||||
def _silence_sql_logging(self):
|
||||
import logging
|
||||
|
||||
@@ -287,6 +287,7 @@ def stage_assets(h):
|
||||
'serialnumber': (m['serialnumber'] or '').strip() or None,
|
||||
'businessunitid': h.ids.get('businessunit', m['businessunitid']),
|
||||
'mapx': m['mapleft'], 'mapy': m['maptop'],
|
||||
'levelid': h.defaultlevelid,
|
||||
'notes': m['machinenotes'],
|
||||
'dateadded': str(m['dateadded']) if m['dateadded'] else None,
|
||||
'modifieddate': str(m['lastupdated']) if m['lastupdated'] else None,
|
||||
@@ -345,6 +346,7 @@ def stage_printers(h):
|
||||
'iscsf': _truthy_bit(p['iscsf']),
|
||||
'installpath': p['installpath'], 'pin': p['printerpin'],
|
||||
'notes': p['printernotes'], 'mapx': p['mapleft'], 'mapy': p['maptop'],
|
||||
'levelid': h.defaultlevelid,
|
||||
'modelnumberid': h.ids.get('model', p['modelid']),
|
||||
'locationid': h.ids.get('location', p['machineid']),
|
||||
}
|
||||
@@ -386,7 +388,8 @@ def stage_metrology(h):
|
||||
'name': (f"{pcname} {toolname}").strip() or toolname,
|
||||
'measuringtooltypeid': typeid,
|
||||
'businessunitid': h.ids.get('businessunit', m['businessunitid']),
|
||||
'mapx': m['mapleft'], 'mapy': m['maptop']})
|
||||
'mapx': m['mapleft'], 'mapy': m['maptop'],
|
||||
'levelid': h.defaultlevelid})
|
||||
if status not in (200, 201):
|
||||
continue
|
||||
tool_assetid = _id_of(data, 'assetid')
|
||||
|
||||
Reference in New Issue
Block a user