Merge remote-tracking branch 'origin/main' into wilm
This commit is contained in:
86
CHANGELOG.md
86
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
42
frontend/src/components/SearchSuggestion.vue
Normal file
42
frontend/src/components/SearchSuggestion.vue
Normal 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>
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
56
frontend/src/composables/searchSuggestion.js
Normal file
56
frontend/src/composables/searchSuggestion.js
Normal 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 }
|
||||
}
|
||||
35
frontend/src/router/routes/securesend.js
Normal file
35
frontend/src/router/routes/securesend.js
Normal 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' }
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
149
frontend/src/views/securesend/SecuresendDetail.vue
Normal file
149
frontend/src/views/securesend/SecuresendDetail.vue
Normal 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>
|
||||
228
frontend/src/views/securesend/SecuresendForm.vue
Normal file
228
frontend/src/views/securesend/SecuresendForm.vue
Normal 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>
|
||||
142
frontend/src/views/securesend/SecuresendList.vue
Normal file
142
frontend/src/views/securesend/SecuresendList.vue
Normal 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>
|
||||
14
frontend/src/views/vendors/VendorsList.vue
vendored
14
frontend/src/views/vendors/VendorsList.vue
vendored
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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 `
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 || ''
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
172
plugins/printers/frontend/views/tonerForecastNames.spec.js
Normal file
172
plugins/printers/frontend/views/tonerForecastNames.spec.js
Normal 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('-')
|
||||
})
|
||||
})
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
5
plugins/tools/api/__init__.py
Normal file
5
plugins/tools/api/__init__.py
Normal 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
176
plugins/tools/api/routes.py
Normal 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})
|
||||
150
plugins/tools/frontend/labelPages.js
Normal file
150
plugins/tools/frontend/labelPages.js
Normal 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 }
|
||||
}
|
||||
113
plugins/tools/frontend/labelPages.spec.js
Normal file
113
plugins/tools/frontend/labelPages.spec.js
Normal 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')
|
||||
})
|
||||
})
|
||||
80
plugins/tools/frontend/labelVars.js
Normal file
80
plugins/tools/frontend/labelVars.js
Normal 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),
|
||||
}
|
||||
}
|
||||
76
plugins/tools/frontend/labelVars.spec.js
Normal file
76
plugins/tools/frontend/labelVars.spec.js
Normal 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))
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
16
plugins/tools/migrations/env.py
Normal file
16
plugins/tools/migrations/env.py
Normal 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()
|
||||
24
plugins/tools/migrations/script.py.mako
Normal file
24
plugins/tools/migrations/script.py.mako
Normal 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"}
|
||||
@@ -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')
|
||||
10
plugins/tools/models/__init__.py
Normal file
10
plugins/tools/models/__init__.py
Normal 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']
|
||||
77
plugins/tools/models/securesend.py
Normal file
77
plugins/tools/models/securesend.py
Normal 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,
|
||||
}
|
||||
@@ -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 [
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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:
|
||||
|
||||
169
shopdb/core/services/spellfix.py
Normal file
169
shopdb/core/services/spellfix.py
Normal 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]
|
||||
@@ -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'),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
191
tests/test_core/test_spellfix.py
Normal file
191
tests/test_core/test_spellfix.py
Normal 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
|
||||
@@ -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'
|
||||
|
||||
326
tests/test_plugins/test_collector_deviceid.py
Normal file
326
tests/test_plugins/test_collector_deviceid.py
Normal 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
|
||||
128
tests/test_plugins/test_knowledgebase_search_words.py
Normal file
128
tests/test_plugins/test_knowledgebase_search_words.py
Normal 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') == []
|
||||
@@ -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) == []
|
||||
|
||||
163
tests/test_plugins/test_supply_unknown_level.py
Normal file
163
tests/test_plugins/test_supply_unknown_level.py
Normal 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'
|
||||
@@ -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):
|
||||
|
||||
@@ -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():
|
||||
|
||||
161
tests/test_plugins/test_tools_securesend.py
Normal file
161
tests/test_plugins/test_tools_securesend.py
Normal 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'])
|
||||
Reference in New Issue
Block a user