Merge remote-tracking branch 'origin/main' into wilm

This commit is contained in:
benjamin.baker
2026-08-24 14:52:59 -04:00
66 changed files with 5026 additions and 391 deletions

View File

@@ -10,6 +10,92 @@ ADR-007 and ADR-002.
## [Unreleased]
### Added
- **The code generator can print a back side.** A badge card is two-sided and
the generator only ever emitted one page per card. The Back side section
turns on a second page per card carrying either the same code again, a fixed
block of text, its own per-row content (CSV `back` / `backlabel`), or
nothing. Page order is a choice, because the two kinds of printer want
opposite things: **interleaved** front-back-front-back for a duplex card
printer, **grouped** all-fronts-then-all-backs for a single-sided printer
that gets its stack flipped and reloaded. Getting that backwards prints
perfectly readable cards with the wrong back on every one, which is why the
order is pinned by tests rather than by looking at the preview.
- **The gap between a code and its caption is its own control.** It was the
quiet zone doing double duty: closing up a caption meant shaving the clear
space a scanner needs, and the only way to get a code to scan was to push the
caption away from it. Quiet zone now means only the code's own margin; gap
means only the distance to the caption, and adds to the quiet zone rather
than eating into it. Every stock preset carries a gap that suits it.
- **Contents can be positioned inside the label without moving the label.**
Align and Vertical place the code and caption block anywhere on the stock,
and Content X/Y nudge it from there. The existing X/Y nudge kept its
behavior and is now labeled Media X/Y: it moves the whole printed area, for
a printer whose origin is off, which is a different problem from composing a
label.
- **A picture can go on the label.** Pick an image once and it prints on every
label - above the code, beside it, or behind everything as a watermark - on
the front, the back, or both. The file never leaves the browser, so an
air-gapped site can use it, and it is deliberately not saved between visits:
a data URL runs to megabytes and would take every sizing value with it when
the storage quota gave out.
- **The back can carry its own code, in its own symbology.** "Back carries" now
defaults to a code rather than text, takes per-row content from the CSV
`back` column, and has its own QR/CODE128 choice - a QR on the front that
opens the record, a CODE128 on the back that the bench scanner already reads.
A back set to carry a code can no longer be laid out as text-only: that
combination printed a blank card that looked like the code had failed, and it
is corrected on load for anyone who already saved it.
### Fixed
- **A zero bar height printed a barcode 176 wide and 0 high.** Nothing rejected
it: the value went straight into a CSS length, `height: 0in` is perfectly
valid, and an image at `height: 100%` of a zero-height box is zero pixels
tall. A cleared field was its own version of the same bug - `'' + 'in'` is not
a length, the browser dropped the declaration, and the box fell back to auto
height. Every measurement now goes through `labelVars()`, which will not emit
NaN, an empty length, or a zero where a zero means the box stops existing;
padding, quiet zone and gap still accept zero, because there it is a real
answer. The stylesheet carries its own fallbacks too, though those only cover
a property that is absent - a property set to garbage is dropped at computed
value time and takes the fallback with it, which is why the sanitiser is the
fix and the CSS is only a net.
- **A caption could eat the barcode.** The code box and the caption both grew,
so any caption took half the label and kept going: "BAY 12 PRESS LINE 1" on
1.00 x 0.50in stock left a 36px stub, which is a code that is present,
unscannable, and reads as one that failed to render. The caption now takes
only the room it needs and a CODE128 keeps at least 40% of the label, all of
it when stacked. A code that genuinely fails to render says so in the preview
now, in red, rather than leaving white space that looks like a label with no
code on it.
- **The generator prints from inside the app shell.** It was a full-screen
route with no sidebar and a narrow column of controls on the left. It is now
an ordinary page - navigation, full width - and `style.css` hides the shell at
print time, so the stock still gets nothing but labels. Verified against a
real print PDF rather than a preview.
- A test print paired each page with a code by loop position while the codes
are keyed to position in the whole run, so a two-sided test card could carry
the wrong code. The page index now travels with the page.
- The back label's dotted preview outline outspecified the print reset and drew
a dashed rule down the edge of every second card.
### Changed
- **Settings on the left, labels on the right.** The controls were a narrow
column with the preview trailing underneath, so the labels being adjusted were
off the bottom of the screen. The preview is now a column of its own that
stays in view and scrolls by itself, and it folds back to one column on a
narrow window. Note for anyone changing it: that scrolling column is exactly
the kind of ancestor that clips a multi-page print to whatever was on screen,
so print resets its overflow - verified by printing a six-page two-sided run,
not by looking at the preview.
- CSV parsing and page building moved out of the generator component into
`plugins/tools/frontend/labelPages.js`, where the column contract and the
two-sided page order are covered by tests.
## [0.12.0] - 2026-08-20
Printers become a property of the bay rather than of whoever last walked up to a

View File

@@ -1763,3 +1763,31 @@ td.actions .btn + .btn {
independently. */
.clickable-row { cursor: pointer; }
.clickable-row:hover td { background: var(--bg); }
/* --- Printing from inside the app shell -------------------------------------
*
* Core's label pages are full-screen routes precisely so the shell cannot reach
* the stock. A tool someone works in for ten minutes wants the navigation
* though, so the shell is hidden here instead: the sidebar goes, the content
* column gives back the margin it was holding for it, and the notification
* banner - which is chrome, not content - goes with them.
*
* Anything the page itself must keep off the paper marks itself .no-print. */
@media print {
.sidebar,
.notification-banner,
.no-print {
display: none !important;
}
.app-layout {
display: block !important;
min-height: 0 !important;
}
.main-content {
margin-left: 0 !important;
padding: 0 !important;
overflow: visible !important;
}
}

View File

@@ -97,11 +97,15 @@ const blueprintUrl = computed(() => {
// the wrong level's dimensions is precisely how a marker ends up plausibly
// placed and wrong.
const markerX = computed(() => {
return (props.left / dimensionsFor(props.levelid).width) * 100
const fraction = props.left / dimensionsFor(props.levelid).width
const { offsetX, scaleX } = paintedBox.value
return (offsetX + fraction * scaleX) * 100
})
const markerY = computed(() => {
return (props.top / dimensionsFor(props.levelid).height) * 100
const fraction = props.top / dimensionsFor(props.levelid).height
const { offsetY, scaleY } = paintedBox.value
return (offsetY + fraction * scaleY) * 100
})
// Marker style with counter-scale to maintain constant size
@@ -111,6 +115,42 @@ const markerStyle = computed(() => ({
transform: `translate(-50%, -50%) scale(${1 / zoom.value})`
}))
// The frame the marker's percentages are measured against MUST be the frame the
// drawing is painted in. The image used object-fit: contain inside a fixed
// 390x300 box, so a level whose drawing is not that aspect ratio was letterboxed
// or pillarboxed - and the marker, positioned at a percentage of the BOX, then
// sat in a different coordinate space from the drawing inside it. A tall
// drawing pillarboxes, every marker's x shifts, and one near the left edge
// lands in the grey gutter beside the map. Levels near 390:300 looked correct,
// which is why it only affected some assets.
//
// The preview is a fixed box and object-fit: contain paints the drawing inside it
// at the drawing's own aspect ratio, so the drawing does not fill the box: it is
// letterboxed on one axis. A marker placed at a percentage of the BOX therefore
// lands somewhere else than the same percentage of the DRAWING, on any level
// whose shape is not the box's.
//
// So convert: work out where the painted drawing sits inside the box, then map
// the marker into box coordinates. Doing it here rather than by resizing the
// frame keeps the centring transform below honest - its percentages resolve
// against the element, so the element has to stay the size of the box.
const PREVIEW_WIDTH = 390 // keep in step with .map-preview in the styles
const PREVIEW_HEIGHT = 300
const PREVIEW_ASPECT = PREVIEW_WIDTH / PREVIEW_HEIGHT
// Fraction of the box the drawing covers, and the gap before it, per axis.
const paintedBox = computed(() => {
const { width, height } = dimensionsFor(props.levelid)
const levelAspect = width / height
if (levelAspect >= PREVIEW_ASPECT) {
// Wider than the box: full width, bars above and below.
const scale = PREVIEW_ASPECT / levelAspect
return { scaleX: 1, offsetX: 0, scaleY: scale, offsetY: (1 - scale) / 2 }
}
// Taller than the box: full height, bars left and right.
const scale = levelAspect / PREVIEW_ASPECT
return { scaleX: scale, offsetX: (1 - scale) / 2, scaleY: 1, offsetY: 0 }
})
// Transform style that centers on the marker and zooms toward it
const transformStyle = computed(() => {
// Calculate translation to center the marker in the preview
@@ -263,6 +303,9 @@ watch(currentTheme, () => {
.map-transform {
position: relative;
/* Fills the preview. The centring transform translates by percentages, which
resolve against THIS element, so it must stay the size of the box it is
centring within. The letterbox is accounted for in the marker maths. */
width: 100%;
height: 100%;
transition: transform 0.15s ease-out;
@@ -271,7 +314,10 @@ watch(currentTheme, () => {
.map-image {
width: 100%;
height: 100%;
/* contain, so a level of any shape is shown whole and undistorted. Where the
bars fall is computed in paintedBox and folded into the marker position. */
object-fit: contain;
display: block;
}
.marker-dot {

View File

@@ -0,0 +1,42 @@
<template>
<!-- Renders nothing at all when there is no suggestion, so a list can drop
this in beside its empty state without an extra v-if of its own. -->
<p v-if="suggestion" class="search-suggestion">
Did you mean
<button type="button" class="linklike" @click="$emit('pick', suggestion)">
{{ suggestion }}
</button>?
</p>
</template>
<script setup>
defineProps({
suggestion: { type: String, default: null },
})
defineEmits(['pick'])
</script>
<style scoped>
.search-suggestion {
margin: 0.5rem 0 0;
color: var(--text-light, #666);
font-size: 0.9rem;
}
/* A button, not an anchor: it re-runs the search in place rather than
navigating, and a link that does not link is a lie to anyone using a
keyboard or a screen reader. */
.linklike {
background: none;
border: none;
padding: 0;
font: inherit;
color: var(--primary, #0066cc);
cursor: pointer;
text-decoration: underline;
}
.linklike:hover {
text-decoration: none;
}
</style>

View File

@@ -1,5 +1,6 @@
import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useSearchSuggestion } from './searchSuggestion'
// Keep a list page's current page and search term in the URL query so the
// browser Back button restores them. Without this a list keeps page in local
@@ -16,6 +17,18 @@ import { useRoute, useRouter } from 'vue-router'
// deep link) so the list reloads at the restored page.
// extraKeys optional extra query keys to persist (e.g. ['typeid']); each
// gets a ref exposed under the returned `extras` object.
//
// SPELLING SUGGESTIONS come along for free, because this composable already
// owns the search term. A list reports how many rows it got and gets back a
// suggestion to offer when that number is zero:
//
// const { search, suggestion, reportCount } = useListQuery({ onChange: load })
// // at the end of load(): reportCount(rows.value.length)
// // in the empty state: <SearchSuggestion :suggestion="suggestion"
// // @pick="applySuggestion" />
//
// A list that never calls reportCount simply never offers one, which is what
// every list did before.
export function useListQuery(options = {}) {
const route = useRoute()
const router = useRouter()
@@ -98,5 +111,17 @@ export function useListQuery(options = {}) {
if (changed) onChange()
})
return { page, search, extras, setPage, setSearch, setExtra }
const { suggestion, checkSuggestion, clearSuggestion } = useSearchSuggestion()
// Called by the list after a fetch. Zero rows for a non-empty search is the
// only moment a suggestion is worth asking for.
function reportCount(count) {
if (count > 0 || !search.value) clearSuggestion()
else checkSuggestion(search.value)
}
return {
page, search, extras, setPage, setSearch, setExtra,
suggestion, reportCount,
}
}

View File

@@ -0,0 +1,56 @@
import { ref } from 'vue'
import api from '../api'
/**
* "Did you mean" for a search that found nothing.
*
* ONE composable for every list in the product. Thirty routes take a search
* parameter across a dozen files, so carrying a suggestion in each of their
* responses would be a large change now and a thing every future plugin has to
* remember. Instead a page that renders no rows asks for a spelling, and a page
* that has not adopted this shows nothing - exactly what it showed before.
*
* Usage in a list view:
*
* const { suggestion, checkSuggestion, clearSuggestion } = useSearchSuggestion()
* // after a fetch:
* items.value.length ? clearSuggestion() : checkSuggestion(searchTerm.value)
*
* and in the template, beside the empty state:
*
* <SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
*/
export function useSearchSuggestion() {
const suggestion = ref(null)
// Guards a slow answer arriving after a newer search has already been typed,
// which would offer a spelling for a query nobody is looking at any more.
let latest = 0
function clearSuggestion() {
suggestion.value = null
latest += 1
}
async function checkSuggestion(query) {
const term = (query || '').trim()
if (term.length < 2) {
clearSuggestion()
return
}
const ticket = ++latest
try {
const response = await api.get('/search/suggest', { params: { q: term } })
if (ticket !== latest) return
const offered = response.data?.data?.suggestion || null
// Never offer back what was typed: a suggestion identical to the query
// reads as a broken feature.
suggestion.value = offered && offered !== term.toLowerCase() ? offered : null
} catch (err) {
// A search that found nothing is already the answer. Failing to improve
// on it is not worth an error in front of anyone.
if (ticket === latest) suggestion.value = null
}
}
return { suggestion, checkSuggestion, clearSuggestion }
}

View File

@@ -0,0 +1,35 @@
/**
* Securesend plugin routes.
*
* Auto-discovered by the router via import.meta.glob, so no registration
* edit is needed. Every route carries meta.plugin 'securesend' so the ADR-009
* guard redirects to the dashboard when the securesend backend plugin is
* disabled. Form routes add requiresAuth so anonymous users cannot reach
* create or edit.
*/
export default [
{
path: 'securesend',
name: 'securesend',
component: () => import('../../views/securesend/SecuresendList.vue'),
meta: { plugin: 'securesend' }
},
{
path: 'securesend/new',
name: 'securesend-new',
component: () => import('../../views/securesend/SecuresendForm.vue'),
meta: { requiresAuth: true, plugin: 'securesend' }
},
{
path: 'securesend/:id',
name: 'securesend-detail',
component: () => import('../../views/securesend/SecuresendDetail.vue'),
meta: { plugin: 'securesend' }
},
{
path: 'securesend/:id/edit',
name: 'securesend-edit',
component: () => import('../../views/securesend/SecuresendForm.vue'),
meta: { requiresAuth: true, plugin: 'securesend' }
}
]

View File

@@ -24,15 +24,19 @@ import JsBarcode from 'jsbarcode'
import { getQrLogo } from '@/utils/siteSettings'
// Label stock actually loaded in the printers, with the code size, padding and
// quiet zone that fit each one. Sizes are inches, because that is what the
// stock is sold as and what @page takes.
// Label stock actually loaded in the printers, with the code size, padding,
// quiet zone and code-to-caption gap that fit each one. Sizes are inches,
// because that is what the stock is sold as and what @page takes.
//
// quiet and gap are NOT the same measurement. quiet is the clear space a
// scanner needs around the code and is fixed by the symbology; gap is only how
// far the caption sits from it, and is a matter of taste per stock.
export const LABEL_PRESETS = [
{ id: 'zebra1x05', name: '1.00 x 0.50 in (Zebra gap)', labelwidth: 1.0, labelheight: 0.5, codesize: 0.4286, padding: 0, quiet: 0.035, labelfont: 7 },
{ id: 'zebra2x1', name: '2.00 x 1.00 in', labelwidth: 2.0, labelheight: 1.0, codesize: 0.85, padding: 0.03, quiet: 0.05, labelfont: 10 },
{ id: 'zebra225x125', name: '2.25 x 1.25 in', labelwidth: 2.25, labelheight: 1.25, codesize: 1.05, padding: 0.04, quiet: 0.06, labelfont: 11 },
{ id: 'zebra4x6', name: '4.00 x 6.00 in (shipping)', labelwidth: 4.0, labelheight: 6.0, codesize: 3.0, padding: 0.15, quiet: 0.12, labelfont: 20 },
{ id: 'badge', name: '2.13 x 3.38 in (badge)', labelwidth: 2.13, labelheight: 3.38, codesize: 1.5, padding: 0.15, quiet: 0.1, labelfont: 12 },
{ id: 'zebra1x05', name: '1.00 x 0.50 in (Zebra gap)', labelwidth: 1.0, labelheight: 0.5, codesize: 0.4286, padding: 0, quiet: 0.035, gap: 0.03, labelfont: 7 },
{ id: 'zebra2x1', name: '2.00 x 1.00 in', labelwidth: 2.0, labelheight: 1.0, codesize: 0.85, padding: 0.03, quiet: 0.05, gap: 0.05, labelfont: 10 },
{ id: 'zebra225x125', name: '2.25 x 1.25 in', labelwidth: 2.25, labelheight: 1.25, codesize: 1.05, padding: 0.04, quiet: 0.06, gap: 0.06, labelfont: 11 },
{ id: 'zebra4x6', name: '4.00 x 6.00 in (shipping)', labelwidth: 4.0, labelheight: 6.0, codesize: 3.0, padding: 0.15, quiet: 0.12, gap: 0.12, labelfont: 20 },
{ id: 'badge', name: '2.13 x 3.38 in (badge)', labelwidth: 2.13, labelheight: 3.38, codesize: 1.5, padding: 0.15, quiet: 0.1, gap: 0.1, labelfont: 12 },
]
// CODE128 is what the scanners on the floor are configured for, and a label

View File

@@ -42,6 +42,7 @@
<div v-else-if="results.length === 0" class="no-results">
No results found for "{{ query }}"
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
</div>
<div v-else class="results-list">
@@ -94,12 +95,14 @@
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { searchApi, knowledgebaseApi } from '../api'
import SearchSuggestion from '../components/SearchSuggestion.vue'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const results = ref([])
const suggestion = ref(null)
const query = ref('')
const searchInput = ref('')
const activeFilter = ref('all')
@@ -175,6 +178,7 @@ const filteredResults = computed(() => {
async function search(q) {
if (!q || q.length < 2) {
results.value = []
suggestion.value = null
return
}
@@ -184,6 +188,10 @@ async function search(q) {
try {
const response = await searchApi.search(q)
const data = response.data.data
// The global endpoint carries the suggestion with the results, so no second
// call is needed here. A list page that has no such key uses the
// useSearchSuggestion composable against /search/suggest instead.
suggestion.value = data?.suggestion || null
// Handle ServiceNOW redirect
if (data?.redirect?.type === 'servicenow') {
@@ -212,6 +220,14 @@ async function search(q) {
}
}
// Take the offered spelling: put it in the box and search it, so the person
// sees what was searched rather than results for a word they did not type.
function applySuggestion(word) {
searchInput.value = word
suggestion.value = null
performSearch()
}
function performSearch() {
const term = searchInput.value.trim()
if (!term) return

View File

@@ -0,0 +1,149 @@
<template>
<div class="detail-page" v-if="item">
<div class="hero-card">
<div class="hero-content">
<div class="hero-title-row">
<h1 class="hero-title">{{ item.name || item.assetnumber || 'Securesend' }}</h1>
<router-link
v-if="authStore.isAuthenticated"
:to="`/securesend/${itemId}/edit`"
class="btn btn-secondary"
>
Edit
</router-link>
</div>
<div class="hero-details">
<div class="detail-item" v-if="item.assetnumber">
<span class="label">Asset #</span>
<span class="value">{{ item.assetnumber }}</span>
</div>
<div class="detail-item" v-if="item.serialnumber">
<span class="label">Serial</span>
<span class="value mono">{{ item.serialnumber }}</span>
</div>
</div>
</div>
</div>
<div class="content-grid">
<div class="content-column">
<div class="section-card">
<h3 class="section-title">Securesend Information</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Example Field</span>
<span class="info-value">{{ item.examplefield || '-' }}</span>
</div>
</div>
</div>
</div>
<div class="content-column">
<div class="section-card">
<h3 class="section-title">Asset Information</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Asset Number</span>
<span class="info-value">{{ item.assetnumber || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Name</span>
<span class="info-value">{{ item.name || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Serial Number</span>
<span class="info-value mono">{{ item.serialnumber || '-' }}</span>
</div>
</div>
</div>
</div>
</div>
<div class="action-bar" v-if="authStore.isAuthenticated">
<router-link :to="`/securesend/${itemId}/edit`" class="btn btn-primary">Edit</router-link>
<button @click="confirmDelete" class="btn btn-danger">Delete</button>
</div>
</div>
<div v-else-if="loading" class="loading-container">
<div class="loading">Loading...</div>
</div>
<div v-else class="error-container">
<p>Record not found</p>
<router-link to="/securesend" class="btn btn-secondary">Back to Securesend</router-link>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import api from '../../api'
import { useAuthStore } from '../../stores/auth'
// local api client. move into src/api/index.js (see
// plugins/securesend/frontend-api-snippet.js) then swap for:
// import { securesendApi } from '../../api'
const securesendApi = {
list(params = {}) { return api.get('/securesend', { params }) },
get(itemId) { return api.get(`/securesend/${itemId}`) },
create(data) { return api.post('/securesend', data) },
update(itemId, data) { return api.put(`/securesend/${itemId}`, data) },
remove(itemId) { return api.delete(`/securesend/${itemId}`) }
}
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const itemId = route.params.id
const item = ref(null)
const loading = ref(true)
onMounted(loadItem)
async function loadItem() {
loading.value = true
try {
const response = await securesendApi.get(itemId)
item.value = response.data.data
} catch (error) {
console.error('Error loading securesend:', error)
item.value = null
} finally {
loading.value = false
}
}
async function confirmDelete() {
if (confirm('Delete this record?')) {
try {
await securesendApi.remove(itemId)
router.push('/securesend')
} catch (error) {
console.error('Error deleting securesend:', error)
}
}
}
</script>
<style scoped>
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
.action-bar {
display: flex;
gap: 1rem;
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
}
.loading-container,
.error-container {
text-align: center;
padding: 3rem;
color: var(--text-light);
}
</style>

View File

@@ -0,0 +1,228 @@
<template>
<div>
<div class="page-header">
<h2>{{ isEdit ? 'Edit Securesend' : 'Add Securesend' }}</h2>
</div>
<div class="card form-card">
<form @submit.prevent="submitForm">
<fieldset>
<legend>Asset Information</legend>
<div class="form-row">
<div class="form-group">
<label for="assetnumber">Asset Number *</label>
<input
id="assetnumber"
v-model="form.assetnumber"
type="text"
class="form-control"
required
:disabled="isEdit"
/>
</div>
<div class="form-group">
<label for="name">Name</label>
<input
id="name"
v-model="form.name"
type="text"
class="form-control"
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="serialnumber">Serial Number</label>
<input
id="serialnumber"
v-model="form.serialnumber"
type="text"
class="form-control"
/>
</div>
</div>
</fieldset>
<fieldset>
<legend>Securesend Details</legend>
<div class="form-row">
<div class="form-group">
<label for="examplefield">Example Field</label>
<input
id="examplefield"
v-model="form.examplefield"
type="text"
class="form-control"
/>
</div>
</div>
</fieldset>
<div class="form-actions">
<button type="button" class="btn btn-secondary" @click="cancel">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : (isEdit ? 'Save Changes' : 'Create') }}
</button>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import api from '../../api'
// local api client. move into src/api/index.js (see
// plugins/securesend/frontend-api-snippet.js) then swap for:
// import { securesendApi } from '../../api'
const securesendApi = {
list(params = {}) { return api.get('/securesend', { params }) },
get(itemId) { return api.get(`/securesend/${itemId}`) },
create(data) { return api.post('/securesend', data) },
update(itemId, data) { return api.put(`/securesend/${itemId}`, data) },
remove(itemId) { return api.delete(`/securesend/${itemId}`) }
}
const route = useRoute()
const router = useRouter()
const itemId = route.params.id
const isEdit = computed(() => !!itemId)
const form = ref({
assetnumber: '',
name: '',
serialnumber: '',
examplefield: ''
})
const saving = ref(false)
const error = ref('')
onMounted(async () => {
if (isEdit.value) {
await loadItem()
}
})
async function loadItem() {
try {
const response = await securesendApi.get(itemId)
const data = response.data.data
form.value.assetnumber = data.assetnumber || ''
form.value.name = data.name || ''
form.value.serialnumber = data.serialnumber || ''
form.value.examplefield = data.examplefield || ''
} catch (loadError) {
console.error('Error loading securesend:', loadError)
error.value = 'Failed to load record'
}
}
async function submitForm() {
saving.value = true
error.value = ''
try {
const payload = {
assetnumber: form.value.assetnumber,
name: form.value.name || null,
serialnumber: form.value.serialnumber || null,
examplefield: form.value.examplefield || null
}
let redirectId = itemId
if (isEdit.value) {
await securesendApi.update(itemId, payload)
} else {
const response = await securesendApi.create(payload)
redirectId = response.data.data?.assetid
}
router.push(redirectId ? `/securesend/${redirectId}` : '/securesend')
} catch (submitError) {
console.error('Error saving securesend:', submitError)
error.value = 'Failed to save record'
} finally {
saving.value = false
}
}
function cancel() {
if (isEdit.value) {
router.push(`/securesend/${itemId}`)
} else {
router.push('/securesend')
}
}
</script>
<style scoped>
.form-card {
max-width: 800px;
}
fieldset {
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
legend {
font-weight: 600;
padding: 0 0.5rem;
color: var(--text);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1rem;
}
.form-row:last-child {
margin-bottom: 0;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-group label {
margin-bottom: 0.375rem;
font-weight: 500;
color: var(--text);
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
}
.error-message {
margin-top: 1rem;
padding: 0.75rem 1rem;
background: var(--danger);
color: white;
border-radius: 6px;
}
@media (max-width: 600px) {
.form-row {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,142 @@
<template>
<div>
<div class="page-header">
<h2>Securesend</h2>
<router-link to="/securesend/new" class="btn btn-primary">Add Securesend</router-link>
</div>
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search..."
@input="debouncedSearch"
/>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Asset Tag</th>
<th>Name</th>
<th>Example Field</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.assetid">
<td>{{ item.assetnumber || '-' }}</td>
<td>{{ item.name || '-' }}</td>
<td>{{ item.examplefield || '-' }}</td>
<td class="actions">
<router-link
:to="`/securesend/${item.assetid}`"
class="btn btn-secondary btn-sm"
>
View
</router-link>
</td>
</tr>
<tr v-if="items.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">
No securesend records found
</td>
</tr>
</tbody>
</table>
</div>
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import api from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
// local api client. move into src/api/index.js (see
// plugins/securesend/frontend-api-snippet.js) then swap for:
// import { securesendApi } from '../../api'
const securesendApi = {
list(params = {}) { return api.get('/securesend', { params }) },
get(itemId) { return api.get(`/securesend/${itemId}`) },
create(data) { return api.post('/securesend', data) },
update(itemId, data) { return api.put(`/securesend/${itemId}`, data) },
remove(itemId) { return api.delete(`/securesend/${itemId}`) }
}
const items = ref([])
const loading = ref(true)
const search = ref('')
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(25)
let searchTimeout = null
onMounted(loadItems)
async function loadItems() {
loading.value = true
try {
const params = { page: page.value, perpage: perPage.value }
if (search.value) params.search = search.value
const response = await securesendApi.list(params)
items.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || 1
} catch (error) {
console.error('Error loading securesend:', error)
} finally {
loading.value = false
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
loadItems()
}, 300)
}
function goToPage(target) {
if (target >= 1 && target <= totalPages.value) {
page.value = target
loadItems()
}
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadItems()
}
</script>
<style scoped>
.filters {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.filters .form-control {
flex: 1;
min-width: 150px;
}
</style>

View File

@@ -55,6 +55,7 @@
<tr v-if="vendors.length === 0">
<td colspan="5" style="text-align: center; color: var(--text-light);">
No vendors found
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
</td>
</tr>
</tbody>
@@ -189,11 +190,21 @@ import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError'
import { useListQuery } from '@/composables/listQuery'
import SearchSuggestion from '@/components/SearchSuggestion.vue'
const toast = useToast()
const vendors = ref([])
const loading = ref(true)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadVendors })
const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadVendors })
// Take the offered spelling. setSearch only syncs the URL and the watcher
// that reloads is suppressed because search.value already holds the new
// term, so load directly.
function applySuggestion(word) {
search.value = word
setSearch(word)
loadVendors()
}
const totalPages = ref(1)
const perPage = ref(20)
@@ -232,6 +243,7 @@ async function loadVendors() {
const response = await vendorsApi.list(params)
vendors.value = response.data.data || []
reportCount(vendors.value.length)
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) {
console.error('Error loading vendors:', err)

View File

@@ -639,6 +639,17 @@ def update_computer(computer_id: int):
asset = comp.asset
# An UPDATE may not blank the asset number. Create validates it and the
# column is NOT NULL, but the conflict check below only runs when the value
# DIFFERS, and '' never collides with anything - so a payload carrying an
# empty assetnumber wrote it straight through. A form that loaded blank
# (a failed reference load, a partial fetch) then saved the blank over a
# real record.
if 'assetnumber' in data and not (data['assetnumber'] or '').strip():
return error_response(
ErrorCodes.VALIDATION_ERROR,
'assetnumber cannot be empty')
# Check for conflicting assetnumber
if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber:
if Asset.query.filter_by(assetnumber=data['assetnumber']).first():

View File

@@ -172,6 +172,7 @@ try {
# 2. C:\Enrollment\cmm\cmmid.txt - CMM bay id (e.g. CMM3).
# 3. C:\Enrollment\machine-number.txt - imaging value.
# keyence / genspect / part-marker have no per-bay id -> no machineNo sent.
# Those bays name their DEVICE instead, in asset-id.txt below.
$machineNo = ''
foreach ($regPath in @(
'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\General',
@@ -236,6 +237,45 @@ try {
Log "WARN could not read logged-in user: $($_.Exception.Message)"
}
# The DEVICE hanging off this PC - the ONE enrollment file for every bay that
# has no NTLARS/eDNC MachineNo registry to identify it. It names the asset and
# nothing else: it does not say what kind of device that is, because the pc-type
# already does, so the same file serves a part marker, a Keyence, a Genspect or
# anything added later without a new file or a client change.
#
# Deliberately NOT machine-number.txt. machinenumber answers "which bay is
# this" and is what GE-Enforce TargetMachineNumbers gates on, so naming a device
# there would silently stop every bay-gated manifest entry from matching.
#
# WHY A FILE AT ALL: every other identity the server can fall back to is derived
# from the PC - a prior link from this PC, or an asset number built from this
# hostname - so none of them survive a PC SWAP. The replacement box mints a
# SECOND record for the same physical device while the first keeps its history
# under a dead PC's name. This file is what survives.
#
# The server ADOPTS the named asset, refuses a value it cannot resolve, and
# refuses one that resolves to the wrong kind of thing - so a typo warns instead
# of inventing a phantom device.
$deviceId = ''
$idFile = 'C:\Enrollment\asset-id.txt'
if (Test-Path -LiteralPath $idFile) {
try {
$deviceId = ([string](Get-Content -LiteralPath $idFile -First 1 -ErrorAction Stop)).Trim()
if ($deviceId) { Log "deviceId from ${idFile}: $deviceId" }
} catch { Log "WARN could not read ${idFile}: $($_.Exception.Message)" }
}
# 0.12.0 shipped measuringtool-id.txt before the file was generalised. Read it
# as a fallback so a bay already staged with one keeps reporting.
if (-not $deviceId) {
$mtFile = 'C:\Enrollment\measuringtool-id.txt'
if (Test-Path -LiteralPath $mtFile) {
try {
$deviceId = ([string](Get-Content -LiteralPath $mtFile -First 1 -ErrorAction Stop)).Trim()
if ($deviceId) { Log "deviceId from ${mtFile} (pre-0.13 name): $deviceId" }
} catch { Log "WARN could not read ${mtFile}: $($_.Exception.Message)" }
}
}
# imaging pc-type (gea-shopfloor-*), read from the enrollment file. Sent only
# when present; absent -> server leaves existing pctype untouched (a bare report
# never re-types a PC). Unmapped values return a warning, not an error.
@@ -302,6 +342,7 @@ $body = @{ hostname = $hostname }
if ($serialNumber) { $body['serialnumber'] = $serialNumber }
if ($machineNo) { $body['machinenumber'] = $machineNo }
if ($pcType) { $body['pctype'] = $pcType }
if ($deviceId) { $body['deviceid'] = $deviceId }
if ($manufacturer) { $body['vendorname'] = $manufacturer }
if ($model) { $body['modelnumber'] = $model }
if ($osVersion) { $body['osname'] = $osVersion }
@@ -312,8 +353,8 @@ $body['lastcheckin'] = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:s
$json = $body | ConvertTo-Json -Compress -Depth 4
Log ("POST {0} host={1} serial={2} pcType={3} make={4} model={5} os={6} boot={7} machineNo={8} user={9} ip={10}" -f `
$ApiUrl, $hostname, $serialNumber, $pcType, $manufacturer, $model, $osVersion, $lastBootTime, $machineNo, $loggedInUser, $corpIp)
Log ("POST {0} host={1} serial={2} pcType={3} make={4} model={5} os={6} boot={7} machineNo={8} user={9} ip={10} deviceId={11}" -f `
$ApiUrl, $hostname, $serialNumber, $pcType, $manufacturer, $model, $osVersion, $lastBootTime, $machineNo, $loggedInUser, $corpIp, $deviceId)
try {
$resp = Invoke-RestMethod -Uri $ApiUrl -Method Post -Body $json `

View File

@@ -66,6 +66,7 @@
<tr v-if="computers.length === 0">
<td colspan="8" style="text-align: center; color: var(--text-light);">
No computers found
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
</td>
</tr>
</tbody>
@@ -91,10 +92,20 @@ import { computersApi } from '@/api'
import PaginationBar from '@/components/PaginationBar.vue'
import { colorStyle } from '@/utils/colorStyle'
import { useListQuery } from '@/composables/listQuery'
import SearchSuggestion from '@/components/SearchSuggestion.vue'
const computers = ref([])
const loading = ref(true)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadComputers })
const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadComputers })
// Take the offered spelling. setSearch only syncs the URL and the watcher
// that reloads is suppressed because search.value already holds the new
// term, so load directly.
function applySuggestion(word) {
search.value = word
setSearch(word)
loadComputers()
}
const totalPages = ref(1)
const perPage = ref(20)
@@ -115,6 +126,7 @@ async function loadComputers() {
const response = await computersApi.list(params)
computers.value = response.data.data || []
reportCount(computers.value.length)
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading computers:', error)

View File

@@ -106,12 +106,24 @@ class ComputersPlugin(BasePlugin):
'fields': {
'hostname': {'type': 'string', 'required': True},
'machinenumber': {'type': 'string'},
# The instrument this PC drives, named by the enrollment file
# measuringtool-id.txt. SEPARATE from machinenumber on purpose:
# "which bay is this" and "which instrument is this" are
# different facts, and machinenumber is what GE-Enforce
# TargetMachineNumbers gates on - repointing it at a tool would
# silently stop every bay-gated manifest entry matching.
# The DEVICE hanging off this PC, named by the enrollment file
# asset-id.txt. One file for every bay that has no NTLARS/eDNC
# MachineNo registry to identify it, and it does NOT say what
# kind of device it is: the pc-type already does, through
# SUBORDINATE_DEVICE_MAP.
#
# SEPARATE from machinenumber on purpose: "which bay is this"
# and "which device is this" are different facts, and
# machinenumber is what GE-Enforce TargetMachineNumbers gates
# on - repointing it at a device would silently stop every
# bay-gated manifest entry matching.
#
# Named deviceid rather than assetid because assetid is this
# contract's own RESPONSE field for a PC's integer primary key.
'deviceid': {'type': 'string'},
# Shipped in 0.12.0, before the file was generalised. Accepted
# as an alias so a bay staged with measuringtool-id.txt keeps
# reporting; deviceid wins when both arrive.
'measuringtoolid': {'type': 'string'},
'pctype': {'type': 'string'},
'pcsubtype': {'type': 'string'},
@@ -394,14 +406,22 @@ class ComputersPlugin(BasePlugin):
# marker PC must not also claim the operation directly: several markers
# serve one operation number, so direct claims would fight over it. The
# marker is partof the operation and control propagates along that rail.
partmarkers = self._sync_partmarker(comp, pctype, machinenumber,
warnings)
# asset-id.txt, falling back to the 0.12.0 field name. One value feeds
# BOTH device paths: the file names the device and nothing else, so
# which sync consumes it follows from the pc-type.
deviceid = (payload.get('deviceid')
or payload.get('measuringtoolid') or '').strip() or None
# PC -> machine link from the reported machine number.
if partmarkers:
machinelinks = []
else:
partmarkers = self._sync_partmarker(comp, pctype, machinenumber,
warnings, deviceid=deviceid)
# PC -> machine link from the reported machine number. Only a PC that
# is NOT a marker PC (None above) takes it: a marker PC reaches its
# operation through `partof` on the marker, never by claiming it.
if partmarkers is None:
machinelinks = self._sync_machine_link(comp, machinenumber, warnings)
else:
machinelinks = []
# Printer relationship sync (only when the payload carried printer data).
printerlinks = self._sync_printer_links(comp.asset, payload, warnings)
@@ -410,7 +430,7 @@ class ComputersPlugin(BasePlugin):
# get an attached MeasuringTool asset auto-created and linked.
measuringtoollinks = self._sync_measuringtool_link(
comp.asset, pctype, hostname, warnings,
measuringtoolid=payload.get('measuringtoolid'),
measuringtoolid=deviceid,
machinenumber=machinenumber)
db.session.commit()
@@ -426,7 +446,10 @@ class ComputersPlugin(BasePlugin):
'measuringtoollinkcount': len(measuringtoollinks),
'accessprotocols': accessprotocols,
'machinelinks': machinelinks,
'partmarkers': partmarkers,
# `or []`: None is the internal "not a marker PC" signal and
# has no business in the response, where it would read as a
# different state from "a marker PC that linked nothing".
'partmarkers': partmarkers or [],
},
}
@@ -750,7 +773,132 @@ class ComputersPlugin(BasePlugin):
'machinenumber': machine.assetnumber,
'superseded': len(held)}]
def _sync_partmarker(self, comp, pctype, machinenumber, warnings):
def _is_device_of_type(self, asset, spec):
"""Is this asset the kind of device the pc-type's spec describes?
asset-id.txt names a device and says nothing about its type, so the
type is what proves the file points somewhere sensible. A measuring
tool is checked against the measuring-tool set; anything else is
checked against its machine type, which is where a Part Marker lives.
"""
if asset is None:
return False
if spec.get('assettype') == 'measuring_tool':
return asset.assetid in self._measuringtool_assetids()
try:
from plugins.machines.models import Machine, MachineType
except ImportError:
return False
row = (db.session.query(MachineType.machinetype)
.join(Machine, Machine.machinetypeid == MachineType.machinetypeid)
.filter(Machine.assetid == asset.assetid).first())
return bool(row) and row[0] == spec['typename']
def _ensure_device_rows(self, deviceasset, spec, pcasset, controls, label,
warnings, active=True):
"""Get-or-create the extension row and the PC -> device control link.
Shared by the named-device and minted paths: an adopted asset may have
no extension row, and the control link may already exist under another
label, so both are get-or-create rather than insert.
"""
from shopdb.api import AssetRelationship
try:
from plugins.machines.models import Machine, MachineType
except ImportError:
warnings.append('machines plugin unavailable; {} device skipped'
.format(spec['typename']))
return
if not Machine.query.filter_by(assetid=deviceasset.assetid).first():
devicetype = MachineType.query.filter_by(
machinetype=spec['typename']).first()
if not devicetype:
devicetype = MachineType(machinetype=spec['typename'],
description=spec.get('description'))
db.session.add(devicetype)
db.session.flush()
db.session.add(Machine(assetid=deviceasset.assetid,
machinetypeid=devicetype.machinetypeid))
controlrow = AssetRelationship.query.filter_by(
sourceassetid=pcasset.assetid,
targetassetid=deviceasset.assetid,
relationshiptypeid=controls.relationshiptypeid).first()
if controlrow is not None:
controlrow.isactive = active
if not controlrow.label:
controlrow.label = label
else:
db.session.add(AssetRelationship(
sourceassetid=pcasset.assetid,
targetassetid=deviceasset.assetid,
relationshiptypeid=controls.relationshiptypeid,
label=label,
isactive=active))
def _device_incumbents_yield(self, deviceasset, pcasset, label, warnings):
"""Settle a device already controlled by a DIFFERENT PC.
A physical instrument or marker hangs off one PC. Before this, both
paths only ever looked at links whose source was THIS PC, so a bay
naming a device another PC still holds produced two active `controls`
rows with no warning - the replaced PC kept its link forever, and a
file copied to a second bay claimed the device silently.
The machine link settled this years ago and this reuses its rule rather
than inventing a second one: an incumbent that has gone quiet past the
claim window, or been moved off In Use, has yielded, and its link is
ARCHIVED (never deleted, so "which PC drove this in June" stays
answerable). An incumbent still alive keeps the device, and the
challenger is recorded dormant instead of contesting a link that can
only have one holder.
Returns True when this PC may hold the device actively.
"""
from shopdb.api import AssetRelationship, RelationshipType, Asset
controls = RelationshipType.query.filter_by(
relationshiptype='controls').first()
if not controls or not deviceasset or not pcasset:
return True
# EVERY collector-owned holder, not just ones under this label. On a
# CMM the instrument IS the reported bay, so the incumbent's link is the
# machine sync's row under MACHINE_LINK_ORIGIN. Filtering on our own
# label found no incumbent there, handed the challenger an active link,
# and left two live holders of one instrument - each invisible to the
# other because they were labelled differently.
#
# A row made by hand carries neither label and is deliberately NOT
# counted: a person's link is not the collector's to archive.
ourlabels = (label, MACHINE_LINK_ORIGIN, MEASURINGTOOL_LINK_ORIGIN,
PARTMARKER_LINK_ORIGIN)
others = AssetRelationship.query.filter(
AssetRelationship.targetassetid == deviceasset.assetid,
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
AssetRelationship.label.in_(ourlabels),
AssetRelationship.isactive.is_(True),
AssetRelationship.sourceassetid != pcasset.assetid,
).all()
if not others:
return True
holding = []
for rel in others:
if self._incumbent_has_yielded(rel.sourceassetid):
rel.isactive = False
else:
holder = db.session.get(Asset, rel.sourceassetid)
holding.append(holder.assetnumber if holder else rel.sourceassetid)
if holding:
warnings.append(
'{} is still controlled by {}; recorded but not activated'
.format(deviceasset.assetnumber, ', '.join(str(h) for h in holding)))
return False
return True
def _sync_partmarker(self, comp, pctype, machinenumber, warnings,
deviceid=None):
"""Give a part-marker PC a marker asset of its own, under its operation.
Several Telesis markers serve one operation number - 0613, 0615 and
@@ -760,10 +908,19 @@ class ComputersPlugin(BasePlugin):
question about an individual marker (how many are there, which port,
which one failed) could be asked at all.
One marker per PC, so the PC identifies the marker and the collector can
mint it the same way it already mints a CMM or a Keyence unit for a
metrology PC. The marker is a machine asset of type Part Marker, the PC
`controls` it, and the marker is `partof` the operation it serves.
One marker per PC. The marker is a machine asset of type Part Marker,
the PC `controls` it, and the marker is `partof` the operation it
serves.
IDENTITY COMES FROM asset-id.txt FIRST, because everything else here is
derived from the PC and a PC is not permanent. Reuse looks for a prior
link from THIS PC asset and adoption looks up `<PC number>-PARTMARKER`,
so both survive a re-image and neither survives a PC SWAP: a new
hostname is a new PC asset with no prior link and a predicted number
that has never existed, so the same physical Telesis unit gets a second
record while the first keeps its config and backup history under a dead
PC's name. That is the failure the metrology path already learned from,
where minting from the hostname left 43 instruments shadowed by twins.
That last rail is why the PC does not also claim the operation directly:
`controls` propagates through `partof` (seeded in reference-data), so
@@ -782,7 +939,16 @@ class ComputersPlugin(BasePlugin):
# Only a device that FILES UNDER an operation goes through here.
# A measuring tool is a subordinate device too, but it does not
# share a machine number, so it keeps the simpler path.
return []
#
# NONE, not []. None is the ONLY answer that means "not a marker PC,
# run the ordinary machine link". Every other exit below means "this
# IS a marker PC" - possibly one that linked nothing - and returning
# [] there let the caller fall through to the machine link, which
# for a marker PC points at the OPERATION. That is the direct claim
# this whole path exists to prevent: several markers serve one
# operation, so two marker PCs would contest a link that can only
# have one holder, while the warning said "not linked".
return None
pcasset = comp.asset
controls = RelationshipType.query.filter_by(
@@ -801,8 +967,37 @@ class ComputersPlugin(BasePlugin):
label = spec['label']
# Reuse this PC's existing device before minting one, so a re-image
# never leaves a second device behind for the same physical unit.
# --- 1. an explicitly named device wins over everything -------------
#
# NAMING A DEVICE IS A COMMITMENT. If the bay says which device it
# drives and that name does not resolve, the answer is to link nothing
# and say so - NOT to fall through and mint <HOST>-PARTMARKER, which is
# the hostname-derived twin this whole path exists to prevent. The
# warning used to be true about the typo'd number and false about the
# twin: it warned, then minted anyway.
#
# Silence still means "work it out", so a bay with no file keeps the
# reuse-then-mint behaviour it has always had.
named = (deviceid or '').strip()
namedasset = None
if named:
candidate = self._asset_by_number(named)
if candidate is None:
# Warn rather than invent: a typo must not mint a phantom
# device that nobody can account for.
warnings.append(
'no asset for device {!r}; not linked'.format(named))
return []
if not self._is_device_of_type(candidate, spec):
# The name resolved, but not to this pc-type's device. Refuse
# and say which asset it hit, rather than filing an unrelated
# asset under a device label the collector also owns.
warnings.append(
'asset {!r} is not a {}; not linked'.format(
candidate.assetnumber, spec['typename']))
return []
namedasset = candidate
existing = AssetRelationship.query.filter(
AssetRelationship.sourceassetid == pcasset.assetid,
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
@@ -811,9 +1006,32 @@ class ComputersPlugin(BasePlugin):
reuse = next((rel for rel in existing if rel.isactive), None) \
or (existing[0] if existing else None)
if reuse:
reuse.isactive = True
if namedasset is not None:
# The file is authoritative FOR THIS PC, but the device may still be
# held by another one. Settle that first: a yielded incumbent is
# archived, a live one keeps it and this link is recorded dormant.
mayhold = self._device_incumbents_yield(
namedasset, pcasset, label, warnings)
# The file is authoritative. A PC that previously minted its own
# marker and is now told the real one keeps only the named link;
# the stale link is archived by the one-marker-per-PC sweep below.
markerasset = namedasset
reuse = next((rel for rel in existing
if rel.targetassetid == namedasset.assetid), None)
if reuse is not None:
reuse.isactive = True
self._ensure_device_rows(markerasset, spec, pcasset, controls,
label, warnings, active=mayhold)
elif reuse:
# Re-check incumbency HERE too, not only on the named path. A
# challenger recorded dormant keeps its row, so the cycle after its
# asset-id.txt goes missing found that row as `reuse` and flipped it
# active with no incumbent check and no warning - two live holders,
# arrived at by deleting a file. _sync_machine_link never had this
# hole because it re-checks unconditionally.
markerasset = db.session.get(Asset, reuse.targetassetid)
reuse.isactive = self._device_incumbents_yield(
markerasset, pcasset, label, warnings)
else:
coretype = AssetType.query.filter_by(
assettype=spec['assettype']).first()
@@ -852,26 +1070,8 @@ class ComputersPlugin(BasePlugin):
db.session.add(markerasset)
db.session.flush()
# The extension row may be missing on an adopted asset, and the link
# may already exist under another label - both get-or-create.
if not Machine.query.filter_by(assetid=markerasset.assetid).first():
db.session.add(Machine(assetid=markerasset.assetid,
machinetypeid=devicetype.machinetypeid))
controlrow = AssetRelationship.query.filter_by(
sourceassetid=pcasset.assetid,
targetassetid=markerasset.assetid,
relationshiptypeid=controls.relationshiptypeid).first()
if controlrow is not None:
controlrow.isactive = True
if not controlrow.label:
controlrow.label = label
else:
db.session.add(AssetRelationship(
sourceassetid=pcasset.assetid,
targetassetid=markerasset.assetid,
relationshiptypeid=controls.relationshiptypeid,
label=label,
isactive=True))
self._ensure_device_rows(markerasset, spec, pcasset, controls,
label, warnings)
# One marker per PC: archive any other collector marker link.
for rel in existing:
@@ -1179,10 +1379,14 @@ class ComputersPlugin(BasePlugin):
candidate = self._asset_by_number(named)
if candidate is None:
# Warn rather than invent: a typo in the file must not mint a
# phantom instrument that nobody can account for.
# phantom instrument that nobody can account for. RETURN, do
# not fall through - minting <HOST>-CMM here would create the
# hostname-derived twin this resolution order exists to avoid,
# while the warning claimed nothing was linked.
warnings.append(
'no asset for measuring tool {!r}; not linked'.format(named))
elif candidate.assetid not in self._measuringtool_assetids():
return []
if candidate.assetid not in self._measuringtool_assetids():
# The name resolved, but not to an instrument. measuringtool-id
# .txt holding a machine number would otherwise link the PC to
# that MACHINE under a measuring-tool label - a link that reads
@@ -1191,8 +1395,8 @@ class ComputersPlugin(BasePlugin):
warnings.append(
'asset {!r} is not a measuring tool; not linked'.format(
candidate.assetnumber))
else:
adopted = candidate
return []
adopted = candidate
# --- 2. a prior collector link (reactivate + retype) -----------------
reuse = next((rel for rel in existing if rel.isactive), None) \
@@ -1206,6 +1410,11 @@ class ComputersPlugin(BasePlugin):
pcasset, controls, machinenumber)
if adopted is not None:
# Settle any OTHER PC still holding this instrument first: a yielded
# incumbent is archived, a live one keeps it and this link is
# recorded dormant rather than becoming a second active holder.
mayhold = self._device_incumbents_yield(
adopted, pcasset, MEASURINGTOOL_LINK_ORIGIN, warnings)
# CLAIM the existing link rather than adding a second one. A row for
# (pc, tool, controls) usually already exists - that is how the tool
# was found - and assetrelationships is unique on exactly that
@@ -1229,20 +1438,24 @@ class ComputersPlugin(BasePlugin):
# is ours from here, so a later cycle reuses it instead of
# minting.
claimed.label = MEASURINGTOOL_LINK_ORIGIN
claimed.isactive = True
claimed.isactive = mayhold
else:
db.session.add(AssetRelationship(
sourceassetid=pcasset.assetid,
targetassetid=adopted.assetid,
relationshiptypeid=controls.relationshiptypeid,
label=MEASURINGTOOL_LINK_ORIGIN))
label=MEASURINGTOOL_LINK_ORIGIN,
isactive=mayhold))
if adopted.measuringtool and tooltype:
adopted.measuringtool.measuringtooltypeid = \
tooltype.measuringtooltypeid
targetid = adopted.assetid
elif reuse:
reuse.isactive = True
# Same re-check as the marker path: a dormant challenger must not be
# promoted just because its enrollment file stopped being readable.
toolasset = db.session.get(Asset, reuse.targetassetid)
reuse.isactive = self._device_incumbents_yield(
toolasset, pcasset, MEASURINGTOOL_LINK_ORIGIN, warnings)
if toolasset and toolasset.measuringtool and tooltype:
toolasset.measuringtool.measuringtooltypeid = \
tooltype.measuringtooltypeid

View File

@@ -43,6 +43,39 @@ def _visible_articles():
KnowledgeBase.appid.notin_(retired)))
def _search_clause(search):
"""Articles containing EVERY word of the search, each word in the title, the
keywords, or the topic's name, in any order.
A single `ilike('%CMM Community%')` needs the whole phrase contiguous in ONE
field. An article tagged 'community' under the CMM topic has 'CMM' only in
its topic's name and 'Community' only in its keywords, so it matched nothing
and the search came back empty. Splitting the query and requiring each word
somewhere is what people mean when they type two words.
Kept in step with `_word_match` in shopdb/core/api/search.py, which does the
same job for global search. Two copies because the core helper is internal
and not on the plugin contract surface (ADR-001).
Active applications only for the topic. A retired application is not a topic
anyone should be offered: matching its name surfaced its articles and printed
the retired app as their subject, which reads as though it were still in
service.
"""
words = [w for w in search.split() if w] or ['']
return db.and_(*[
db.or_(
KnowledgeBase.shortdescription.ilike(f'%{w}%'),
KnowledgeBase.keywords.ilike(f'%{w}%'),
KnowledgeBase.appid.in_(
db.session.query(Application.appid).filter(
Application.appname.ilike(f'%{w}%'),
Application.isactive.is_(True))),
)
for w in words
])
@knowledgebase_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_articles():
@@ -51,26 +84,11 @@ def list_articles():
query = _visible_articles()
# Search: title, keywords, and the topic (its Application's name). The topic
# is matched via an appid subquery instead of a join so it does not collide
# with the sort=='topic' join below; articles with no app just miss that
# clause and still match on title/keywords.
# The topic is matched via an appid subquery instead of a join so it does not
# collide with the sort=='topic' join below; articles with no app just miss
# that clause and still match on title/keywords.
if search := request.args.get('search'):
like = f'%{search}%'
# Active applications only. A retired application is not a topic anyone
# should be offered: matching its name surfaced its articles and printed
# the retired app as their subject, which reads as though it were still
# in service.
topic_appids = db.session.query(Application.appid).filter(
Application.appname.ilike(like),
Application.isactive.is_(True))
query = query.filter(
db.or_(
KnowledgeBase.shortdescription.ilike(like),
KnowledgeBase.keywords.ilike(like),
KnowledgeBase.appid.in_(topic_appids)
)
)
query = query.filter(_search_clause(search))
# Filter by topic/application
if appid := request.args.get('appid'):

View File

@@ -88,6 +88,7 @@
<tr v-if="articles.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">
No articles found
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
</td>
</tr>
</tbody>
@@ -112,12 +113,22 @@ import { ref, onMounted } from 'vue'
import { knowledgebaseApi, applicationsApi } from '@/api'
import PaginationBar from '@/components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
import SearchSuggestion from '@/components/SearchSuggestion.vue'
const loading = ref(true)
const articles = ref([])
const topics = ref([])
const stats = ref(null)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadArticles })
const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadArticles })
// Take the offered spelling. setSearch only syncs the URL and the watcher
// that reloads is suppressed because search.value already holds the new
// term, so load directly.
function applySuggestion(word) {
search.value = word
setSearch(word)
loadArticles()
}
const perPage = ref(20)
const totalPages = ref(1)
const topicFilter = ref('')
@@ -161,6 +172,7 @@ async function loadArticles() {
const response = await knowledgebaseApi.list(params)
articles.value = response.data.data || []
reportCount(articles.value.length)
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading articles:', error)

View File

@@ -437,6 +437,17 @@ def update_machine(machine_id: int):
asset = mach.asset
# An UPDATE may not blank the asset number. Create validates it and the
# column is NOT NULL, but the conflict check below only runs when the value
# DIFFERS, and '' never collides with anything - so a payload carrying an
# empty assetnumber wrote it straight through. A form that loaded blank
# (a failed reference load, a partial fetch) then saved the blank over a
# real record.
if 'assetnumber' in data and not (data['assetnumber'] or '').strip():
return error_response(
ErrorCodes.VALIDATION_ERROR,
'assetnumber cannot be empty')
# Check for conflicting assetnumber
if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber:
if Asset.query.filter_by(assetnumber=data['assetnumber']).first():

View File

@@ -7,6 +7,11 @@
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<!-- The record failed to load. Show the reason INSTEAD of the form: an
empty edit form is indistinguishable from a record whose fields are
genuinely empty, and saving it would write the blanks back. -->
<div v-else-if="loadFailed" class="error-message">{{ error }}</div>
<form v-else @submit.prevent="saveMachine">
<!-- Identity Section -->
<h3 class="form-section-title">Identity</h3>
@@ -425,6 +430,10 @@ const statuses = ref([])
const vendors = ref([])
const locations = ref([])
const models = ref([])
// Set when the record itself could not be loaded, as opposed to a reference
// list failing. The form is withheld entirely, because an empty edit form is
// indistinguishable from a record whose fields are genuinely empty.
const loadFailed = ref(false)
const businessunits = ref([])
const pcs = ref([])
const relationshipTypes = ref([])
@@ -506,9 +515,29 @@ onMounted(async () => {
relationshipTypeId.value = controlsType.relationshiptypeid
}
// Load machine if editing
// Load machine if editing.
//
// ITS OWN try/catch, deliberately. This used to sit inside the same block
// as the eight reference loads above, so ONE transient failure among them -
// a page of listAll() timing out on a busy collector cycle - rejected the
// whole thing and rendered a fully editable EDIT form with every field
// BLANK, including the required Asset Number, next to a Save button. Typing
// a number and saving then wrote the blank-loaded values over a real
// machine. The reference lists degrade to empty dropdowns; the record must
// not degrade at all.
if (isEdit.value) {
const response = await machinesApi.get(route.params.id)
let response
try {
response = await machinesApi.get(route.params.id)
} catch (err) {
console.error('Error loading machine:', err)
error.value = 'Could not load this machine. Nothing has been changed - '
+ 'reload the page rather than saving, or the blank form would '
+ 'overwrite the record.'
loadFailed.value = true
loading.value = false
return
}
const data = response.data.data
currentAssetId.value = data.assetid || null
currentMachine.value = data

View File

@@ -36,7 +36,9 @@
</tr>
</thead>
<tbody>
<tr v-for="item in machines" :key="item.assetid" class="clickable-row" @click="$router.push(`/machines/${item.machine?.machineid || item.assetid}`)">
<tr v-for="item in machines" :key="item.assetid"
:class="{ 'clickable-row': item.machine?.machineid }"
@click="item.machine?.machineid && $router.push(`/machines/${item.machine.machineid}`)">
<td>
{{ item.assetnumber }}<template v-if="item.dualpathpartner"> / {{ item.dualpathpartner.assetnumber }}</template>
</td>
@@ -58,17 +60,26 @@
</td>
<td>{{ item.locationname || '-' }}</td>
<td class="actions" @click.stop>
<!-- /machines/:id keys on machineid, the plugin extension id, NOT
the assetid. Falling back to the assetid lands on whichever
machine happens to carry that number: a wrong page that looks
right, which is worse than a 404 (see the same fix in
EnforcementReports and the warning in BackupHistory). With no
machineid there is no page to link to, so show nothing. -->
<router-link
:to="`/machines/${item.machine?.machineid || item.assetid}`"
v-if="item.machine?.machineid"
:to="`/machines/${item.machine.machineid}`"
class="btn btn-secondary btn-sm"
>
View
</router-link>
<span v-else>-</span>
</td>
</tr>
<tr v-if="machines.length === 0">
<td colspan="8" style="text-align: center; color: var(--text-light);">
No machines found
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
</td>
</tr>
</tbody>
@@ -95,10 +106,22 @@ import { colorStyle } from "@/utils/colorStyle"
import { machinesApi } from '@/api'
import PaginationBar from '@/components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
import SearchSuggestion from '@/components/SearchSuggestion.vue'
const machines = ref([])
const loading = ref(true)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadMachines })
const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadMachines })
// Take the offered spelling: put it in the box and search it, so what was
// searched is what the person sees.
function applySuggestion(word) {
search.value = word
setSearch(word)
// setSearch only syncs the URL, and the watcher that reloads is suppressed
// because search.value already holds the new term - the same trap the global
// search page documents. Load directly.
loadMachines()
}
const totalPages = ref(1)
const perPage = ref(20)
@@ -119,6 +142,7 @@ async function loadMachines() {
const response = await machinesApi.list(params)
machines.value = response.data.data || []
reportCount(machines.value.length)
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading machines:', error)

View File

@@ -316,6 +316,17 @@ def update_tool(tool_id: int):
data = request.get_json() or {}
asset = tool.asset
# An UPDATE may not blank the asset number. Create validates it and the
# column is NOT NULL, but the conflict check below only runs when the value
# DIFFERS, and '' never collides with anything - so a payload carrying an
# empty assetnumber wrote it straight through. A form that loaded blank
# (a failed reference load, a partial fetch) then saved the blank over a
# real record.
if 'assetnumber' in data and not (data['assetnumber'] or '').strip():
return error_response(
ErrorCodes.VALIDATION_ERROR,
'assetnumber cannot be empty')
if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber:
if Asset.query.filter_by(assetnumber=data['assetnumber']).first():
return error_response(ErrorCodes.CONFLICT,

View File

@@ -73,6 +73,7 @@
<tr v-if="tools.length === 0">
<td colspan="8" style="text-align: center; color: var(--text-light);">
No measuring tools found
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
</td>
</tr>
</tbody>
@@ -97,11 +98,21 @@ import { colorStyle } from '@/utils/colorStyle'
import { measuringtoolsApi } from '@/api'
import PaginationBar from '@/components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
import SearchSuggestion from '@/components/SearchSuggestion.vue'
const tools = ref([])
const types = ref([])
const loading = ref(true)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadTools })
const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadTools })
// Take the offered spelling. setSearch only syncs the URL and the watcher
// that reloads is suppressed because search.value already holds the new
// term, so load directly.
function applySuggestion(word) {
search.value = word
setSearch(word)
loadTools()
}
const typeid = ref('')
const calibrationstatus = ref('')
const totalPages = ref(1)
@@ -133,6 +144,7 @@ async function loadTools() {
const response = await measuringtoolsApi.list(params)
tools.value = response.data.data || []
reportCount(tools.value.length)
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) {
console.error('Error loading measuring tools:', err)

View File

@@ -483,6 +483,17 @@ def update_network_device(device_id: int):
asset = netdev.asset
# An UPDATE may not blank the asset number. Create validates it and the
# column is NOT NULL, but the conflict check below only runs when the value
# DIFFERS, and '' never collides with anything - so a payload carrying an
# empty assetnumber wrote it straight through. A form that loaded blank
# (a failed reference load, a partial fetch) then saved the blank over a
# real record.
if 'assetnumber' in data and not (data['assetnumber'] or '').strip():
return error_response(
ErrorCodes.VALIDATION_ERROR,
'assetnumber cannot be empty')
# Check for conflicting assetnumber
if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber:
if Asset.query.filter_by(assetnumber=data['assetnumber']).first():

View File

@@ -33,22 +33,26 @@
{{ heroWarranty.label }}<template v-if="heroWarranty.enddate"> - {{ warrantyDate(heroWarranty.enddate) }}</template>
</span>
</div>
<!-- hero-detail / -label / -value are the styled classes every other
detail page uses. This page had its own detail-item / label / value
names, which match nothing in the stylesheet, so the label and the
value rendered as one run-together string. -->
<div class="hero-details">
<div class="detail-item" v-if="device.assetnumber">
<span class="label">Asset #</span>
<span class="value">{{ device.assetnumber }}</span>
<div class="hero-detail" v-if="device.assetnumber">
<span class="hero-detail-label">Asset #</span>
<span class="hero-detail-value">{{ device.assetnumber }}</span>
</div>
<div class="detail-item" v-if="device.serialnumber">
<span class="label">Serial</span>
<span class="value mono">{{ device.serialnumber }}</span>
<div class="hero-detail" v-if="device.serialnumber">
<span class="hero-detail-label">Serial</span>
<span class="hero-detail-value mono">{{ device.serialnumber }}</span>
</div>
<div class="detail-item" v-if="device.locationname">
<span class="label">Location</span>
<span class="value">{{ device.locationname }}</span>
<div class="hero-detail" v-if="device.locationname">
<span class="hero-detail-label">Location</span>
<span class="hero-detail-value">{{ device.locationname }}</span>
</div>
<div class="detail-item" v-if="device.businessunitname">
<span class="label">Business Unit</span>
<span class="value">{{ device.businessunitname }}</span>
<div class="hero-detail" v-if="device.businessunitname">
<span class="hero-detail-label">Business Unit</span>
<span class="hero-detail-value">{{ device.businessunitname }}</span>
</div>
</div>
<div class="hero-features" v-if="device.networkdevice">

View File

@@ -24,9 +24,18 @@
type="text"
class="form-control"
:required="isEdit"
:disabled="isEdit"
:disabled="isEdit && !assetNumberMissing"
:placeholder="generatedAssetNumber || 'leave blank to generate from the name'"
/>
<!-- Locked while editing, because an asset number is identity and
changing it silently re-keys the record. The exception is a
record that HAS no number: those exist (an update path used to
accept a blank), the server now rejects a blank on save, and
with the field disabled there was no way to supply one - the
row could not be saved at all until it was fixed elsewhere. -->
<small class="form-hint" v-if="assetNumberMissing">
This device has no asset number. Give it one to save.
</small>
<small class="form-hint" v-if="!isEdit && !form.assetnumber && generatedAssetNumber">
Will be created as <code>{{ generatedAssetNumber }}</code>
</small>
@@ -161,7 +170,7 @@
<label for="modelnumberid">Model</label>
<select id="modelnumberid" v-model="form.modelnumberid" class="form-control">
<option value="">Select Model</option>
<option v-for="m in models" :key="m.modelnumberid" :value="m.modelnumberid">
<option v-for="m in filteredModels" :key="m.modelnumberid" :value="m.modelnumberid">
{{ m.modelnumber }}
</option>
</select>
@@ -393,8 +402,21 @@ const generatedAssetNumber = computed(() => {
if (name.toUpperCase().startsWith(prefix + '-')) return name
return `${prefix}-${name}`
})
// True when the record loaded WITHOUT an asset number, which unlocks the field
// so it can be repaired. Captured at load: once typed, the field must stay open.
const assetNumberMissing = ref(false)
const vendors = ref([])
const models = ref([])
// Models belong to a vendor, so picking one narrows the list - the same
// behaviour PCForm, MachineForm and PrinterForm have. With no vendor chosen the
// whole catalogue shows, rather than an empty dropdown that reads as "no models
// exist" when it means "pick a vendor first".
const filteredModels = computed(() => {
if (!form.value.vendorid) return models.value
return models.value.filter(m => m.vendorid === form.value.vendorid)
})
const locations = ref([])
const statuses = ref([])
const businessUnits = ref([])
@@ -471,6 +493,9 @@ async function loadDevice() {
// Populate form with existing data
form.value.assetnumber = data.assetnumber || ''
// Remember that it arrived empty, so the field stays editable while the
// user types the number this record should have had.
assetNumberMissing.value = !(data.assetnumber || '').trim()
form.value.name = data.name || ''
form.value.serialnumber = data.serialnumber || ''
form.value.gaugelabreference = data.gaugelabreference || ''

View File

@@ -97,6 +97,7 @@
<tr v-if="devices.length === 0">
<td colspan="9" style="text-align: center; color: var(--text-light);">
No network devices found
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
</td>
</tr>
</tbody>
@@ -122,13 +123,23 @@ import { colorStyle } from "@/utils/colorStyle"
import { networkApi, vendorsApi, locationsApi } from '@/api'
import PaginationBar from '@/components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
import SearchSuggestion from '@/components/SearchSuggestion.vue'
const devices = ref([])
const deviceTypes = ref([])
const vendors = ref([])
const locations = ref([])
const loading = ref(true)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadDevices })
const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadDevices })
// Take the offered spelling. setSearch only syncs the URL and the watcher
// that reloads is suppressed because search.value already holds the new
// term, so load directly.
function applySuggestion(word) {
search.value = word
setSearch(word)
loadDevices()
}
const selectedType = ref(null)
const vendorFilter = ref('')
const locationFilter = ref('')
@@ -220,6 +231,7 @@ async function loadDevices() {
const response = await networkApi.list(params)
devices.value = response.data.data || []
reportCount(devices.value.length)
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading network devices:', error)

View File

@@ -1963,6 +1963,17 @@ def update_printer(printer_id: int):
asset = printer.asset
# An UPDATE may not blank the asset number. Create validates it and the
# column is NOT NULL, but the conflict check below only runs when the value
# DIFFERS, and '' never collides with anything - so a payload carrying an
# empty assetnumber wrote it straight through. A form that loaded blank
# (a failed reference load, a partial fetch) then saved the blank over a
# real record.
if 'assetnumber' in data and not (data['assetnumber'] or '').strip():
return error_response(
ErrorCodes.VALIDATION_ERROR,
'assetnumber cannot be empty')
# Check for conflicting assetnumber
if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber:
if Asset.query.filter_by(assetnumber=data['assetnumber']).first():
@@ -2098,7 +2109,9 @@ def _annotate_supply(supply, vendor_name, modelnumberid):
Waste cartridge direction depends on vendor, so classification lives in
the supply_parts helper. Part numbers come from the modelsupplies table.
"""
level = supply.get('level', 0)
# .get('level') with no default: a supply with no reading carries None, and
# a 0 default would put back the very substitution this reports around.
level = supply.get('level')
name = supply.get('name', 'Unknown')
supplytype = derivesupplytype(name)
color = derivecolor(name, supply.get('color'))
@@ -2106,6 +2119,7 @@ def _annotate_supply(supply, vendor_name, modelnumberid):
return {
'name': name,
'level': level,
'lastseen': supply.get('lastseen'),
'color': color,
'supplytype': supplytype,
'status': cls['status'],
@@ -2149,6 +2163,7 @@ def _get_low_supplies_data():
results = []
total_checked = 0
unknown_printers = 0
for printer, asset, comm, vendor, model in unique_printers:
supplies = service.getsuppliesbyip_cached(comm.ipaddress)
@@ -2167,13 +2182,24 @@ def _get_low_supplies_data():
# shown it. The whole report exists to answer "what needs replacing".
annotated = []
has_low = False
has_unknown = False
for s in supplies:
item = _annotate_supply(s, vendor_name, modelnumberid)
if item['status'] == 'ok':
continue
# A supply nobody could read is not a supply that needs replacing,
# so it does not join the list of things to act on. It is counted
# instead: a printer that has gone quiet should be visible as that,
# not disappear from the report and not masquerade as empty.
if item['status'] == 'unknown':
has_unknown = True
continue
has_low = True
annotated.append(item)
if has_unknown:
unknown_printers += 1
if has_low:
# location name for the report row
# Via the relationship, the way the printers list does it. The
@@ -2211,7 +2237,12 @@ def _get_low_supplies_data():
'summary': {
'total_checked': total_checked,
'low': low_count,
'critical': critical_count
'critical': critical_count,
# Printers with at least one unreadable supply. They are NOT in
# 'printers' above - nothing about them needs ordering - but a
# count that only ever went up when a cartridge ran low would hide
# a fleet quietly going dark.
'unknown': unknown_printers
}
}

View File

@@ -215,8 +215,14 @@
<div v-for="supply in supplies" :key="supply.itemid || supply.name" class="supply-item">
<div class="supply-header">
<span class="supply-name">{{ supply.name }}</span>
<span class="supply-level" :class="supply.status">
{{ supply.level !== null ? `${supply.level}%` : 'N/A' }}
<!-- N/A is not the same as 0%. A supply Zabbix has no usable
reading for arrives with level null and status 'unknown';
the title says which of the two reasons it is, because
"no data" and "last seen three weeks ago" need different
people to fix them. -->
<span class="supply-level" :class="supply.status"
:title="supply.status === 'unknown' ? unknownReason(supply) : null">
{{ supply.level !== null && supply.level !== undefined ? `${supply.level}%` : 'N/A' }}
</span>
</div>
<div class="supply-bar">
@@ -227,7 +233,7 @@
></div>
</div>
<div class="supply-meta">
<span>{{ formatSupplyType(supply.supplytype) }}<template v-if="supply.iswaste"> ({{ supply.remaining }}% remaining)</template></span>
<span>{{ formatSupplyType(supply.supplytype) }}<template v-if="supply.iswaste && supply.remaining !== null"> ({{ supply.remaining }}% remaining)</template></span>
<span v-if="supply.partnumbers && supply.partnumbers.length" class="supply-parts">
<span
v-for="part in supply.partnumbers"
@@ -363,6 +369,15 @@ function formatSupplyType(supplytype) {
return supplytype.charAt(0).toUpperCase() + supplytype.slice(1)
}
// Why a supply reads N/A. lastseen is the epoch of the newest reading Zabbix
// holds: absent means the item has never collected, present means it has but
// the reading is too old to present as current. Those are different faults and
// the tooltip is the only place either one is stated.
function unknownReason(supply) {
if (!supply.lastseen) return 'Zabbix has no reading for this supply'
return `No reading since ${new Date(supply.lastseen * 1000).toLocaleString()}`
}
function formatDate(dateStr) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString()
@@ -414,6 +429,10 @@ function formatDate(dateStr) {
.supply-level.ok { color: var(--success); }
.supply-level.low { color: var(--warning); }
.supply-level.critical { color: var(--danger); }
/* Muted on purpose. An unknown level is not a severity - colouring it like one
would put it in the same reading as critical, which is the confusion this
whole change exists to end. The help cursor points at the tooltip. */
.supply-level.unknown { color: var(--text-light); cursor: help; }
.supply-bar {
height: 10px;

View File

@@ -68,6 +68,7 @@
<tr v-if="printers.length === 0">
<td colspan="7" style="text-align: center; color: var(--text-light);">
No printers found
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
</td>
</tr>
</tbody>
@@ -92,12 +93,24 @@ import { ref, onMounted } from 'vue'
import { printersApi } from '@/api'
import PaginationBar from '@/components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
import SearchSuggestion from '@/components/SearchSuggestion.vue'
const printers = ref([])
const printerTypes = ref([])
const typeFilter = ref('')
const loading = ref(true)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadPrinters })
const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadPrinters })
// Take the offered spelling: put it in the box and search it, so what was
// searched is what the person sees.
function applySuggestion(word) {
search.value = word
setSearch(word)
// setSearch only syncs the URL, and the watcher that reloads is suppressed
// because search.value already holds the new term - the same trap the global
// search page documents. Load directly.
loadPrinters()
}
const totalPages = ref(1)
const perPage = ref(20)
@@ -125,6 +138,7 @@ async function loadPrinters() {
const response = await printersApi.list(params)
printers.value = response.data.data || []
reportCount(printers.value.length)
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading printers:', error)

View File

@@ -42,7 +42,11 @@
<ul v-else class="order-list">
<li v-for="item in mapped" :key="orderKey(item)">
<span class="quantity">{{ item.quantity }}x</span>
<span class="partnumber">{{ item.partnumber }}</span>
<!-- Name here too, number on hover. 'Copy list' still writes the
part number, which is what an order is placed against. -->
<span class="supplyname" :title="item.partnumber">
{{ item.marketingname || item.partnumber }}
</span>
<span class="muted order-detail">
{{ colorLabel(item.color) }}<template v-if="item.model">, {{ item.model }}</template>
</span>
@@ -90,7 +94,7 @@
<th class="collevel">Level</th>
<th>Runs out</th>
<th>Rate</th>
<th>Changed</th>
<th title="Cartridge changes seen anywhere in the history window, and how long the CURRENT cartridge has been in">Replacements</th>
</tr>
</thead>
<tbody>
@@ -107,7 +111,16 @@
</td>
<td>{{ c.name }}</td>
<td>
<span v-if="firstPart(c)" class="partnumber">{{ firstPart(c) }}</span>
<!-- The cartridge's NAME, with the part number on hover. A
name says which toner this is; 'W2020A' only identifies it
once you already know. The number is what purchasing wants,
so it stays one hover away and stays in the copied order
list verbatim. Falls back to the number when a supply has
no name on file - marketingname is nullable. -->
<span v-if="firstPart(c)" class="supplyname"
:title="firstPart(c).partnumber">
{{ firstPart(c).marketingname || firstPart(c).partnumber }}
</span>
<span v-else class="muted">-</span>
</td>
<td class="collevel">
@@ -123,11 +136,25 @@
<span class="days" :class="c.band">{{ daysText(c) }}</span>
</td>
<td class="muted small">
{{ c.burnrateperday != null ? c.burnrateperday + '%/day' : '-' }}
<template v-if="c.burnrateperday != null">
{{ c.burnrateperday }}%/day<!--
--><span v-if="c.rateunstable" class="unstable"
title="This cartridge's usage has varied a lot between readings - a burst then a lull, or the reverse. The estimate is the best available, not a measurement.">~</span>
</template>
<template v-else>-</template>
</td>
<td class="muted small">
{{ c.replacements || 0 }}
<span v-if="c.basisdays">/ {{ c.basisdays }}d</span>
<!-- Two DIFFERENT spans, so they must not be joined by a
word that implies one contains the other. Replacements are
counted across the whole history window; basisdays is only
how long the CURRENT cartridge has been in. "2 in 4.0d"
claimed two changes within four days, which is the shape
that reads as broken data - and here it would be the
label lying, not the data. -->
<template v-if="c.basisdays">
{{ c.replacements || 0 }}, this one {{ c.basisdays }}d
</template>
<template v-else>{{ c.replacements || 0 }}</template>
</td>
</tr>
</tbody>
@@ -245,9 +272,13 @@ function colorLabel(color) {
return color.charAt(0).toUpperCase() + color.slice(1)
}
// The whole part row, not just its number: the table shows the name and keeps
// the number for the hover, and both come from here. Several capacity tiers can
// match; the first is the standard one, which is what orderlist() groups on and
// what the low-supplies report shows first.
function firstPart(cartridge) {
const parts = cartridge.partnumbers || []
return parts.length ? parts[0].partnumber : null
return parts.length ? parts[0] : null
}
function levelText(cartridge) {
@@ -262,11 +293,32 @@ function barWidth(cartridge) {
return Math.max(0, Math.min(100, level)) + '%'
}
// daysleft is fractional, so the last day of a cartridge's life is readable
// instead of collapsing to "empty". Only an actual zero is empty; anything above
// it has time left and the row says how much, in whatever unit carries meaning.
const HOURS_PER_DAY = 24
const MINUTES_PER_HOUR = 60
function daysText(cartridge) {
if (cartridge.daysleft == null) return '-'
if (cartridge.daysleft === 0) return 'empty'
if (cartridge.daysleft === 1) return '1 day'
return cartridge.daysleft + ' days'
const days = cartridge.daysleft
if (days == null) return '-'
if (days <= 0) return 'empty'
if (days < 1) {
const hours = days * HOURS_PER_DAY
// Under an hour, hours would round to 0 and read as empty again.
if (hours < 1) {
const minutes = Math.max(1, Math.round(hours * MINUTES_PER_HOUR))
return `${minutes} min`
}
const rounded = Math.round(hours)
return rounded === 1 ? '1 hour' : `${rounded} hours`
}
// Whole days from here. The estimate is not precise enough for "3.7 days" to
// mean more than "about 4", and a column of decimals is harder to scan.
const rounded = Math.round(days)
return rounded === 1 ? '1 day' : `${rounded} days`
}
function orderKey(item) {
@@ -362,8 +414,10 @@ onMounted(load)
text-align: left;
}
.unmapped-block .order-list li { border-top: 1px dashed var(--border); }
.partnumber { font-family: monospace; font-weight: 700; }
.partnumber.unmapped { font-weight: 400; color: var(--warning); }
/* A name is prose, not a code, so it does not get the monospace the part number
used to carry here. The help cursor is the only signal that the number is on
the hover. Replaced .partnumber, which nothing renders any more. */
.supplyname { font-weight: 700; cursor: help; }
.order-detail { font-size: 0.9rem; }
.order-printers { font-size: 0.8rem; margin-left: auto; text-align: right; }
@@ -424,6 +478,10 @@ onMounted(load)
.days.empty { color: var(--danger); }
.days.soon { color: var(--warning); }
.small { font-size: 0.8rem; }
/* Marks a rate the intervals do not agree on. Deliberately quiet - it qualifies
the number beside it rather than competing with the urgency bands. */
.unstable { margin-left: 2px; font-weight: 600; cursor: help; }
.empty-state { padding: 2rem; text-align: center; }
.footnote { margin-top: 1rem; font-size: 0.85rem; }
</style>

View File

@@ -0,0 +1,172 @@
import { describe, it, expect, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
// The forecast identifies a cartridge by its NAME - '414X Black' - and keeps the
// part number on the hover. A part number alone ('W2020X') only identifies the
// toner to someone who already knows it, and the two places that name a supply
// (the order list and the Part column) had shown nothing else.
//
// The number has to survive somewhere: it is what an order is placed against.
// It stays in the title attribute and, verbatim and unlabelled, in the text that
// 'Copy list' writes to the clipboard. These specs pin both halves.
//
// marketingname is nullable, so every display falls back to the number.
vi.mock('@/api', () => ({
printersApi: { supplyForecast: vi.fn() },
}))
vi.mock('@/utils/apiError', () => ({ apiError: (e) => String(e) }))
import { printersApi } from '@/api'
import TonerForecast from './TonerForecast.vue'
function payload({ marketingname = '414X Black', partnumber = 'W2020X' } = {}) {
const part = { partnumber, marketingname, capacitytier: 'high', pageyield: 7500 }
return {
data: {
data: {
available: true,
days: 90,
horizondays: 14,
summary: { bands: { empty: 1 }, replacements: 3, toorder: 1 },
cartridges: [{
printerid: 1, printername: 'GageLab-Xerox-C415', assetnumber: 'P-1',
ipaddress: '10.49.215.12', name: 'Black toner', color: 'black',
currentlevel: 4, band: 'empty', daysleft: 0, burnrateperday: 1.8,
rateunstable: false, replacements: 3, basisdays: 12,
partnumbers: [part],
}],
unestimated: [],
orderlist: [{
partnumber, marketingname, color: 'black', supplytype: 'toner',
model: 'Versalink C415', alternates: [], quantity: 2,
printers: [{ printerid: 1, printername: 'GageLab-Xerox-C415', daysleft: 0 }],
}],
},
},
}
}
async function render(overrides) {
printersApi.supplyForecast.mockResolvedValue(payload(overrides))
const wrapper = mount(TonerForecast, {
global: { stubs: { 'router-link': { template: '<a><slot /></a>' } } },
})
await flushPromises()
return wrapper
}
describe('the supply is named, not numbered', () => {
it('shows the name in the order list', async () => {
const wrapper = await render()
const name = wrapper.find('.order-list .supplyname')
expect(name.text()).toBe('414X Black')
})
it('shows the name in the Part column', async () => {
const wrapper = await render()
const cells = wrapper.findAll('tbody .supplyname')
expect(cells.length).toBeGreaterThan(0)
expect(cells[0].text()).toBe('414X Black')
})
it('keeps the part number on the hover, both places', async () => {
const wrapper = await render()
for (const el of wrapper.findAll('.supplyname')) {
expect(el.attributes('title')).toBe('W2020X')
}
})
it('never renders the part number as the visible label', async () => {
const wrapper = await render()
for (const el of wrapper.findAll('.supplyname')) {
expect(el.text()).not.toBe('W2020X')
}
})
})
describe('a supply with no name on file', () => {
it('falls back to the part number rather than showing nothing', async () => {
const wrapper = await render({ marketingname: null })
for (const el of wrapper.findAll('.supplyname')) {
expect(el.text()).toBe('W2020X')
}
})
})
describe('what purchasing receives is unchanged', () => {
it('copies the part number, not the name', async () => {
const written = []
Object.assign(navigator, {
clipboard: { writeText: (text) => { written.push(text); return Promise.resolve() } },
})
const wrapper = await render()
await wrapper.find('.order-head button').trigger('click')
await flushPromises()
expect(written).toHaveLength(1)
expect(written[0]).toContain('W2020X')
expect(written[0]).not.toContain('414X Black')
})
})
// --- how long is left ------------------------------------------------------
//
// daysleft used to be int(level / rate), so anything under a full day
// truncated to 0 and the view printed "empty". A cartridge with six hours in it
// still prints, and the row exists to say how long. Only a real zero is empty.
describe('the countdown reads in whatever unit carries meaning', () => {
async function daysCell(daysleft) {
const band = daysleft === null ? 'soon' : daysleft <= 0 ? 'empty' : 'soon'
const base = payload()
base.data.data.cartridges[0].daysleft = daysleft
base.data.data.cartridges[0].band = band
printersApi.supplyForecast.mockResolvedValue(base)
const wrapper = mount(TonerForecast, {
global: { stubs: { 'router-link': { template: '<a><slot /></a>' } } },
})
await flushPromises()
// Only the 'empty' band starts expanded - what is already out is what the
// page opens on. Every band renders a header whether or not it holds
// anything, so the right one has to be picked by its key rather than by
// position; clicking the first would close 'empty' instead.
if (band !== 'empty') {
const head = wrapper.findAll('.band-head')
.find(b => b.find(`.band-count.${band}`).exists())
await head.trigger('click')
await flushPromises()
}
return wrapper.find('tbody .days').text()
}
it('says empty only for an actual zero', async () => {
expect(await daysCell(0)).toBe('empty')
})
it('reads in hours below a day', async () => {
expect(await daysCell(0.25)).toBe('6 hours')
})
it('does not pluralise a single hour', async () => {
expect(await daysCell(1 / 24)).toBe('1 hour')
})
it('reads in minutes below an hour, never as empty', async () => {
const text = await daysCell(0.02)
expect(text).toBe('29 min')
expect(text).not.toBe('empty')
})
it('never rounds a live cartridge down to empty', async () => {
expect(await daysCell(0.0001)).toBe('1 min')
})
it('reads in whole days above a day', async () => {
expect(await daysCell(3.7)).toBe('4 days')
expect(await daysCell(1.2)).toBe('1 day')
})
it('still shows a dash when there is no estimate', async () => {
expect(await daysCell(null)).toBe('-')
})
})

View File

@@ -323,7 +323,9 @@ class PrintersPlugin(BasePlugin):
click.echo(f'Supply levels for {ip}:')
for supply in supplies:
click.echo(f" {supply['name']}: {supply['level']}%")
level = supply.get('level')
shown = 'unknown' if level is None else f'{level}%'
click.echo(f" {supply['name']}: {shown}")
@printerscli.command('seed-supplies')
def seedsuppliescommand():

View File

@@ -20,21 +20,70 @@ from datetime import datetime, timezone
# new cartridge rather than noise.
REPLACEMENT_RISE = 10
# A replacement must also LAND high. A fresh cartridge reads near full, so a
# rise that stops mid-range is a gauge bouncing, not a swap. Without this the
# count was any +10 between readings, and the two common noise shapes both
# scored: a supply reading 0 or near-0 while it was out of the machine, then the
# real level again (0 -> 60 counts as +60), and a coarse gauge ticking back up
# after a reseat or a power cycle. That is how one cartridge claimed five
# changes in ninety days.
#
# A site that fits PART-USED cartridges will under-count with this rule. That is
# the right way round: a missed swap widens the run and slows the estimate,
# while a phantom swap resets the run and throws the estimate away entirely.
NEW_CARTRIDGE_LEVEL = 80
# How far ABOVE its previous level a reading may come back and still count as a
# recovery rather than a new cartridge. Small, because it exists for gauge
# noise: a real swap returns near full, well past this.
RECOVERY_TOLERANCE = 2
# How long a dip may last and still be a bad reading rather than real use.
# Levels alone cannot separate the two: a swap to full, ordinary consumption,
# then another swap has the same SHAPE as an outage that recovers. What differs
# is elapsed time. A supply out of the machine, a door open or a bad poll spans
# minutes to an hour or two at the few-minute polling these items use; a level
# that stays down for days is genuinely down, and deleting those readings would
# hide a real empty period.
MAX_DIP_HOURS = 6
# Below this many readings a slope is arithmetic, not evidence. Two points
# through a coarse gauge can "prove" any rate at all.
MIN_POINTS_FOR_ESTIMATE = 4
# A rate needs TIME behind it, not just readings. Supply items are often polled
# every few minutes, so four readings can span a quarter of an hour - and a 2
# point drop across fifteen minutes extrapolates to nearly 200 percent a day,
# which is how a cartridge sitting at 82 percent gets forecast to run out in a
# fortnight. The history window can be short for the same reason: the fetch
# keeps the newest rows up to a per-item cap, so a fast-polled item returns days
# rather than the ninety asked for.
#
# Two days is the smallest span that survives a printer's daily rhythm - a
# single heavy morning does not become the whole picture.
MIN_DAYS_FOR_ESTIMATE = 2
# Many printers report in 10% steps, so a fortnight can pass on one plateau.
# Without a minimum observed drop the slope reads as zero and the forecast
# says "never", which is worse than saying nothing.
MIN_DROP_FOR_ESTIMATE = 2
# At or below this, the cartridge is done and the arithmetic stops being the
# useful answer. A supply sitting at 1% that drains a tenth of a point a day
# computes to ten days; a printer at 1% is out of toner as far as anyone
# standing at it is concerned, and it is what should be ordered first. Rate is
# still reported - only the days-left figure is floored.
EMPTY_LEVEL = 5
# How far the fastest and slowest intervals may differ before a single rate
# stops being a fair summary. A cartridge that ran at 10 percent/day for two
# days and 0.2 percent/day since is not described by any one number, and a
# precise-looking figure invites more trust than it has earned.
RATE_SPREAD_FACTOR = 5
# Empty means empty. This was 5, on the reasoning that a printer at 1% is out
# of toner as far as anyone standing at it is concerned - but flooring the
# days-left of everything at or below 5% threw away the only number the row
# exists to give. A cartridge at 2% draining a point a day has two days left,
# and two days is worth knowing; reporting it as already gone puts it beside
# cartridges that genuinely are, and there is no way back to the difference.
#
# Anything above zero gets its real estimate, in hours if that is what it comes
# to. Only a reading of zero is empty.
EMPTY_LEVEL = 0
def _asfloat(value):
@@ -58,18 +107,103 @@ def normalise(points):
continue
out.append((datetime.fromtimestamp(seconds, tz=timezone.utc), level))
out.sort(key=lambda p: p[0])
return drop_spikes(out)
def _recovered(previous, following, tolerance=RECOVERY_TOLERANCE):
"""Did the level come BACK to where it was, rather than up to a new one?
Toner only falls, so a dip that recovers returns to at or below the level it
left - consumption carried on while the reading was junk. A cartridge that
was CHANGED comes back HIGHER than the level before the dip.
That asymmetry is the whole test. Comparing the absolute difference instead
treated a genuine swap of a nearly-full cartridge (95, then 5, then 100) as
a recovery and deleted the evidence, because 100 and 95 are close. The small
upward tolerance is for gauge noise, not for swaps.
"""
return following <= previous + tolerance
def drop_spikes(points, rise=REPLACEMENT_RISE):
"""Remove dips that RECOVER to where they came from, however long they run.
A single reading far below both neighbours, then a recovery, is a big
upward step that scores as a cartridge change. That is how a cartridge
claimed five changes in ninety days.
NOT a Zabbix timeout: an item that does not answer records nothing, it does
not write a zero. The dip is a value the device really reported - a supply
pulled out to be shaken and reseated, a door open mid-poll, or a site whose
preprocessing maps the Printer MIB's "unknown" sentinels (-1/-2/-3, which
cannot land in the unsigned history table) onto 0.
The filter keys on SHAPE rather than cause, which is why it holds for all of
them: a level that comes back to where it was did not get a new cartridge.
ANY LENGTH, not just one reading. At the few-minute polling these items
often use, a door left open or a supply out of the machine spans several
polls, and a filter that only removed single readings left the original
failure in place for every cartridge above NEW_CARTRIDGE_LEVEL.
A genuine near-empty reading before a swap (30, 5, 100) does not recover -
it comes back HIGHER than 30 - so it is kept and the swap still counts.
"""
if len(points) < 3:
return points
out = [points[0]]
index = 1
while index < len(points) - 1:
previous = points[index - 1][1]
if (previous - points[index][1]) < rise:
out.append(points[index])
index += 1
continue
# A dip starts here. Take every consecutive reading that stays down.
end = index
while end < len(points) - 1 and (previous - points[end][1]) >= rise:
end += 1
following = points[end][1]
# ONE bad sample is a candidate whatever the cadence: the reading is an
# instant, and the gap to its neighbours says nothing about how long the
# supply was actually out. SEVERAL consecutive low readings only count
# as one outage if they are close together - spread over days they are a
# real absence, and they also have the same shape as a swap, ordinary
# consumption, then another swap.
spanhours = (points[end][0] - points[index - 1][0]).total_seconds() / 3600
brief = (end - index) == 1 or spanhours <= MAX_DIP_HOURS
if (brief
and (following - points[end - 1][1]) >= rise
and _recovered(previous, following)):
index = end # drop the whole stretch
continue
for keep in range(index, end):
out.append(points[keep])
index = end
out.append(points[-1])
return out
def find_replacements(points, rise=REPLACEMENT_RISE):
def _is_replacement(previous, current, rise=REPLACEMENT_RISE,
newlevel=NEW_CARTRIDGE_LEVEL):
"""One definition of "a cartridge was changed", used by every caller.
The rise must be big enough to clear gauge noise AND land near full, which
is what a new cartridge reads. Both conditions, because either alone admits
a shape that is not a swap.
"""
return (current - previous) >= rise and current >= newlevel
def find_replacements(points, rise=REPLACEMENT_RISE,
newlevel=NEW_CARTRIDGE_LEVEL):
"""Timestamps where the level jumped up - one per cartridge change.
Returns [] for a series that only falls. A rise smaller than `rise` is
treated as noise, not a replacement.
Returns [] for a series that only falls.
"""
replacements = []
for (_, previous), (when, current) in zip(points, points[1:]):
if current - previous >= rise:
if _is_replacement(previous, current, rise, newlevel):
replacements.append(when)
return replacements
@@ -84,28 +218,78 @@ def current_run(points, rise=REPLACEMENT_RISE):
return []
start = 0
for index in range(1, len(points)):
if points[index][1] - points[index - 1][1] >= rise:
if _is_replacement(points[index - 1][1], points[index][1], rise):
start = index
return points[start:]
def interval_rates(points):
"""Percent-per-day for each consecutive pair, falling intervals only.
A rise inside a run is gauge noise (a swap would have ended the run), and
a flat interval is real information - a cartridge that did not move - so it
stays in at zero.
"""
rates = []
for (whenprev, prev), (when, current) in zip(points, points[1:]):
days = (when - whenprev).total_seconds() / 86400
if days <= 0:
continue
drop = prev - current
if drop < 0:
continue
rates.append(drop / days)
return rates
def _median(values):
ordered = sorted(values)
count = len(ordered)
if not count:
return None
middle = count // 2
if count % 2:
return ordered[middle]
return (ordered[middle - 1] + ordered[middle]) / 2
def burn_rate(points):
"""Percent consumed per day over these readings, or None.
THE MEDIAN OF THE PER-INTERVAL RATES, not the slope between the first and
last reading. Two endpoints cannot tell "steady" from "burst then stopped":
a cartridge that lost 20 percent in two days and then barely moved for a
month reads as 0.83 percent/day forever after, so the report keeps promising
it will run out long after printing slowed. The burst is one interval among
many to a median, and one of two points to a secant.
None means "no honest estimate": too few readings, no elapsed time, or a
drop too small to distinguish from a gauge that has not moved yet.
"""
if len(points) < MIN_POINTS_FOR_ESTIMATE:
return None
first_when, first_level = points[0]
last_when, last_level = points[-1]
days = (last_when - first_when).total_seconds() / 86400
if days <= 0:
total_days = (points[-1][0] - points[0][0]).total_seconds() / 86400
if total_days < MIN_DAYS_FOR_ESTIMATE:
return None
drop = first_level - last_level
if drop < MIN_DROP_FOR_ESTIMATE:
# The overall drop still gates the estimate: a gauge sitting on one plateau
# has not proved anything yet, whatever the intervals say.
if points[0][1] - points[-1][1] < MIN_DROP_FOR_ESTIMATE:
return None
return drop / days
rate = _median(interval_rates(points))
if not rate:
# Every interval flat or rising, yet the run dropped overall - the
# movement is all in intervals the median discarded. Fall back to the
# whole-run slope rather than reporting nothing.
return (points[0][1] - points[-1][1]) / total_days
return rate
def rate_is_unstable(points, factor=RATE_SPREAD_FACTOR):
"""True when the intervals disagree enough that one number oversells it."""
rates = [r for r in interval_rates(points) if r > 0]
if len(rates) < 2:
return False
return max(rates) >= min(rates) * factor
def analyse(points, rise=REPLACEMENT_RISE, currentlevel=None):
@@ -131,6 +315,7 @@ def analyse(points, rise=REPLACEMENT_RISE, currentlevel=None):
points = normalise(points)
result = {
'currentlevel': currentlevel, 'daysleft': None, 'burnrateperday': None,
'rateunstable': False,
'reason': None, 'replacements': 0, 'lastreplaced': None,
'basisdays': 0, 'points': [],
}
@@ -164,8 +349,8 @@ def analyse(points, rise=REPLACEMENT_RISE, currentlevel=None):
rate = burn_rate(run)
# Empty is empty. Ordering by a rate below this level ranks a dead
# cartridge behind a healthy one that happens to be draining faster.
# Empty is empty, and only zero is empty. Ordering by a rate at this level
# ranks a dead cartridge behind a healthy one that happens to drain faster.
if level <= EMPTY_LEVEL:
result['daysleft'] = 0
result['burnrateperday'] = round(rate, 2) if rate is not None else None
@@ -173,7 +358,8 @@ def analyse(points, rise=REPLACEMENT_RISE, currentlevel=None):
if rate is None:
# Say which of the three it is; "no estimate" alone invites a bug report.
if len(run) < MIN_POINTS_FOR_ESTIMATE:
basis = result.get('basisdays') or 0
if len(run) < MIN_POINTS_FOR_ESTIMATE or basis < MIN_DAYS_FOR_ESTIMATE:
result['reason'] = ('replaced recently' if replacements
else 'not enough history yet')
else:
@@ -181,7 +367,16 @@ def analyse(points, rise=REPLACEMENT_RISE, currentlevel=None):
return result
result['burnrateperday'] = round(rate, 2)
result['daysleft'] = max(0, int(level / rate))
# Fractional, NOT int(). Truncating meant every cartridge with less than a
# full day left reported 0, which the bands read as empty and the view
# printed as "empty" - a cartridge with six hours in it was indistinguishable
# from one with nothing. Two decimal places is a quarter of an hour, which is
# finer than the estimate deserves but costs nothing and keeps the ordering
# of two nearly-spent cartridges meaningful.
result['daysleft'] = round(max(0.0, level / rate), 2)
# Say so when the intervals disagree wildly. The number is still the best
# estimate available; the flag stops it reading as a measurement.
result['rateunstable'] = rate_is_unstable(run)
return result

View File

@@ -68,19 +68,34 @@ def derivecolor(name: str, tagcolor: Optional[str] = None) -> str:
return "none"
def classifysupply(level: float, name: str, vendor: Optional[str]) -> Dict:
"""Classify one supply item into ok/low/critical.
def classifysupply(level: Optional[float], name: str,
vendor: Optional[str]) -> Dict:
"""Classify one supply item into ok/low/critical, or unknown.
Waste cartridge fill is inverted vs a toner level: a full waste cartridge
is bad. Standard vendors report waste as percent FULL (high = bad). Xerox
EC/AltaLink series report waste as percent capacity REMAINING (low = bad),
same direction as toner. Normalise everything to percent remaining first.
A level of None means Zabbix has no usable reading; the status is 'unknown'
and remaining is None. Callers must not treat that as a low supply.
"""
lowername = (name or "").lower()
iswaste = "waste" in lowername
isdrum = "drum" in lowername or "imaging" in lowername
isxerox = bool(vendor) and "xerox" in vendor.lower()
# No reading is its own answer, not a level. It cannot be classified ok,
# low or critical without asserting something nobody measured - and every
# one of those three is a claim the reader would act on.
if level is None:
return {
"status": "unknown",
"remaining": None,
"iswaste": iswaste,
"isdrum": isdrum,
}
if iswaste and not isxerox:
remaining = 100 - level
else:

View File

@@ -208,18 +208,64 @@ class ZabbixService:
continue
if str(item.get("state", "0")) != "0":
continue
try:
level = int(float(item.get("lastvalue", 0)))
except (ValueError, TypeError):
level = 0
level, lastseen = self._readlevel(item)
supplies.append({
"name": item.get("name", "Unknown"),
"level": level,
"lastseen": lastseen,
"color": self._extract_color(item),
"itemid": item.get("itemid"),
})
return supplies
# How old the newest reading may be and still be presented as the level.
# Generous on purpose: supply items are polled in hours, not seconds, and
# calling a slow poller "unknown" would be its own lie. This exists for the
# host that stopped answering, not for one that answers rarely.
STALE_AFTER_SECONDS = 24 * 3600
@classmethod
def _readlevel(cls, item):
"""(level, lastseen) for one supply item; level is None when unknown.
An item that has never collected returns lastvalue as an EMPTY STRING,
and one whose host went quiet keeps a lastvalue that is arbitrarily old.
Both used to become 0 - the empty string because float('') raises and
the handler substituted zero, the stale one because nothing ever read
lastclock, which was fetched and then ignored.
Zero is not a safe stand-in for "no reading". It is a legitimate value
meaning the cartridge is spent, so the substitution was indistinguishable
from a real empty - and it hit every item on the host at once, which is
what a printer that has gone dark looks like. Downstream that reached
`level <= EMPTY_LEVEL`, so the forecast gave it daysleft 0 and the order
list asked purchasing to buy a full set of cartridges for a printer
nobody had measured.
None says "unknown" and every consumer already handles it: the detail
page renders N/A, the alert loop skips it, and analyse() falls back to
the last real level in history rather than inventing one.
"""
raw = item.get("lastvalue")
if raw is None or str(raw).strip() == "":
return None, None
try:
level = int(float(raw))
except (ValueError, TypeError):
return None, None
try:
lastseen = int(item.get("lastclock") or 0)
except (ValueError, TypeError):
lastseen = 0
if lastseen <= 0:
# A value with no clock cannot be aged. Trust the value and say the
# age is unknown rather than discarding a reading that may be fine.
return level, None
if time.time() - lastseen > cls.STALE_AFTER_SECONDS:
return None, lastseen
return level, lastseen
# Per item, per call. Zabbix applies `limit` to the WHOLE result set, not
# per item, so the budget is multiplied by the number of items asked for.
HISTORY_PER_ITEM = 2000

View File

@@ -0,0 +1,5 @@
"""Tech Tools API package."""
from .routes import tools_bp
__all__ = ['tools_bp']

176
plugins/tools/api/routes.py Normal file
View File

@@ -0,0 +1,176 @@
"""Secure send: create a one-view link for a credential, and redeem it.
Lives in Tech Tools because that is where somebody looks for a utility. It is
the one tool here that is NOT purely browser-side: a link that expires and dies
on first read needs somewhere to keep the ciphertext until then.
TWO DIFFERENT DOORS, on purpose.
CREATE needs a login and a permission. Anonymous creation would be an
untraceable way to move data out of a system that otherwise records who did
what, and the creator is the only person these rows can ever name.
READ needs neither, because the recipient usually has no account here and the
link is normally handed over in a call or a chat. The unguessable token IS the
authorisation - which is why it is 128 bits of randomness and why the row is
spent on the first successful read.
WHAT KEEPS THIS FROM BECOMING A DOCUMENT TRANSFER. The key never reaches the
server, so the server cannot look at what it holds and cannot tell a password
from a page of a drawing. The only lever it has is SIZE, and MAX_CIPHERTEXT is
therefore not a tuning knob - it is the whole enforcement. Everything else (line
counts, file signatures, refusing a pasted file) runs in the browser, guides an
honest person, and is bypassable by anyone willing to POST directly.
"""
import secrets as pysecrets
from datetime import datetime, timedelta, timezone
from flask import Blueprint, request
from flask_jwt_extended import jwt_required, get_jwt_identity
from shopdb.api import (
db,
success_response, error_response, ErrorCodes,
require_permission,
)
from ..models import SecureSecret
tools_bp = Blueprint('tools', __name__)
# Base64 of an encrypted credential. A password is well under a hundred
# characters; this is generous for a connection string and useless for a
# document. See the module docstring: this limit is the only server-side control
# that a zero-knowledge design leaves available.
MAX_CIPHERTEXT = 4096
# Hours. The default is short because the normal case is "read it while we are
# on this call"; the maximum exists so nothing lingers for a week.
DEFAULT_EXPIRY_HOURS = 24
MAX_EXPIRY_HOURS = 168
MAX_VIEWS = 5
def _utcnow():
return datetime.now(timezone.utc).replace(tzinfo=None)
@tools_bp.route('/secrets', methods=['POST'])
@jwt_required()
@require_permission('tools.secrets')
def create_secret():
"""Store an already-encrypted secret and return its token."""
data = request.get_json(silent=True) or {}
ciphertext = (data.get('ciphertext') or '').strip()
iv = (data.get('iv') or '').strip()
if not ciphertext or not iv:
return error_response(ErrorCodes.VALIDATION_ERROR,
'ciphertext and iv are required')
if len(ciphertext) > MAX_CIPHERTEXT:
# Say what the limit is FOR, not just that it exists. Somebody hitting
# it is usually trying to send a file, and the answer to that is a
# different tool rather than a bigger number here.
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Secret is too large ({} of {} characters). This is for passwords '
'and short text, not documents - the server cannot read what it '
'holds, so size is the only limit it can enforce.'.format(
len(ciphertext), MAX_CIPHERTEXT))
# `is None`, not `or`: an explicit 0 is falsy, so `or DEFAULT` silently
# turned "expire immediately" into "expire in a day" instead of refusing it.
# A caller who sends a number should be told when it is not allowed, not
# quietly given a different one.
rawhours = data.get('expiryhours')
rawviews = data.get('views')
try:
hours = int(DEFAULT_EXPIRY_HOURS if rawhours is None else rawhours)
views = int(1 if rawviews is None else rawviews)
except (TypeError, ValueError):
return error_response(ErrorCodes.VALIDATION_ERROR,
'expiryhours and views must be whole numbers')
if not 1 <= hours <= MAX_EXPIRY_HOURS:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'expiryhours must be between 1 and {}'.format(MAX_EXPIRY_HOURS))
if not 1 <= views <= MAX_VIEWS:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'views must be between 1 and {}'.format(MAX_VIEWS))
try:
userid = int(get_jwt_identity())
except (TypeError, ValueError):
userid = None
secret = SecureSecret(
token=pysecrets.token_hex(16), # 128 bits; the link is the credential
ciphertext=ciphertext,
iv=iv,
expiresat=_utcnow() + timedelta(hours=hours),
viewsremaining=views,
createdbyuserid=userid,
)
db.session.add(secret)
db.session.commit()
return success_response(secret.to_dict(), http_code=201)
@tools_bp.route('/secrets/<token>', methods=['POST'])
def redeem_secret(token):
"""Hand over the ciphertext once, then spend the view.
POST rather than GET on purpose. A GET is prefetched by link previews in
chat and mail clients, and a one-view secret consumed by a preview window is
a secret the recipient never gets to see.
A missing, expired and spent secret all answer the SAME way. Distinguishing
them would confirm to anyone holding a guessed token whether it ever named
anything.
"""
gone = error_response(
ErrorCodes.NOT_FOUND,
'This link is not valid. It may have been opened already, or expired.',
http_code=404)
secret = SecureSecret.query.filter_by(token=(token or '').strip()).first()
if secret is None:
return gone
now = _utcnow()
if secret.is_expired(now) or secret.is_spent():
# Tidy up on the way past rather than waiting for a sweeper: a spent
# secret has no reason to keep its ciphertext.
db.session.delete(secret)
db.session.commit()
return gone
payload = {'ciphertext': secret.ciphertext, 'iv': secret.iv}
secret.viewsremaining -= 1
secret.viewcount += 1
secret.lastviewedat = now
if secret.viewsremaining <= 0:
db.session.delete(secret)
db.session.commit()
return success_response(payload)
@tools_bp.route('/secrets/<token>', methods=['GET'])
def peek_secret(token):
"""Whether a link is still live, WITHOUT spending it.
The read page asks this first so it can render "this secret is waiting, and
opening it will destroy it" rather than consuming the only view to find out
there was something there.
"""
secret = SecureSecret.query.filter_by(token=(token or '').strip()).first()
live = bool(secret and not secret.is_expired() and not secret.is_spent())
return success_response({'available': live})

View File

@@ -0,0 +1,150 @@
// Rows in, printed pages out.
//
// This is the half of the generator a preview cannot check. Two things live
// here for that reason: the CSV columns, which are a contract with whoever
// builds the file, and the PAGE ORDER of a two-sided card, which decides
// whether card 3's back lands on card 3 or on card 4. Both are the kind of
// wrong you only find once the stock is used up.
//
// Nothing here knows how a code is drawn or how big a label is. That is
// utils/codes.js and the component's CSS respectively.
// Rendering thousands of codes locks the tab, so stop at a number a person
// would actually feed a label printer in one run. The cap counts CARDS, not
// pages - a two-sided run of 500 is 1000 pages and that is fine.
export const MAX_LABELS = 500
export const MAX_COPIES = 1000
const CONTENT_HEADERS = ['content', 'text', 'data', 'value', 'url', 'qr']
const LABEL_HEADERS = ['label', 'name', 'caption', 'description']
const COPIES_HEADERS = ['copies', 'qty', 'quantity', 'count']
const BACK_HEADERS = ['back', 'backcontent', 'reverse']
const BACKLABEL_HEADERS = ['backlabel', 'backcaption', 'backname']
/** Split one CSV line, honoring quoted fields and "" escapes. */
export function splitCsvLine(line) {
const fields = []
let current = ''
let inQuotes = false
for (let i = 0; i < line.length; i++) {
const char = line[i]
if (inQuotes) {
if (char === '"' && line[i + 1] === '"') { current += '"'; i++ }
else if (char === '"') { inQuotes = false }
else { current += char }
} else if (char === '"') {
inQuotes = true
} else if (char === ',') {
fields.push(current); current = ''
} else {
current += char
}
}
fields.push(current)
return fields.map(field => field.trim())
}
/**
* Parse the label CSV into rows.
*
* Columns: content (required), label, copies, back, backlabel. A header row is
* optional; without one the order is the order above. back/backlabel are only
* read when the back side is set to take its own content per row.
*/
export function parseCsv(text) {
const lines = String(text || '').split(/\r?\n/).filter(line => line.trim() !== '')
if (!lines.length) return []
let columns = { content: 0, label: 1, copies: 2, back: 3, backlabel: 4 }
let start = 0
const first = splitCsvLine(lines[0]).map(field => field.toLowerCase())
if (first.some(field => CONTENT_HEADERS.includes(field))) {
// Named header: map by name so column order does not matter.
const indexOf = names => first.findIndex(field => names.includes(field))
columns = {
content: indexOf(CONTENT_HEADERS),
label: indexOf(LABEL_HEADERS),
copies: indexOf(COPIES_HEADERS),
back: indexOf(BACK_HEADERS),
backlabel: indexOf(BACKLABEL_HEADERS),
}
start = 1
}
const at = (fields, index) => (index >= 0 ? (fields[index] || '') : '')
const rows = []
for (let i = start; i < lines.length; i++) {
const fields = splitCsvLine(lines[i])
const content = at(fields, columns.content)
if (!content) continue
const copies = columns.copies >= 0 ? parseInt(fields[columns.copies], 10) : 1
rows.push({
content,
label: at(fields, columns.label),
copies: Number.isFinite(copies) && copies > 0 ? Math.min(copies, MAX_COPIES) : 1,
back: at(fields, columns.back),
backlabel: at(fields, columns.backlabel),
})
}
return rows
}
/** The back page belonging to one row, per the chosen back source. */
function backPageFor(row, back) {
const fallback = back.text || ''
switch (back.source) {
// The same payload again, so either face of a card scans. Worth it on a
// badge that can end up in a holder either way round.
case 'code':
return { content: row.content, label: row.label || '', side: 'back' }
// Per-row back: the CSV carries it. A row that left it empty still gets a
// page, because a card printer counts pages and a missing one shifts every
// back after it onto the wrong front.
case 'column':
return { content: row.back || '', label: row.backlabel || fallback, side: 'back' }
case 'blank':
return { content: '', label: '', side: 'back' }
case 'text':
default:
return { content: '', label: fallback, side: 'back' }
}
}
/**
* Expand rows by their copies count and, when a back side is enabled, pair
* each card with its back page.
*
* `back.order` is the one that matters on the floor:
* interleave - front, back, front, back. What a duplex card printer's driver
* expects: it takes pages two at a time.
* grouped - every front, then every back. For a single-sided printer,
* where the stack comes out, gets flipped and goes back in.
* Getting this backwards prints readable cards with the wrong names on the
* back, which is worse than a jam because nothing looks broken.
*/
export function buildPages(rows, options = {}) {
const { back = {}, max = MAX_LABELS } = options
const fronts = []
const backs = []
let dropped = 0
for (const row of rows || []) {
const wanted = Number.isFinite(row.copies) && row.copies > 0
? Math.min(row.copies, MAX_COPIES) : 1
for (let i = 0; i < wanted; i++) {
if (fronts.length >= max) { dropped += 1; continue }
fronts.push({ content: row.content, label: row.label || '', side: 'front' })
if (back.enabled) backs.push(backPageFor(row, back))
}
}
let pages = fronts
if (back.enabled) {
pages = back.order === 'grouped'
? [...fronts, ...backs]
: fronts.flatMap((front, index) => [front, backs[index]])
}
return { pages, fronts, backs, dropped }
}

View File

@@ -0,0 +1,113 @@
// Page order is the one thing here that costs money to get wrong: a run of
// badge cards with the backs one card out looks perfect until someone reads
// one. These tests pin the order and the CSV contract.
import { describe, it, expect } from 'vitest'
import { parseCsv, splitCsvLine, buildPages, MAX_LABELS } from './labelPages'
const sides = pages => pages.map(page => page.side).join(' ')
const contents = pages => pages.map(page => page.content || '-').join(' ')
describe('splitCsvLine', () => {
it('keeps a comma that is inside quotes', () => {
expect(splitCsvLine('WJ-0001,"Bay 12, press",2')).toEqual(['WJ-0001', 'Bay 12, press', '2'])
})
it('unescapes a doubled quote', () => {
expect(splitCsvLine('x,"the ""big"" one"')).toEqual(['x', 'the "big" one'])
})
})
describe('parseCsv', () => {
it('reads a named header in any column order', () => {
const rows = parseCsv('copies,label,content\n3,Line 1,WJ-0001')
expect(rows).toEqual([
{ content: 'WJ-0001', label: 'Line 1', copies: 3, back: '', backlabel: '' },
])
})
it('falls back to positional columns with no header', () => {
const rows = parseCsv('WJ-0001,Line 1,2,WJ-0001-B,Reverse')
expect(rows[0]).toEqual({
content: 'WJ-0001', label: 'Line 1', copies: 2,
back: 'WJ-0001-B', backlabel: 'Reverse',
})
})
it('skips a row with no content rather than printing a blank sticker', () => {
expect(parseCsv('content,label\n,orphan\nWJ-0002,real')).toHaveLength(1)
})
it('defaults copies to 1 when it is missing or junk', () => {
expect(parseCsv('content,copies\nA,\nB,zero')[0].copies).toBe(1)
expect(parseCsv('content,copies\nA,\nB,zero')[1].copies).toBe(1)
})
})
describe('buildPages without a back side', () => {
it('expands copies and emits fronts only', () => {
const { pages, backs } = buildPages([{ content: 'A', label: 'a', copies: 3 }])
expect(sides(pages)).toBe('front front front')
expect(backs).toHaveLength(0)
})
it('caps cards, not pages, and says how many it dropped', () => {
const { fronts, dropped } = buildPages([{ content: 'A', copies: MAX_LABELS + 5 }])
expect(fronts).toHaveLength(MAX_LABELS)
expect(dropped).toBe(5)
})
})
describe('buildPages with a back side', () => {
const rows = [
{ content: 'A', label: 'a', copies: 2, back: 'A2', backlabel: 'a2' },
{ content: 'B', label: 'b', copies: 1, back: 'B2', backlabel: 'b2' },
]
it('interleaves front and back, which is what a duplex driver takes', () => {
const { pages } = buildPages(rows, { back: { enabled: true, source: 'column', order: 'interleave' } })
expect(sides(pages)).toBe('front back front back front back')
expect(contents(pages)).toBe('A A2 A A2 B B2')
})
it('groups every front then every back, for a flip-and-reload printer', () => {
const { pages } = buildPages(rows, { back: { enabled: true, source: 'column', order: 'grouped' } })
expect(sides(pages)).toBe('front front front back back back')
// Same card order in both halves, or the flipped stack pairs up wrong.
expect(contents(pages)).toBe('A A B A2 A2 B2')
})
it('still emits a back for a row that left the back column empty', () => {
const { pages } = buildPages(
[{ content: 'A', copies: 1 }, { content: 'B', copies: 1, back: 'B2' }],
{ back: { enabled: true, source: 'column', order: 'interleave' } })
// A page count that changes per row would shift every later back onto the
// wrong front.
expect(sides(pages)).toBe('front back front back')
expect(contents(pages)).toBe('A - B B2')
})
it('repeats the front payload when the back is the same code', () => {
const { backs } = buildPages(rows, { back: { enabled: true, source: 'code' } })
expect(contents(backs)).toBe('A A B')
})
it('puts the same fixed text on every back and no code', () => {
const { backs } = buildPages(rows, { back: { enabled: true, source: 'text', text: 'Property of GE' } })
expect(backs.every(page => page.content === '')).toBe(true)
expect(backs.every(page => page.label === 'Property of GE')).toBe(true)
})
it('leaves a blank back entirely empty but still counted', () => {
const { pages, backs } = buildPages(rows, { back: { enabled: true, source: 'blank' } })
expect(pages).toHaveLength(6)
expect(backs.every(page => !page.content && !page.label)).toBe(true)
})
it('falls back to the fixed text when a row has no back label', () => {
const { backs } = buildPages([{ content: 'A', copies: 1, back: 'A2' }],
{ back: { enabled: true, source: 'column', text: 'Return to IT' } })
expect(backs[0].label).toBe('Return to IT')
})
})

View File

@@ -0,0 +1,80 @@
// The label's measurements, turned into the CSS custom properties the preview
// and @page read.
//
// This exists because of one failure mode. A number input that is cleared does
// not hold 0, it holds an empty string, and `'' + 'in'` is not a length. The
// browser drops the whole declaration, the box falls back to `height: auto`,
// and an image sized `height: 100%` of an auto-height parent computes to ZERO.
// The label then renders 176x0: full width, no height, no barcode - which
// reads as "the code stopped rendering" and not as "a field is empty".
//
// So nothing here may emit NaN, an empty string, or a negative length. Every
// value falls back to something printable, and the fallbacks are the defaults
// the page ships with.
export const LABEL_VAR_NAMES = [
'--tool-label-w', '--tool-label-h', '--tool-pad', '--tool-code', '--tool-barh',
'--tool-quiet', '--tool-gap', '--tool-font', '--tool-align', '--tool-valign',
'--tool-content-x', '--tool-content-y', '--tool-nudge-x', '--tool-nudge-y',
'--tool-pic', '--tool-pic-opacity',
]
export const VAR_FALLBACKS = {
labelwidth: 1, labelheight: 0.5, padding: 0, codesize: 0.4286, barheight: 0.3,
quiet: 0.035, gap: 0.03, labelfont: 7, picturesize: 0.5, pictureopacity: 0.15,
}
/** A length that must be greater than zero for the box to exist at all. */
function positive(value, fallback) {
const number = Number(value)
return Number.isFinite(number) && number > 0 ? number : fallback
}
/** A length that may legitimately be zero - padding, a quiet zone, a gap. */
function nonNegative(value, fallback) {
const number = Number(value)
return Number.isFinite(number) && number >= 0 ? number : fallback
}
/** An offset, which is signed: -0.05in is a real answer. */
function offset(value) {
const number = Number(value)
return Number.isFinite(number) ? number : 0
}
const ALIGNMENTS = ['flex-start', 'center', 'flex-end']
function alignment(value) {
return ALIGNMENTS.includes(value) ? value : 'center'
}
/**
* Map the generator's settings to CSS custom properties, in inches.
*
* Returns a plain object of property name to value, ready to hand to
* `style.setProperty`. Every entry is a valid CSS value whatever it was given.
*/
export function labelVars(state = {}) {
const inches = value => value + 'in'
const opacity = Number(state.pictureopacity)
return {
'--tool-label-w': inches(positive(state.labelwidth, VAR_FALLBACKS.labelwidth)),
'--tool-label-h': inches(positive(state.labelheight, VAR_FALLBACKS.labelheight)),
'--tool-pad': inches(nonNegative(state.padding, VAR_FALLBACKS.padding)),
'--tool-code': inches(positive(state.codesize, VAR_FALLBACKS.codesize)),
'--tool-barh': inches(positive(state.barheight, VAR_FALLBACKS.barheight)),
'--tool-quiet': inches(nonNegative(state.quiet, VAR_FALLBACKS.quiet)),
'--tool-gap': inches(nonNegative(state.gap, VAR_FALLBACKS.gap)),
'--tool-font': positive(state.labelfont, VAR_FALLBACKS.labelfont) + 'pt',
'--tool-align': alignment(state.align),
'--tool-valign': alignment(state.valign),
'--tool-content-x': inches(offset(state.contentx)),
'--tool-content-y': inches(offset(state.contenty)),
'--tool-nudge-x': inches(offset(state.nudgex)),
'--tool-nudge-y': inches(offset(state.nudgey)),
'--tool-pic': inches(positive(state.picturesize, VAR_FALLBACKS.picturesize)),
'--tool-pic-opacity': String(
Number.isFinite(opacity) && opacity > 0 && opacity <= 1
? opacity : VAR_FALLBACKS.pictureopacity),
}
}

View File

@@ -0,0 +1,76 @@
// The 176x0 label: a cleared number field emitted `NaNin`, the browser dropped
// the declaration, and the code box fell back to auto height, which is zero for
// an image sized at 100% of it. Every case below is a value a number input can
// actually hold.
import { describe, it, expect } from 'vitest'
import { labelVars, LABEL_VAR_NAMES, VAR_FALLBACKS } from './labelVars'
const EMPTY_INPUT = '' // what v-model.number leaves behind on a cleared field
describe('labelVars', () => {
it('emits every property the stylesheet reads', () => {
const vars = labelVars({})
for (const name of LABEL_VAR_NAMES) expect(vars[name]).toBeTruthy()
})
it('never emits NaN or an empty length, whatever it is handed', () => {
for (const junk of [EMPTY_INPUT, NaN, null, undefined, 'abc', Infinity]) {
const vars = labelVars({
labelwidth: junk, labelheight: junk, barheight: junk, codesize: junk,
quiet: junk, gap: junk, padding: junk, labelfont: junk, picturesize: junk,
contentx: junk, contenty: junk, nudgex: junk, nudgey: junk,
pictureopacity: junk,
})
for (const name of LABEL_VAR_NAMES) {
expect(vars[name]).not.toMatch(/NaN|Infinity|undefined|null/)
expect(vars[name]).not.toBe('in')
}
}
})
it('falls back to a printable bar height rather than a zero-height box', () => {
expect(labelVars({ barheight: EMPTY_INPUT })['--tool-barh'])
.toBe(VAR_FALLBACKS.barheight + 'in')
expect(labelVars({ barheight: 0 })['--tool-barh'])
.toBe(VAR_FALLBACKS.barheight + 'in')
expect(labelVars({ barheight: -2 })['--tool-barh'])
.toBe(VAR_FALLBACKS.barheight + 'in')
})
it('keeps a real measurement', () => {
const vars = labelVars({ labelwidth: 2.13, labelheight: 3.38, barheight: 0.5 })
expect(vars['--tool-label-w']).toBe('2.13in')
expect(vars['--tool-label-h']).toBe('3.38in')
expect(vars['--tool-barh']).toBe('0.5in')
})
it('lets padding, quiet zone and gap be zero, because zero is a real answer', () => {
const vars = labelVars({ padding: 0, quiet: 0, gap: 0 })
expect(vars['--tool-pad']).toBe('0in')
expect(vars['--tool-quiet']).toBe('0in')
expect(vars['--tool-gap']).toBe('0in')
})
it('keeps offsets signed - a negative nudge is the whole point of a nudge', () => {
const vars = labelVars({ contentx: -0.05, nudgey: -0.1, contenty: 0.02 })
expect(vars['--tool-content-x']).toBe('-0.05in')
expect(vars['--tool-nudge-y']).toBe('-0.1in')
expect(vars['--tool-content-y']).toBe('0.02in')
})
it('refuses an alignment it does not recognise', () => {
expect(labelVars({ align: 'flex-end' })['--tool-align']).toBe('flex-end')
expect(labelVars({ align: 'sideways' })['--tool-align']).toBe('center')
expect(labelVars({ valign: EMPTY_INPUT })['--tool-valign']).toBe('center')
})
it('clamps watermark opacity into a range that still prints', () => {
expect(labelVars({ pictureopacity: 0.4 })['--tool-pic-opacity']).toBe('0.4')
expect(labelVars({ pictureopacity: 0 })['--tool-pic-opacity'])
.toBe(String(VAR_FALLBACKS.pictureopacity))
expect(labelVars({ pictureopacity: 5 })['--tool-pic-opacity'])
.toBe(String(VAR_FALLBACKS.pictureopacity))
})
})

View File

@@ -3,9 +3,12 @@
*
* `default` = AppLayout child routes; `toplevel` = full-screen routes.
*
* The index sits inside the app shell. Each tool that PRINTS is toplevel, the
* way every other label page in this repo is: printing from inside AppLayout
* would put the sidebar and header on the label stock.
* The generator sits INSIDE the app shell, unlike the label pages in core.
* Those are toplevel because a print from inside AppLayout would put the
* sidebar on the label stock - which is true unless the print stylesheet says
* otherwise, and style.css now does: the shell is hidden at print time so only
* the labels reach the page. A tool someone spends ten minutes in wants the
* navigation; a one-shot print page does not.
*/
export default [
{
@@ -14,11 +17,8 @@ export default [
component: () => import('./views/ToolsIndex.vue'),
meta: { requiresAuth: true, plugin: 'tools' }
},
]
export const toplevel = [
{
path: '/tools/codes',
path: 'tools/codes',
name: 'tools-codes',
component: () => import('./views/CodeGenerator.vue'),
meta: { requiresAuth: true, plugin: 'tools' }

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,15 @@
{
"name": "tools",
"version": "1.0.0",
"description": "Tech Tools: shop-floor utilities that run entirely in the browser (barcode/QR label generator, with room for more)",
"description": "Tech Tools: technician utilities, mostly browser-side (barcode/QR labels) plus secure send for handing somebody a credential by one-view link",
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=0.16.0,<1.0.0",
"provides": {
"features": ["code_generator"]
}
"features": [
"code_generator",
"secure_send"
]
},
"api_prefix": "/api/tools"
}

View File

@@ -0,0 +1,16 @@
"""Alembic environment for the tools plugin migration chain.
Delegates to the shared runner in shopdb.plugins.alembic_template, which filters
the metadata to this plugin's tables and drives Alembic against the per-plugin
version table alembic_version_tools. See ADR-008 for the ownership
model. Unlike the ten cutover plugins whose 0001 is a no-op anchor, this plugin
is NEW: its 0001 baseline really CREATES its tables, because the core chain
never built them.
"""
import os
os.environ['PLUGIN_NAME'] = 'tools'
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
run_migrations()

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,63 @@
"""tools plugin baseline: the secure send table.
Creates the one table this plugin owns. Tech Tools was schemaless until secure
send arrived: a link that expires and dies on first read has to keep the
ciphertext somewhere until it is claimed. Not an asset extension: a shared
credential is a short-lived event rather than a thing the site inventories, so
there is no assetid and no asset type.
Explicit Alembic ops rather than the create_plugin_tables helper, matching the
backups baseline: the helper builds a per-plugin MetaData filtered to this
plugin's tables, so the foreign key to the core users table cannot resolve at
CreateTable-compile time.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'tools0001securesend'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'securesendsecrets',
sa.Column('secretid', sa.Integer(), nullable=False),
# 128 bits of randomness as hex. With no login on the read side this is
# the credential, so it must never be a sequence.
sa.Column('token', sa.String(length=64), nullable=False),
sa.Column('ciphertext', sa.Text(), nullable=False),
sa.Column('iv', sa.String(length=64), nullable=False),
sa.Column('expiresat', sa.DateTime(), nullable=False),
sa.Column('viewsremaining', sa.Integer(), nullable=False,
server_default='1'),
sa.Column('createdbyuserid', sa.Integer(), nullable=True),
sa.Column('lastviewedat', sa.DateTime(), nullable=True),
sa.Column('viewcount', sa.Integer(), nullable=False, server_default='0'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False,
server_default=sa.true()),
sa.ForeignKeyConstraint(['createdbyuserid'], ['users.userid'],
ondelete='SET NULL'),
sa.PrimaryKeyConstraint('secretid'),
)
# Unique, because the token is looked up on every read and two rows sharing
# one would make a link ambiguous.
op.create_index('ixsecuresendsecretstoken', 'securesendsecrets',
['token'], unique=True)
# Expiry sweeps scan on this.
op.create_index('ixsecuresendsecretsexpiresat', 'securesendsecrets',
['expiresat'])
op.create_index('ixsecuresendsecretscreatedby', 'securesendsecrets',
['createdbyuserid'])
def downgrade():
op.drop_index('ixsecuresendsecretscreatedby', table_name='securesendsecrets')
op.drop_index('ixsecuresendsecretsexpiresat', table_name='securesendsecrets')
op.drop_index('ixsecuresendsecretstoken', table_name='securesendsecrets')
op.drop_table('securesendsecrets')

View File

@@ -0,0 +1,10 @@
"""Tech Tools models.
Most tools run entirely in the browser and own nothing. Secure send is the
exception: handing somebody a credential needs storage, an expiry and a view
counter, so this plugin does carry one table.
"""
from .securesend import SecureSecret
__all__ = ['SecureSecret']

View File

@@ -0,0 +1,77 @@
"""A secret handed over by link, encrypted before it ever reaches here.
NOT AN ASSET, so this is a standalone table rather than an extension keyed by
assetid: a shared credential is an event with a short life, not a thing the site
owns and inventories.
WHAT THIS TABLE CAN AND CANNOT SEE. The AES key lives in the URL fragment, which
browsers never send to a server, so what is stored is ciphertext and an IV and
nothing else. Compromising this table yields no secrets. It also means the
server cannot inspect what it is holding - which is the right trade for a
credential, and the reason the SIZE limit in the routes carries the whole weight
of "this is not a document transfer".
"""
from datetime import datetime, timezone
from shopdb.api import db, BaseModel
def _utcnow():
return datetime.now(timezone.utc).replace(tzinfo=None)
class SecureSecret(BaseModel):
"""One shared secret: opaque id, ciphertext, and the rules for its life."""
__tablename__ = 'securesendsecrets'
secretid = db.Column(db.Integer, primary_key=True)
# The unguessable half of the link. With no login on the read side this IS
# the credential, so it is 128 bits of randomness rendered as hex - never a
# sequence, which would be enumerable by anyone who received one link.
token = db.Column(db.String(64), unique=True, nullable=False, index=True)
# Base64 of the AES-GCM ciphertext, and the IV it was sealed with. Opaque
# here by design.
ciphertext = db.Column(db.Text, nullable=False)
iv = db.Column(db.String(64), nullable=False)
# Both limits apply, whichever comes first.
expiresat = db.Column(db.DateTime, nullable=False, index=True)
viewsremaining = db.Column(db.Integer, nullable=False, default=1)
# Who created it. The reader is anonymous by design, so this is the only
# person the row can name - and the reason creation requires a login.
createdbyuserid = db.Column(
db.Integer,
db.ForeignKey('users.userid', ondelete='SET NULL'),
nullable=True,
index=True,
)
# Recorded for the audit trail; the content of what was read is not
# recoverable, but that it was read, and when, is.
lastviewedat = db.Column(db.DateTime, nullable=True)
viewcount = db.Column(db.Integer, nullable=False, default=0)
def is_expired(self, now=None):
return (now or _utcnow()) >= self.expiresat
def is_spent(self):
return self.viewsremaining <= 0
def to_dict(self):
"""NEVER includes the ciphertext.
This is the shape the CREATOR sees back - a receipt. Handing the payload
back on create would put the secret in a second place for no reason.
"""
return {
'token': self.token,
'expiresat': self.expiresat.isoformat() if self.expiresat else None,
'viewsremaining': self.viewsremaining,
'viewcount': self.viewcount,
'createddate': self.createddate.isoformat() if self.createddate else None,
}

View File

@@ -1,13 +1,19 @@
"""Tech Tools plugin.
A section for small technician utilities. Every tool here runs entirely in the
browser: no API, no tables, no state. That is deliberate - these are the things
someone reaches for at a bench with no network guarantee, and an air-gapped
site gets them for free.
A section for small technician utilities. MOST tools here run entirely in the
browser - no API, no tables, no state - which is deliberate: these are the
things someone reaches for at a bench with no network guarantee, and an
air-gapped site gets them for free.
The plugin exists so the section can carry a nav entry and be included in or
left out of a per-site build (ADR-013). The tools themselves are frontend-only,
which is why get_blueprint and get_models are both empty.
SECURE SEND IS THE EXCEPTION, and it is why this plugin now has a blueprint, a
table and a migration chain where it once had none. A link that expires and dies
on first read cannot be browser-only: something has to hold the ciphertext until
it is claimed, and forget it afterwards. The secret itself is still encrypted in
the browser - the key travels in the URL fragment and never reaches this server -
so what is stored is ciphertext nobody on this side can read.
The plugin also exists so the section can carry a nav entry and be included in or
left out of a per-site build (ADR-013).
"""
import logging
@@ -17,30 +23,51 @@ from flask import Blueprint, Flask
from shopdb.plugins.base import BasePlugin, PluginMeta
from .models import SecureSecret
from .api import tools_bp
logger = logging.getLogger(__name__)
class ToolsPlugin(BasePlugin):
"""Registers the Tech Tools section. All tools are client-side."""
"""Registers the Tech Tools section and the one tool that needs a server."""
@property
def meta(self) -> PluginMeta:
return PluginMeta(
name='tools',
version='1.0.0',
description='Tech Tools: browser-side technician utilities',
description=('Tech Tools: technician utilities, mostly browser-side '
'(barcode/QR labels) plus secure send for handing '
'somebody a credential by one-view link'),
author='ShopDB Team',
dependencies=[],
core_version='>=0.16.0,<1.0.0',
api_prefix='/api/tools',
)
def get_blueprint(self) -> Optional[Blueprint]:
# No API surface. The tools never leave the browser.
return None
# Secure send only. Every other tool is still browser-side.
return tools_bp
def get_models(self) -> List[Type]:
# No tables. Nothing here is persisted.
return []
return [SecureSecret]
def get_permissions(self) -> List:
"""One permission, on CREATING a secret only.
There is deliberately no read permission: the recipient usually has no
account here, and the unguessable token is what authorises the read.
Gating that side would make the tool useless for the case it exists for -
handing somebody a credential while you are on a call with them.
No delete permission either. A secret is spent by being read or by
expiring, and both remove the row; a hand-delete route would only add a
way to destroy the record of a secret that had not been read yet.
"""
return [
('tools.secrets', 'Create secure send links', 'tools'),
]
def get_navigation_items(self) -> List[dict]:
return [

View File

@@ -506,6 +506,17 @@ def update_asset(asset_id: int):
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
# An UPDATE may not blank the asset number. Create validates it and the
# column is NOT NULL, but the conflict check below only runs when the value
# DIFFERS, and '' never collides with anything - so a payload carrying an
# empty assetnumber wrote it straight through. A form that loaded blank
# (a failed reference load, a partial fetch) then saved the blank over a
# real record.
if 'assetnumber' in data and not (data['assetnumber'] or '').strip():
return error_response(
ErrorCodes.VALIDATION_ERROR,
'assetnumber cannot be empty')
# Check for conflicting assetnumber
if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber:
if Asset.query.filter_by(assetnumber=data['assetnumber']).first():

View File

@@ -9,13 +9,14 @@ from flask import Blueprint, request, current_app
from flask_jwt_extended import jwt_required
from sqlalchemy.orm import joinedload
from shopdb.extensions import db
from shopdb.extensions import db, cache
from shopdb.core.models import (
Application, Setting,
Asset, AssetType, Communication, Vendor, Model,
CustomField, CustomFieldValue
)
from shopdb.core.api.settings import get_cached_settings
from shopdb.core.services.spellfix import Vocabulary
from shopdb.utils.responses import success_response
logger = logging.getLogger(__name__)
@@ -31,7 +32,7 @@ search_bp = Blueprint('search', __name__)
# cannot reorder. Relevance is applied afterwards in Python, over a stable set.
def _word_match(query, *columns):
def _word_match(query, *columns, extra=None):
"""SQL clause matching rows that contain EVERY word of the query, each word
in any of the columns, in any order.
@@ -39,14 +40,25 @@ def _word_match(query, *columns):
multi-word search returned nothing. This splits the query into words and ANDs
them (OR across the columns per word): 'CSF Roles' matches a row with 'CSF'
and 'Roles' anywhere in the searched fields.
`extra` is an optional callable taking one word and returning a further
clause to OR in for that word. Use it where a word may be satisfied by a
RELATED row rather than a column of this table - a knowledge base article
matching on its topic's name, say. Splitting per word matters there: without
it a word can only ever be matched by the columns, so a query naming the
topic and a keyword ('CMM Community') matches neither side alone.
"""
words = [w for w in query.split() if w]
if not words:
words = ['']
return db.and_(*[
db.or_(*[c.ilike(f'%{w}%') for c in columns])
for w in words
])
def per_word(word):
clauses = [c.ilike(f'%{word}%') for c in columns]
if extra is not None:
clauses.append(extra(word))
return db.or_(*clauses)
return db.and_(*[per_word(w) for w in words])
def _require_enabled(name):
@@ -253,6 +265,10 @@ def _search_knowledgebase(query, search_term):
subject, which reads as though it were still in service.
A null topic still matches. Not every article is about an application.
The topic's NAME is searched too, per word. An article tagged 'community'
under the CMM topic has neither word in both of its own columns, so a search
for 'CMM Community' found it in neither the plugin's listing nor here.
"""
results = []
try:
@@ -260,12 +276,24 @@ def _search_knowledgebase(query, search_term):
from plugins.knowledgebase.models import KnowledgeBase
retired = db.session.query(Application.appid).filter(
Application.isactive.is_(False))
def topic_named(word):
"""Articles whose topic is an ACTIVE application named like word.
Active only, for the same reason the retired filter above exists: a
decommissioned application is not a topic anyone should be offered.
"""
return KnowledgeBase.appid.in_(
db.session.query(Application.appid).filter(
Application.appname.ilike(f'%{word}%'),
Application.isactive.is_(True)))
kb_articles = KnowledgeBase.query.filter(
KnowledgeBase.isactive == True,
db.or_(KnowledgeBase.appid.is_(None),
KnowledgeBase.appid.notin_(retired)),
_word_match(query, KnowledgeBase.shortdescription,
KnowledgeBase.keywords)
KnowledgeBase.keywords, extra=topic_named)
).order_by(KnowledgeBase.clicks.desc(),
KnowledgeBase.linkid).limit(20).all()
@@ -976,6 +1004,94 @@ def _check_smart_redirect(query, classification):
return None
# How long a harvested vocabulary is reused. Long enough that a burst of
# searching does not rebuild it, short enough that something entered this
# morning is correctable before lunch.
_VOCABULARY_TTL = 600
# Per-column ceiling on harvested terms. A correction is only offered when a
# search found NOTHING, so the vocabulary exists to catch a typo against the
# names people actually use - not to mirror the database. Without a bound this
# would load every asset number on a site into memory to answer one query.
_VOCABULARY_LIMIT = 5000
def _search_vocabulary():
"""Words worth correcting a search against, harvested from the data.
From the DATA and not a dictionary, because no dictionary holds Genspect,
Telesis, Keyence or wax-trace - and because a vendor added this morning is
then correctable this morning.
Cached: this runs only on a search that found nothing, but a person who
mistypes once usually mistypes twice.
"""
cached = cache.get('search_vocabulary')
if cached is not None:
return cached
vocabulary = Vocabulary()
columns = (
(Asset, (Asset.assetnumber, Asset.name)),
(Vendor, (Vendor.vendor,)),
(Model, (Model.modelnumber,)),
(Application, (Application.appname,)),
(AssetType, (AssetType.assettype,)),
)
for model, fields in columns:
for field in fields:
try:
rows = (db.session.query(field)
.filter(field.isnot(None))
.distinct()
.limit(_VOCABULARY_LIMIT)
.all())
except Exception:
# A plugin table missing on a lean install must not break
# search. No vocabulary is worse than the alternative, not
# fatal.
logger.debug('vocabulary: skipped %s', field, exc_info=True)
continue
for (value,) in rows:
vocabulary.add(value)
cache.set('search_vocabulary', vocabulary, timeout=_VOCABULARY_TTL)
return vocabulary
def _suggestion_for(query):
"""A better spelling of `query`, or None. Never raises into a search."""
try:
return _search_vocabulary().suggest_query(query)
except Exception:
logger.warning('vocabulary lookup failed', exc_info=True)
return None
@search_bp.route('/suggest', methods=['GET'])
@jwt_required(optional=True)
def search_suggest():
"""A better spelling for a query that found nothing, or null.
ITS OWN ENDPOINT, deliberately, rather than a `suggestion` key added to
every list response. Thirty routes take a search parameter across a dozen
files, so folding it into each one is a large change today and a thing every
future plugin has to remember. A page that finds no rows calls this instead;
a page that has not adopted it simply shows no suggestion, which is what it
showed before.
Returns null rather than a poor guess. The caller offers "did you mean" and
a person decides - nothing searches on their behalf.
"""
query = request.args.get('q', '').strip()
if not query or len(query) < 2 or len(query) > 200:
return success_response({'query': query, 'suggestion': None})
return success_response({
'query': query,
'suggestion': _suggestion_for(query),
})
@search_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def global_search():
@@ -1072,6 +1188,21 @@ def global_search():
total_all = len(unique_results)
# A search that found NOTHING is the only evidence of what people cannot
# find, and it is not otherwise recorded anywhere. Logged at INFO with a
# stable prefix so a week of it can be grepped into a list, which is what
# should decide whether spelling correction is worth wiring in - the recent
# multi-word and cross-field matching changes may already have fixed a good
# share of what used to fail. Guessing at that would be building for a
# problem nobody has measured.
suggestion = None
if not unique_results:
logger.info('search-no-results query=%r', query)
# Offered, never applied. The response carries the better spelling and
# the caller decides: silently searching for something else is how
# somebody orders the wrong cartridge.
suggestion = _suggestion_for(query)
# Limit total results
unique_results = unique_results[:50]
@@ -1083,6 +1214,8 @@ def global_search():
'total_all': total_all,
'counts': type_counts,
}
if suggestion:
response_data['suggestion'] = suggestion
redirect = _check_smart_redirect(query, classification)
if redirect:

View File

@@ -0,0 +1,169 @@
"""Suggest a spelling for a search word that found nothing.
WHY NOT SOUNDEX. MySQL has it; SQLite does not, and the suite runs on SQLite
while production runs MySQL - so a SOUNDEX() in a query is either an error in
every test or a production path no test ever executes. It is also wrong for this
data: soundex is English-name phonetics, four characters wide, and it DISCARDS
DIGITS, so CSF16 and CSF17 hash identically. Half of what people search here is
an identifier.
So this is character-based, runs in Python, and behaves the same on both
dialects.
THE VOCABULARY IS THE DATA. No dictionary knows Genspect, Telesis, Keyence or
wax-trace. The terms come from the columns being searched, which also means the
corpus is never stale: a vendor added this morning is correctable this morning.
IT SUGGESTS, IT DOES NOT REWRITE. The caller offers "did you mean" on a search
that found nothing. Silently searching for something else is how somebody orders
the wrong cartridge.
"""
from collections import defaultdict
# Longest edit distance allowed, by length of the word being corrected. A short
# word has few ways to be wrong, so one edit is already a different word: 'bay'
# to 'bar' is not a typo anybody wants guessed for them.
_DISTANCE_BY_LENGTH = ((4, 1), (8, 2))
_MAX_DISTANCE = 3
# Below this a word is too short to correct at all.
MIN_LENGTH = 3
# Candidates sharing no trigram with the query are not scored. Cuts the work
# from the whole vocabulary to a handful without changing the answer, because a
# word within the distances above always shares at least one trigram.
_TRIGRAM = 3
def _digits(word):
return ''.join(c for c in word if c.isdigit())
def max_distance(word):
for length, allowed in _DISTANCE_BY_LENGTH:
if len(word) <= length:
return allowed
return _MAX_DISTANCE
def damerau_levenshtein(first, second, cutoff=None):
"""Edit distance counting a transposition as ONE edit, not two.
Transposition is the commonest typing error - 'Keyecne' for 'Keyence' - and
plain Levenshtein charges two for it, which pushes real typos past the
threshold on short words.
`cutoff` abandons a comparison that cannot come in under it. Most candidates
are hopeless and this is the difference between scoring a vocabulary and
scanning one.
"""
if first == second:
return 0
if abs(len(first) - len(second)) > (cutoff if cutoff is not None else 99):
return (cutoff or 99) + 1
previous = list(range(len(second) + 1))
beforelast = None
for i, a in enumerate(first, 1):
current = [i] + [0] * len(second)
for j, b in enumerate(second, 1):
cost = 0 if a == b else 1
current[j] = min(previous[j] + 1, # deletion
current[j - 1] + 1, # insertion
previous[j - 1] + cost) # substitution
if (beforelast is not None and i > 1 and j > 1
and a == second[j - 2] and first[i - 2] == b):
current[j] = min(current[j], beforelast[j - 2] + cost)
if cutoff is not None and min(current) > cutoff:
return cutoff + 1
beforelast, previous = previous, current
return previous[-1]
def _trigrams(word):
padded = ' %s ' % word
return {padded[i:i + _TRIGRAM] for i in range(len(padded) - _TRIGRAM + 1)}
class Vocabulary:
"""Words harvested from searchable data, indexed for near-match lookup."""
def __init__(self, terms=()):
self.words = set()
self._bytrigram = defaultdict(set)
for term in terms:
self.add(term)
def add(self, term):
for word in str(term or '').split():
word = word.strip().lower()
if len(word) < MIN_LENGTH:
continue
if word in self.words:
continue
self.words.add(word)
for gram in _trigrams(word):
self._bytrigram[gram].add(word)
def candidates(self, word):
found = set()
for gram in _trigrams(word):
found |= self._bytrigram.get(gram, set())
return found
def suggest(self, word):
"""The best correction for `word`, or None to leave it alone.
Returns None rather than a poor guess: a wrong suggestion costs more
than no suggestion, because somebody acts on it.
"""
word = (word or '').strip().lower()
if len(word) < MIN_LENGTH or word in self.words:
return None
limit = max_distance(word)
best, bestdistance = None, limit + 1
for candidate in self.candidates(word):
if not _acceptable(word, candidate):
continue
distance = damerau_levenshtein(word, candidate, cutoff=limit)
if distance < bestdistance:
best, bestdistance = candidate, distance
elif distance == bestdistance and best is not None:
# A tie means two equally good answers and no way to choose.
# Offering either one implies a confidence that is not there.
best = None
return best if bestdistance <= limit else None
def suggest_query(self, query):
"""Correct a whole query, or None if nothing in it can be improved."""
words = [w for w in (query or '').split() if w]
if not words:
return None
fixed, changed = [], False
for word in words:
suggestion = self.suggest(word)
if suggestion and suggestion != word.lower():
fixed.append(suggestion)
changed = True
else:
fixed.append(word)
return ' '.join(fixed) if changed else None
def _acceptable(word, candidate):
"""Guards that keep a correction from destroying an identifier.
DIGITS MUST MATCH EXACTLY. CSF16 to CSF17 is one edit and a catastrophe: it
is a different bay, a different machine, a different cartridge. Any term
carrying digits is treated as an identifier, and an identifier is either
right or not correctable.
THE FIRST CHARACTER MUST MATCH. Typing errors land in the middle and at the
end far more often than on the first key, so requiring it costs almost no
recall and removes most of the nonsense.
"""
if _digits(word) != _digits(candidate):
return False
return bool(word) and bool(candidate) and word[0] == candidate[0]

View File

@@ -68,6 +68,9 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
'printers': ('printertypes', 'printers', 'modelsupplies', 'printerdrivers',
'printersupplyalerts', 'printerobservedqueues'),
'slides': ('tvslides',),
# Tech Tools was schemaless until secure send: every other tool in it
# runs in the browser and owns nothing.
'tools': ('securesendsecrets',),
'usb': ('usbdevicetypes', 'usbdevices', 'usbcheckouts'),
'warranty': ('warranties', 'warrantyassets'),
}

View File

@@ -50,7 +50,19 @@ EXEMPT_ENDPOINTS = {'knowledgebase.track_click', 'users.update_user',
# badge-attributed server-side. Decision record in
# docs/proposals/printedparts-plugin.md; justification in
# the plugin README.
'printedparts.kiosk_take'}
'printedparts.kiosk_take',
# Redeeming a shared secret. The recipient usually has no
# account here, so the unguessable 128-bit token IS the
# authorisation - gating this on a role would make the
# feature useless for the only case it exists for, handing
# somebody a credential while you are on a call with them.
#
# A mutation because reading SPENDS the secret, and a POST
# rather than a GET because chat and mail clients prefetch
# GET urls and a preview must not consume a one-view link.
# CREATING one is permission-gated and IS swept.
# Justification in plugins/tools/README.md.
'tools.redeem_secret'}
@pytest.fixture(autouse=True)

View File

@@ -0,0 +1,191 @@
"""Correcting a misspelled search word without wrecking an identifier.
Pure arithmetic over a word list, so it is tested that way. The cases that
matter most are the ones where it must REFUSE: a wrong suggestion is acted on,
and here that means the wrong bay or the wrong cartridge.
"""
from shopdb.core.services.spellfix import (
Vocabulary, damerau_levenshtein, max_distance,
)
SHOPFLOOR = [
'Keyence vision system', 'Genspect', 'Telesis part marker',
'wax-trace', 'Zebra ZT411', 'calibration', 'Haas Automation',
'CSF16', 'CSF17', 'MT-1042', 'FC733SY3', 'printer', 'grinder',
]
def vocab():
return Vocabulary(SHOPFLOOR)
# ------------------------------------------------------------------ distance
def test_a_transposition_costs_one_edit_not_two():
"""The commonest typing error. Plain Levenshtein charges two, which pushes
a real typo past the threshold on a short word."""
assert damerau_levenshtein('keyecne', 'keyence') == 1
def test_distance_allowed_scales_with_length():
assert max_distance('bay') == 1
assert max_distance('grinder') == 2
assert max_distance('calibration') == 3
# ------------------------------------------------------------------ suggests
def test_it_corrects_vocabulary_no_dictionary_would_hold():
assert vocab().suggest('keyecne') == 'keyence'
assert vocab().suggest('genspct') == 'genspect'
assert vocab().suggest('telisis') == 'telesis'
def test_a_word_that_is_already_right_is_left_alone():
assert vocab().suggest('keyence') is None
assert vocab().suggest('printer') is None
def test_a_word_nothing_resembles_gets_no_guess():
"""A wrong suggestion costs more than no suggestion."""
assert vocab().suggest('helicopter') is None
# ------------------------------------------------------- identifier guards
def test_an_asset_number_is_never_corrected_to_a_different_one():
"""THE case this exists for. CSF16 to CSF17 is one edit and a different bay.
Both are in the vocabulary, so without the digit guard the near miss is
exactly the kind of thing edit distance loves to 'fix'.
"""
assert vocab().suggest('csf18') is None
assert vocab().suggest('mt-1043') is None
def test_a_typo_in_the_letters_of_an_identifier_still_corrects():
"""The guard is on the DIGITS, not on identifiers as a whole: the digits
have to match, and here they do."""
assert vocab().suggest('cfs16') == 'csf16'
def test_the_first_character_must_match():
"""Typos land in the middle and at the end. Requiring the first key costs
little recall and removes most of the nonsense."""
assert vocab().suggest('teyence') is None
def test_a_tie_produces_no_suggestion():
"""Two equally good answers means there is no answer. CSF16 and CSF17 are
both one edit from CSF1x, and offering either implies a confidence that is
not there - but the digit guard rejects both first, so build a case where
only the tie is in play."""
tied = Vocabulary(['barn', 'bard'])
assert tied.suggest('bare') is None
# ---------------------------------------------------------------- queries
def test_a_multi_word_query_corrects_only_what_is_wrong():
assert vocab().suggest_query('keyecne calibration') == 'keyence calibration'
def test_a_query_that_is_entirely_fine_returns_none():
assert vocab().suggest_query('keyence calibration') is None
def test_short_words_are_left_alone():
"""Below three characters there is not enough word to correct."""
assert vocab().suggest('cs') is None
assert vocab().suggest_query('cs') is None
def test_the_vocabulary_comes_from_the_data():
"""A vendor added this morning is correctable this morning."""
fresh = vocab()
assert fresh.suggest('renishw') is None
fresh.add('Renishaw')
assert fresh.suggest('renishw') == 'renishaw'
# ------------------------------------------------------- wired into search
def test_a_search_that_finds_nothing_offers_a_spelling(client, db, auth_headers):
"""End to end: the vocabulary is harvested from real rows, and a misspelled
query that returns no results comes back with a better spelling."""
from shopdb.core.models import Vendor
from shopdb.extensions import cache
db.session.add(Vendor(vendor='Keyence'))
db.session.commit()
cache.delete('search_vocabulary')
resp = client.get('/api/search?q=keyecne', headers=auth_headers)
assert resp.status_code == 200
data = resp.get_json()['data']
assert data['results'] == []
assert data.get('suggestion') == 'keyence'
def test_a_search_that_finds_something_offers_nothing(client, db, auth_headers):
from shopdb.core.models import Vendor
from shopdb.extensions import cache
db.session.add(Vendor(vendor='Keyence'))
db.session.commit()
cache.delete('search_vocabulary')
resp = client.get('/api/search?q=keyence', headers=auth_headers)
assert 'suggestion' not in resp.get_json()['data']
def test_a_mistyped_asset_number_is_not_corrected_to_a_real_one(
client, db, auth_headers):
"""The guard, through the endpoint. CSF17 exists; CSF18 does not; and the
answer to "CSF18" must be no results and NO suggestion, not a different
bay."""
from shopdb.core.models import Asset, AssetType
from shopdb.extensions import cache
assettype = AssetType.query.first() or AssetType(assettype='machine')
if not assettype.assettypeid:
db.session.add(assettype)
db.session.flush()
db.session.add(Asset(assetnumber='CSF17', assettypeid=assettype.assettypeid))
db.session.commit()
cache.delete('search_vocabulary')
resp = client.get('/api/search?q=CSF18', headers=auth_headers)
data = resp.get_json()['data']
assert data['results'] == []
assert 'suggestion' not in data
def test_the_suggest_endpoint_serves_any_page(client, db, auth_headers):
"""The shared endpoint every list uses, rather than a suggestion key added
to thirty search routes."""
from shopdb.core.models import Vendor
from shopdb.extensions import cache
db.session.add(Vendor(vendor='Keyence'))
db.session.commit()
cache.delete('search_vocabulary')
resp = client.get('/api/search/suggest?q=keyecne', headers=auth_headers)
assert resp.status_code == 200
assert resp.get_json()['data']['suggestion'] == 'keyence'
def test_the_suggest_endpoint_says_null_rather_than_guessing(client, db,
auth_headers):
from shopdb.extensions import cache
cache.delete('search_vocabulary')
resp = client.get('/api/search/suggest?q=helicopter', headers=auth_headers)
assert resp.get_json()['data']['suggestion'] is None
def test_the_suggest_endpoint_ignores_a_too_short_query(client, auth_headers):
resp = client.get('/api/search/suggest?q=c', headers=auth_headers)
assert resp.status_code == 200
assert resp.get_json()['data']['suggestion'] is None

View File

@@ -71,6 +71,10 @@ EXPECTED_HEAD_REVISION['network'] = 'network0003prefix'
# printedparts is post-cutover: its 0001 really creates its tables; 0004 adds
# the per-transaction revision column.
EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0004txnrev'
# Built after the ADR-008 cutover, so its 0001 genuinely creates the table
# rather than stamping an anchor. Tech Tools owns a table now because secure
# send needs one; every other tool in that plugin is browser-side.
EXPECTED_HEAD_REVISION['tools'] = 'tools0001securesend'
# notifications indexes businessunitid, then adds the per-type grace window and
# the shared board category.
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0005boardorder'

View File

@@ -0,0 +1,326 @@
"""asset-id.txt names the device, and that name survives a PC swap.
Every other identity the collector has for a subordinate device is derived from
the PC: reuse looks for a prior link from THIS PC asset, and adoption looks up
`<PC number>-<SUFFIX>`. Both survive a re-image and neither survives a swap - a
new hostname is a new PC asset with no prior link and a predicted number that
has never existed, so the same physical device gets a second record while the
first keeps its history under a dead PC's name.
That is the failure that left 43 measuring tools shadowed by minted twins. These
tests pin the fix for BOTH device families, because the part-marker path was
modelled on the metrology path as it stood before it was fixed.
"""
import pytest
from shopdb.core.models import Asset, AssetType
from shopdb.core.models.relationship import RelationshipType, AssetRelationship
KEY = 'deviceid-key'
@pytest.fixture
def collector_key(app):
old = app.config.get('COLLECTOR_API_KEY')
app.config['COLLECTOR_API_KEY'] = KEY
yield KEY
app.config['COLLECTOR_API_KEY'] = old
@pytest.fixture
def rig(db):
for name in ('computer', 'machine', 'measuring_tool'):
if not AssetType.query.filter_by(assettype=name).first():
db.session.add(AssetType(assettype=name))
for name in ('controls', 'partof'):
if not RelationshipType.query.filter_by(relationshiptype=name).first():
db.session.add(RelationshipType(relationshiptype=name))
db.session.commit()
def _report(client, key, hostname, pctype, **extra):
payload = {'hostname': hostname, 'pctype': pctype}
payload.update(extra)
return client.post('/api/collector/computers', json=payload,
headers={'X-API-Key': key})
def _asset(db, assetnumber, assettype='machine'):
"""A bare asset: an operation, or something that is NOT a device."""
at = AssetType.query.filter_by(assettype=assettype).first()
asset = Asset(assetnumber=assetnumber, assettypeid=at.assettypeid)
db.session.add(asset)
db.session.commit()
return asset
def _marker(db, assetnumber):
"""A Part Marker the collector did NOT create - the real unit on the floor.
The extension row and machine type are what make it a marker; a bare asset
of the right number is deliberately refused, which the wrong-type test pins.
"""
from plugins.machines.models import Machine, MachineType
at = AssetType.query.filter_by(assettype='machine').first()
mtype = MachineType.query.filter_by(machinetype='Part Marker').first()
if mtype is None:
mtype = MachineType(machinetype='Part Marker')
db.session.add(mtype)
db.session.flush()
asset = Asset(assetnumber=assetnumber, assettypeid=at.assettypeid)
db.session.add(asset)
db.session.flush()
db.session.add(Machine(assetid=asset.assetid,
machinetypeid=mtype.machinetypeid))
db.session.commit()
return asset
def _tool(db, assetnumber):
"""A measuring tool the collector did NOT create."""
from plugins.measuringtools.models import MeasuringTool
at = AssetType.query.filter_by(assettype='measuring_tool').first()
asset = Asset(assetnumber=assetnumber, assettypeid=at.assettypeid)
db.session.add(asset)
db.session.flush()
db.session.add(MeasuringTool(assetid=asset.assetid))
db.session.commit()
return asset
def _controlled(pcname, label):
"""Asset numbers this PC controls under a collector label."""
pc = Asset.query.filter(Asset.assetnumber.ilike(pcname)).first()
if pc is None:
return []
rels = AssetRelationship.query.filter_by(
sourceassetid=pc.assetid, label=label, isactive=True).all()
return sorted(Asset.query.filter_by(assetid=r.targetassetid).first().assetnumber
for r in rels)
# --------------------------------------------------------------- part markers
def _retire(db, hostname):
"""Move a PC off In Use, which is the one-step way to yield its device."""
from shopdb.core.models import AssetStatus
retired = AssetStatus.query.filter_by(status='Retired').first()
if retired is None:
retired = AssetStatus(status='Retired')
db.session.add(retired)
db.session.flush()
pc = Asset.query.filter(Asset.assetnumber.ilike(hostname)).first()
pc.statusid = retired.statusid
db.session.commit()
def test_a_pc_swap_does_not_mint_a_second_marker(client, db, rig, collector_key):
"""THE case this exists for. Same physical marker, two different PCs.
Neither PC mints a twin. Who HOLDS the marker is settled separately, by the
two tests below - this one pins only that the physical unit stays one row.
"""
_asset(db, '0613')
marker = _marker(db, 'PM-0613-A')
first = _report(client, collector_key, 'FMARK100',
pctype='gea-shopfloor-partmarker',
machinenumber='0613', deviceid='PM-0613-A')
assert first.status_code in (200, 201), first.get_data(as_text=True)[:300]
# The bay's PC is replaced. New hostname, same marker named in asset-id.txt.
second = _report(client, collector_key, 'FMARK200',
pctype='gea-shopfloor-partmarker',
machinenumber='0613', deviceid='PM-0613-A')
assert second.status_code in (200, 201), second.get_data(as_text=True)[:300]
assert Asset.query.filter_by(assetnumber='FMARK100-PARTMARKER').first() is None
assert Asset.query.filter_by(assetnumber='FMARK200-PARTMARKER').first() is None
assert Asset.query.filter_by(assetnumber='PM-0613-A').count() == 1
assert marker.assetid == Asset.query.filter_by(
assetnumber='PM-0613-A').first().assetid
def test_a_live_incumbent_keeps_the_marker_and_the_challenger_is_dormant(
client, db, rig, collector_key):
"""Two PCs naming one device must not both hold it actively.
Before this, neither device path looked at who else held the target, so a
replaced PC kept its link forever and a copied asset-id.txt claimed the same
marker from every bay, silently.
"""
_asset(db, '0613')
_marker(db, 'PM-0613-A')
_report(client, collector_key, 'FMARK100', pctype='gea-shopfloor-partmarker',
machinenumber='0613', deviceid='PM-0613-A')
resp = _report(client, collector_key, 'FMARK200',
pctype='gea-shopfloor-partmarker',
machinenumber='0613', deviceid='PM-0613-A')
assert _controlled('FMARK100', 'collector:partmarker') == ['PM-0613-A']
assert _controlled('FMARK200', 'collector:partmarker') == []
warnings = ' '.join(resp.get_json()['data'].get('warnings', []))
assert 'FMARK100' in warnings and 'PM-0613-A' in warnings
def test_handover_completes_once_the_incumbent_yields(client, db, rig,
collector_key):
"""The swap case as it actually happens: the old PC is retired or goes quiet."""
_asset(db, '0613')
_marker(db, 'PM-0613-A')
_report(client, collector_key, 'FMARK100', pctype='gea-shopfloor-partmarker',
machinenumber='0613', deviceid='PM-0613-A')
_retire(db, 'FMARK100')
_report(client, collector_key, 'FMARK200', pctype='gea-shopfloor-partmarker',
machinenumber='0613', deviceid='PM-0613-A')
assert _controlled('FMARK200', 'collector:partmarker') == ['PM-0613-A']
# Archived, never deleted: "which PC drove this in June" stays answerable.
assert _controlled('FMARK100', 'collector:partmarker') == []
assert AssetRelationship.query.filter_by(label='collector:partmarker').count() >= 2
def test_without_the_file_a_swap_still_mints_the_old_way(client, db, rig,
collector_key):
"""The unfixed behaviour, pinned so the file's value stays visible."""
_asset(db, '0614')
assert _report(client, collector_key, 'FMARK300',
pctype='gea-shopfloor-partmarker',
machinenumber='0614').status_code in (200, 201)
assert _report(client, collector_key, 'FMARK400',
pctype='gea-shopfloor-partmarker',
machinenumber='0614').status_code in (200, 201)
assert Asset.query.filter_by(assetnumber='FMARK300-PARTMARKER').first()
assert Asset.query.filter_by(assetnumber='FMARK400-PARTMARKER').first()
def test_an_unknown_device_warns_and_links_nothing(client, db, rig,
collector_key):
_asset(db, '0616')
resp = _report(client, collector_key, 'FMARK500',
pctype='gea-shopfloor-partmarker',
machinenumber='0616', deviceid='PM-TYPO')
assert resp.status_code in (200, 201)
assert Asset.query.filter_by(assetnumber='PM-TYPO').first() is None
warnings = ' '.join(resp.get_json()['data'].get('warnings', []))
assert 'PM-TYPO' in warnings
# LINKS NOTHING, which is what the name claims. This used to warn and then
# mint FMARK500-PARTMARKER anyway - the twin the whole path exists to stop -
# and the test passed because it only checked that PM-TYPO was not created.
assert Asset.query.filter_by(assetnumber='FMARK500-PARTMARKER').first() is None
assert _controlled('FMARK500', 'collector:partmarker') == []
# AND it must not fall through to claiming the OPERATION directly. A marker
# PC reports the operation as its machine number, so "link nothing" that
# let the ordinary machine link run traded a phantom marker for a contested
# operation - several marker PCs share one, and it holds a single link.
assert _controlled('FMARK500', 'collector:machine') == []
def test_a_device_of_the_wrong_type_is_refused(client, db, rig, collector_key):
"""A machine number pasted into asset-id.txt must not become a marker."""
_asset(db, '0617')
_asset(db, 'PLAIN-MACHINE')
resp = _report(client, collector_key, 'FMARK600',
pctype='gea-shopfloor-partmarker',
machinenumber='0617', deviceid='PLAIN-MACHINE')
assert resp.status_code in (200, 201)
assert _controlled('FMARK600', 'collector:partmarker') == []
assert _controlled('FMARK600', 'collector:machine') == []
assert Asset.query.filter_by(assetnumber='FMARK600-PARTMARKER').first() is None
warnings = ' '.join(resp.get_json()['data'].get('warnings', []))
assert 'PLAIN-MACHINE' in warnings
def test_repeat_cycles_with_the_file_are_stable(client, db, rig, collector_key):
_asset(db, '0618')
_marker(db, 'PM-0618-A')
codes = [_report(client, collector_key, 'FMARK700',
pctype='gea-shopfloor-partmarker',
machinenumber='0618', deviceid='PM-0618-A').status_code
for _ in range(3)]
assert codes == [codes[0]] * 3, codes
assert _controlled('FMARK700', 'collector:partmarker') == ['PM-0618-A']
# ------------------------------------------------------------ measuring tools
def test_the_same_file_serves_a_metrology_bay(client, db, rig, collector_key):
"""One file, no device type in it: the pc-type decides which sync uses it."""
_tool(db, 'MT-9001')
resp = _report(client, collector_key, 'KEYENCE100',
pctype='gea-shopfloor-keyence', deviceid='MT-9001')
assert resp.status_code in (200, 201), resp.get_data(as_text=True)[:300]
assert _controlled('KEYENCE100', 'collector:measuringtool') == ['MT-9001']
assert Asset.query.filter_by(assetnumber='KEYENCE100-KEYENCE').first() is None
def test_the_0120_field_name_still_works(client, db, rig, collector_key):
"""measuringtool-id.txt shipped in 0.12.0; a staged bay keeps reporting."""
_tool(db, 'MT-9002')
resp = _report(client, collector_key, 'KEYENCE200',
pctype='gea-shopfloor-keyence', measuringtoolid='MT-9002')
assert resp.status_code in (200, 201)
assert _controlled('KEYENCE200', 'collector:measuringtool') == ['MT-9002']
def test_deviceid_wins_when_both_arrive(client, db, rig, collector_key):
_tool(db, 'MT-9003')
_tool(db, 'MT-9004')
resp = _report(client, collector_key, 'KEYENCE300',
pctype='gea-shopfloor-keyence',
deviceid='MT-9003', measuringtoolid='MT-9004')
assert resp.status_code in (200, 201)
assert _controlled('KEYENCE300', 'collector:measuringtool') == ['MT-9003']
def test_a_dormant_challenger_is_not_promoted_when_its_file_disappears(
client, db, rig, collector_key):
"""Deleting asset-id.txt must not hand a device to the challenger.
The dormant row this feature creates is found as `reuse` on the next cycle.
Reactivating it unconditionally meant a second PC took a live device by
losing a file - no warning, two active holders.
"""
_asset(db, '0613')
_marker(db, 'PM-0613-A')
_report(client, collector_key, 'FMARK100', pctype='gea-shopfloor-partmarker',
machinenumber='0613', deviceid='PM-0613-A')
_report(client, collector_key, 'FMARK200', pctype='gea-shopfloor-partmarker',
machinenumber='0613', deviceid='PM-0613-A')
assert _controlled('FMARK200', 'collector:partmarker') == []
# Next cycle: the file is gone, so no deviceid is sent at all.
_report(client, collector_key, 'FMARK200', pctype='gea-shopfloor-partmarker',
machinenumber='0613')
assert _controlled('FMARK100', 'collector:partmarker') == ['PM-0613-A']
assert _controlled('FMARK200', 'collector:partmarker') == []
def test_an_incumbent_under_another_collector_label_is_still_seen(
client, db, rig, collector_key):
"""On a CMM the instrument IS the bay, so the incumbent's link is the
machine sync's row under collector:machine. Counting only our own label
left two live holders of one instrument, each invisible to the other.
"""
_tool(db, 'CMM4')
# A LIVE incumbent: it reported through the collector, so it has a computer
# row and a recent check-in. A bare asset would correctly be treated as
# yielded, since something that cannot report cannot be holding anything.
_report(client, collector_key, 'WCMM100',
pctype='gea-shopfloor-keyence', deviceid='CMM4')
# Relabel its link to the machine sync's origin, which is the shape a CMM
# really has: there the instrument IS the reported bay.
pc = Asset.query.filter(Asset.assetnumber.ilike('WCMM100')).first()
held = AssetRelationship.query.filter_by(sourceassetid=pc.assetid).first()
held.label = 'collector:machine'
db.session.commit()
resp = _report(client, collector_key, 'WCMM200',
pctype='gea-shopfloor-keyence', deviceid='CMM4')
assert _controlled('WCMM200', 'collector:measuringtool') == []
warnings = ' '.join(resp.get_json()['data'].get('warnings', []))
assert 'CMM4' in warnings

View File

@@ -0,0 +1,128 @@
"""Searching a knowledge base article by words that live in different fields.
An article's searchable text is spread across three places: its title, its
keywords, and the name of its topic. People do not know or care which word came
from which, so they type the two words that identify the article and expect it
back - 'CMM Community' for an article tagged `community` under the CMM topic.
Both search paths used one `ilike('%CMM Community%')`, which needs that exact
phrase contiguous in a SINGLE field. No article has it, so the search returned
nothing at all and the article looked absent rather than unfindable. Every word
must be found somewhere, in any order, across all three fields.
Covers the plugin's own listing (`GET /api/knowledgebase?search=`, what the KB
page uses) and global search (`GET /api/search?q=`), which had the fault in two
different shapes: the listing did not split words, and global search split them
but never looked at the topic.
"""
import pytest
from shopdb.core.models import Application
from plugins.knowledgebase.models import KnowledgeBase
@pytest.fixture
def library(db):
"""Articles whose identifying words are deliberately spread across fields."""
topics = {
'cmm': Application(appname='Zzcmm', isactive=True),
'other': Application(appname='Zzunrelated', isactive=True),
'retired': Application(appname='Zzretired Zzcmm', isactive=False),
}
db.session.add_all(topics.values())
db.session.flush()
articles = {
# The reported case: neither word is in the same field as the other.
'split': KnowledgeBase(
shortdescription='Zzprotect viewer notes', linkurl='u1',
keywords='zzcommunity license', appid=topics['cmm'].appid,
isactive=True),
# Both words present but not adjacent, inside one field.
'gapped': KnowledgeBase(
shortdescription='Zzpower outage plan alert zznotification',
linkurl='u2', keywords='', appid=None, isactive=True),
# Holds only one of the two words; must NOT come back.
'halfmatch': KnowledgeBase(
shortdescription='Zzprotect viewer only', linkurl='u3',
keywords='license', appid=topics['other'].appid, isactive=True),
# Retired topic, matches both words. The existing rule still wins.
'retired': KnowledgeBase(
shortdescription='Zzprotect viewer retired', linkurl='u4',
keywords='zzcommunity', appid=topics['retired'].appid,
isactive=True),
}
db.session.add_all(articles.values())
db.session.commit()
return {'topics': topics, 'articles': articles}
def listed(client, search):
"""Titles from the plugin's own listing, the one the KB page calls."""
resp = client.get('/api/knowledgebase', query_string={'search': search})
assert resp.status_code == 200, resp.get_json()
return [row['shortdescription'] for row in resp.get_json()['data']]
def globally(client, search):
"""Knowledge base titles from global search."""
resp = client.get('/api/search', query_string={'q': search})
assert resp.status_code == 200, resp.get_json()
return [r['title'] for r in resp.get_json()['data']['results']
if r['type'] == 'knowledgebase']
# --- the reported case ------------------------------------------------------
def test_listing_finds_an_article_by_its_topic_and_its_keyword(client, library):
"""'Zzcmm Zzcommunity': one word from the topic's name, one from the keywords.
This is the bug as reported. A phrase match could satisfy neither field.
"""
assert 'Zzprotect viewer notes' in listed(client, 'Zzcmm Zzcommunity')
def test_global_search_finds_an_article_by_its_topic_and_its_keyword(client, library):
"""Global search split the words but only ever looked at title and keywords,
so a word that only the topic could satisfy failed there too."""
assert 'Zzprotect viewer notes' in globally(client, 'Zzcmm Zzcommunity')
# --- word order and adjacency ----------------------------------------------
def test_word_order_does_not_matter(client, library):
assert 'Zzprotect viewer notes' in listed(client, 'Zzcommunity Zzcmm')
def test_words_need_not_be_adjacent_within_one_field(client, library):
"""'plan' and 'alert' sit between them in the title."""
assert 'Zzpower outage plan alert zznotification' in listed(
client, 'Zzpower zznotification')
def test_the_whole_phrase_still_matches(client, library):
"""Splitting must not break the searches that already worked."""
assert 'Zzprotect viewer notes' in listed(client, 'Zzprotect viewer')
# --- what must still be excluded -------------------------------------------
def test_an_article_holding_only_one_word_is_not_returned(client, library):
"""EVERY word must be found. ORing them would return most of the library and
bury the article the words actually describe."""
found = listed(client, 'Zzprotect Zzcommunity')
assert 'Zzprotect viewer only' not in found
assert 'Zzprotect viewer notes' in found
def test_a_retired_topic_still_hides_the_article(client, library):
"""Matching more words must not become a way past the retired rule."""
assert 'Zzprotect viewer retired' not in listed(client, 'Zzprotect Zzcommunity')
assert 'Zzprotect viewer retired' not in globally(client, 'Zzprotect Zzcommunity')
def test_a_retired_topic_name_is_not_a_way_in(client, library):
"""The retired application is named 'Zzretired Zzcmm'; its name matching a word
must not surface its articles."""
assert listed(client, 'Zzretired') == []

View File

@@ -8,6 +8,8 @@ purchasing decision.
from datetime import datetime, timedelta, timezone
import pytest
from plugins.printers.services.supply_history import (
analyse, band, burn_rate, current_run, find_replacements, normalise,
orderlist, soonest,
@@ -113,28 +115,69 @@ def test_live_level_drives_the_countdown_not_the_stored_one():
assert result['daysleft'] == 1 # 20 / 20, not 40 / 20
def test_a_nearly_empty_cartridge_reads_as_empty_not_as_weeks_away():
"""1% draining a tenth of a point a day computes to ten days. It is out."""
result = analyse(series([2.0, 1.7, 1.4, 1.1]), currentlevel=1)
def test_a_nearly_empty_cartridge_keeps_its_real_countdown():
"""Only zero is empty.
This used to floor everything at or below 5% to daysleft 0, so 1% draining
a tenth of a point a day was reported as gone. It has ten days in it, and
ten days is the only thing the row exists to say. Flooring it put the
cartridge beside ones that genuinely are empty with no way back to the
difference.
"""
# 3.0 down to 1.0 at a tenth of a point a day: a real, slow, measured drop.
slow = [round(3.0 - 0.1 * i, 2) for i in range(21)]
result = analyse(series(slow), currentlevel=1)
assert result['daysleft'] == pytest.approx(10, abs=0.1)
def test_a_cartridge_with_hours_left_is_not_reported_as_empty():
"""The other half of the floor: int() truncated the division, so anything
under a full day became 0 and read as empty. A cartridge with six hours in
it still prints."""
result = analyse(series([100, 75, 50, 25]), currentlevel=6)
assert 0 < result['daysleft'] < 1
assert round(result['daysleft'] * 24) == 6
def test_an_actual_zero_is_still_empty():
"""The half that must not regress. Zero means spent, and spent is ordered
first."""
result = analyse(series([2.0, 1.7, 1.4, 1.1]), currentlevel=0)
assert result['daysleft'] == 0
def test_empty_sorts_ahead_of_a_fast_healthy_cartridge():
empty = analyse(series([2.0, 1.7, 1.4, 1.1]), currentlevel=1)
empty = analyse(series([2.0, 1.7, 1.4, 1.1]), currentlevel=0)
healthy = analyse(series([100, 80, 60, 40]))
assert empty['daysleft'] < healthy['daysleft']
def test_a_low_live_level_is_actionable_without_any_history():
def test_an_empty_live_level_is_actionable_without_any_history():
"""A printer new to Zabbix still reports it is out of toner."""
result = analyse([], currentlevel=1)
result = analyse([], currentlevel=0)
assert result['daysleft'] == 0
assert result['reason'] is None
def test_a_low_live_level_with_no_history_cannot_be_counted_down():
"""A rate needs two readings. At 1% with none, there is nothing to divide.
Previously the 5% floor answered this by asserting daysleft 0, which was
right by accident and wrong in general - it said "empty" about a level
nobody had watched move. It reports no estimate and says why; the report
lists it under "No estimate yet" rather than on the order list.
"""
result = analyse([], currentlevel=1)
assert result['daysleft'] is None
assert result['reason'] == 'no history'
def test_no_history_and_a_healthy_level_still_says_no_history():
result = analyse([], currentlevel=80)
@@ -267,3 +310,161 @@ def test_forecast_endpoint_says_so_when_zabbix_is_absent(client, db):
assert 'not configured' in body['reason']
assert body['cartridges'] == []
assert body['orderlist'] == []
# --------------------------------------------------------------------------
# Noise that used to read as cartridge changes, and bursts that used to bias
# the rate for the life of the cartridge. Both were reported from the floor:
# "5 changes in 90 days, that's hard to believe", and a cartridge that dropped
# 20 percent in two days then barely moved.
# --------------------------------------------------------------------------
def test_a_poll_returning_zero_is_not_a_cartridge_change():
"""0 then a real level is an SNMP error or a calibrating printer.
The rise is +60, which cleared the old threshold on its own. It does not
land near full, so it is not a swap.
"""
points = normalise(series([70, 60, 0, 60, 55, 50]))
assert find_replacements(points) == []
def test_a_gauge_ticking_back_up_mid_range_is_not_a_change():
"""A coarse gauge after a reseat or a power cycle. Lands at 55, not full."""
points = normalise(series([70, 60, 45, 55, 50, 45]))
assert find_replacements(points) == []
def test_a_real_swap_is_still_counted():
"""Near-empty to near-full. The shape a cartridge change actually makes."""
points = normalise(series([30, 15, 5, 100, 95, 90]))
assert len(find_replacements(points)) == 1
def test_the_run_starts_at_the_real_swap_not_at_the_noise():
"""current_run and find_replacements must agree on what a change is.
They read the same predicate now; when they did not, a phantom rise reset
the run and threw away the history the estimate needed.
"""
points = normalise(series([90, 0, 85, 80, 75, 70]))
assert find_replacements(points) == []
assert len(current_run(points)) == len(points)
def test_an_early_burst_does_not_dominate_the_rate_forever():
"""20 percent in two days, then a month of almost nothing.
The endpoint slope reads the burst forever: (100-75)/30 = 0.83 %/day, so
the report keeps promising the cartridge runs out long after printing
stopped. The median sees one fast interval among many quiet ones.
"""
levels = [100, 90, 80] + [80 - i * 0.2 for i in range(1, 28)]
rate = burn_rate(normalise(series(levels)))
assert rate is not None
# The old endpoint slope gives ~0.88 for this series, so a threshold of 1.0
# passed with the bug still in. The median gives ~0.2.
assert rate < 0.5, rate
detail = analyse(series(levels))
assert detail['rateunstable'] is True
def test_a_steady_cartridge_is_not_flagged_unstable():
detail = analyse(series([100, 95, 90, 85, 80, 75, 70]))
assert detail['burnrateperday'] == 5.0
assert detail['rateunstable'] is False
def test_a_real_near_empty_reading_before_a_swap_is_kept():
"""30, 5, 100 is a cartridge run to the end and changed - not a spike.
The dip filter must not eat it: the 5 does not RECOVER to 30, it jumps to
full, which is the shape of a swap rather than of a bad poll.
"""
points = normalise(series([40, 30, 5, 100, 95, 90]))
assert 5.0 in [level for _, level in points]
assert len(find_replacements(points)) == 1
def test_minutes_of_readings_do_not_forecast_weeks():
"""Supply items are often polled every few minutes.
Four readings a quarter of an hour apart, with a 2 point drop between the
ends, used to extrapolate to nearly 200 percent a day - so a cartridge
sitting at 82 percent was forecast to run out in a fortnight. A rate needs
time behind it, and without it the honest answer is no estimate.
"""
minutes = 5
detail = analyse(series([84, 83, 83, 82], hours=minutes / 60))
assert detail['burnrateperday'] is None
assert detail['daysleft'] is None
assert detail['reason'] == 'not enough history yet'
assert band(detail['daysleft']) is None # lands in "No estimate yet"
def test_a_run_with_enough_days_still_estimates():
"""The guard must not silence a genuinely slow, genuinely long run."""
detail = analyse(series([84, 83, 82, 81, 80, 79]))
assert detail['burnrateperday'] == 1.0
assert detail['daysleft'] == 79
def test_a_multi_poll_outage_is_one_dip_not_a_cartridge_change():
"""A door open or a supply out of the machine spans several polls.
These items are often polled every few minutes, so an outage covers more
than one reading. Removing only SINGLE readings left the original failure
in place for any cartridge above NEW_CARTRIDGE_LEVEL: 0 -> 90 clears the
rise and lands near full, so it scored as a swap.
"""
points = normalise(series([90, 0, 0, 90, 88, 86], hours=5 / 60))
assert [level for _, level in points] == [90.0, 90.0, 88.0, 86.0]
assert find_replacements(points) == []
def test_a_long_absence_is_not_deleted_as_noise():
"""Days at zero is a real empty period, not a bad poll.
Only a BRIEF multi-reading dip is removed. Deleting a level that stayed
down for days would hide exactly the outage someone needs to see.
"""
points = normalise(series([90, 0, 0, 90, 88, 86]))
assert 0.0 in [level for _, level in points]
def test_a_swap_of_a_nearly_full_cartridge_is_not_eaten_by_the_dip_filter():
"""95, then 5, then 100 is a swap, not a dip that recovered.
Comparing the absolute difference treated it as a recovery, because 100 and
95 are close, and deleted the evidence: the run then spanned two cartridges
and the replacement went uncounted. Toner only falls, so a recovery comes
back at or BELOW where it left - a new cartridge comes back higher.
"""
points = normalise(series([98, 95, 5, 100, 96, 92]))
assert 5.0 in [level for _, level in points]
assert len(find_replacements(points)) == 1
def test_consumption_between_two_swaps_is_not_mistaken_for_a_dip():
"""Swap to full, print for days, swap again: same shape, different thing.
The readings between the swaps are far below both bounding levels, which is
what a dip looks like. What separates them is elapsed time.
"""
points = normalise(series([80, 60, 40, 20, 100, 80, 60, 95, 70]))
assert [level for _, level in points] == [80.0, 60.0, 40.0, 20.0,
100.0, 80.0, 60.0, 95.0, 70.0]
assert len(find_replacements(points)) == 2
def test_a_rise_that_stops_mid_range_is_not_a_cartridge_change():
"""Isolates the near-full rule from the dip filter.
50 -> 75 clears the rise and is NOT a recovering dip (75 comes back higher
than the 60 before it), so the dip filter leaves it alone. Only the rule
that a swap must LAND near full rejects it. A new cartridge does not read
75 percent.
"""
points = normalise(series([60, 50, 75, 73, 71]))
assert 50.0 in [level for _, level in points]
assert find_replacements(points) == []

View File

@@ -0,0 +1,163 @@
"""A supply nobody could read is unknown, not empty.
Reported as "CSF04 now reports all 0%", on the printer's own page as well as the
reports. Every supply on one host reading zero at the same moment is not what a
set of cartridges does; it is what a host that stopped answering looks like.
`getsuppliesbyip` turned every unreadable item into 0:
try:
level = int(float(item.get("lastvalue", 0)))
except (ValueError, TypeError):
level = 0
An item that has never collected returns lastvalue as an EMPTY STRING, so
float('') raised and the handler substituted zero. `lastclock` was fetched in
the same request and never read by anything, so a reading from three weeks ago
was presented as current.
Zero is a legitimate level meaning "spent", which is what made the substitution
dangerous rather than merely wrong: it reached `level <= EMPTY_LEVEL` in the
forecast, which set daysleft to 0, which put the printer on the order list. The
report asked purchasing to buy a full set of cartridges for a printer nobody had
measured.
None means unknown, and it has to stay None the whole way out.
"""
import time
import pytest
from plugins.printers.services.zabbix_service import ZabbixService
from plugins.printers.services.supply_parts import classifysupply
from plugins.printers.services.supply_history import analyse, EMPTY_LEVEL
def item(lastvalue, lastclock=None, name='Black Cartridge'):
"""One Zabbix item.get row, shaped as the API returns it."""
if lastclock is None:
lastclock = int(time.time()) - 600
return {'itemid': '1', 'name': name,
'lastvalue': lastvalue, 'lastclock': str(lastclock)}
# --- reading one item -------------------------------------------------------
def test_an_item_that_never_collected_is_unknown_not_zero():
"""The reported case. Zabbix returns '' for an item with no data."""
level, lastseen = ZabbixService._readlevel(item(''))
assert level is None
assert lastseen is None
def test_a_missing_lastvalue_is_unknown_not_zero():
level, _ = ZabbixService._readlevel({'lastvalue': None, 'lastclock': '0'})
assert level is None
def test_an_unparseable_lastvalue_is_unknown_not_zero():
level, _ = ZabbixService._readlevel(item('n/a'))
assert level is None
def test_a_real_zero_is_still_zero():
"""The whole point of the distinction: an empty cartridge must survive it.
If this returned None, an actually-spent cartridge would stop being ordered.
"""
level, _ = ZabbixService._readlevel(item('0'))
assert level == 0
def test_a_normal_reading_comes_back_with_its_clock():
when = int(time.time()) - 300
level, lastseen = ZabbixService._readlevel(item('47', lastclock=when))
assert level == 47
assert lastseen == when
def test_a_float_reading_is_truncated_to_an_int():
level, _ = ZabbixService._readlevel(item('47.8'))
assert level == 47
# --- staleness --------------------------------------------------------------
def test_a_reading_older_than_the_window_is_unknown():
"""lastclock was fetched and then ignored, so a host that went quiet weeks
ago kept presenting its last level as current."""
stale = int(time.time()) - (ZabbixService.STALE_AFTER_SECONDS + 60)
level, lastseen = ZabbixService._readlevel(item('47', lastclock=stale))
assert level is None
assert lastseen == stale, 'the age must survive, so the UI can say how old'
def test_a_reading_inside_the_window_is_kept():
recent = int(time.time()) - (ZabbixService.STALE_AFTER_SECONDS - 3600)
level, _ = ZabbixService._readlevel(item('47', lastclock=recent))
assert level == 47
def test_a_value_with_no_clock_is_trusted():
"""A reading that cannot be aged is not thereby wrong. Discarding it would
lose real levels wherever lastclock is absent."""
level, lastseen = ZabbixService._readlevel(
{'lastvalue': '47', 'lastclock': '0'})
assert level == 47
assert lastseen is None
# --- classification ---------------------------------------------------------
def test_an_unknown_level_classifies_as_unknown():
result = classifysupply(None, 'Black Cartridge', 'HP')
assert result['status'] == 'unknown'
assert result['remaining'] is None
def test_an_unknown_level_is_not_critical():
"""'critical' is a claim someone acts on. It may not be made about a supply
nobody measured."""
assert classifysupply(None, 'Black Cartridge', 'HP')['status'] != 'critical'
def test_a_real_zero_is_still_critical():
assert classifysupply(0, 'Black Cartridge', 'HP')['status'] == 'critical'
def test_an_unknown_waste_cartridge_does_not_invert_into_full():
"""Waste is scored as 100 - level. With level 0 standing in for unknown,
an unreadable waste cartridge read as 100% full, which is the alarm state."""
result = classifysupply(None, 'Waste Toner Container', 'HP')
assert result['status'] == 'unknown'
assert result['remaining'] is None
# --- the forecast -----------------------------------------------------------
def test_an_unknown_level_with_no_history_gets_no_countdown():
"""0 reached `level <= EMPTY_LEVEL` and became daysleft 0 - the step that
put CSF04 on the order list."""
result = analyse([], currentlevel=None)
assert result['daysleft'] is None
assert result['reason'] == 'no history'
def test_a_real_zero_with_no_history_still_counts_as_due_now():
assert analyse([], currentlevel=0)['daysleft'] == 0
assert EMPTY_LEVEL >= 0
def test_an_unknown_level_falls_back_to_the_last_real_reading():
"""History is evidence the live read lacks. Preferring it beats inventing a
level, and beats throwing the cartridge off the report.
Clock is unix seconds and level falls over time, which is the only direction
toner goes; a rising series would read as a cartridge change.
"""
now = int(time.time())
points = [(now - n * 86400, 50 + n * 2) for n in range(20, -1, -1)]
result = analyse(points, currentlevel=None)
assert result['currentlevel'] == 50, 'the newest stored reading'
assert result['daysleft'] is not None, 'a forecast is still possible'

View File

@@ -16,9 +16,13 @@ from plugins.printers.models import ModelSupply, Printer
LEVELS = {
# itemid: (name, live level, history of daily readings)
'10': ('Black Cartridge', 1, [6, 5, 4, 3]),
# Black is a REAL zero, which is the only thing that means empty. Magenta is
# low but not zero and keeps its own countdown - under a day here, which the
# old int() truncation collapsed to 0 and reported as empty alongside black.
'10': ('Black Cartridge', 0, [6, 5, 4, 3]),
'11': ('Cyan Cartridge', 40, [100, 90, 80, 70, 60, 50, 40]),
'12': ('Yellow Cartridge', 92, [95, 94, 93, 92]),
'13': ('Magenta Cartridge', 3, [100, 76, 52, 28]),
}
START = 1780000000
@@ -111,7 +115,8 @@ def test_a_row_is_a_cartridge_not_a_printer(client, printer_with_parts):
data = forecast(client)
names = sorted(c['name'] for c in data['cartridges'] + data['unestimated'])
assert names == ['Black Cartridge', 'Cyan Cartridge', 'Yellow Cartridge']
assert names == ['Black Cartridge', 'Cyan Cartridge', 'Magenta Cartridge',
'Yellow Cartridge']
def test_each_cartridge_carries_its_own_countdown(client, printer_with_parts):
@@ -120,18 +125,30 @@ def test_each_cartridge_carries_its_own_countdown(client, printer_with_parts):
data = forecast(client)
bylevel = {c['name']: c for c in data['cartridges']}
assert bylevel['Black Cartridge']['daysleft'] == 0 # live level 1%
assert bylevel['Black Cartridge']['daysleft'] == 0 # live level 0%
assert bylevel['Cyan Cartridge']['daysleft'] == 4 # 40% at 10%/day
assert len({c['daysleft'] for c in data['cartridges']}) > 1
def test_a_low_cartridge_keeps_a_countdown_measured_in_hours(client,
printer_with_parts):
"""3% at 24%/day is about three hours. It used to arrive as 0 - int()
truncated the division - so it was indistinguishable from black, which is
genuinely spent."""
data = forecast(client)
magenta = {c['name']: c for c in data['cartridges']}['Magenta Cartridge']
assert 0 < magenta['daysleft'] < 1
assert round(magenta['daysleft'] * 24) == 3
def test_cartridges_are_banded_by_urgency(client, printer_with_parts):
data = forecast(client)
byname = {c['name']: c['band'] for c in data['cartridges']}
assert byname['Black Cartridge'] == 'empty'
assert byname['Cyan Cartridge'] == 'soon'
assert data['summary']['bands']['empty'] == 1
assert data['summary']['bands']['empty'] == 1, 'only the real zero'
def test_the_order_list_names_the_part_and_the_quantity(client, printer_with_parts):

View File

@@ -27,11 +27,39 @@ def test_meta_matches_manifest():
assert meta.core_version == manifest['core_version']
def test_no_backend_surface():
"""Client-side only: no blueprint to register, no tables to migrate."""
def test_the_only_backend_surface_is_secure_send():
"""Tech Tools used to have NO backend, and mostly still should.
This asserted `get_blueprint() is None` until secure send arrived: a link
that expires and dies on first read cannot be browser-only, because
something has to hold the ciphertext until it is claimed. The guard is kept
rather than deleted, narrowed to the one exception - a future tool that
quietly grows an endpoint or a table has to change this test, and therefore
make the decision on purpose rather than by accident.
"""
from flask import Flask
from plugins.tools.models import SecureSecret
plugin = ToolsPlugin()
assert plugin.get_blueprint() is None
assert plugin.get_models() == []
assert plugin.get_models() == [SecureSecret]
probe = Flask(__name__)
probe.register_blueprint(plugin.get_blueprint(), url_prefix='/api/tools')
served = sorted(
(str(rule), sorted(rule.methods - {'HEAD', 'OPTIONS'}))
for rule in probe.url_map.iter_rules()
if not str(rule).startswith('/static'))
# Methods are asserted, not just paths: REDEEMING MUST BE POST-ONLY. Chat
# and mail clients prefetch GET urls to build previews, and a preview that
# could redeem would consume a one-view secret before its recipient ever
# opened the link. The GET on the same path reports availability only.
assert served == [
('/api/tools/secrets', ['POST']),
('/api/tools/secrets/<token>', ['GET']),
('/api/tools/secrets/<token>', ['POST']),
], served
def test_navigation_item():

View File

@@ -0,0 +1,161 @@
"""Handing over a credential by link: who may create, who may read, what dies.
The interesting tests here are the refusals. A secret that leaks, outlives its
window, or is consumed by a link preview before its recipient ever sees it, all
fail in ways nobody notices until somebody needs the password.
"""
import pytest
from plugins.tools.models import SecureSecret
from plugins.tools.api.routes import MAX_CIPHERTEXT
CIPHER = 'ZmFrZS1jaXBoZXJ0ZXh0LWZvci10ZXN0cw=='
IV = 'MTIzNDU2Nzg5MDEy'
def _create(client, headers, **overrides):
body = {'ciphertext': CIPHER, 'iv': IV}
body.update(overrides)
return client.post('/api/tools/secrets', json=body, headers=headers)
# ------------------------------------------------------------------- creating
def test_creating_requires_a_login(client):
"""Anonymous creation would be an untraceable way to move data out of a
system that otherwise records who did what."""
resp = client.post('/api/tools/secrets', json={'ciphertext': CIPHER, 'iv': IV})
assert resp.status_code in (401, 403, 422)
def test_a_logged_in_user_with_the_permission_can_create(client, auth_headers):
resp = _create(client, auth_headers)
assert resp.status_code in (200, 201), resp.get_data(as_text=True)[:300]
data = resp.get_json()['data']
assert data['token']
assert len(data['token']) == 32 # 128 bits of randomness, as hex
# The receipt must NOT hand the payload back: that would put the secret in a
# second place for no reason.
assert 'ciphertext' not in data
def test_two_secrets_never_share_a_token(client, auth_headers):
first = _create(client, auth_headers).get_json()['data']['token']
second = _create(client, auth_headers).get_json()['data']['token']
assert first != second
# -------------------------------------------------------------- size limiting
def test_something_document_sized_is_refused(client, auth_headers):
"""The ONLY server-side control a zero-knowledge design leaves available.
The key never reaches the server, so it cannot tell a password from a page
of a drawing - it can only bound how much it will hold.
"""
resp = _create(client, auth_headers, ciphertext='A' * (MAX_CIPHERTEXT + 1))
assert resp.status_code == 400
body = resp.get_data(as_text=True)
assert 'too large' in body.lower()
# The message has to explain what the limit is FOR: somebody hitting it is
# usually trying to send a file, and the answer is a different tool.
assert 'document' in body.lower()
def test_a_credential_sized_secret_is_fine(client, auth_headers):
assert _create(client, auth_headers,
ciphertext='A' * 200).status_code in (200, 201)
# --------------------------------------------------------------- reading once
def test_anyone_can_read_it_without_logging_in(client, auth_headers):
"""The recipient usually has no account here. The unguessable token is the
authorisation."""
token = _create(client, auth_headers).get_json()['data']['token']
resp = client.post('/api/tools/secrets/%s' % token)
assert resp.status_code == 200
assert resp.get_json()['data']['ciphertext'] == CIPHER
def test_the_second_read_finds_nothing(client, auth_headers):
token = _create(client, auth_headers).get_json()['data']['token']
assert client.post('/api/tools/secrets/%s' % token).status_code == 200
assert client.post('/api/tools/secrets/%s' % token).status_code == 404
def test_a_spent_secret_is_deleted_not_just_marked(client, db, auth_headers):
"""A secret that has been read has no reason to keep its ciphertext."""
token = _create(client, auth_headers).get_json()['data']['token']
client.post('/api/tools/secrets/%s' % token)
assert SecureSecret.query.filter_by(token=token).first() is None
def test_more_than_one_view_can_be_allowed(client, auth_headers):
token = _create(client, auth_headers, views=2).get_json()['data']['token']
assert client.post('/api/tools/secrets/%s' % token).status_code == 200
assert client.post('/api/tools/secrets/%s' % token).status_code == 200
assert client.post('/api/tools/secrets/%s' % token).status_code == 404
# ------------------------------------------------------- link previews (Teams)
def test_a_get_does_not_spend_the_secret(client, auth_headers):
"""THE case that would break this in practice.
Teams, Outlook and Safe Links prefetch GET urls to build previews and scan
for malware. If a GET redeemed the secret, the preview would consume it and
the recipient would open the link to find it already used. Redeeming is a
POST for exactly this reason.
"""
token = _create(client, auth_headers).get_json()['data']['token']
peek = client.get('/api/tools/secrets/%s' % token)
assert peek.status_code == 200
assert peek.get_json()['data']['available'] is True
assert 'ciphertext' not in peek.get_json()['data']
# Still there for the person it was meant for.
assert client.post('/api/tools/secrets/%s' % token).status_code == 200
def test_peeking_at_a_spent_link_says_so_without_leaking(client, auth_headers):
token = _create(client, auth_headers).get_json()['data']['token']
client.post('/api/tools/secrets/%s' % token)
peek = client.get('/api/tools/secrets/%s' % token)
assert peek.get_json()['data']['available'] is False
# ------------------------------------------------------------------- expiry
def test_an_expired_secret_is_gone(client, db, auth_headers):
from datetime import datetime, timedelta, timezone
token = _create(client, auth_headers).get_json()['data']['token']
secret = SecureSecret.query.filter_by(token=token).first()
secret.expiresat = (datetime.now(timezone.utc).replace(tzinfo=None)
- timedelta(minutes=1))
db.session.commit()
assert client.post('/api/tools/secrets/%s' % token).status_code == 404
assert SecureSecret.query.filter_by(token=token).first() is None
def test_an_absurd_expiry_is_refused(client, auth_headers):
assert _create(client, auth_headers, expiryhours=10000).status_code == 400
assert _create(client, auth_headers, expiryhours=0).status_code == 400
# ------------------------------------------------------------ not enumerable
def test_an_unknown_token_answers_the_same_as_a_spent_one(client, auth_headers):
"""Distinguishing them would confirm to anyone holding a guessed token
whether it ever named anything."""
token = _create(client, auth_headers).get_json()['data']['token']
client.post('/api/tools/secrets/%s' % token)
spent = client.post('/api/tools/secrets/%s' % token)
never = client.post('/api/tools/secrets/%s' % ('f' * 32))
assert spent.status_code == never.status_code == 404
assert (spent.get_json()['data']['error']['message']
== never.get_json()['data']['error']['message'])