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

This commit is contained in:
benjamin.baker
2026-09-03 17:35:48 -04:00
112 changed files with 5182 additions and 857 deletions

View File

@@ -405,6 +405,12 @@ export const vendorsApi = {
list(params = {}) {
return api.get('/vendors', { params })
},
// Every vendor, paged. perpage is capped at 100 server-side, so a picker
// asking for 500 silently showed the first 100 and dropped the rest - with a
// 200, so nothing looked wrong.
listAll(params = {}) {
return fetchAllPages('/vendors', params)
},
get(id) {
return api.get(`/vendors/${id}`)
},
@@ -667,6 +673,10 @@ export const businessunitsApi = {
list(params = {}) {
return api.get('/businessunits', { params })
},
// See vendorsApi.listAll: the cap is 100 whatever the caller asks for.
listAll(params = {}) {
return fetchAllPages('/businessunits', params)
},
get(id) {
return api.get(`/businessunits/${id}`)
},
@@ -1306,6 +1316,11 @@ export const slidesApi = {
},
remove(surface, files) {
return api.post(`/slides/${surface}/delete`, { files })
},
// Per-slide hold time. The route has always existed; nothing called it, so
// the documented per-slide seconds could only be set with curl.
update(surface, slideid, data) {
return api.patch(`/slides/${surface}/${slideid}`, data)
}
}

View File

@@ -1153,6 +1153,18 @@ td.actions .btn + .btn {
margin: 0 0 1rem 0;
line-height: 1.5;
}
/* A settings block whose feature is not built yet. Reads as a warning rather
than body text, because the failure it prevents is an administrator
believing a control does something. */
.setting-notice {
border-left: 3px solid var(--warning);
background: var(--bg);
color: var(--text);
font-size: 0.9rem;
line-height: 1.5;
margin: 0 0 1rem 0;
padding: 0.6rem 0.8rem;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));

View File

@@ -1,251 +1,272 @@
<template>
<div class="employee-search">
<div class="employee-search-container">
<input
v-model="searchQuery"
type="text"
class="form-control"
:placeholder="placeholder"
:disabled="disabled"
@input="onSearch"
@keydown.enter.prevent="addCustom"
/>
<div v-if="results.length" class="employee-dropdown">
<div
v-for="emp in results"
:key="emp.SSO"
class="employee-option"
@click="selectEmployee(emp)"
>
<span class="emp-name">{{ emp.First_Name }} {{ emp.Last_Name }}</span>
<span class="emp-sso">{{ emp.SSO }}</span>
</div>
</div>
</div>
<small v-if="allowCustom" class="form-hint">Search for employees or press Enter to add a custom name</small>
<!-- Selected employee(s) display -->
<div v-if="multiple && selectedList.length" class="selected-employees">
<div
v-for="(emp, idx) in selectedList"
:key="idx"
class="selected-employee"
>
<span>{{ emp.name }}</span>
<span v-if="emp.sso && !emp.sso.startsWith('NAME:')" class="emp-sso-tag">{{ emp.sso }}</span>
<button v-if="!disabled" type="button" class="btn-remove" @click="removeEmployee(idx)">&times;</button>
</div>
</div>
<div v-else-if="!multiple && selected" class="selected-employee-display">
<span>{{ selected.name }}</span>
<span v-if="selected.sso" class="emp-sso-tag">{{ selected.sso }}</span>
<button v-if="!disabled" type="button" class="btn-remove" @click="clearSelection">&times;</button>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { employeesApi } from '../api'
const props = defineProps({
modelValue: { type: [Object, Array], default: null },
multiple: { type: Boolean, default: false },
allowCustom: { type: Boolean, default: false },
placeholder: { type: String, default: 'Search by name...' },
disabled: { type: Boolean, default: false }
})
const emit = defineEmits(['update:modelValue'])
const searchQuery = ref('')
const results = ref([])
const selected = ref(null)
const selectedList = ref([])
let searchTimeout = null
// Initialize from modelValue
watch(() => props.modelValue, (val) => {
if (props.multiple) {
selectedList.value = val || []
} else {
selected.value = val
}
}, { immediate: true })
async function onSearch() {
if (searchTimeout) clearTimeout(searchTimeout)
const query = searchQuery.value.trim()
if (query.length < 2) {
results.value = []
return
}
searchTimeout = setTimeout(async () => {
try {
const response = await employeesApi.search(query)
results.value = response.data.data || []
} catch (err) {
console.error('Employee search error:', err)
results.value = []
}
}, 300)
}
function selectEmployee(emp) {
const employee = {
sso: String(emp.SSO),
name: `${emp.First_Name} ${emp.Last_Name}`.trim()
}
if (props.multiple) {
// Check if already selected
if (selectedList.value.some(e => e.sso === employee.sso)) {
return
}
selectedList.value.push(employee)
emit('update:modelValue', selectedList.value)
} else {
selected.value = employee
emit('update:modelValue', employee)
}
searchQuery.value = ''
results.value = []
}
function addCustom() {
if (!props.allowCustom) return
const name = searchQuery.value.trim()
if (!name) return
const employee = {
sso: `NAME:${name}`,
name: name
}
if (props.multiple) {
selectedList.value.push(employee)
emit('update:modelValue', selectedList.value)
} else {
selected.value = employee
emit('update:modelValue', employee)
}
searchQuery.value = ''
results.value = []
}
function removeEmployee(idx) {
selectedList.value.splice(idx, 1)
emit('update:modelValue', selectedList.value)
}
function clearSelection() {
selected.value = null
emit('update:modelValue', null)
}
</script>
<style scoped>
.employee-search-container {
position: relative;
}
.employee-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-card-solid, #1a1a1a);
border: 1px solid var(--border);
border-radius: 0.25rem;
max-height: 200px;
overflow-y: auto;
z-index: 100;
}
.employee-option {
padding: 0.5rem 0.75rem;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
}
.employee-option:hover {
background: rgba(255, 255, 255, 0.1);
}
.emp-name {
font-weight: 500;
}
.emp-sso {
font-size: 0.85rem;
color: var(--text-light);
}
.form-hint {
display: block;
margin-top: 0.25rem;
font-size: 0.8rem;
color: var(--text-light);
}
/* Multiple selection */
.selected-employees {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.selected-employee {
display: flex;
align-items: center;
gap: 0.5rem;
background: var(--primary);
color: white;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.9rem;
}
/* Single selection */
.selected-employee-display {
display: flex;
align-items: center;
gap: 0.5rem;
background: var(--primary);
color: white;
padding: 0.5rem 0.75rem;
border-radius: 0.25rem;
margin-top: 0.5rem;
}
.emp-sso-tag {
font-size: 0.8rem;
opacity: 0.8;
}
.btn-remove {
background: none;
border: none;
color: white;
cursor: pointer;
padding: 0;
font-size: 1.2rem;
line-height: 1;
opacity: 0.7;
margin-left: auto;
}
.btn-remove:hover {
opacity: 1;
}
</style>
<template>
<div class="employee-search">
<div class="employee-search-container">
<input
v-model="searchQuery"
type="text"
class="form-control"
:placeholder="placeholder"
:disabled="disabled"
@input="onSearch"
@keydown.enter.prevent="addCustom"
/>
<div v-if="results.length" class="employee-dropdown">
<div
v-for="emp in results"
:key="emp.SSO"
class="employee-option"
@click="selectEmployee(emp)"
>
<span class="emp-name">{{ emp.First_Name }} {{ emp.Last_Name }}</span>
<span class="emp-sso">{{ emp.SSO }}</span>
</div>
</div>
</div>
<small v-if="allowCustom" class="form-hint">Search for employees or press Enter to add a custom name</small>
<!-- Selected employee(s) display -->
<div v-if="multiple && selectedList.length" class="selected-employees">
<div
v-for="(emp, idx) in selectedList"
:key="idx"
class="selected-employee"
>
<span>{{ emp.name }}</span>
<span v-if="emp.sso && !emp.sso.startsWith('NAME:')" class="emp-sso-tag">{{ emp.sso }}</span>
<button v-if="!disabled" type="button" class="btn-remove" @click="removeEmployee(idx)">&times;</button>
</div>
</div>
<div v-else-if="!multiple && selected" class="selected-employee-display">
<span>{{ selected.name }}</span>
<span v-if="selected.sso" class="emp-sso-tag">{{ selected.sso }}</span>
<button v-if="!disabled" type="button" class="btn-remove" @click="clearSelection">&times;</button>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { employeesApi } from '../api'
import { resolveEmployeeName } from '../composables/employeeResolve'
const props = defineProps({
modelValue: { type: [Object, Array], default: null },
multiple: { type: Boolean, default: false },
allowCustom: { type: Boolean, default: false },
placeholder: { type: String, default: 'Search by name...' },
disabled: { type: Boolean, default: false }
})
const emit = defineEmits(['update:modelValue'])
const searchQuery = ref('')
const results = ref([])
const selected = ref(null)
const selectedList = ref([])
let searchTimeout = null
// Initialize from modelValue
watch(() => props.modelValue, (val) => {
if (props.multiple) {
selectedList.value = val || []
} else {
selected.value = val
}
}, { immediate: true })
async function onSearch() {
if (searchTimeout) clearTimeout(searchTimeout)
const query = searchQuery.value.trim()
if (query.length < 2) {
results.value = []
return
}
searchTimeout = setTimeout(async () => {
try {
const response = await employeesApi.search(query)
results.value = response.data.data || []
} catch (err) {
console.error('Employee search error:', err)
results.value = []
}
}, 300)
}
function selectEmployee(emp) {
const employee = {
sso: String(emp.SSO),
name: `${emp.First_Name} ${emp.Last_Name}`.trim()
}
if (props.multiple) {
// Check if already selected
if (selectedList.value.some(e => e.sso === employee.sso)) {
return
}
selectedList.value.push(employee)
emit('update:modelValue', selectedList.value)
} else {
selected.value = employee
emit('update:modelValue', employee)
}
searchQuery.value = ''
results.value = []
}
async function addCustom() {
const name = searchQuery.value.trim()
if (!name) return
// Look it up before deciding it is free text. Picking a suggestion gave a
// real SSO; typing the same name in full and pressing Enter gave
// `NAME:<text>`, linked to nothing - and the chip looked the same either
// way. Nobody types a name in full expecting a different record.
const { match, matches, failed } = await resolveEmployeeName(name)
if (match) {
selectEmployee(match)
return
}
if (matches && matches.length) {
// Ambiguous: show the candidates rather than guessing, which is exactly
// when guessing wrong matters.
results.value = matches
return
}
if (failed) {
// The directory is unreachable. Falling through to a custom label here
// would quietly turn a real employee into free text.
return
}
if (!props.allowCustom) return
const employee = {
sso: `NAME:${name}`,
name: name
}
if (props.multiple) {
selectedList.value.push(employee)
emit('update:modelValue', selectedList.value)
} else {
selected.value = employee
emit('update:modelValue', employee)
}
searchQuery.value = ''
results.value = []
}
function removeEmployee(idx) {
selectedList.value.splice(idx, 1)
emit('update:modelValue', selectedList.value)
}
function clearSelection() {
selected.value = null
emit('update:modelValue', null)
}
</script>
<style scoped>
.employee-search-container {
position: relative;
}
.employee-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-card-solid, #1a1a1a);
border: 1px solid var(--border);
border-radius: 0.25rem;
max-height: 200px;
overflow-y: auto;
z-index: 100;
}
.employee-option {
padding: 0.5rem 0.75rem;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
}
.employee-option:hover {
background: rgba(255, 255, 255, 0.1);
}
.emp-name {
font-weight: 500;
}
.emp-sso {
font-size: 0.85rem;
color: var(--text-light);
}
.form-hint {
display: block;
margin-top: 0.25rem;
font-size: 0.8rem;
color: var(--text-light);
}
/* Multiple selection */
.selected-employees {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.selected-employee {
display: flex;
align-items: center;
gap: 0.5rem;
background: var(--primary);
color: white;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.9rem;
}
/* Single selection */
.selected-employee-display {
display: flex;
align-items: center;
gap: 0.5rem;
background: var(--primary);
color: white;
padding: 0.5rem 0.75rem;
border-radius: 0.25rem;
margin-top: 0.5rem;
}
.emp-sso-tag {
font-size: 0.8rem;
opacity: 0.8;
}
.btn-remove {
background: none;
border: none;
color: white;
cursor: pointer;
padding: 0;
font-size: 1.2rem;
line-height: 1;
opacity: 0.7;
margin-left: auto;
}
.btn-remove:hover {
opacity: 1;
}
</style>

View File

@@ -1,83 +1,96 @@
<template>
<div class="pagination-bar" v-if="totalPages > 1 || showPerPage">
<div class="pagination-info">
<select
v-if="showPerPage"
:value="perPage"
class="perpage-select"
@change="$emit('update:perPage', Number($event.target.value))"
>
<option v-for="opt in perPageOptions" :key="opt" :value="opt">
{{ opt }} per page
</option>
</select>
</div>
<div class="pagination" v-if="totalPages > 1">
<button :disabled="page === 1" @click="$emit('update:page', page - 1)">
Prev
</button>
<button
v-for="p in visiblePages"
:key="p"
:class="{ active: p === page, ellipsis: p === '...' }"
:disabled="p === '...'"
@click="p !== '...' && $emit('update:page', p)"
>
{{ p }}
</button>
<button :disabled="page === totalPages" @click="$emit('update:page', page + 1)">
Next
</button>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
page: { type: Number, required: true },
totalPages: { type: Number, required: true },
perPage: { type: Number, default: 20 },
showPerPage: { type: Boolean, default: true }
})
defineEmits(['update:page', 'update:perPage'])
const perPageOptions = [10, 20, 50, 100]
const visiblePages = computed(() => {
const total = props.totalPages
const current = props.page
const pages = []
if (total <= 7) {
for (let i = 1; i <= total; i++) pages.push(i)
return pages
}
pages.push(1)
if (current > 3) {
pages.push('...')
}
const start = Math.max(2, current - 1)
const end = Math.min(total - 1, current + 1)
for (let i = start; i <= end; i++) {
pages.push(i)
}
if (current < total - 2) {
pages.push('...')
}
pages.push(total)
return pages
})
</script>
<template>
<div class="pagination-bar" v-if="totalPages > 1 || showPerPage">
<div class="pagination-info">
<select
v-if="showPerPage"
:value="perPage"
class="perpage-select"
@change="$emit('update:perPage', Number($event.target.value))"
>
<option v-for="opt in perPageOptions" :key="opt" :value="opt">
{{ opt }} per page
</option>
</select>
</div>
<div class="pagination" v-if="totalPages > 1">
<button :disabled="page === 1" @click="$emit('update:page', page - 1)">
Prev
</button>
<button
v-for="p in visiblePages"
:key="p"
:class="{ active: p === page, ellipsis: p === '...' }"
:disabled="p === '...'"
@click="p !== '...' && $emit('update:page', p)"
>
{{ p }}
</button>
<button :disabled="page === totalPages" @click="$emit('update:page', page + 1)">
Next
</button>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
page: { type: Number, required: true },
totalPages: { type: Number, required: true },
perPage: { type: Number, default: 20 },
showPerPage: { type: Boolean, default: true }
})
defineEmits(['update:page', 'update:perPage'])
// A page size the caller actually uses must be selectable, or the control renders
// blank: NetworkDevicesList defaults to 25, which was in no option, so
// :value="25" matched nothing and the box showed empty - the operator could not
// see the current page size at all. Any size the component is handed is folded
// in rather than silently dropped.
const BASE_PER_PAGE_OPTIONS = [10, 20, 50, 100]
const perPageOptions = computed(() => {
const current = Number(props.perPage)
if (!current || BASE_PER_PAGE_OPTIONS.includes(current)) {
return BASE_PER_PAGE_OPTIONS
}
return [...BASE_PER_PAGE_OPTIONS, current].sort((a, b) => a - b)
})
const visiblePages = computed(() => {
const total = props.totalPages
const current = props.page
const pages = []
if (total <= 7) {
for (let i = 1; i <= total; i++) pages.push(i)
return pages
}
pages.push(1)
if (current > 3) {
pages.push('...')
}
const start = Math.max(2, current - 1)
const end = Math.min(total - 1, current + 1)
for (let i = start; i <= end; i++) {
pages.push(i)
}
if (current < total - 2) {
pages.push('...')
}
pages.push(total)
return pages
})
</script>

View File

@@ -0,0 +1,56 @@
// Asset-type ids resolved by NAME, once per page load.
//
// Four asset forms carried a hardcoded id - `const COMPUTER_ASSETTYPEID = 2`,
// and the same for machines, printers and network devices - to tell
// CustomFieldsInputs which type's fields to render. The ids come from seed
// order, not from anything stable: on the instance those constants were written
// against, computer was 2; on a freshly seeded one it is 1 and 2 is
// network_device. So the PC form asked for another type's custom fields,
// rendered them, and dropped whatever the operator typed - with a success
// toast, because the values were keyed to a type the record does not have.
//
// A name is the stable thing. `assettype` is a fixed vocabulary (ADR-001) and
// the API already publishes the mapping; nothing else should hardcode a number.
import { reactive } from 'vue'
import { assetsApi } from '../api'
const state = reactive({
byname: {},
loaded: false
})
let inflight = null
export async function loadAssetTypeIds() {
if (state.loaded) return state
if (inflight) return inflight
inflight = assetsApi.types.list()
.then((response) => {
const rows = response?.data?.data || response?.data || []
const byname = {}
rows.forEach((row) => {
if (row && row.assettype) byname[row.assettype] = row.assettypeid
})
state.byname = byname
state.loaded = true
return state
})
.catch(() => {
// Leave it unloaded rather than caching a failure: a form that could not
// resolve its type renders no custom fields, which is honest. Guessing an
// id is what caused the defect this replaces.
state.loaded = false
return state
})
.finally(() => { inflight = null })
return inflight
}
// Undefined until loaded, and undefined for a name the server does not know.
// CustomFieldsInputs treats an undefined assettypeid as "nothing to render",
// which is the safe reading: better a missing panel than the wrong type's.
export function assetTypeId(name) {
return state.byname[name]
}
export { state }

View File

@@ -0,0 +1,94 @@
// Asset-type ids must come from the server by name.
//
// Four forms hardcoded the id. Those constants were written against one
// instance's seed order and were wrong on another: PCForm said 2, and on a
// freshly seeded database 2 is network_device. The form asked
// CustomFieldsInputs for that type's fields, rendered them, and dropped what
// the operator typed - with a success toast, because the values were keyed to
// a type the record does not have.
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../api', () => ({
assetsApi: {
types: {
list: vi.fn()
}
}
}))
import { assetsApi } from '../api'
import { loadAssetTypeIds, assetTypeId, state } from './assetTypeIds'
function reset() {
state.byname = {}
state.loaded = false
assetsApi.types.list.mockReset()
}
describe('assetTypeIds', () => {
beforeEach(reset)
it('resolves an id by name from what the server actually has', async () => {
assetsApi.types.list.mockResolvedValue({
data: { data: [
{ assettypeid: 1, assettype: 'computer' },
{ assettypeid: 2, assettype: 'network_device' },
{ assettypeid: 4, assettype: 'machine' }
] }
})
await loadAssetTypeIds()
expect(assetTypeId('computer')).toBe(1)
expect(assetTypeId('network_device')).toBe(2)
expect(assetTypeId('machine')).toBe(4)
})
it('survives a differently seeded site, which is the whole point', async () => {
// The same names, different ids. A hardcoded 2 would be network_device
// here and computer in the other fixture.
assetsApi.types.list.mockResolvedValue({
data: { data: [
{ assettypeid: 2, assettype: 'computer' },
{ assettypeid: 3, assettype: 'network_device' }
] }
})
await loadAssetTypeIds()
expect(assetTypeId('computer')).toBe(2)
expect(assetTypeId('network_device')).toBe(3)
})
it('fetches once even when several forms ask at the same time', async () => {
assetsApi.types.list.mockResolvedValue({
data: { data: [{ assettypeid: 1, assettype: 'computer' }] }
})
await Promise.all([loadAssetTypeIds(), loadAssetTypeIds(), loadAssetTypeIds()])
await loadAssetTypeIds()
expect(assetsApi.types.list).toHaveBeenCalledTimes(1)
})
it('returns undefined rather than a guess when the load fails', async () => {
assetsApi.types.list.mockRejectedValue(new Error('network down'))
await loadAssetTypeIds()
// Undefined means CustomFieldsInputs renders nothing. A missing panel is
// honest; the wrong type's panel is what this replaces.
expect(assetTypeId('computer')).toBeUndefined()
expect(state.loaded).toBe(false)
})
it('returns undefined for a type the server does not know', async () => {
assetsApi.types.list.mockResolvedValue({
data: { data: [{ assettypeid: 1, assettype: 'computer' }] }
})
await loadAssetTypeIds()
expect(assetTypeId('forklift')).toBeUndefined()
})
})

View File

@@ -0,0 +1,55 @@
// Turning what somebody typed into an employee, before giving up on it.
//
// Both employee pickers had the same shape: click a suggestion and you get the
// person, with their SSO; type the whole name and press Enter and you get a
// free-text label (`NAME:Jane Smith`) that is linked to nothing. It looks
// identical on screen - the chip says "Jane Smith" either way - and the
// consequences only show up later: no photo, no directory link, and a
// recognition that cannot be found by the person it is about.
//
// Nobody types a name in full expecting a different outcome from picking it
// off the list. So a typed name is looked up first, and only becomes a custom
// label when the directory genuinely does not have it.
//
// An ambiguous name is NOT guessed at. Two people called J Smith is exactly
// when picking the wrong one matters most, so the caller is told to choose.
import { employeesApi } from '../api'
function fullname(employee) {
return `${employee.First_Name || ''} ${employee.Last_Name || ''}`.trim()
}
// { match } when one employee is clearly meant, { matches } when several are
// possible, {} when the directory has nobody by that name.
export async function resolveEmployeeName(name) {
const query = (name || '').trim()
if (!query) return {}
let rows = []
try {
const response = await employeesApi.search(query)
rows = response?.data?.data || []
} catch (err) {
// A directory that cannot be reached must not silently turn a real
// employee into a custom label - the caller decides what to do about it.
// The MESSAGE, not the error object: a rejected mock handed to
// console.error is re-surfaced by the test runner as an unhandled error,
// which fails the very test that proves this branch works.
console.warn('Employee lookup failed:', err && err.message)
return { failed: true }
}
if (!rows.length) return {}
// An exact full-name match wins over a partial one: searching "Dana Reyes"
// can also return "Dana Reyes-Smith", and the person typing the shorter name
// meant the shorter name.
const exact = rows.filter(
(row) => fullname(row).toLowerCase() === query.toLowerCase())
if (exact.length === 1) return { match: exact[0] }
if (exact.length > 1) return { matches: exact }
if (rows.length === 1) return { match: rows[0] }
return { matches: rows }
}
export { fullname }

View File

@@ -0,0 +1,81 @@
// Typing a name in full must reach the same employee as picking it off the list.
//
// Both pickers turned a typed name straight into `NAME:<text>` - a free-text
// label linked to nothing. On screen the chip is identical to a real
// selection, so the difference only shows up later: no photo, no directory
// record, and a recognition that cannot be found by the person it is about.
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../api', () => ({
employeesApi: { search: vi.fn() }
}))
import { employeesApi } from '../api'
import { resolveEmployeeName } from './employeeResolve'
const DANA = { SSO: 270015376, First_Name: 'Dana', Last_Name: 'Reyes' }
const DANA_HYPHEN = { SSO: 270099999, First_Name: 'Dana', Last_Name: 'Reyes-Smith' }
const SAM = { SSO: 270000001, First_Name: 'Sam', Last_Name: 'Reyes' }
function found(rows) {
employeesApi.search.mockResolvedValue({ data: { data: rows } })
}
describe('resolveEmployeeName', () => {
beforeEach(() => employeesApi.search.mockReset())
it('resolves a full name to the employee', async () => {
found([DANA])
const { match } = await resolveEmployeeName('Dana Reyes')
expect(match.SSO).toBe(270015376)
})
it('prefers an exact full-name match over a longer partial one', async () => {
// Searching "Dana Reyes" also returns "Dana Reyes-Smith"; the person who
// typed the shorter name meant the shorter name.
found([DANA, DANA_HYPHEN])
const { match, matches } = await resolveEmployeeName('Dana Reyes')
expect(matches).toBeUndefined()
expect(match.SSO).toBe(270015376)
})
it('refuses to guess between genuinely ambiguous matches', async () => {
found([DANA, SAM])
const { match, matches } = await resolveEmployeeName('Reyes')
expect(match).toBeUndefined()
expect(matches).toHaveLength(2)
})
it('reports nothing found, so the caller may fall back to a label', async () => {
found([])
const result = await resolveEmployeeName('Somebody External')
expect(result.match).toBeUndefined()
expect(result.matches).toBeUndefined()
expect(result.failed).toBeUndefined()
})
it('says the lookup FAILED rather than reporting nobody', async () => {
// The difference matters: "not in the directory" may become a custom
// label, "could not ask" must not.
// A THENABLE that calls its reject callback, rather than a mock returning
// a rejected promise. The await sees the same failure, but no rejected
// promise object exists for the runner to collect as an unhandled error -
// which it does even when the code under test catches it, failing the very
// test that proves the catch works.
employeesApi.search.mockImplementationOnce(() => {
const failure = new Error('network down')
return { then: (_ok, fail) => fail(failure) }
})
// resolves.toEqual, so the runner sees the rejection consumed by an
// assertion rather than as an unhandled error of its own.
await expect(resolveEmployeeName('Dana Reyes')).resolves.toEqual({
failed: true
})
})
it('does not call the directory for an empty box', async () => {
const result = await resolveEmployeeName(' ')
expect(employeesApi.search).not.toHaveBeenCalled()
expect(result).toEqual({})
})
})

View File

@@ -83,6 +83,12 @@
<span v-if="result.location" class="result-location">{{ result.location }}</span>
<span v-if="result.ticketnumber" class="result-ticket">{{ result.ticketnumber }}</span>
<span v-if="result.iscurrent" class="badge badge-success">Active</span>
<!-- Retired assets are searchable on purpose (the retire
dialog promises they can be restored, and the search box
is where somebody looks for one). They rank below live
assets and say so, or a retired record looks like a
current one. -->
<span v-if="result.retired" class="badge badge-secondary">retired</span>
</div>
</div>
<button

View File

@@ -8,8 +8,10 @@
<div class="setting-group">
<h3>Audit Log Retention</h3>
<p class="setting-description">
Configure how long audit logs are retained. Older logs will be automatically purged.
Set to 0 to keep logs indefinitely.
How long audit entries are kept. 0 keeps everything forever, which is
the default. Saving a period does NOT delete anything by itself:
purge from Settings &gt; Site &amp; Facility, or run
<code>flask audit purge --apply</code> on the server.
</p>
<div class="setting-row">
@@ -17,13 +19,13 @@
<span>Retention Period (days)</span>
<input
type="number"
v-model="settings.audit_retention_days"
v-model="settings.audit_retentiondays"
min="0"
placeholder="90"
@blur="saveSetting('audit_retention_days', settings.audit_retention_days)"
placeholder="0"
@blur="saveSetting('audit_retentiondays', settings.audit_retentiondays)"
:disabled="saving"
>
<small class="input-hint">0 = keep forever, recommended: 90 days</small>
<small class="input-hint">0 = keep forever</small>
</label>
</div>

View File

@@ -7,9 +7,16 @@
<div class="section-card">
<div class="setting-group">
<h3>SAML Single Sign-On</h3>
<p class="setting-notice">
<strong>Not implemented yet.</strong> These settings are stored so a
site can be configured ahead of the feature landing, but nothing
consumes them today: there is no SAML login route, and no assertion
is accepted. Sign-in is local accounts only, whatever is set here.
</p>
<p class="setting-description">
Enable SAML SSO for enterprise authentication. Users can sign in using your organization's
identity provider (Azure AD, Okta, etc.). Local login can remain enabled as a fallback.
When it ships, users will sign in through your organization's identity
provider (Azure AD, Okta, etc.), with local login available as a
fallback.
</p>
<div class="setting-row">
@@ -82,19 +89,26 @@
</label>
</div>
<!-- Locked on until SAML exists. A toggle that reads "Allow Local
Login: off" while password login still works is the dangerous
one on this page: an administrator can believe they have closed
the local door, and nothing tells them otherwise. It is not a
setting anyone can honour yet, so it does not accept a click. -->
<div class="setting-row">
<label class="toggle-label">
<span>Allow Local Login</span>
<button
class="toggle-btn"
:class="{ active: settings.saml_allow_local_login }"
@click="toggleSetting('saml_allow_local_login')"
:disabled="saving"
class="toggle-btn active"
disabled
title="Local login cannot be turned off until SAML sign-in exists"
>
<span class="toggle-slider"></span>
</button>
</label>
<small class="input-hint toggle-hint">Keep enabled for admin fallback access</small>
<small class="input-hint toggle-hint">
Always on for now. Local accounts are the only way in until SAML
sign-in ships, so this cannot be turned off yet.
</small>
</div>
<div class="setting-row">
@@ -148,15 +162,20 @@ const {
// Per-page connection status (stays with the page, not the composable).
const samlStatus = computed(() => {
if (!settings.saml_enabled) return 'inactive'
if (!settings.saml_idp_metadata_url || !settings.saml_entity_id) return 'warning'
// 'pending' rather than a healthy state even when every field is filled in:
// nothing is connected until the feature exists.
return 'pending'
})
// Never says "available". It used to report "Configured - SAML login
// available" as soon as two fields held text, which is a claim about a feature
// that does not exist: an administrator could reasonably read that as SSO
// being live and stop issuing local accounts.
const samlMessage = computed(() => {
if (!settings.saml_enabled) return 'Disabled - using local authentication only'
if (!settings.saml_idp_metadata_url) return 'IdP metadata URL not configured'
if (!settings.saml_entity_id) return 'Entity ID not configured'
return 'Configured - SAML login available'
if (!settings.saml_enabled) return 'Off - local accounts only'
if (!settings.saml_idp_metadata_url) return 'IdP metadata URL not set'
if (!settings.saml_entity_id) return 'Entity ID not set'
return 'Settings saved - waiting on SAML support, local accounts still in use'
})
onMounted(loadSettings)

View File

@@ -0,0 +1,95 @@
"""Merge the second audit-retention setting into the one that is actually read.
Two keys existed for one setting. shopdb/core/services/auditretention.py reads
`audit_retentiondays` (seeded under category 'site', default 0). A second key
`audit_retention_days` was seeded under category 'audit' with a default of 90,
and the Audit & Logging settings page wrote to that one - so an administrator
set a retention period, reloaded, saw it persisted, and nothing ever consulted
it. Retention stayed off while the page said otherwise.
The page now writes the key that is read, and the duplicate is out of the seed
list. This handles the sites that already have it: if an operator set a real
value on the dead key and never set the live one, that intent is carried over
rather than dropped. Their intent was "keep 30 days", and the fix should not
silently discard it.
Not carried over when the live key already holds a non-default value - that one
was set through the page that worked, and it wins.
The dead key is then deleted. Leaving it would preserve the ambiguity that
caused this, and nothing reads it.
Revision ID: 7d39_audit_retention_key_merge
Revises: 7d38_widen_importbatch_status
"""
from alembic import op
import sqlalchemy as sa
revision = '7d39_audit_retention_key_merge'
down_revision = '7d38_widen_importbatch_status'
branch_labels = None
depends_on = None
DEAD = 'audit_retention_days'
LIVE = 'audit_retentiondays'
def _value(bind, key):
return bind.execute(
sa.text('SELECT value FROM settings WHERE `key` = :key'
if bind.dialect.name == 'mysql'
else 'SELECT value FROM settings WHERE "key" = :key'),
{'key': key}).scalar()
def upgrade():
bind = op.get_bind()
if not sa.inspect(bind).has_table('settings'):
return
dead = _value(bind, DEAD)
if dead is None:
return
live = _value(bind, LIVE)
# '90' was the dead key's seeded default, so it is not evidence of intent.
operator_set_dead = dead not in (None, '', '90')
live_is_default = live in (None, '', '0')
if operator_set_dead and live_is_default:
if live is None:
bind.execute(sa.text(
'INSERT INTO settings (`key`, value, valuetype, category, '
'description, createddate, modifieddate, isactive) VALUES '
'(:key, :value, :valuetype, :category, :description, '
'NOW(), NOW(), 1)'
if bind.dialect.name == 'mysql' else
'INSERT INTO settings ("key", value, valuetype, category, '
'description, createddate, modifieddate, isactive) VALUES '
'(:key, :value, :valuetype, :category, :description, '
"datetime('now'), datetime('now'), 1)"),
{'key': LIVE, 'value': dead, 'valuetype': 'integer',
'category': 'site',
'description': 'Delete audit log entries older than this many '
'days. 0 keeps everything forever.'})
else:
bind.execute(sa.text(
'UPDATE settings SET value = :value WHERE `key` = :key'
if bind.dialect.name == 'mysql' else
'UPDATE settings SET value = :value WHERE "key" = :key'),
{'value': dead, 'key': LIVE})
bind.execute(sa.text(
'DELETE FROM settings WHERE `key` = :key'
if bind.dialect.name == 'mysql' else
'DELETE FROM settings WHERE "key" = :key'), {'key': DEAD})
def downgrade():
"""Deliberately a no-op.
Re-creating a key that nothing reads, to sit beside the one that works,
would restore the defect. The live setting keeps whatever value it holds.
"""

View File

@@ -0,0 +1,73 @@
"""Store a maintenance date as a DATE, because that is what it is.
lastmaintenancedate and nextmaintenancedate were DATETIME columns holding
midnight. BaseModel.to_dict serialises a datetime as isoformat + 'Z', so
2026-12-15 00:00:00 went out as "2026-12-15T00:00:00Z" and a browser west of
UTC rendered it as 14 December. The maintenance panel showed every date one
calendar day early on this site, and would show it a day LATE east of UTC.
There is no time in a maintenance date. Nobody records that a machine was
serviced at midnight; the form offers a date picker, the CSV importer takes
'2026-06-01', and the value is compared against other dates. Every sibling
already models it correctly - measuringtools.lastcalibrationdate,
warranties.startdate and enddate are all db.Date - so this is machines being
the odd one out rather than a new decision.
As a Date, to_dict emits "2026-12-15" with no timezone marker at all, and
nothing can shift it.
MySQL truncates the (midnight) time component on the ALTER, so no data moves.
SQLite stores what it is given and does not enforce column types, so the
conversion is a no-op there and is skipped.
Revision ID: 7d40_machine_maintenance_dates
Revises: 7d39_audit_retention_key_merge
"""
from alembic import op
import sqlalchemy as sa
revision = '7d40_machine_maintenance_dates'
down_revision = '7d39_audit_retention_key_merge'
branch_labels = None
depends_on = None
COLUMNS = ('lastmaintenancedate', 'nextmaintenancedate')
def _machines_table(bind):
inspector = sa.inspect(bind)
if 'machines' not in inspector.get_table_names():
return None
return {column['name'] for column in inspector.get_columns('machines')}
def upgrade():
bind = op.get_bind()
if bind.dialect.name == 'sqlite':
return
present = _machines_table(bind)
if not present:
return
for column in COLUMNS:
if column in present:
op.alter_column('machines', column,
existing_type=sa.DateTime(),
type_=sa.Date(),
existing_nullable=True)
def downgrade():
bind = op.get_bind()
if bind.dialect.name == 'sqlite':
return
present = _machines_table(bind)
if not present:
return
for column in COLUMNS:
if column in present:
op.alter_column('machines', column,
existing_type=sa.Date(),
type_=sa.DateTime(),
existing_nullable=True)

View File

@@ -0,0 +1,56 @@
"""Revoke a guest grant that never did anything, before it locks the role.
tools.secrets was declared guest-grantable, so the role grid offered it and
stored it - and the secure-send route never consulted the guest ROLE at all. It
decides on the securesend_allow_guests setting, which fails closed and carries
the rate limit. A site that ticked the box got no change in behaviour, in either
direction.
The plugin no longer declares it grantable. That alone would repeat the deadlock
fixed in 723a4ff: update_role refuses the WHOLE request when a submitted
permission is not grantable, and the role grid resubmits what the role already
holds - so a site whose guest role holds tools.secrets could no longer save the
guest role at all, including to change something else entirely.
So the grant is removed here, from the one role that could hold it. Nothing is
lost: it granted nothing. Guest secret-link creation stays exactly where it was,
under securesend_allow_guests.
Only the GUEST role is touched. A signed-in role holding tools.secrets is
unaffected - that permission works normally for an account.
Revision ID: 7d41_drop_guest_tools_secrets
Revises: 7d40_machine_maintenance_dates
"""
from alembic import op
import sqlalchemy as sa
revision = '7d41_drop_guest_tools_secrets'
down_revision = '7d40_machine_maintenance_dates'
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
inspector = sa.inspect(bind)
tables = set(inspector.get_table_names())
if not {'roles', 'permissions', 'rolepermissions'} <= tables:
return
bind.execute(sa.text(
'DELETE FROM rolepermissions WHERE roleid IN '
'(SELECT roleid FROM roles WHERE rolename = :guest) '
'AND permissionid IN '
'(SELECT permissionid FROM permissions WHERE name = :permission)'),
{'guest': 'guest', 'permission': 'tools.secrets'})
def downgrade():
"""Deliberately a no-op.
Re-granting a permission that nothing reads would restore the confusion
rather than any capability.
"""

View File

@@ -116,13 +116,25 @@ def asset_revisions(assetid):
revisions = query.order_by(BackupRevision.backuprevisionid.desc()).all()
# "Latest" is per kind, not per asset, so an unfiltered listing still marks
# the current revision of each kind correctly.
# "Latest" is per kind AND per source chain. Per kind alone was wrong on
# the case this exists for: NTLARS settings describe a machine but are read
# off the controlling PC, so a machine with two reporting PCs has two
# independent chains of the same kind. Marking only the highest revision id
# meant the second PC's current revision was badged as history and lost its
# "last verified" stamp - the machine looked like it had one source that
# kept re-reporting, rather than two.
#
# sourcehostname is the chain: it records which PC produced the revision.
# A revision with none (an older row, or a share-drop with no host) forms
# its own chain under the empty key, which keeps the per-kind behaviour for
# everything that has only ever had one source.
seen = set()
out = []
for revision in revisions:
islatest = revision.backupkind not in seen
seen.add(revision.backupkind)
chain = (revision.backupkind,
(revision.sourcehostname or '').strip().lower())
islatest = chain not in seen
seen.add(chain)
out.append(_summary(revision, islatest=islatest))
return success_response(out)
@@ -297,6 +309,23 @@ def diff_revision(backuprevisionid):
againstid = request.args.get('against', type=int)
if againstid:
other = db.session.get(BackupRevision, againstid)
# It has to belong to the SAME asset. Any revision id was accepted, so
# ?against=<another asset's revision> rendered that asset's
# configuration values as the "before" side of this asset's history -
# a diff that reads as a change on this machine and describes a
# different one. The default branch below has always been asset-scoped;
# only the explicit one was not.
if other is not None and other.assetid != revision.assetid:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Revision {} belongs to a different asset and cannot be '
'compared against this one'.format(againstid))
if other is not None and other.backupkind != revision.backupkind:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Revision {} is a {} backup and this one is {} - there is '
'nothing to compare'.format(againstid, other.backupkind,
revision.backupkind))
else:
# The previous revision FROM THE SAME PC. Several PCs can share a
# machine number, and diffing across them reported one device's COM port

View File

@@ -7,7 +7,7 @@ from shopdb.api import db, Asset, AssetType, OperatingSystem, Application, Audit
from ..models import Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess
from shopdb.api import require_permission, require_role, apply_import_timestamps
from shopdb.api import require_permission, require_role, apply_import_timestamps, intarg
computers_bp = Blueprint('computers', __name__)
@@ -258,13 +258,42 @@ def _computer_access_links(comp, domain=None):
return links
def _ip_comtype():
"""The 'IP' communication type, created if this site has none.
Both the create and the update path did `if ip_comtype:` and silently did
nothing when the row was missing - so a person typed an address, saved, got
a 200, and the address was gone. A missing reference row is a seeding gap,
not a reason to discard what somebody typed.
"""
comtype = CommunicationType.query.filter_by(comtype='IP').first()
if comtype is None:
comtype = CommunicationType(comtype='IP', description='IP address')
db.session.add(comtype)
db.session.flush()
return comtype
def _sync_access_methods(comp, data):
"""Replace a computer's enabled protocols from data['accessmethods'] (a list
of {protocolid, portoverride?}). No-op if the key is absent, so callers that
don't touch access aren't affected."""
don't touch access aren't affected.
Returns a {'old': [...], 'new': [...]} change entry when the set actually
moved, or None. The caller records it: which remote-access protocols reach
a shop-floor PC, and on which ports, is exactly the kind of change an audit
log exists to remember, and this wrote nothing at all.
"""
if 'accessmethods' not in data:
return
return None
desired = data.get('accessmethods') or []
def describe(rows):
return sorted('%s:%s' % (row.protocolid, row.portoverride or 'default')
for row in rows)
before = describe(ComputerAccess.query.filter_by(
computerid=comp.computerid).all())
ComputerAccess.query.filter_by(computerid=comp.computerid).delete()
seen = set()
for m in desired:
@@ -287,6 +316,13 @@ def _sync_access_methods(comp, data):
isactive=True,
))
db.session.flush()
after = describe(ComputerAccess.query.filter_by(
computerid=comp.computerid).all())
if before != after:
return {'old': before, 'new': after}
return None
# =============================================================================
# Computers CRUD
@@ -380,20 +416,20 @@ def list_computers():
)
# Computer type filter
if type_id := request.args.get('typeid', request.args.get('type_id')):
query = query.filter(Computer.computertypeid == int(type_id))
if (type_id := intarg('typeid', intarg('type_id'))) is not None:
query = query.filter(Computer.computertypeid == type_id)
# OS filter
if os_id := request.args.get('osid', request.args.get('os_id')):
query = query.filter(Computer.osid == int(os_id))
if (os_id := intarg('osid', intarg('os_id'))) is not None:
query = query.filter(Computer.osid == os_id)
# Location filter
if location_id := request.args.get('locationid', request.args.get('location_id')):
query = query.filter(Asset.locationid == int(location_id))
if (location_id := intarg('locationid', intarg('location_id'))) is not None:
query = query.filter(Asset.locationid == location_id)
# Business unit filter
if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')):
query = query.filter(Asset.businessunitid == int(bu_id))
if (bu_id := intarg('businessunitid', intarg('businessunit_id'))) is not None:
query = query.filter(Asset.businessunitid == bu_id)
# Shopfloor filter (by the Shopfloor computer type)
if shopfloor := request.args.get('shopfloor'):
@@ -587,7 +623,7 @@ def create_computer():
# Optional primary IP communication
if data.get('ipaddress'):
ip_comtype = CommunicationType.query.filter_by(comtype='IP').first()
ip_comtype = _ip_comtype()
if ip_comtype:
db.session.add(Communication(
assetid=asset.assetid,
@@ -596,7 +632,9 @@ def create_computer():
isprimary=True,
))
# Remote-access protocols
# Remote-access protocols. The return value is not recorded here: a create
# is already described in full by its 'created' entry, and there is no
# previous state to compare against.
_sync_access_methods(comp, data)
# Preserve legacy timestamps in import mode (no-op otherwise)
@@ -697,20 +735,27 @@ def update_computer(computer_id: int):
ip = (data.get('ipaddress') or '').strip()
primary = Communication.query.filter_by(
assetid=asset.assetid, isprimary=True).first()
# Recorded, like every other field on this form. The address a PC
# answers on is how somebody finds it later; changing it wrote no audit
# entry at all, so a machine that moved had no history of moving.
oldip = primary.ipaddress if primary else None
if (oldip or None) != (ip or None):
changes['ipaddress'] = {'old': oldip, 'new': ip or None}
if ip:
if primary:
primary.ipaddress = ip
else:
ip_comtype = CommunicationType.query.filter_by(comtype='IP').first()
if ip_comtype:
db.session.add(Communication(
assetid=asset.assetid, comtypeid=ip_comtype.comtypeid,
ipaddress=ip, isprimary=True))
db.session.add(Communication(
assetid=asset.assetid,
comtypeid=_ip_comtype().comtypeid,
ipaddress=ip, isprimary=True))
elif primary:
primary.ipaddress = None
# Remote-access protocols
_sync_access_methods(comp, data)
accesschange = _sync_access_methods(comp, data)
if accesschange:
changes['accessmethods'] = accesschange
# Audit log if there were changes
if changes:

View File

@@ -2,7 +2,14 @@
<div>
<div class="page-header">
<h2>Site Policies</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Site Policy</button>
<button v-if="!featureOff" class="btn btn-primary" @click="openModal()">+ Add Site Policy</button>
</div>
<div v-if="featureOff" class="card feature-off-notice">
<strong>Site policies are not enabled on this site.</strong>
<p>Nothing can be created here until the feature is turned on.
The list below is empty because the feature is off, not
because nothing has been configured.</p>
</div>
<div class="card">
@@ -110,6 +117,7 @@ const toast = useToast()
const policies = ref([])
const configPolicies = ref([])
const loading = ref(true)
const featureOff = ref(false)
const showModal = ref(false)
const editing = ref(null)
@@ -135,6 +143,10 @@ async function loadData() {
configPolicies.value = config.data.data || []
} catch (err) {
console.error('Error loading business policies:', err)
// The API answers 404 'Policy feature is not enabled' when the
// flag is off. Without this the page rendered its empty table
// and an Add button for a feature that cannot accept one.
featureOff.value = err?.response?.status === 404
} finally {
loading.value = false
}
@@ -214,6 +226,13 @@ async function remove(p) {
</script>
<style scoped>
.feature-off-notice {
border-left: 3px solid var(--warning);
margin-bottom: 1rem;
}
.feature-off-notice p {
margin: 0.4rem 0 0 0;
}
.item-row { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
.order-badge {
display: inline-flex; align-items: center; justify-content: center;

View File

@@ -2,7 +2,14 @@
<div>
<div class="page-header">
<h2>Configuration Policies</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Policy</button>
<button v-if="!featureOff" class="btn btn-primary" @click="openModal()">+ Add Policy</button>
</div>
<div v-if="featureOff" class="card feature-off-notice">
<strong>Configuration policies are not enabled on this site.</strong>
<p>Nothing can be created here until the feature is turned on. The
list is empty because the feature is off, not because nothing
has been configured.</p>
</div>
<div class="card">
@@ -123,6 +130,7 @@ const apps = ref([])
const actionCatalog = ref([])
const monitors = ref([])
const loading = ref(true)
const featureOff = ref(false)
const showModal = ref(false)
const editing = ref(null)
@@ -145,8 +153,8 @@ async function loadData() {
// 500'd, which looked like data loss.
const [pol, types, appList, actions, monitorList] = await Promise.allSettled([
computersApi.policies.configuration.list(),
computersApi.types.list({ perpage: 200 }),
applicationsApi.listAll({ perpage: 200 }),
computersApi.types.listAll(),
applicationsApi.listAll(),
computersApi.policies.actions(),
endpointsApi.monitors.list()
])
@@ -155,8 +163,13 @@ async function loadData() {
if (pol.status === 'fulfilled') {
policies.value = pol.value.data.data || []
featureOff.value = false
} else {
failures.push('policies')
// The API answers 404 'Policy feature is not enabled' when the flag is
// off. Without telling them apart the page rendered an empty table and
// an Add button for a feature that cannot accept one.
featureOff.value = pol.reason?.response?.status === 404
if (!featureOff.value) failures.push('policies')
console.error('Error loading policies:', pol.reason)
}
@@ -284,6 +297,13 @@ async function remove(p) {
</script>
<style scoped>
.feature-off-notice {
border-left: 3px solid var(--warning);
margin-bottom: 1rem;
}
.feature-off-notice p {
margin: 0.4rem 0 0 0;
}
.modal-lg {
max-width: 760px;
}

View File

@@ -10,11 +10,21 @@
<div class="header-actions">
<router-link :to="`/print/asset-label/computer/${$route.params.id}`" class="btn btn-secondary" target="_blank">Print Label</router-link>
<router-link :to="`/pcs/${$route.params.id}/edit${fromQuery}`" class="btn btn-primary">Edit</router-link>
<button @click="confirmRetire" class="btn btn-danger">Retire</button>
<button v-if="!isRetired" @click="confirmRetire" class="btn btn-danger">Retire</button>
<!-- A retired record offered Edit and Retire exactly like a live
one, and nothing said it was retired. Restoring it was possible
through the API and nowhere in the product. -->
<button v-if="isRetired" @click="restore" class="btn btn-primary">Restore</button>
<router-link :to="returnTo" class="btn btn-secondary">{{ returnLabel }}</router-link>
</div>
</div>
<div v-if="computer && computer.isactive === false" class="retired-banner card">
<strong>This PC is retired.</strong>
It stays out of every list and report, and its history and relationships
are intact. Restore puts it back.
</div>
<div v-if="loading" class="loading">Loading...</div>
<template v-else-if="computer">
@@ -644,6 +654,21 @@ async function confirmRestart() {
// Retire, not delete: the server deactivates the asset, so the PC
// leaves every list and report while its history and relationships stay put.
// Reversible by an admin setting isactive back to true.
const isRetired = computed(() => computer.value?.isactive === false)
async function restore() {
const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
try {
await computersApi.update(route.params.id, { isactive: true })
toast.success(`Restored ${label}`)
const response = await computersApi.get(route.params.id)
computer.value = response.data.data
} catch (error) {
console.error('Error restoring:', error)
toast.error(apiError(error, 'Failed to restore'))
}
}
async function confirmRetire() {
const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
@@ -660,6 +685,10 @@ async function confirmRetire() {
</script>
<style scoped>
.retired-banner {
border-left: 3px solid var(--warning);
margin-bottom: 1rem;
}
.portal-link {
font-size: 0.78rem;
margin-left: 0.5rem;

View File

@@ -143,6 +143,10 @@
v-model="form.locationid"
class="form-control"
>
<!-- Blank means "no location of its own", which is a real state:
the PC then follows whatever it is attached to. The hint
below says where that currently resolves to, so leaving the
box empty is an informed choice rather than a missing one. -->
<option value="">Select location...</option>
<option
v-for="l in locations"
@@ -152,6 +156,33 @@
{{ l.locationname }}
</option>
</select>
<small v-if="!form.locationid && inheritedLocation" class="input-hint">
No location of its own. It currently shows
<strong>{{ inheritedLocation.locationname }}</strong>, inherited
from {{ inheritedLocation.inheritedfrom }}. Pick one here only to
override that.
</small>
</div>
<div class="form-group">
<!-- The PC detail page has always rendered a Business Unit row,
and the API has always accepted the field - only the form had
no control, so it could be seen and never set from the UI. -->
<label for="businessunitid">Business Unit</label>
<select
id="businessunitid"
v-model="form.businessunitid"
class="form-control"
>
<option value="">Select business unit...</option>
<option
v-for="bu in businessunits"
:key="bu.businessunitid"
:value="bu.businessunitid"
>
{{ bu.businessunit }}
</option>
</select>
</div>
</div>
@@ -364,7 +395,7 @@
import { ref, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { computersApi, assetsApi, vendorsApi, locationsApi, modelsApi, operatingsystemsApi,
printersApi, relationshipTypesApi } from '@/api'
printersApi, relationshipTypesApi, businessunitsApi } from '@/api'
import ShopFloorMap from '@/components/ShopFloorMap.vue'
import { loadMapConfig, levelOptions, levelName, state as mapConfig }
from '@/composables/mapConfig'
@@ -373,6 +404,7 @@ import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
import PrinterAssignmentPicker from '@/components/PrinterAssignmentPicker.vue'
import { currentTheme } from '@/stores/theme'
import { useIdentifierFlags } from '@/composables/identifierSettings'
import { loadAssetTypeIds, assetTypeId } from '@/composables/assetTypeIds'
import { isPluginEnabled, loadEnabledPlugins } from '@/composables/enabledPlugins'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
@@ -390,13 +422,20 @@ const isEdit = computed(() => !!route.params.id)
// falls back to the PCs list.
const returnTo = computed(() => (route.query.from === 'fleet' ? '/endpoints' : '/pcs'))
// Seeded asset-type id for computers (see /api/assets/types). Custom-field
// values are keyed by the underlying asset id, captured on load / create.
const COMPUTER_ASSETTYPEID = 2
// Resolved by NAME at load, never hardcoded: the id comes from seed
// order, so the constant that used to live here pointed at a different
// asset type on a differently-seeded site - the form then rendered THAT
// type's custom fields and dropped whatever was typed into them.
const COMPUTER_ASSETTYPEID = computed(() => assetTypeId('computer'))
const customFieldsRef = ref(null)
const printerPickerRef = ref(null)
const currentAssetId = ref(null)
// What this PC resolves to today when it owns no location, captured at load.
// Read-only: the form must never post it back as the record's own, which is
// exactly what binding the location select to the merged `locationid` did.
const inheritedLocation = ref(null)
// PC Number (assetnumber) defaults to the serial number while the user hasn't
// typed their own. Editable; only auto-fills on a new PC.
const manualPcNumber = ref(false)
@@ -470,6 +509,7 @@ function setProtocolPort(protocolid, value) {
const vendors = ref([])
const models = ref([])
const locations = ref([])
const businessunits = ref([])
const operatingsystems = ref([])
@@ -519,17 +559,20 @@ const filteredVendors = computed(() => {
})
onMounted(async () => {
await loadAssetTypeIds()
try {
// Load reference data
// perpage 100 so dropdowns aren't truncated to the default 20-row page
const [ptRes, statusRes, vendorRes, allModels, locRes, osRes, protoRes] = await Promise.all([
const [ptRes, statusRes, vendorRes, allModels, locRes, osRes, protoRes,
allBusinessUnits] = await Promise.all([
computersApi.types.list({ perpage: 100 }),
assetsApi.statuses.list(),
vendorsApi.list({ perpage: 100 }),
vendorsApi.listAll(),
modelsApi.listAll({ category: 'PC' }), // backend caps perpage at 100; page through all
locationsApi.listAll(), // backend caps perpage at 100; page through all
operatingsystemsApi.list({ perpage: 100 }),
computersApi.protocols.list(),
businessunitsApi.listAll(),
// Handles its own failure: a site without printers still gets a form.
])
@@ -540,6 +583,7 @@ onMounted(async () => {
locations.value = locRes || []
operatingsystems.value = osRes.data.data || []
protocols.value = protoRes.data.data || []
businessunits.value = allBusinessUnits || []
// Load PC if editing (asset-based shape: extension under pc.computer)
if (isEdit.value) {
@@ -550,6 +594,7 @@ onMounted(async () => {
const primaryComm = pc.communications?.find(c => c.isprimary) || pc.communications?.[0]
inheritedLocation.value = pc.inheritedlocation || null
form.value = {
machinenumber: pc.assetnumber || '',
alias: pc.name && pc.name.toUpperCase() !== 'NONE' ? pc.name : '',
@@ -561,7 +606,12 @@ onMounted(async () => {
statusid: pc.statusid || '',
vendorid: ext.vendorid || '',
modelnumberid: ext.modelnumberid || '',
locationid: pc.locationid || '',
// The OWN location, not the resolved one. Binding to `locationid`
// meant the form pre-selected a location this PC had inherited from a
// machine, and saving wrote it back as the PC's own - so an edit that
// never touched location silently stopped the PC following its parent.
locationid: pc.ownlocationid || '',
businessunitid: pc.businessunitid || '',
osid: ext.osid || '',
loggedinuser: ext.loggedinuser || '',
azuredeviceid: ext.azuredeviceid || '',
@@ -572,9 +622,9 @@ onMounted(async () => {
portoverride: a.portoverride ?? null
})),
notes: pc.notes || '',
mapx: pc.mapx ?? null,
mapy: pc.mapy ?? null,
levelid: pc.levelid ?? null,
mapx: pc.ownmapx ?? null,
mapy: pc.ownmapy ?? null,
levelid: pc.ownlevelid ?? null,
ipaddress: primaryComm?.ipaddress || ''
}
@@ -640,6 +690,7 @@ async function savePC() {
vendorid: form.value.vendorid || null,
modelnumberid: form.value.modelnumberid || null,
locationid: form.value.locationid || null,
businessunitid: form.value.businessunitid || null,
osid: form.value.osid || null,
loggedinuser: form.value.loggedinuser || null,
azuredeviceid: form.value.azuredeviceid || null,
@@ -649,10 +700,22 @@ async function savePC() {
notes: form.value.notes || null,
ipaddress: form.value.ipaddress || null,
mapx: form.value.mapx,
mapy: form.value.mapy
mapy: form.value.mapy,
// ADR-017: a position without its level cannot be drawn on the right
// floor plan. The picker sets all three and this payload sent two, so a
// picked pin saved a levelid of null and then rendered nowhere - the
// form even showed "Position: x, y" back, because it kept the value it
// had never sent.
levelid: form.value.levelid
}
// only set display name when an alias is given, so we don't clobber it
if (form.value.alias) {
// An emptied alias has to be SENT, or it cannot be cleared: the payload
// simply omitted `name`, the old value survived, and the form came back
// showing the alias the operator had just deleted, with no error and no
// toast. On CREATE the field is still omitted when blank, because there
// the server may fill it in and sending null would overwrite that.
if (isEdit.value) {
payload.name = form.value.alias || null
} else if (form.value.alias) {
payload.name = form.value.alias
}

View File

@@ -107,7 +107,7 @@ onMounted(() => loadData())
async function loadData() {
loading.value = true
try {
const response = await computersApi.types.list({ perpage: 200, active: false })
const response = await computersApi.types.listAll({ active: false })
pcTypes.value = response.data.data || []
} catch (err) {
console.error('Error loading PC types:', err)

View File

@@ -15,7 +15,14 @@
placeholder="Search computers..."
@input="debouncedSearch"
/>
</div>
<!-- Retire is a soft delete and the confirm dialog promises the record
"can be restored". The API kept that promise (?active=false lists
them, PUT isactive restores); nothing in the UI could reach it. -->
<label class="checkbox-label">
<input type="checkbox" v-model="showRetired" @change="loadComputers" />
Show retired
</label>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
@@ -65,7 +72,15 @@
</tr>
<tr v-if="computers.length === 0">
<td colspan="8" style="text-align: center; color: var(--text-light);">
No computers found
<!-- A 403 is not an empty result. Rendering "No computers
found" to somebody who simply may not see them tells
them the fleet is empty, and they go looking for the
data rather than for the permission. -->
<template v-if="denied">
You do not have permission to view computers. Ask an
administrator for the computers.view permission.
</template>
<template v-else>No computers found</template>
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
</td>
</tr>
@@ -106,6 +121,9 @@ function applySuggestion(word) {
setSearch(word)
loadComputers()
}
const showRetired = ref(false)
// Told apart from an empty list: see the table's empty row.
const denied = ref(false)
const totalPages = ref(1)
const perPage = ref(20)
@@ -123,6 +141,8 @@ async function loadComputers() {
perpage: perPage.value
}
if (search.value) params.search = search.value
// active=false means "include retired", matching the API.
if (showRetired.value) params.active = 'false'
const response = await computersApi.list(params)
computers.value = response.data.data || []
@@ -130,6 +150,7 @@ async function loadComputers() {
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading computers:', error)
denied.value = error?.response?.status === 403
} finally {
loading.value = false
}

View File

@@ -25,7 +25,7 @@ from shopdb.api import (
employee_connection,
require_role,
)
from shopdb.api import Setting
from shopdb.api import Setting, intarg
from shopdb.api import send_upload
from ..models import DirectoryEmployee
@@ -287,7 +287,7 @@ def search_employees():
- limit: Max results (default 10)
"""
query = request.args.get('q', '').strip()
limit = min(int(request.args.get('limit', 10)), 50)
limit = min(intarg('limit', 10), 50)
if len(query) < 2:
return error_response(
@@ -295,12 +295,31 @@ def search_employees():
'Search query must be at least 2 characters'
)
# A multi-word query is a FULL NAME. Each word was matched against first
# name or last name on its own, so "Dana Reyes" found nobody: no single
# column contains that string. Somebody typing the whole name - which is
# what a person does before pressing Enter, rather than picking from the
# list - got an empty result and their entry became free text linked to no
# employee record.
words = query.split()
if _selfhosted():
term = f'%{query}%'
rows = (DirectoryEmployee.query
.filter(db.or_(DirectoryEmployee.firstname.ilike(term),
DirectoryEmployee.lastname.ilike(term),
db.cast(DirectoryEmployee.sso, db.String).ilike(term)))
rows = DirectoryEmployee.query
if len(words) > 1:
# Every word has to appear in the name, in either column, so
# "Reyes Dana" works as well as "Dana Reyes".
for word in words:
term = f'%{word}%'
rows = rows.filter(db.or_(
DirectoryEmployee.firstname.ilike(term),
DirectoryEmployee.lastname.ilike(term)))
else:
term = f'%{query}%'
rows = rows.filter(db.or_(
DirectoryEmployee.firstname.ilike(term),
DirectoryEmployee.lastname.ilike(term),
db.cast(DirectoryEmployee.sso, db.String).ilike(term)))
rows = (rows
.order_by(DirectoryEmployee.lastname, DirectoryEmployee.firstname)
.limit(limit).all())
return success_response([_with_photo_url(e.to_dict()) for e in rows])
@@ -308,15 +327,32 @@ def search_employees():
try:
conn = employee_connection()
with conn.cursor() as cur:
cur.execute(f'''
SELECT {_FIELDS}
FROM employees
WHERE First_Name LIKE %s
OR Last_Name LIKE %s
OR CAST(SSO AS CHAR) LIKE %s
ORDER BY Last_Name, First_Name
LIMIT %s
''', (f'%{query}%', f'%{query}%', f'%{query}%', limit))
if len(words) > 1:
# Same rule as the self-hosted branch: every word must appear
# somewhere in the name, so a full name finds the person.
clauses = ' AND '.join(
['(First_Name LIKE %s OR Last_Name LIKE %s)'] * len(words))
params = []
for word in words:
params.extend([f'%{word}%', f'%{word}%'])
params.append(limit)
cur.execute(f'''
SELECT {_FIELDS}
FROM employees
WHERE {clauses}
ORDER BY Last_Name, First_Name
LIMIT %s
''', tuple(params))
else:
cur.execute(f'''
SELECT {_FIELDS}
FROM employees
WHERE First_Name LIKE %s
OR Last_Name LIKE %s
OR CAST(SSO AS CHAR) LIKE %s
ORDER BY Last_Name, First_Name
LIMIT %s
''', (f'%{query}%', f'%{query}%', f'%{query}%', limit))
employees = cur.fetchall()
conn.close()
return success_response([_with_photo_url(e) for e in employees])

View File

@@ -1376,8 +1376,13 @@ def enqueue_endpoint_command(asset_id: int):
# Destructive actions (for example system_restart) demand a stricter grant
# ON TOP OF endpoints.command, so a remote reboot cannot ride the routine
# toggle permission.
# caller_holds, not current_user.haspermission: the question is what THIS
# caller may do, and a scoped personal token is a narrower caller than its
# owner. Asking the owner let a token scoped to endpoints.command queue a
# remote restart, a script run and an app deploy - the exact bypass
# require_permission exists to prevent, reintroduced by checking by hand.
elevated = elevated_permission_for(action)
if elevated and not current_user.haspermission(elevated):
if elevated and not caller_holds(elevated):
return error_response(
ErrorCodes.FORBIDDEN,
f'Action {action!r} requires the {elevated} permission',

View File

@@ -15,7 +15,7 @@ and ShopDB, the same discipline the backups collector uses for machine numbers.
import json
import logging
from datetime import datetime
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Optional, Type
@@ -56,18 +56,35 @@ KNOWN_PAYLOAD_KEYS = frozenset({
def _parse_dt(value):
"""Best-effort parse of an ISO-8601 string to naive UTC, or None."""
"""Best-effort parse of an ISO-8601 string to naive UTC, or None.
astimezone(tz=None) converts to the SERVER's local zone, not to UTC, so an
agent reporting 17:26:16Z was stored as 13:26:16 on an EDT server and then
re-served labelled 'Z'. The same row's server-written lastreportedat stayed
true UTC, so one row carried two time bases four hours apart, and the
dashboard's 48-hour stability window - which compares a real UTC cutoff
against these values - dropped events four hours early.
Invisible on a UTC server, which is why it survived: nothing in the
Dockerfile, the compose files or the Windows deploy pins TZ, so whether the
data is right depends on the host's clock settings.
AGENT-CONTRACT.md mandates UTC on the wire. A NAIVE value is therefore
taken as UTC already, rather than guessed at.
"""
if not value:
return None
if isinstance(value, datetime):
return value.replace(tzinfo=None)
if value.tzinfo is not None:
return value.astimezone(timezone.utc).replace(tzinfo=None)
return value
text = str(value).strip().replace('Z', '+00:00')
try:
parsed = datetime.fromisoformat(text)
except ValueError:
return None
if parsed.tzinfo is not None:
parsed = parsed.astimezone(tz=None).replace(tzinfo=None)
parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
return parsed

View File

@@ -454,6 +454,15 @@ def create_scope():
if not scopename:
return error_response(ErrorCodes.VALIDATION_ERROR,
'scopename is required', http_code=400)
# The name becomes a directory on the imaging share. Refused here as well
# as at the write, so a bad name never reaches the database in the first
# place - see valid_scopename for what a traversing name did.
if not service.valid_scopename(scopename):
return error_response(
ErrorCodes.VALIDATION_ERROR,
'scopename may contain only letters, digits, dot, dash and '
'underscore, and must start with a letter or digit',
http_code=400)
if phase not in PHASES:
return error_response(ErrorCodes.VALIDATION_ERROR,
f'phase must be one of {PHASES}', http_code=400)

View File

@@ -10,6 +10,7 @@ Kept out of the CLI and routes so both share one implementation:
import hashlib
import json
import os
import re
import tempfile
from datetime import datetime, timezone
@@ -532,6 +533,30 @@ def store_inline_payload(entry, filename, contenttype, rawbytes):
return payload
# A scope name becomes a DIRECTORY on the imaging share, so it has to be a
# single safe path segment. It was validated only as "not empty", which meant a
# name like "../escape" or an absolute path wrote the manifest outside the
# share root - proven by writing /tmp/manifest.json and files one and two
# levels above it. The real exposure is the permission split: geenforce.manage
# creates the scope, geenforce.publish writes the file, so a manage-only author
# could aim a publisher's write anywhere that process can reach.
#
# Allowed: letters, digits, dot, dash, underscore. Not '.' or '..', and no
# separator of either platform - the share is written from Linux and read by
# Windows, so a backslash is a separator too.
SCOPENAME_PATTERN = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]*$')
def valid_scopename(scopename):
"""True when this name is safe to use as a directory on the share."""
name = (scopename or '').strip()
if not name or name in ('.', '..') or len(name) > 100:
return False
if '/' in name or '\\' in name or '\x00' in name:
return False
return bool(SCOPENAME_PATTERN.match(name))
def export_scope_to_share(scopename, phase, shareroot):
"""Write a scope's current published JSON to the share, backing up the old
file to _meta/history first. Returns the written path."""
@@ -546,7 +571,21 @@ def export_scope_to_share(scopename, phase, shareroot):
if phase == 'preinstall':
target = os.path.join(shareroot, 'preinstall.json')
else:
# Checked again here, not only at creation: rows predating the
# validation exist, and this is the line that actually writes.
if not valid_scopename(scopename):
raise ValueError(
f'Refusing to export scope {scopename!r}: the name is not a '
f'safe directory name')
target = os.path.join(shareroot, scopename, 'manifest.json')
# Belt and braces: whatever the name, the resolved target must sit inside
# the share root.
root = os.path.realpath(shareroot)
if os.path.commonpath([root, os.path.realpath(os.path.dirname(target))]) != root:
raise ValueError(
f'Refusing to export scope {scopename!r}: it resolves outside the '
f'share root')
os.makedirs(os.path.dirname(target), exist_ok=True)
if os.path.isfile(target):

View File

@@ -503,8 +503,19 @@ def restore_revision(guideid, revisionid):
'You do not have permission to edit this guide',
http_code=403)
before = guide.revision
authoring.restore(guide, revision, userid=_userid())
db.session.commit()
# save() dedups on the content hash, so restoring the version that is
# already current writes nothing. Saying "Restored version N as a new draft
# version" in that case describes a revision that does not exist - and the
# revision list, unchanged, quietly disagrees with the message.
if guide.revision == before:
return success_response(
guide.to_dict(includeblocks=True),
message='Version {} is already the current content, so nothing '
'changed'.format(revision.revision))
return success_response(guide.to_dict(includeblocks=True),
message='Restored version {} as a new draft version'
.format(revision.revision))

View File

@@ -4,6 +4,7 @@ from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.api import (
intarg,
db,
Application,
success_response,
@@ -16,7 +17,7 @@ from shopdb.api import (
from ..models import KnowledgeBase
from shopdb.api import require_permission, apply_import_timestamps
from shopdb.api import require_permission, apply_import_timestamps, intarg
knowledgebase_bp = Blueprint('knowledgebase', __name__)
@@ -91,8 +92,8 @@ def list_articles():
query = query.filter(_search_clause(search))
# Filter by topic/application
if appid := request.args.get('appid'):
query = query.filter(KnowledgeBase.appid == int(appid))
if (appid := intarg('appid')) is not None:
query = query.filter(KnowledgeBase.appid == appid)
# Exact-match natural-key lookups for idempotent import. linkurl is the
# stable natural key; shortdescription (the title) is offered as a fallback.

View File

@@ -7,7 +7,8 @@ from shopdb.api import db, Asset, AssetType, AuditLog, Vendor, success_response,
from ..models import Machine, MachineType
from shopdb.api import require_permission, apply_import_timestamps
from shopdb.api import (require_permission, apply_import_timestamps, intarg,
DateValueError, parse_date_fields)
machines_bp = Blueprint('machines', __name__)
@@ -197,20 +198,20 @@ def list_machines():
)
# Machine type filter
if type_id := request.args.get('typeid', request.args.get('type_id')):
query = query.filter(Machine.machinetypeid == int(type_id))
if (type_id := intarg('typeid', intarg('type_id'))) is not None:
query = query.filter(Machine.machinetypeid == type_id)
# Vendor filter
if vendor_id := request.args.get('vendorid', request.args.get('vendor_id')):
query = query.filter(Machine.vendorid == int(vendor_id))
if (vendor_id := intarg('vendorid', intarg('vendor_id'))) is not None:
query = query.filter(Machine.vendorid == vendor_id)
# Location filter
if location_id := request.args.get('locationid', request.args.get('location_id')):
query = query.filter(Asset.locationid == int(location_id))
if (location_id := intarg('locationid', intarg('location_id'))) is not None:
query = query.filter(Asset.locationid == location_id)
# Business unit filter
if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')):
query = query.filter(Asset.businessunitid == int(bu_id))
if (bu_id := intarg('businessunitid', intarg('businessunit_id'))) is not None:
query = query.filter(Asset.businessunitid == bu_id)
# Sorting
sort_by = request.args.get('sort', 'assetnumber')
@@ -336,6 +337,14 @@ def create_machine():
- mapx, mapy, notes
"""
data = request.get_json()
# Refused rather than stored raw or nulled: these are db.Date columns
# (migration 7d40), so a string would be rejected by the engine and a
# mistyped one used to become NULL further down the stack.
try:
maintenancedates = parse_date_fields(
data, ('lastmaintenancedate', 'nextmaintenancedate'))
except DateValueError as bad:
return error_response(ErrorCodes.VALIDATION_ERROR, str(bad))
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
@@ -388,8 +397,8 @@ def create_machine():
modelnumberid=data.get('modelnumberid'),
requiresmanualconfig=data.get('requiresmanualconfig', False),
islocationonly=data.get('islocationonly', False),
lastmaintenancedate=data.get('lastmaintenancedate'),
nextmaintenancedate=data.get('nextmaintenancedate'),
lastmaintenancedate=maintenancedates.get('lastmaintenancedate'),
nextmaintenancedate=maintenancedates.get('nextmaintenancedate'),
maintenanceintervaldays=data.get('maintenanceintervaldays'),
controllervendorid=data.get('controllervendorid'),
controllermodelid=data.get('controllermodelid')
@@ -427,6 +436,14 @@ def update_machine(machine_id: int):
)
data = request.get_json()
# Refused rather than stored raw or nulled: these are db.Date columns
# (migration 7d40), so a string would be rejected by the engine and a
# mistyped one used to become NULL further down the stack.
try:
maintenancedates = parse_date_fields(
data, ('lastmaintenancedate', 'nextmaintenancedate'))
except DateValueError as bad:
return error_response(ErrorCodes.VALIDATION_ERROR, str(bad))
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
@@ -471,7 +488,7 @@ def update_machine(machine_id: int):
# Update machine fields
machine_fields = ['machinetypeid', 'vendorid', 'modelnumberid',
'requiresmanualconfig', 'islocationonly',
'lastmaintenancedate', 'nextmaintenancedate', 'maintenanceintervaldays',
'maintenanceintervaldays',
'controllervendorid', 'controllermodelid']
for key in machine_fields:
if key in data:
@@ -481,6 +498,14 @@ def update_machine(machine_id: int):
changes[key] = {'old': old_val, 'new': new_val}
setattr(mach, key, data[key])
# The maintenance dates are real dates now (migration 7d40), so they are
# parsed rather than assigned as whatever string arrived - and a mistyped
# one is refused instead of stored or nulled, like every other date.
for key, parsed in maintenancedates.items():
if getattr(mach, key) != parsed:
changes[key] = {'old': getattr(mach, key), 'new': parsed}
setattr(mach, key, parsed)
# Audit log if there were changes
if changes:
AuditLog.log('updated', 'Machine', entityid=mach.machineid,
@@ -553,16 +578,22 @@ def dashboard_summary():
by_type_query = by_type_query.filter(Asset.assetid.notin_(secondaryassetids))
by_type = by_type_query.group_by(MachineType.machinetype).all()
# Count by status
# Count by status. The dualpath exclusion applies here too: it was applied
# to total and bytype and NOT to this one, so the status bars summed to
# more than the total they sit under - by exactly the number of dualpath
# secondaries. A breakdown that does not add up to its own headline is the
# kind of thing somebody spends an afternoon reconciling.
from shopdb.api import AssetStatus
by_status = db.session.query(
by_status_query = db.session.query(
AssetStatus.status,
db.func.count(Machine.machineid)
).join(Asset, Asset.assetid == Machine.assetid
).join(AssetStatus, AssetStatus.statusid == Asset.statusid
).filter(Asset.isactive == True
).group_by(AssetStatus.status
).all()
).filter(Asset.isactive == True)
if secondaryassetids:
by_status_query = by_status_query.filter(
Asset.assetid.notin_(secondaryassetids))
by_status = by_status_query.group_by(AssetStatus.status).all()
return success_response({
'total': total,

View File

@@ -9,14 +9,24 @@
<router-link :to="`/print/asset-label/machine/${machine?.machine?.machineid}`" class="btn btn-secondary" v-if="machine" target="_blank">
Print Label
</router-link>
<router-link :to="`/machines/${machine?.machine?.machineid}/edit`" class="btn btn-primary" v-if="machine">
<router-link :to="`/machines/${machine?.machine?.machineid}/edit`" class="btn btn-primary" v-if="machine && !isRetired">
Edit
</router-link>
<button @click="confirmRetire" class="btn btn-danger" v-if="machine">Retire</button>
<button @click="confirmRetire" class="btn btn-danger" v-if="machine && !isRetired">Retire</button>
<!-- A retired record offered Edit and Retire exactly like a live one,
with nothing on the page saying it was retired. Restoring it was
possible through the API and nowhere in the product. -->
<button @click="restore" class="btn btn-primary" v-if="machine && isRetired">Restore</button>
<router-link to="/machines" class="btn btn-secondary">Back to List</router-link>
</div>
</div>
<div v-if="machine && isRetired" class="retired-banner card">
<strong>This machine is retired.</strong>
It stays out of every list and report, and its history and relationships
are intact. Restore puts it back.
</div>
<div v-if="loading" class="loading">Loading...</div>
<template v-else-if="machine">
@@ -295,7 +305,7 @@ const { heroWarranty, warrantyDate } = useWarrantyBadge(() => machine.value?.ass
// relationships render via the shared AssetRelationships card
onMounted(async () => {
async function loadMachine() {
try {
const response = await machinesApi.get(route.params.id)
machine.value = response.data.data
@@ -305,7 +315,9 @@ onMounted(async () => {
} finally {
loading.value = false
}
})
}
onMounted(loadMachine)
function getStatusClass(status) {
if (!status) return 'badge-info'
@@ -324,6 +336,20 @@ function formatDate(dateStr) {
// Retire, not delete: the server deactivates the asset, so the machine
// leaves every list and report while its history and relationships stay put.
// Reversible by an admin setting isactive back to true.
const isRetired = computed(() => machine.value?.isactive === false)
async function restore() {
const label = machine.value?.assetnumber || machine.value?.name || 'this machine'
try {
await machinesApi.update(machine.value?.machine?.machineid, { isactive: true })
toast.success(`Restored ${label}`)
await loadMachine()
} catch (error) {
console.error('Error restoring machine:', error)
toast.error(apiError(error, 'Failed to restore machine'))
}
}
async function confirmRetire() {
const label = machine.value?.assetnumber || machine.value?.name || 'this machine'
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
@@ -340,6 +366,10 @@ async function confirmRetire() {
</script>
<style scoped>
.retired-banner {
border-left: 3px solid var(--warning);
margin-bottom: 1rem;
}
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}

View File

@@ -416,6 +416,7 @@ import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
import PrinterAssignmentPicker from '@/components/PrinterAssignmentPicker.vue'
import { currentTheme } from '@/stores/theme'
import { useIdentifierFlags } from '@/composables/identifierSettings'
import { loadAssetTypeIds, assetTypeId } from '@/composables/assetTypeIds'
import { apiError } from '@/utils/apiError'
import { planControllingPcChange } from './controllingPc'
@@ -426,8 +427,11 @@ const router = useRouter()
const isEdit = computed(() => !!route.params.id)
// Seeded asset-type id for machines (see /api/assets/types).
const MACHINE_ASSETTYPEID = 1
// Resolved by NAME at load, never hardcoded: the id comes from seed
// order, so the constant that used to live here pointed at a different
// asset type on a differently-seeded site - the form then rendered THAT
// type's custom fields and dropped whatever was typed into them.
const MACHINE_ASSETTYPEID = computed(() => assetTypeId('machine'))
const customFieldsRef = ref(null)
const printerPickerRef = ref(null)
const currentAssetId = ref(null)
@@ -571,15 +575,16 @@ watch(() => form.value.controllervendorid, (newVal, oldVal) => {
})
onMounted(async () => {
await loadAssetTypeIds()
try {
// Load reference data in parallel
const [typesRes, statusRes, vendorRes, locRes, allModels, buRes, pcsRes, relTypesRes] = await Promise.all([
machinesApi.types.list(),
assetsApi.statuses.list(),
vendorsApi.list({ perpage: 500 }),
vendorsApi.listAll(),
locationsApi.listAll(), // backend caps perpage at 100; page through all
modelsApi.listAll({ category: 'Equipment' }), // backend caps perpage at 100; page through all
businessunitsApi.list({ perpage: 500 }),
businessunitsApi.listAll(),
computersApi.listAll(), // backend caps perpage at 100; page through all
assetsApi.types.list() // Used for relationship types, will fix below
])
@@ -763,7 +768,13 @@ async function saveMachine() {
maintenanceintervaldays: form.value.maintenanceintervaldays || null,
notes: form.value.notes || null,
mapx: form.value.mapx,
mapy: form.value.mapy
mapy: form.value.mapy,
// ADR-017: a position without its level cannot be drawn on the right
// floor plan. The picker sets all three and this payload sent two, so a
// picked pin saved a levelid of null and then rendered nowhere - the
// form even showed "Position: x, y" back, because it kept the value it
// had never sent.
levelid: form.value.levelid
}
let savedMachine

View File

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

View File

@@ -15,6 +15,14 @@
placeholder="Search machines..."
@input="debouncedSearch"
/>
<!-- Retire is a soft delete and the confirm dialog promises the record
"can be restored". The API kept that promise all along (?active=false
lists them, PUT isactive restores) but nothing in the UI could reach
it, so a retired machine was simply gone. -->
<label class="checkbox-label">
<input type="checkbox" v-model="showRetired" @change="loadMachines" />
Show retired
</label>
</div>
<div class="card">
@@ -41,6 +49,7 @@
@click="item.machine?.machineid && $router.push(`/machines/${item.machine.machineid}`)">
<td>
{{ item.assetnumber }}<template v-if="item.dualpathpartner"> / {{ item.dualpathpartner.assetnumber }}</template>
<span v-if="item.isactive === false" class="badge badge-secondary">retired</span>
</td>
<td>{{ item.name || '-' }}</td>
<td class="mono">{{ item.serialnumber || '-' }}</td>
@@ -122,6 +131,7 @@ function applySuggestion(word) {
// search page documents. Load directly.
loadMachines()
}
const showRetired = ref(false)
const totalPages = ref(1)
const perPage = ref(20)
@@ -139,6 +149,8 @@ async function loadMachines() {
perpage: perPage.value
}
if (search.value) params.search = search.value
// active=false means "include retired", matching the API.
if (showRetired.value) params.active = 'false'
const response = await machinesApi.list(params)
machines.value = response.data.data || []

View File

@@ -73,8 +73,12 @@ class Machine(BaseModel):
)
# Maintenance tracking
lastmaintenancedate = db.Column(db.DateTime, nullable=True)
nextmaintenancedate = db.Column(db.DateTime, nullable=True)
# Dates, not datetimes: there is no time in a maintenance date, and a
# DATETIME went out of to_dict as "...T00:00:00Z", which a browser west of
# UTC rendered as the previous day. measuringtools and warranties already
# model theirs as db.Date. See migration 7d40.
lastmaintenancedate = db.Column(db.Date, nullable=True)
nextmaintenancedate = db.Column(db.Date, nullable=True)
maintenanceintervaldays = db.Column(db.Integer, nullable=True)
# Controller info (for CNC machines)

View File

@@ -20,6 +20,7 @@ from shopdb.api import (
success_response, error_response, paginated_response, ErrorCodes,
get_pagination_params, paginate_query,
require_permission, apply_import_timestamps,
DateValueError, parse_date_fields,
)
from ..models import MeasuringTool, MeasuringToolType, derive_status, STATUS_COLORS
@@ -27,16 +28,6 @@ from ..models import MeasuringTool, MeasuringToolType, derive_status, STATUS_COL
measuringtools_bp = Blueprint('measuringtools', __name__)
def _parse_date(value):
"""Accept 'YYYY-MM-DD' (or None/empty) -> date or None."""
if not value:
return None
try:
return datetime.strptime(str(value)[:10], '%Y-%m-%d').date()
except (ValueError, TypeError):
return None
def _merged(tool, today=None):
"""Asset core dict with the measuringtools extension nested under 'measuringtool'."""
result = tool.asset.to_dict() if tool.asset else {}
@@ -191,19 +182,32 @@ def list_tools():
query = query.filter(Asset.statusid == status_id)
query = query.order_by(Asset.assetnumber)
items, total = paginate_query(query, page, per_page)
today = date.today()
data = [_merged(tool, today) for tool in items]
# Calibration status is derived, so it cannot be a SQL filter - apply it
# to the built rows. Filtering post-pagination is acceptable here because
# the dataset is a gage lab's worth of tools, not a fleet.
# Calibration status is DERIVED (see derive_status), so it cannot be a SQL
# filter. It used to be applied to the rows of the current page, which
# meant the page showed the matches that happened to fall on it while
# meta.pagination.total counted the unfiltered set: asking for 'overdue'
# with one overdue tool in sixteen returned an empty page 1, a total of 16
# and eight pages, with the actual row on page 3. A filter that hides
# matching rows and miscounts the rest is worse than no filter.
#
# So when this filter is in play the whole matching set is built and
# filtered before paging. That is the same tradeoff the previous comment
# named - a gage lab's worth of tools, not a fleet - spent where it buys
# correct answers rather than a cheaper query.
calibrationstatus = request.args.get('calibrationstatus')
if calibrationstatus:
data = [d for d in data
if d['measuringtool']['calibrationstatus'] == calibrationstatus]
matched = [_merged(tool, today) for tool in query.all()]
matched = [row for row in matched
if row['measuringtool']['calibrationstatus'] == calibrationstatus]
total = len(matched)
start = (page - 1) * per_page
data = matched[start:start + per_page]
return paginated_response(data, page, per_page, total)
items, total = paginate_query(query, page, per_page)
data = [_merged(tool, today) for tool in items]
return paginated_response(data, page, per_page, total)
@@ -240,6 +244,12 @@ def _asset_type_id():
def create_tool():
"""Create a measuring tool: one Asset core row + one extension row."""
data = request.get_json() or {}
# Refused, not nulled - see the note at the update site.
try:
dates = parse_date_fields(
data, ('lastcalibrationdate', 'nextcalibrationdate'))
except DateValueError as bad:
return error_response(ErrorCodes.VALIDATION_ERROR, str(bad))
if not data.get('assetnumber'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'assetnumber is required')
if Asset.query.filter_by(assetnumber=data['assetnumber']).first():
@@ -275,8 +285,8 @@ def create_tool():
assetid=asset.assetid,
measuringtooltypeid=data.get('measuringtooltypeid'),
calibrationintervaldays=data.get('calibrationintervaldays'),
lastcalibrationdate=_parse_date(data.get('lastcalibrationdate')),
nextcalibrationdate=_parse_date(data.get('nextcalibrationdate')),
lastcalibrationdate=dates.get('lastcalibrationdate'),
nextcalibrationdate=dates.get('nextcalibrationdate'),
calibrationprovider=(data.get('calibrationprovider') or '').strip() or None,
notes=(data.get('notes') or '').strip() or None,
)
@@ -309,6 +319,12 @@ def update_tool(tool_id: int):
return error_response(ErrorCodes.NOT_FOUND,
f'Measuring tool {tool_id} not found', http_code=404)
data = request.get_json() or {}
# Refused, not nulled - see the note at the update site.
try:
dates = parse_date_fields(
data, ('lastcalibrationdate', 'nextcalibrationdate'))
except DateValueError as bad:
return error_response(ErrorCodes.VALIDATION_ERROR, str(bad))
asset = tool.asset
# An UPDATE may not blank the asset number. Create validates it and the
@@ -339,9 +355,11 @@ def update_tool(tool_id: int):
if getattr(tool, key) != data[key]:
changes[key] = {'old': getattr(tool, key), 'new': data[key]}
setattr(tool, key, data[key])
for key in ('lastcalibrationdate', 'nextcalibrationdate'):
if key in data:
setattr(tool, key, _parse_date(data[key]))
# Refused, not nulled: a mistyped calibration date used to store NULL and
# answer 200, which flipped the tool to 'unknown' and dropped it out of the
# calibration report and the map overlay.
for key, parsed in dates.items():
setattr(tool, key, parsed)
if changes:
AuditLog.log('updated', 'MeasuringTool', entityid=tool.measuringtoolid,

View File

@@ -9,11 +9,21 @@
<router-link :to="`/measuringtools/${tool?.measuringtool?.measuringtoolid}/edit`" class="btn btn-primary" v-if="tool">
Edit
</router-link>
<button @click="confirmRetire" class="btn btn-danger" v-if="tool">Retire</button>
<button v-if="tool && !isRetired" @click="confirmRetire" class="btn btn-danger">Retire</button>
<!-- A retired record offered Edit and Retire exactly like a live
one, and nothing said it was retired. Restoring it was possible
through the API and nowhere in the product. -->
<button v-if="tool && isRetired" @click="restore" class="btn btn-primary">Restore</button>
<router-link to="/measuringtools" class="btn btn-secondary">Back to List</router-link>
</div>
</div>
<div v-if="tool && tool.isactive === false" class="retired-banner card">
<strong>This measuring tool is retired.</strong>
It stays out of every list and report, and its history and relationships
are intact. Restore puts it back.
</div>
<div v-if="loading" class="loading">Loading...</div>
<template v-else-if="tool">
@@ -181,7 +191,7 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { colorStyle } from '@/utils/colorStyle'
import { measuringtoolsApi } from '@/api'
@@ -227,6 +237,21 @@ onMounted(async () => {
// Retire, not delete: the server deactivates the asset, so the measuring tool
// leaves every list and report while its history and relationships stay put.
// Reversible by an admin setting isactive back to true.
const isRetired = computed(() => tool.value?.isactive === false)
async function restore() {
const label = tool.value?.assetnumber || tool.value?.name || 'this tool'
try {
await measuringtoolsApi.update(route.params.id, { isactive: true })
toast.success(`Restored ${label}`)
const response = await measuringtoolsApi.get(route.params.id)
tool.value = response.data.data
} catch (error) {
console.error('Error restoring:', error)
toast.error(apiError(error, 'Failed to restore'))
}
}
async function confirmRetire() {
const label = tool.value?.assetnumber || tool.value?.name || 'this measuring tool'
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
@@ -243,6 +268,10 @@ async function confirmRetire() {
</script>
<style scoped>
.retired-banner {
border-left: 3px solid var(--warning);
margin-bottom: 1rem;
}
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}

View File

@@ -179,10 +179,10 @@ const businessunits = ref([])
onMounted(async () => {
try {
const [typesResponse, statusResponse, locResponse, buResponse, assetTypesResponse] = await Promise.all([
measuringtoolsApi.types.list({ perpage: 200 }),
measuringtoolsApi.types.list({ perpage: 100 }),
assetsApi.statuses.list(),
locationsApi.listAll(), // backend caps perpage at 100; page through all
businessunitsApi.list({ perpage: 500 }),
businessunitsApi.listAll(),
assetsApi.types.list(),
])
types.value = typesResponse.data.data || []

View File

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

View File

@@ -28,7 +28,14 @@
<option value="current">Current</option>
<option value="unknown">Unknown</option>
</select>
</div>
<!-- Retire is a soft delete and the confirm dialog promises the record
"can be restored". The API kept that promise (?active=false lists
them, PUT isactive restores); nothing in the UI could reach it. -->
<label class="checkbox-label">
<input type="checkbox" v-model="showRetired" @change="loadTools" />
Show retired
</label>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
@@ -115,6 +122,7 @@ function applySuggestion(word) {
}
const typeid = ref('')
const calibrationstatus = ref('')
const showRetired = ref(false)
const totalPages = ref(1)
const perPage = ref(20)
@@ -126,7 +134,7 @@ function formatDate(d) { return new Date(d + 'T00:00:00').toLocaleDateString() }
onMounted(async () => {
try {
const typesResponse = await measuringtoolsApi.types.list({ perpage: 200 })
const typesResponse = await measuringtoolsApi.types.list({ perpage: 100 })
types.value = typesResponse.data.data || []
} catch (err) {
console.error('Error loading types:', err)
@@ -139,6 +147,8 @@ async function loadTools() {
try {
const params = { page: page.value, perpage: perPage.value }
if (search.value) params.search = search.value
// active=false means "include retired", matching the API.
if (showRetired.value) params.active = 'false'
if (typeid.value) params.typeid = typeid.value
if (calibrationstatus.value) params.calibrationstatus = calibrationstatus.value

View File

@@ -3,12 +3,14 @@
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
import ipaddress
from shopdb.api import db, Asset, AssetType, Vendor, Communication, CommunicationType, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
from ..services.assetnumbers import generate_for_type
from ..models import NetworkDevice, NetworkDeviceType, Subnet, VLAN
from shopdb.api import require_permission, apply_import_timestamps
from shopdb.api import require_permission, apply_import_timestamps, intarg
network_bp = Blueprint('network', __name__)
@@ -232,20 +234,20 @@ def list_network_devices():
)
# Type filter
if type_id := request.args.get('typeid', request.args.get('type_id')):
query = query.filter(NetworkDevice.networkdevicetypeid == int(type_id))
if (type_id := intarg('typeid', intarg('type_id'))) is not None:
query = query.filter(NetworkDevice.networkdevicetypeid == type_id)
# Vendor filter
if vendor_id := request.args.get('vendorid', request.args.get('vendor_id')):
query = query.filter(NetworkDevice.vendorid == int(vendor_id))
if (vendor_id := intarg('vendorid', intarg('vendor_id'))) is not None:
query = query.filter(NetworkDevice.vendorid == vendor_id)
# Location filter
if location_id := request.args.get('locationid', request.args.get('location_id')):
query = query.filter(Asset.locationid == int(location_id))
if (location_id := intarg('locationid', intarg('location_id'))) is not None:
query = query.filter(Asset.locationid == location_id)
# Business unit filter
if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')):
query = query.filter(Asset.businessunitid == int(bu_id))
if (bu_id := intarg('businessunitid', intarg('businessunit_id'))) is not None:
query = query.filter(Asset.businessunitid == bu_id)
# PoE filter
if poe := request.args.get('poe'):
@@ -697,11 +699,27 @@ def create_vlan():
if not data.get('name'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required')
# 802.1Q says 1-4094, and nothing checked. 99999 and -5 were stored, and a
# non-numeric value was worse: the duplicate probe below compared a string
# against an integer column, which MySQL coerces to 0, so 'xyz' matched an
# existing VLAN 0 and the error said "VLAN xyz already exists" about a row
# that had nothing to do with it.
try:
vlannumber = int(data['vlannumber'])
except (TypeError, ValueError):
return error_response(ErrorCodes.VALIDATION_ERROR,
'vlannumber must be a whole number')
if not 1 <= vlannumber <= 4094:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'vlannumber must be between 1 and 4094 (802.1Q)')
data['vlannumber'] = vlannumber
# Check for duplicate VLAN number
if VLAN.query.filter_by(vlannumber=data['vlannumber']).first():
if VLAN.query.filter_by(vlannumber=vlannumber).first():
return error_response(
ErrorCodes.CONFLICT,
f"VLAN {data['vlannumber']} already exists",
f"VLAN {vlannumber} already exists",
http_code=409
)
@@ -784,10 +802,14 @@ def delete_vlan(vlan_id: int):
# Check if VLAN has associated subnets
if vlan.subnets.filter_by(isactive=True).count() > 0:
# 409 like every other in-use guard in the product (network types,
# machine types, vendors). It answered 400, which tells the caller
# their request was malformed when the request was fine and the state
# was the problem.
return error_response(
ErrorCodes.VALIDATION_ERROR,
ErrorCodes.CONFLICT,
'Cannot delete VLAN with associated subnets',
http_code=400
http_code=409
)
vlan.isactive = False
@@ -827,12 +849,12 @@ def list_subnets():
)
# VLAN filter
if vlan_id := request.args.get('vlanid'):
query = query.filter(Subnet.vlanid == int(vlan_id))
if (vlan_id := intarg('vlanid')) is not None:
query = query.filter(Subnet.vlanid == vlan_id)
# Location filter
if location_id := request.args.get('locationid'):
query = query.filter(Subnet.locationid == int(location_id))
if (location_id := intarg('locationid')) is not None:
query = query.filter(Subnet.locationid == location_id)
# Type filter
if subnet_type := request.args.get('type'):
@@ -936,10 +958,30 @@ def create_subnet():
if not data.get('name'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required')
# Validate CIDR format (basic check)
cidr = data['cidr']
# Parsed, not eyeballed for a slash. 'notanip/xx' passed the old check and
# was stored; _devices_in_subnet then caught the ValueError from
# ip_network and returned an empty device list, so the subnet looked real
# and permanently empty. ipaddress is the same library that has to read it
# later, so agreeing with it is the whole test.
cidr = (data['cidr'] or '').strip()
# An explicit prefix is required. ipaddress reads a bare '10.1.1.0' as a
# /32, which is legal and almost never what somebody typing a SUBNET meant
# - it would silently create a subnet holding one address.
if '/' not in cidr:
return error_response(ErrorCodes.VALIDATION_ERROR, 'cidr must be in CIDR notation (e.g., 10.1.1.0/24)')
return error_response(
ErrorCodes.VALIDATION_ERROR,
'cidr needs a prefix length, e.g. 10.1.1.0/24')
try:
network = ipaddress.ip_network(cidr, strict=False)
except ValueError as bad:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'cidr must be a network in CIDR notation, e.g. 10.1.1.0/24 (%s)'
% bad)
# Store the normalised form so 10.1.1.5/24 and 10.1.1.0/24 are one subnet
# rather than two rows nobody can tell apart.
cidr = str(network)
data['cidr'] = cidr
# Check for duplicate CIDR
if Subnet.query.filter_by(cidr=cidr).first():

View File

@@ -334,6 +334,7 @@ import { loadMapConfig, levelOptions, levelName, state as mapConfig }
import Modal from '@/components/Modal.vue'
import { currentTheme } from '@/stores/theme'
import { useIdentifierFlags } from '@/composables/identifierSettings'
import { loadAssetTypeIds, assetTypeId } from '@/composables/assetTypeIds'
import { apiError } from '@/utils/apiError'
const { isEnabled } = useIdentifierFlags()
@@ -350,8 +351,11 @@ const router = useRouter()
const deviceId = route.params.id
const isEdit = computed(() => !!deviceId)
// Seeded asset-type id for network devices (see /api/assets/types).
const NETWORK_ASSETTYPEID = 3
// Resolved by NAME at load, never hardcoded: the id comes from seed
// order, so the constant that used to live here pointed at a different
// asset type on a differently-seeded site - the form then rendered THAT
// type's custom fields and dropped whatever was typed into them.
const NETWORK_ASSETTYPEID = computed(() => assetTypeId('network_device'))
const customFieldsRef = ref(null)
const currentAssetId = ref(null)
@@ -424,6 +428,7 @@ const saving = ref(false)
const error = ref('')
onMounted(async () => {
await loadAssetTypeIds()
await Promise.all([
loadDeviceTypes(),
loadVendors(),

View File

@@ -45,7 +45,14 @@
{{ loc.locationname }}
</option>
</select>
</div>
<!-- Retire is a soft delete and the confirm dialog promises the record
"can be restored". The API kept that promise (?active=false lists
them, PUT isactive restores); nothing in the UI could reach it. -->
<label class="checkbox-label">
<input type="checkbox" v-model="showRetired" @change="loadDevices" />
Show retired
</label>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
@@ -143,6 +150,7 @@ function applySuggestion(word) {
const selectedType = ref(null)
const vendorFilter = ref('')
const locationFilter = ref('')
const showRetired = ref(false)
const totalPages = ref(1)
const perPage = ref(25)
const totalCount = ref(0)
@@ -225,6 +233,8 @@ async function loadDevices() {
perpage: perPage.value
}
if (search.value) params.search = search.value
// active=false means "include retired", matching the API.
if (showRetired.value) params.active = 'false'
if (selectedType.value) params.typeid = selectedType.value
if (vendorFilter.value) params.vendorid = vendorFilter.value
if (locationFilter.value) params.locationid = locationFilter.value

View File

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

View File

@@ -13,7 +13,7 @@ from shopdb.api import db, success_response, error_response, paginated_response,
from ..models import Notification, NotificationType
from ..models.notification import _utc_iso
from shopdb.api import require_permission, Setting
from shopdb.api import require_permission, Setting, intarg
notifications_bp = Blueprint('notifications', __name__)
@@ -355,8 +355,8 @@ def list_notifications():
query = query.filter(Notification.isactive == True)
# Type filter
if type_id := request.args.get('typeid', request.args.get('type_id')):
query = query.filter(Notification.notificationtypeid == int(type_id))
if (type_id := intarg('typeid', intarg('type_id'))) is not None:
query = query.filter(Notification.notificationtypeid == type_id)
# Exact-match lookup for idempotent import. Notifications have no strong
# natural key; ticketnumber is the best available when a ticket is set.

View File

@@ -244,6 +244,7 @@
import { ref, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { notificationsApi, applicationsApi, businessUnitsApi, employeesApi, settingsApi } from '@/api'
import { resolveEmployeeName, fullname } from '@/composables/employeeResolve'
import { apiError } from '@/utils/apiError'
import { zonedInputFromUtc, utcFromZonedInput, DEFAULT_TZ } from '@/utils/datetime'
@@ -459,6 +460,30 @@ async function addCustomEmployee() {
}
selectedEmployees.value.push({ sso: token, name })
} else {
// Look the name up before treating it as free text. Typing a name in
// full and pressing Enter used to produce `NAME:<text>`, linked to
// nothing - no photo, no directory record - while picking the same
// person off the suggestion list produced a real SSO. The chip looked
// identical either way, which is why it went unnoticed.
const { match, matches, failed } = await resolveEmployeeName(token)
if (match) {
const sso = String(match.SSO)
if (selectedEmployees.value.some(e => e.sso === sso)) continue
selectedEmployees.value.push({ sso, name: fullname(match) || token })
continue
}
if (matches && matches.length) {
// Several people match. Guessing is exactly wrong here, so show the
// list and let the operator pick.
employeeResults.value = matches
error.value = `More than one employee matches "${token}" - pick one.`
return
}
if (failed) {
error.value = 'Could not reach the employee directory. Try again, or '
+ 'pick from the list.'
return
}
const key = `NAME:${token}`
if (selectedEmployees.value.some(e => e.sso === key)) continue
selectedEmployees.value.push({ sso: key, name: token })

View File

@@ -75,6 +75,7 @@ def get_item(item_id: int):
import glob
import os
import re
from flask import current_app
from werkzeug.utils import secure_filename
@@ -257,6 +258,52 @@ from ..models import PrintedItemTransaction
from ..services.badges import BadgeError, resolve_badge
class InsufficientStock(Exception):
"""A decrement was refused because the row no longer holds that many.
Carries the quantity the DATABASE holds, not the stale one the caller read,
so the message the operator sees is the truth at the moment of the write.
"""
def __init__(self, available):
super().__init__('insufficient stock')
self.available = available
def _move_quantity(item, quantitychange):
"""Move quantityonhand by quantitychange in ONE statement, and let the row
itself refuse a decrement it cannot cover.
This is the guard, not the caller's earlier `quantity > item.quantityonhand`
check. That check reads, decides in Python, and writes - so two kiosk takes
that interleave both read the same on-hand figure and both pass it. Five
concurrent takes of 5 from a bin holding 5 all succeeded, each wrote its own
5 - 5 = 0, and quantityonhand ended up permanently out of step with the
ledger that recorded all five.
Expressing it as `UPDATE ... WHERE quantityonhand >= :n` makes the database
do the comparing at write time, where it is atomic. A row that no longer
holds enough matches nothing, rowcount is 0, and the take is refused.
Returns the resulting quantity.
"""
query = PrintedItem.query.filter(
PrintedItem.printeditemid == item.printeditemid)
if quantitychange < 0:
query = query.filter(PrintedItem.quantityonhand >= -quantitychange)
moved = query.update(
{PrintedItem.quantityonhand:
PrintedItem.quantityonhand + quantitychange},
synchronize_session=False)
if not moved:
# Read back what is actually there so the refusal can say so.
db.session.rollback()
available = db.session.query(PrintedItem.quantityonhand).filter(
PrintedItem.printeditemid == item.printeditemid).scalar() or 0
raise InsufficientStock(available)
db.session.refresh(item)
return item.quantityonhand
def _ledger_write(item, transactiontype, quantitychange, sso, name, reason=None,
revision=None):
"""Append a ledger row and move the cached quantity in ONE commit.
@@ -266,9 +313,17 @@ def _ledger_write(item, transactiontype, quantitychange, sso, name, reason=None,
alert when this write CROSSES the item's threshold downward - crossing
(not being below) is the natural debounce: one alert per depletion, and
restocking above the threshold rearms it.
Raises InsufficientStock when the row cannot cover the decrement. The
caller has already refused the obvious case; this is the one that only
shows up under concurrency.
"""
quantitybefore = item.quantityonhand
item.quantityonhand += quantitychange
# Derived from what the write ACTUALLY produced, not from what this request
# read earlier. Under concurrency the two differ, and the threshold
# crossing has to be judged on the real numbers or the alert fires for the
# wrong take (or not at all).
quantityafter = _move_quantity(item, quantitychange)
quantitybefore = quantityafter - quantitychange
db.session.add(PrintedItemTransaction(
printeditemid=item.printeditemid,
transactiontype=transactiontype,
@@ -427,7 +482,13 @@ def adjust_item(item_id: int):
except BadgeError as badge_error:
return error_response(ErrorCodes.VALIDATION_ERROR, str(badge_error),
http_code=422)
_ledger_write(item, 'adjust', quantitychange, sso, name, reason=reason)
try:
_ledger_write(item, 'adjust', quantitychange, sso, name, reason=reason)
except InsufficientStock as shortfall:
return error_response(
ErrorCodes.VALIDATION_ERROR,
f'Adjustment would drive stock below zero '
f'(on hand: {shortfall.available})')
return success_response(item.to_dict(), message='Stock adjusted')
@@ -500,8 +561,14 @@ def _kiosk_find_item(itemcode):
matches = []
for candidate in PrintedItem.query.filter_by(isactive=True).all():
for value in (candidate.itemcode, candidate.gagelabtag):
tail = ''.join(ch for ch in (value or '') if ch.isdigit())
if tail and int(tail) == wanted:
# The TRAILING digit run, not every digit in the string. A
# prefix can contain digits - the shipped default is '3DP' -
# and collecting all of them turned 3DP0004 into 30004, so the
# operator typing the 4 printed on the bin got "no part matches
# that barcode" and had to work out that the 3 belonged to the
# prefix. The number on the label is the number at the end.
digits = re.search(r'(\d+)$', (value or '').strip())
if digits and int(digits.group(1)) == wanted:
matches.append(candidate)
break
if len(matches) == 1:
@@ -579,7 +646,16 @@ def kiosk_take():
except (ValueError, TypeError):
revision = None
_ledger_write(item, 'take', -quantity, sso, name, revision=revision)
try:
_ledger_write(item, 'take', -quantity, sso, name, revision=revision)
except InsufficientStock as shortfall:
# Another kiosk took the last of it between this request's read and its
# write. Same message as the pre-check, with the number the database
# actually holds now.
return error_response(
ErrorCodes.VALIDATION_ERROR,
f'Only {shortfall.available} on hand - take fewer or see the '
f'parts team')
return success_response(item.to_dict(),
message=f'Took {quantity}, {item.quantityonhand} left')

View File

@@ -7,10 +7,31 @@
</button>
</header>
<div v-if="error" class="kiosk-error">{{ error }}</div>
<div v-if="error" class="kiosk-error">
{{ error }}
<p v-if="misses >= 3" class="kiosk-hint">
Several scans in a row have found nothing. The kiosk may have been
switched off, or this screen's setup link may have been replaced - ask
an administrator to check Settings &gt; 3D Printed Parts.
</p>
</div>
<!-- This screen was never provisioned. The SERVER cannot say so - the
routes answer a bare 404 on purpose, so a caller without the token
does not learn the door exists - but the browser knows whether it
holds one, and that is not a disclosure. Without this the kiosk drew
its normal scan prompt and answered "Not found" to every scan, which
reads as "we do not stock that part". -->
<section v-if="!provisioned" class="kiosk-step kiosk-unprovisioned">
<p class="kiosk-prompt">This screen is not set up yet</p>
<p class="kiosk-hint">
An administrator opens it once using the kiosk link from
Settings &gt; 3D Printed Parts. The screen remembers it after that.
</p>
</section>
<!-- step 1: the bin label -->
<section v-if="step === 'item'" class="kiosk-step">
<section v-if="provisioned && step === 'item'" class="kiosk-step">
<p class="kiosk-prompt">Scan the barcode on the bin</p>
<!-- one visible field: keyboard-wedge scanners, a plugged-in keyboard or
numpad, and the on-screen keypad all type into it. -->
@@ -108,6 +129,13 @@ const item = ref(null)
const badge = ref('')
const quantity = ref('')
const error = ref('')
// Whether THIS browser holds the kiosk's device token. The server will not say
// (see the template), and it does not need to: an unprovisioned screen should
// say so rather than pretending to be a working kiosk with an empty catalog.
const provisioned = ref(true)
// Consecutive lookups that found nothing. Every scan missing usually means the
// kiosk was switched off, not that no bin is known.
const misses = ref(0)
const doneMessage = ref('')
const submitting = ref(false)
const manualCode = ref('')
@@ -118,6 +146,14 @@ let resetTimer = null
onMounted(async () => {
claimKioskToken(KIOSK_TOKEN_KEYS['/printedparts/kiosk/'])
try {
provisioned.value = Boolean(
localStorage.getItem(KIOSK_TOKEN_KEYS['/printedparts/kiosk/']))
} catch {
// A browser refusing localStorage cannot be provisioned at all, and
// saying so is more use than a scan box that answers nothing.
provisioned.value = false
}
// A kiosk left running for weeks reads this once at load, which is fine: the
// label format is a property of the site's physical tags, not something that
// changes during a shift.
@@ -153,11 +189,17 @@ async function lookupItem(itemcode) {
try {
const response = await printedpartsApi.kioskItem(code)
item.value = response.data.data
misses.value = 0
step.value = 'badge'
manualCode.value = ''
} catch (lookupError) {
error.value = lookupError.response?.data?.data?.error?.message ||
'No part matches that barcode'
// The gate and a genuine miss both answer 404, deliberately, so the client
// cannot tell them apart from one response. What it CAN say is that
// repeated misses may mean the kiosk was switched off rather than that
// every bin is unknown.
misses.value += 1
}
focusEntry()
}
@@ -215,6 +257,14 @@ function reset() {
</script>
<style scoped>
.kiosk-unprovisioned {
text-align: center;
}
.kiosk-hint {
color: var(--text-light);
font-size: 1rem;
margin-top: 0.6rem;
}
.parts-kiosk {
min-height: 100vh;
background: var(--bg);

View File

@@ -36,16 +36,24 @@ class BadgeError(ValueError):
def _parse_badge(badge):
"""Return ('sso'|'payno', digits) or raise BadgeError on unknown shape."""
badge = (badge or '').strip()
if not badge:
raise BadgeError('Scan or enter a badge')
if badge.isdigit():
return 'sso', badge
match = _PAYNO_BADGE.match(badge)
if match:
return 'payno', match.group(1)
raise BadgeError('Unrecognized badge format')
"""Return ('sso'|'payno', digits) or raise BadgeError on unknown shape.
The shapes themselves live in shopdb.api (normalise_badge), because the USB
kiosk reads the same physical badges and used to disagree: it took the
string raw, so the PayNo wrapper resolved to nobody and an operator whose
scanner emits it could take a part and not check out a drive.
That also closed a subtler hole. This used str.isdigit(), which is true for
Arabic-Indic and other decimal forms, so a badge written in them was
accepted, resolved against int() to a REAL employee, and then stored as an
SSO string that joins to nothing.
"""
from shopdb.api import BadgeShapeError, badge_kind, normalise_badge
try:
return badge_kind(badge), normalise_badge(badge)
except BadgeShapeError as bad:
raise BadgeError(str(bad))
def _selfhosted_lookup(digits):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -47,6 +47,15 @@
<img :src="withBase(slide.url)" :alt="slide.filename" class="thumb"
@mouseenter="previewSlide = slide" @mouseleave="previewSlide = null" />
<span class="fname" :title="slide.filename">{{ slide.filename }}</span>
<!-- Per-slide hold time. The API and the feed have always carried
`seconds`, and the manager offered no way to set it, so the one
documented per-slide control was API-only. 0 means "use the
default interval", which is what the feed already does. -->
<label class="seconds" title="Seconds to hold this slide (0 = default)">
<input type="number" min="0" max="600" :value="slide.seconds"
@change="setSeconds(slide, $event.target.value)" />
<span class="seconds-unit">s</span>
</label>
<div class="move">
<button class="btn btn-sm btn-secondary" :disabled="idx === 0" @click="move(idx, -1)" title="Move up">&uarr;</button>
<button class="btn btn-sm btn-secondary" :disabled="idx === slides.length - 1" @click="move(idx, 1)" title="Move down">&darr;</button>
@@ -73,6 +82,8 @@
import { ref, computed, onMounted } from 'vue'
import { slidesApi } from '@/api'
import { withBase } from '@/utils/basePath'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
// Slide currently hovered, shown enlarged. Null when the cursor is elsewhere.
const previewSlide = ref(null)
@@ -133,6 +144,7 @@ const surfaces = [
{ key: 'shopfloor', label: 'Shopfloor Screensaver' }
]
const surface = ref('lobby')
const toast = useToast()
const slides = ref([])
const selected = ref([])
const loading = ref(true)
@@ -176,6 +188,22 @@ async function onUpload(event) {
}
}
async function setSeconds(slide, raw) {
const seconds = Math.max(0, Math.min(600, Number(raw) || 0))
if (seconds === slide.seconds) return
try {
await slidesApi.update(surface.value, slide.slideid, { seconds })
slide.seconds = seconds
toast.success(seconds
? `${slide.filename} holds for ${seconds}s`
: `${slide.filename} uses the default interval`)
} catch (err) {
console.error('Error setting slide seconds:', err)
toast.error(apiError(err, 'Could not set the hold time'))
await load()
}
}
async function move(idx, delta) {
const target = idx + delta
if (target < 0 || target >= slides.value.length) return
@@ -197,6 +225,17 @@ onMounted(load)
</script>
<style scoped>
.seconds {
align-items: center;
display: inline-flex;
gap: 0.25rem;
}
.seconds input {
width: 4.5rem;
}
.seconds-unit {
color: var(--text-light);
}
.tabs {
display: flex;
gap: 4px;

View File

@@ -39,6 +39,7 @@ from flask_jwt_extended import jwt_required, get_jwt_identity, current_user
from shopdb.api import (
db, cache, Setting, client_ip,
success_response, error_response, ErrorCodes,
caller_holds,
)
from ..models import SecureSecret
@@ -155,7 +156,9 @@ def create_secret():
ErrorCodes.VALIDATION_ERROR,
'Too many links from this address. Try again shortly.',
http_code=429)
elif not current_user.haspermission('tools.secrets'):
# caller_holds, for the same reason as the endpoints elevated gate: a
# scoped token is a narrower caller than the person who minted it.
elif not caller_holds('tools.secrets'):
return error_response(
ErrorCodes.FORBIDDEN,
'You do not have permission to create secret links.',

View File

@@ -86,13 +86,21 @@ class ToolsPlugin(BasePlugin):
from where, and never who. That is the cost, and it is why this is the
only write in the product offered to guests at all.
tools.view rides along because tools.secrets without it is a page a
guest holds permission to use and no way to reach: it gates the nav
entry, and the browser tools behind it (codes, conversions) run client
side and touch no data at all. Declared explicitly rather than inferred
from the .view suffix, which is what the grantable rule now asks for.
tools.secrets is NOT here, and that is the fix for a grant that did
nothing. Whether a guest may create a secret link is decided by the
securesend_allow_guests setting - which fails closed, carries the rate
limit, and is read straight from the row rather than the cache. The
route never consulted the guest ROLE, so ticking tools.secrets for
guests in the role grid was accepted, stored, and ignored: the site
looked open and was not, or looked closed and was not. Two mechanisms
for one decision, and the setting is the one with the safeguards.
tools.view stays grantable: it gates the nav entry, and the browser
tools behind it (codes, conversions) run client side and touch no data
at all. Declared explicitly rather than inferred from the .view suffix,
which is what the grantable rule now asks for.
"""
return ['tools.secrets', 'tools.view']
return ['tools.view']
def get_settings_defaults(self) -> List[dict]:
"""The one switch this plugin owns.

View File

@@ -993,16 +993,30 @@ def kiosk_checkout():
'Badge not recognized - see IT to check this device out',
http_code=422)
# Claim the device FIRST, and let the status transition be the
# guard: the read-then-write above cannot stop two scans of the
# same drive that interleave, because both read 'available'. With
# the status in the WHERE clause the second UPDATE matches no row
# and there is no log entry to undo.
cur.execute(
'UPDATE devices SET status = %s '
'WHERE device_id = %s AND status <> %s',
(_STATUS_CHECKED_OUT, device_id, _STATUS_CHECKED_OUT),
)
if not cur.rowcount:
holder = _kiosk_holder_name(cur, device_id)
conn.rollback()
return error_response(
ErrorCodes.CONFLICT,
'That device is already checked out{}'.format(
' to {}'.format(holder) if holder else ''),
http_code=409)
cur.execute(
'INSERT INTO checkinoutlog '
'(badge_number, device_id, action, timestamp, locker_location) '
"VALUES (%s, %s, 'check-out', %s, %s)",
(badge, device_id, _now(), device.get('locker_location')),
)
cur.execute(
'UPDATE devices SET status = %s WHERE device_id = %s',
(_STATUS_CHECKED_OUT, device_id),
)
conn.commit()
finally:
conn.close()

View File

@@ -33,18 +33,56 @@ _FILTER = {'available': STATUS_CHECKED_IN, 'checkedout': STATUS_CHECKED_OUT, 're
def _resolve_name(sso):
"""Best-effort name from the self-hosted employee directory."""
"""Best-effort name from the self-hosted employee directory.
The badge goes through the shared shape rule first. This compared the raw
string, so the PayNo wrapper some scanners emit (0<digits>BZ) resolved to
nobody and the kiosk answered "Badge not recognized - see IT" - for a badge
the parts kiosk a few metres away accepted. It also used str.isdigit(),
true for Arabic-Indic digits, so a badge in those characters reached int()
and matched a real employee.
"""
from shopdb.api import BadgeShapeError, normalise_badge
try:
digits = normalise_badge(sso)
except BadgeShapeError:
return None
try:
from plugins.employees.models import DirectoryEmployee
if sso and str(sso).isdigit():
emp = db.session.get(DirectoryEmployee, int(sso))
if emp:
return f'{emp.firstname} {emp.lastname}'.strip()
emp = db.session.get(DirectoryEmployee, int(digits))
if emp:
return f'{emp.firstname} {emp.lastname}'.strip()
except Exception:
pass
return None
def _kiosk_badge_digits(raw):
"""Return (digits, errorresponse) for a scanned badge.
Every kiosk write stores the DIGITS rather than the string that arrived:
the PayNo wrapper (0<digits>BZ) and the bare number are the same person,
and recording one row under each spelling makes a custody history that
cannot be joined to itself.
An empty scan and an unreadable one get different answers, because they
are different problems for the person standing at the cabinet: one has not
scanned yet, the other has scanned something this site does not know.
"""
from shopdb.api import BadgeShapeError, normalise_badge
if not (raw or '').strip():
return None, error_response(ErrorCodes.VALIDATION_ERROR,
'Scan your badge')
try:
return normalise_badge(raw), None
except BadgeShapeError:
return None, error_response(
ErrorCodes.VALIDATION_ERROR,
'Badge not recognized - see IT', http_code=422)
def _status(device):
if not device.isactive:
return STATUS_RETIRED
@@ -307,9 +345,9 @@ def kiosk_device(device_id):
def kiosk_session(data):
"""See routes.kiosk_session for why the list costs a valid badge."""
badge = (data.get('badge') or '').strip()
if not badge:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Scan your badge')
badge, badgeerror = _kiosk_badge_digits(data.get('badge'))
if badgeerror:
return badgeerror
name = _resolve_name(badge)
if not name:
return error_response(ErrorCodes.VALIDATION_ERROR,
@@ -335,9 +373,9 @@ def kiosk_session(data):
def kiosk_others(data):
badge = (data.get('badge') or '').strip()
if not badge:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Scan your badge')
badge, badgeerror = _kiosk_badge_digits(data.get('badge'))
if badgeerror:
return badgeerror
if not _resolve_name(badge):
return error_response(ErrorCodes.VALIDATION_ERROR,
'Badge not recognized - see IT', http_code=422)
@@ -358,9 +396,9 @@ def kiosk_others(data):
def kiosk_checkout(data):
device_id = (data.get('device_id') or '').strip()
badge = (data.get('badge') or '').strip()
if not badge:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Scan your badge')
badge, badgeerror = _kiosk_badge_digits(data.get('badge'))
if badgeerror:
return badgeerror
device = _find(device_id)
if not device:
@@ -387,12 +425,34 @@ def kiosk_checkout(data):
http_code=422)
now = datetime.now(timezone.utc).replace(tzinfo=None)
# The transition IS the guard. The ischeckedout test above reads, decides
# in Python and then writes, so two kiosk scans of the same drive that
# interleave both read "available" and both insert a custody row - one
# drive recorded out to two people, and the documented 409 never fires.
# Making the flag part of the WHERE clause moves the decision into the
# write, where the database settles it: the second UPDATE matches no row.
claimed = USBDevice.query.filter(
USBDevice.usbdeviceid == device.usbdeviceid,
USBDevice.ischeckedout == False).update(
{USBDevice.ischeckedout: True,
USBDevice.currentuserid: badge,
USBDevice.currentusername: name,
USBDevice.currentcheckoutdate: now},
synchronize_session=False)
if not claimed:
db.session.rollback()
# Re-read the row rather than refreshing the object this request is
# holding: the whole point of arriving here is that the object is
# stale, and the caller deserves the name of whoever actually has it.
holder = db.session.query(USBDevice.currentusername).filter(
USBDevice.usbdeviceid == device.usbdeviceid).scalar()
return error_response(
ErrorCodes.CONFLICT,
'That device is already checked out{}'.format(
' to {}'.format(holder) if holder else ''),
http_code=409)
db.session.add(USBCheckout(usbdeviceid=device.usbdeviceid, sso=badge,
checkoutname=name, checkouttime=now))
device.ischeckedout = True
device.currentuserid = badge
device.currentusername = name
device.currentcheckoutdate = now
db.session.commit()
return success_response({'device_id': device.serialnumber,
'holdername': name},
@@ -402,9 +462,9 @@ def kiosk_checkout(data):
def kiosk_checkin(data):
device_id = (data.get('device_id') or '').strip()
badge = (data.get('badge') or '').strip()
if not badge:
return error_response(ErrorCodes.VALIDATION_ERROR, 'Scan your badge')
badge, badgeerror = _kiosk_badge_digits(data.get('badge'))
if badgeerror:
return badgeerror
forsomeoneelse = bool(data.get('forsomeoneelse'))
device = _find(device_id)
@@ -439,15 +499,38 @@ def kiosk_checkin(data):
'holdername': device.currentusername or ''},
http_code=409)
open_checkout = (USBCheckout.query
.filter_by(usbdeviceid=device.usbdeviceid, checkintime=None)
.order_by(USBCheckout.checkouttime.desc()).first())
if open_checkout:
open_checkout.checkintime = datetime.now(timezone.utc).replace(tzinfo=None)
device.ischeckedout = False
device.currentuserid = None
device.currentusername = None
device.currentcheckoutdate = None
now = datetime.now(timezone.utc).replace(tzinfo=None)
# Guarded the same way as the checkout: only a device that is still out can
# be brought back, and the database decides that, not a Python read from
# earlier in this request.
released = USBDevice.query.filter(
USBDevice.usbdeviceid == device.usbdeviceid,
USBDevice.ischeckedout == True).update(
{USBDevice.ischeckedout: False,
USBDevice.currentuserid: None,
USBDevice.currentusername: None,
USBDevice.currentcheckoutdate: None},
synchronize_session=False)
if not released:
db.session.rollback()
return error_response(ErrorCodes.VALIDATION_ERROR,
'That device is not checked out')
# EVERY open row, not just the newest. One device can only be out once, so
# a second open row is corruption - closing only the newest one left the
# older ones open forever, and the device then reads as returned while its
# custody history says otherwise.
#
# checkinsso/checkinname record WHO HANDED IT BACK, which is not always the
# holder: returning for a colleague is a supported flow, and attributing
# the check-in to the holder made the audit trail name somebody who was not
# there.
(USBCheckout.query
.filter_by(usbdeviceid=device.usbdeviceid, checkintime=None)
.update({USBCheckout.checkintime: now,
USBCheckout.checkinsso: badge,
USBCheckout.checkinname: name},
synchronize_session=False))
db.session.commit()
return success_response({'device_id': device.serialnumber},
message='{} checked in'.format(device.serialnumber))

View File

@@ -0,0 +1,59 @@
"""Record who handed a drive back, not just when.
usbcheckouts held the checkout identity (sso, checkoutname) and, for the
return, only checkintime. Returning a drive for a colleague is a supported
kiosk flow - usb_kiosk_allow_return_for_others gates it and the kiosk asks for
confirmation - but the row it wrote attributed the check-in to the HOLDER. The
person who actually walked the drive back was recorded nowhere, so the custody
history and the audit report both named somebody who was not there.
Two columns rather than one: the badge is what the scanner emits and what joins
to a directory, and the name is what a report has to print when the directory
no longer resolves that badge.
Nullable, and not backfilled. A row closed before this column existed is one
where the returner was not recorded; copying the holder's badge into it would
manufacture evidence that the holder returned it, which is the exact claim this
migration exists to stop making.
Idempotent, so a re-run on an already-migrated database is a no-op.
Revision ID: usb0004checkinidentity
Revises: usb0003scanfields
"""
from alembic import op
import sqlalchemy as sa
revision = 'usb0004checkinidentity'
down_revision = 'usb0003scanfields'
branch_labels = None
depends_on = None
NEW_COLUMNS = (
('checkinsso', sa.String(length=20)),
('checkinname', sa.String(length=100)),
)
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'usbcheckouts' not in insp.get_table_names():
return
cols = {c['name'] for c in insp.get_columns('usbcheckouts')}
for name, coltype in NEW_COLUMNS:
if name not in cols:
op.add_column('usbcheckouts',
sa.Column(name, coltype, nullable=True))
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'usbcheckouts' not in insp.get_table_names():
return
cols = {c['name'] for c in insp.get_columns('usbcheckouts')}
for name, _ in NEW_COLUMNS:
if name in cols:
op.drop_column('usbcheckouts', name)

View File

@@ -128,6 +128,13 @@ class USBCheckout(BaseModel):
# Checkout details
checkouttime = db.Column(db.DateTime, nullable=False, default=_utcnow)
checkintime = db.Column(db.DateTime, nullable=True)
# WHO handed it back. Not always the holder: returning for a colleague is a
# supported flow, and recording the check-in against the holder named
# somebody who was not there. See migration usb0004checkinidentity.
checkinsso = db.Column(db.String(20), nullable=True,
comment='SSO of whoever returned it')
checkinname = db.Column(db.String(100), nullable=True,
comment='Name of whoever returned it')
# Metadata
checkoutreason = db.Column(db.Text, nullable=True, comment='Reason for checkout')

View File

@@ -19,6 +19,7 @@ from shopdb.api import (
db, Asset,
success_response, error_response, ErrorCodes,
require_permission,
DateValueError, parse_date, parse_date_fields,
)
from ..models import Warranty, WarrantyAsset
@@ -42,13 +43,16 @@ def _proof_dir():
return os.path.join(current_app.instance_path, 'warrantyproofs')
def _parse_date(value):
"""Accept 'YYYY-MM-DD' (or None/empty) -> date or None."""
if not value:
return None
def _vendor_date(value):
"""A date off a VENDOR response, which this code does not control.
Kept lenient on purpose, unlike a date a person typed: a provider that
starts returning a shape we do not parse must not fail the refresh of an
otherwise good record. The field is simply left as it was.
"""
try:
return datetime.strptime(value[:10], '%Y-%m-%d').date()
except (ValueError, TypeError):
return parse_date(value)
except DateValueError:
return None
@@ -203,13 +207,20 @@ def create_warranty():
vendor = (data.get('vendor') or '').strip()
if not vendor:
return error_response(ErrorCodes.VALIDATION_ERROR, 'vendor is required')
# Refused, not nulled. A mistyped end date used to store NULL and answer
# 200, which dropped the warranty out of every expiry surface on the
# strength of a typo.
try:
dates = parse_date_fields(data, ('startdate', 'enddate'))
except DateValueError as bad:
return error_response(ErrorCodes.VALIDATION_ERROR, str(bad))
warranty = Warranty(
vendor=vendor,
servicetag=(data.get('servicetag') or '').strip() or None,
provider=(data.get('provider') or 'manual').strip().lower(),
servicelevel=(data.get('servicelevel') or '').strip() or None,
startdate=_parse_date(data.get('startdate')),
enddate=_parse_date(data.get('enddate')),
startdate=dates.get('startdate'),
enddate=dates.get('enddate'),
notes=(data.get('notes') or '').strip() or None,
)
_apply_links(warranty, data.get('assetids'))
@@ -225,6 +236,10 @@ def update_warranty(warrantyid):
if not warranty:
return error_response(ErrorCodes.NOT_FOUND, 'Warranty not found', http_code=404)
data = request.get_json() or {}
try:
dates = parse_date_fields(data, ('startdate', 'enddate'))
except DateValueError as bad:
return error_response(ErrorCodes.VALIDATION_ERROR, str(bad))
if 'vendor' in data:
warranty.vendor = (data['vendor'] or '').strip() or warranty.vendor
if 'servicetag' in data:
@@ -233,10 +248,8 @@ def update_warranty(warrantyid):
warranty.provider = (data['provider'] or 'manual').strip().lower()
if 'servicelevel' in data:
warranty.servicelevel = (data['servicelevel'] or '').strip() or None
if 'startdate' in data:
warranty.startdate = _parse_date(data['startdate'])
if 'enddate' in data:
warranty.enddate = _parse_date(data['enddate'])
for field, parsed in dates.items():
setattr(warranty, field, parsed)
if 'notes' in data:
warranty.notes = (data['notes'] or '').strip() or None
if 'isactive' in data:
@@ -279,9 +292,9 @@ def refresh_warranty(warrantyid):
if result.get('servicelevel'):
warranty.servicelevel = result['servicelevel']
if result.get('startdate'):
warranty.startdate = _parse_date(result['startdate'])
warranty.startdate = _vendor_date(result['startdate'])
if result.get('enddate'):
warranty.enddate = _parse_date(result['enddate'])
warranty.enddate = _vendor_date(result['enddate'])
warranty.lastcheckeddate = datetime.now(timezone.utc).replace(tzinfo=None)
db.session.commit()
return success_response(_warranty_payload(warranty), message='Warranty refreshed')
@@ -363,8 +376,8 @@ def sync_dell():
existing.provider = 'dell'
existing.vendor = existing.vendor or 'Dell'
existing.servicelevel = found.get('servicelevel')
existing.startdate = _parse_date(found.get('startdate'))
existing.enddate = _parse_date(found.get('enddate'))
existing.startdate = _vendor_date(found.get('startdate'))
existing.enddate = _vendor_date(found.get('enddate'))
existing.servicetag = tag
existing.lastcheckeddate = now
updated += 1
@@ -372,8 +385,8 @@ def sync_dell():
warranty = Warranty(
vendor='Dell', provider='dell', servicetag=tag,
servicelevel=found.get('servicelevel'),
startdate=_parse_date(found.get('startdate')),
enddate=_parse_date(found.get('enddate')),
startdate=_vendor_date(found.get('startdate')),
enddate=_vendor_date(found.get('enddate')),
lastcheckeddate=now,
)
warranty.links.append(WarrantyAsset(assetid=assetid))

View File

@@ -195,11 +195,19 @@ while IFS= read -r hit; do
[ -z "$hit" ] && continue
file=${hit%%:*}
line=${hit#*:}; line=${line%%:*}
if ! sed -n "${line},$((line + 8))p" "$file" | grep -q "'levelid'"; then
# Python writes 'levelid', Vue writes levelid: - accept either, so the
# check covers the payloads a browser sends as well as the ones the server
# emits. It was Python-only, which is how three asset forms shipped a
# position with no level.
if ! sed -n "${line},$((line + 8))p" "$file" | grep -qE "'levelid'|levelid:"; then
MISSING_LEVEL="$MISSING_LEVEL$file:$line"$'\n'
fi
done <<EOF
$(grep -rn "'mapx':" --include='*.py' shopdb/ plugins/ scripts/ 2>/dev/null | grep -v '/tests\?/' || true)
$(grep -rn "mapx: " --include='*.vue' --include='*.js' \
frontend/src plugins/*/frontend 2>/dev/null \
| grep -v '.plugins-staged' | grep -v '\.spec\.js' \
| grep -vE "mapx: (null|0)," || true)
EOF
if [ -n "$MISSING_LEVEL" ]; then
echo "FAIL: these emit 'mapx' with no 'levelid' beside it - a position with no"

View File

@@ -368,6 +368,48 @@ def register_error_handlers(app: Flask):
return error_response(ErrorCodes.VALIDATION_ERROR,
'The database rejected that change.', http_code=400)
# STRICT_TRANS_TABLES (see config.SQLALCHEMY_ENGINE_OPTIONS) turns what
# MySQL used to silently adjust into an error: a value too long for its
# column, a date that is not a date, a number that is not a number. That is
# the behaviour we want - the alternative is a 201 for a row the caller did
# not send - but the error is the CALLER's, so it must not read as a server
# fault. Without this it surfaced as a 500 with a traceback in the log.
from sqlalchemy.exc import DataError
@app.errorhandler(DataError)
def handle_data_error(error):
from .extensions import db
db.session.rollback()
message = str(getattr(error, 'orig', error))
app.logger.warning('data error: %s', message)
column = _column_from_data_error(message)
if 'too long' in message.lower():
return error_response(
ErrorCodes.VALIDATION_ERROR,
'That value is too long for %s.' % (column or 'this field'),
http_code=400)
if 'incorrect date' in message.lower() or \
'incorrect datetime' in message.lower():
return error_response(
ErrorCodes.VALIDATION_ERROR,
'That is not a valid date for %s.' % (column or 'this field'),
http_code=400)
return error_response(
ErrorCodes.VALIDATION_ERROR,
'The database rejected that value%s.'
% (' for %s' % column if column else ''),
http_code=400)
# A wrong query argument is the caller's mistake. Registered app-wide so a
# list route does not have to remember to catch it - forgetting was the
# whole defect, in six routes across five plugins.
from .utils.queryargs import QueryArgError
@app.errorhandler(QueryArgError)
def handle_query_arg_error(error):
return error_response(ErrorCodes.VALIDATION_ERROR, str(error),
http_code=400)
@app.errorhandler(ShopDBException)
def handle_shopdb_exception(error):
http_codes = {
@@ -393,6 +435,48 @@ def register_error_handlers(app: Flask):
http_code=404
)
# Werkzeug answers these three itself, in HTML, so a JSON client asking a
# question slightly wrong got a page of markup with no error code and no
# envelope - and on a route the caller cannot see, the 405 also confirmed
# that the path exists, which is how a gated kiosk route meant to look
# absent announced itself.
#
# Only under /api/. Everything else is the SPA, which needs the framework's
# own behaviour (and its own catch-all) left alone.
from flask import request as flask_request
def _api_request():
return flask_request.path.startswith('/api/')
@app.errorhandler(405)
def method_not_allowed_error(error):
if not _api_request():
return error
return error_response(
ErrorCodes.VALIDATION_ERROR,
'That method is not allowed on this endpoint.',
http_code=405)
@app.errorhandler(415)
def unsupported_media_type_error(error):
if not _api_request():
return error
return error_response(
ErrorCodes.VALIDATION_ERROR,
'This endpoint expects a JSON body; send '
'Content-Type: application/json.',
http_code=415)
@app.errorhandler(400)
def bad_request_error(error):
if not _api_request():
return error
# Werkzeug raises this for malformed JSON before any route sees it.
return error_response(
ErrorCodes.VALIDATION_ERROR,
'The request body could not be read as JSON.',
http_code=400)
@app.errorhandler(500)
def internal_error(error):
return error_response(
@@ -410,6 +494,20 @@ def register_error_handlers(app: Flask):
)
def _column_from_data_error(message):
"""Pull the column name out of a MySQL data error, when it names one.
MySQL says "Data too long for column 'username' at row 1"; SQLite says
nothing of the sort. Best effort on purpose - a message that names the
field is much more useful than a generic one, and a message that names
nothing is still better than a 500.
"""
import re
found = re.search(r"column '([^']+)'", message or '')
return found.group(1) if found else None
def register_frontend_routes(app: Flask):
"""Serve Vue frontend static files."""
frontend_dist = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'frontend', 'dist')

View File

@@ -110,6 +110,19 @@ from shopdb.utils.uploads import send_upload, harden_upload_response
# nothing. See shopdb/utils/clientip.py.
from shopdb.utils.clientip import client_ip, strip_port
# Strict date parsing. On the contract surface because every plugin that stores
# a date needs it and the two that rolled their own both swallowed the error.
from shopdb.utils.queryargs import QueryArgError, intarg, intargs
# One badge vocabulary for the site: both kiosks read the same physical badges.
from shopdb.utils.badges import BadgeShapeError, badge_kind, normalise_badge
from shopdb.utils.datevalues import (
DateValueError,
parse_date,
parse_date_fields,
)
# Import-mode helpers: preserve legacy timestamps during a bulk data import
from shopdb.utils.import_mode import (
apply_import_timestamps,
@@ -350,6 +363,15 @@ __all__ = [
# Response + pagination helpers
'success_response',
'error_response',
'DateValueError',
'QueryArgError',
'BadgeShapeError',
'badge_kind',
'normalise_badge',
'intarg',
'intargs',
'parse_date',
'parse_date_fields',
'paginated_response',
'ErrorCodes',
'get_pagination_params',

View File

@@ -38,7 +38,26 @@ class Config:
# Pin the client connection to utf8mb4 so reads/writes match the
# utf8mb4 schema regardless of the server default charset. TestingConfig
# (SQLite) replaces this whole dict, so the charset arg never reaches it.
'connect_args': {'charset': 'utf8mb4'},
#
# STRICT_TRANS_TABLES is the other half, and it is a correctness
# setting rather than a preference. Without it MySQL accepts a write it
# cannot store and adjusts it: a 257-character username becomes the
# first 100 characters, a malformed date becomes '0000-00-00', an
# over-long asset number is cut at 50 and then collides with a
# different one. Every one of those was found in the product answering
# 200 or 201, because nothing in the stack knew the value had changed.
#
# Set on the CONNECTION rather than left to the server, so a site whose
# my.cnf says otherwise still gets it - the sql_mode seen on the 5.6
# dev server was NO_ENGINE_SUBSTITUTION alone. NO_ENGINE_SUBSTITUTION is
# kept alongside it, since dropping it would change how a CREATE TABLE
# naming an unavailable engine behaves.
'connect_args': {
'charset': 'utf8mb4',
'init_command':
"SET SESSION sql_mode='STRICT_TRANS_TABLES,"
"NO_ENGINE_SUBSTITUTION'",
},
}
JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY', 'jwt-secret-key-change-in-production')

View File

@@ -163,7 +163,18 @@ def update_apitoken(tokenid: int):
return error_response(ErrorCodes.VALIDATION_ERROR, 'name cannot be empty')
token.name = newname
if 'isactive' in data:
token.isactive = bool(data['isactive'])
# Revocation is ONE WAY. A revoked token's secret is out there - that
# is why it was revoked - and re-activating the row made the same
# secret work again, so "revoked" meant "paused until somebody flips it
# back". Deactivating through this route is still allowed; bringing one
# back is not, and the answer says to mint a new one.
wanted = bool(data['isactive'])
if wanted and not token.isactive:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'A revoked token cannot be re-activated - its secret is '
'already out. Create a new token instead.')
token.isactive = wanted
if 'scopes' in data:
scopelist, scope_error = _validate_scopes(data.get('scopes'), token.user)
if scope_error is not None:

View File

@@ -2,6 +2,7 @@
import glob
import hashlib
import re
import os
from flask import Blueprint, request, current_app
@@ -20,6 +21,7 @@ from shopdb.utils.responses import (
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.import_mode import apply_import_timestamps
from shopdb.utils.datevalues import DateValueError, parse_date
# Uploads live in the instance dir, like model images and part photos, so a
@@ -326,6 +328,24 @@ def create_version(app_id: int):
if not data or not data.get('version'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'version is required')
# A digest is a claim the delivery path CHECKS against, so a wrong one is
# worse than none: an over-long value was silently truncated to the column
# width and came back as 64 plausible hex characters, indistinguishable
# from a real SHA-256. Either it is a SHA-256 or it is refused.
digest = (data.get('sha256') or '').strip().lower()
if digest and not re.fullmatch(r'[0-9a-f]{64}', digest):
return error_response(
ErrorCodes.VALIDATION_ERROR,
'sha256 must be 64 hexadecimal characters')
# Refused rather than stored raw: a malformed date used to reach MySQL,
# which (before STRICT_TRANS_TABLES) wrote 0000-00-00 and then broke every
# read of this application.
try:
releasedate = parse_date(data.get('releasedate'), 'releasedate')
except DateValueError as bad:
return error_response(ErrorCodes.VALIDATION_ERROR, str(bad))
if AppVersion.query.filter_by(appid=app_id, version=data['version']).first():
return error_response(
ErrorCodes.CONFLICT,
@@ -336,9 +356,9 @@ def create_version(app_id: int):
version = AppVersion(
appid=app_id,
version=data['version'],
releasedate=data.get('releasedate'),
releasedate=releasedate,
notes=data.get('notes'),
sha256=data.get('sha256'),
sha256=digest or None,
silentargs=data.get('silentargs')
)
@@ -558,9 +578,20 @@ def upload_application_image(app_id: int):
@applications_bp.route('/image/<path:filename>', methods=['GET'])
@require_permission('applications.view')
def serve_application_image(filename):
"""Serve an uploaded application image (public - lists and tiles read it)."""
"""Serve an uploaded application image.
Ungated, like every other image route in the product (the branding logo,
the map blueprint, an employee photo, a printed-part picture). A browser
fetching <img src="/api/applications/image/application-3.png"> sends no
Authorization header, so a permission check here does not protect the file
- it just guarantees the picture never appears. The tile fell back to its
broken-image handler on every page, including for an admin.
What is exposed is an application icon to somebody who already knows the
filename, which is the same posture as its four siblings. Registered in
tests/test_core/test_public_read_surface.py with that reasoning.
"""
return send_upload(_appimage_dir(), filename)

View File

@@ -13,6 +13,7 @@ from shopdb.utils.responses import (
ErrorCodes
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.queryargs import intarg
from shopdb.utils.authz import require_permission
from shopdb.utils.import_mode import apply_import_timestamps
@@ -365,20 +366,20 @@ def list_assets():
query = query.join(AssetType).filter(AssetType.assettype == type_name)
# Type filter by ID
if type_id := request.args.get('typeid', request.args.get('type_id')):
query = query.filter(Asset.assettypeid == int(type_id))
if (type_id := intarg('typeid', intarg('type_id'))) is not None:
query = query.filter(Asset.assettypeid == type_id)
# Status filter
if status_id := request.args.get('statusid', request.args.get('status_id')):
query = query.filter(Asset.statusid == int(status_id))
if (status_id := intarg('statusid', intarg('status_id'))) is not None:
query = query.filter(Asset.statusid == status_id)
# Location filter
if location_id := request.args.get('locationid', request.args.get('location_id')):
query = query.filter(Asset.locationid == int(location_id))
if (location_id := intarg('locationid', intarg('location_id'))) is not None:
query = query.filter(Asset.locationid == location_id)
# Business unit filter
if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')):
query = query.filter(Asset.businessunitid == int(bu_id))
if (bu_id := intarg('businessunitid', intarg('businessunit_id'))) is not None:
query = query.filter(Asset.businessunitid == bu_id)
# Sorting
sort_by = request.args.get('sort', 'assetnumber')
@@ -1011,17 +1012,26 @@ def get_assets_map():
Asset.mapy.isnot(None)
)
# Dualpath single-machine collapse: hide the secondary bay marker so a
# dual-bay pair shows one marker; the primary carries the partner label.
# Gated on the site setting (default on).
# Dualpath single-machine collapse on the map: a secondary bay draws its
# OWN marker when somebody has positioned it, and is represented by the
# primary's marker when nobody has.
#
# It used to be excluded unconditionally, which made setting a position on
# a secondary bay a silent no-op: the form accepted the pin, stored it, and
# the map never drew it. One marker per pair is right when the pair sits in
# one place, which is the common case and still the default - but a
# dual-bay machine whose second bay is physically somewhere else is exactly
# what positioning it separately means.
#
# No extra filtering is needed for the fallback half: the query above
# already requires mapx/mapy, so an unpositioned secondary never reaches
# here and the primary's marker stands for both, carrying the combined
# label.
from shopdb.core.services.dualpath import (
resolve_dualpath_pairs, dualpath_single_machine_enabled)
dualpath_collapse = None
if dualpath_single_machine_enabled():
dualpath_collapse = resolve_dualpath_pairs()
if dualpath_collapse.secondaryassetids:
query = query.filter(
Asset.assetid.notin_(dualpath_collapse.secondaryassetids))
selected_assettype = request.args.get('assettype')
@@ -1030,8 +1040,7 @@ def get_assets_map():
query = query.join(AssetType).filter(AssetType.assettype == selected_assettype)
# Filter by subtype (depends on asset type) - case-insensitive matching
if subtype_id := request.args.get('subtype'):
subtype_id = int(subtype_id)
if (subtype_id := intarg('subtype')) is not None:
# Normalize the underscore DB form (measuring_tool, network_device) to
# the space form the branches below compare against.
asset_type_lower = (
@@ -1079,16 +1088,16 @@ def get_assets_map():
pass
# Filter by business unit
if bu_id := request.args.get('businessunitid'):
query = query.filter(Asset.businessunitid == int(bu_id))
if (bu_id := intarg('businessunitid')) is not None:
query = query.filter(Asset.businessunitid == bu_id)
# Filter by status
if status_id := request.args.get('statusid'):
query = query.filter(Asset.statusid == int(status_id))
if (status_id := intarg('statusid')) is not None:
query = query.filter(Asset.statusid == status_id)
# Filter by location
if location_id := request.args.get('locationid'):
query = query.filter(Asset.locationid == int(location_id))
if (location_id := intarg('locationid')) is not None:
query = query.filter(Asset.locationid == location_id)
# Search filter
if search := request.args.get('search'):
@@ -1102,6 +1111,10 @@ def get_assets_map():
assets = query.all()
# Every asset that ends up with a marker. Used to decide whether a dual-bay
# partner draws its own pin or is represented by this one.
positioned_assetids = {asset.assetid for asset in assets}
# Batch-load primary IPs in a single query instead of N+1 per asset.
# Prefer isprimary=True IP, fall back to any IP (comtypeid=1).
asset_ids = [a.assetid for a in assets]
@@ -1153,10 +1166,17 @@ def get_assets_map():
if type_data:
item['typedata'] = type_data
# combined-label partner for a collapsed dual-bay pair (label only)
# Combined label for a dual-bay pair, and ONLY when this marker really
# stands for both bays. A partner that draws its own marker gets its
# own name: labelling two separate pins '2007 / 2008' would say the
# same machine is in two places.
if dualpath_collapse:
partner = dualpath_collapse.partnerbyasset.get(asset.assetid)
item['dualpathpartner'] = partner['assetnumber'] if partner else None
partner_has_marker = bool(
partner and partner['assetid'] in positioned_assetids)
item['dualpathpartner'] = (
partner['assetnumber'] if partner and not partner_has_marker
else None)
data.append(item)

View File

@@ -5,7 +5,8 @@ from flask_jwt_extended import jwt_required
from shopdb.core.models import AuditLog
from shopdb.extensions import db
from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.responses import (success_response, error_response,
paginated_response, ErrorCodes)
from shopdb.utils.authz import require_permission
auditlogs_bp = Blueprint('auditlogs', __name__)
@@ -123,15 +124,12 @@ def list_auditlogs():
for r in rows:
r['userfullname'] = fullnames.get(str(r.get('username')))
return success_response(
rows,
meta={
'page': page,
'perpage': perpage,
'total': pagination.total,
'pages': pagination.pages
}
)
# paginated_response, like every other list in the product. This one built
# its own meta with the fields FLAT and named 'pages', so a client written
# against the documented meta.pagination.{page,perpage,total,totalpages}
# found nothing where it looked - on the one list whose whole purpose is to
# be read by somebody checking what happened.
return paginated_response(rows, page, perpage, pagination.total)
@auditlogs_bp.route('/entity/<entitytype>/<int:entityid>', methods=['GET'])

View File

@@ -36,10 +36,27 @@ def _blueprint_dir():
# =============================================================================
# Read - public
#
# ADR-017 rule 6: the level list is readable without authentication, because the
# printer installer map runs optional-auth before anyone logs in and needs a
# blueprint. It follows the precedent of printer install-list and the slide
# feed, both still public.
#
# afce2437 ("Reading anything needs a permission") swept 165 open GET routes
# and caught these three in the net. Fail-closed, so nothing leaked - but the
# anonymous installer map has drawn no floor plan since, while this same file
# kept the docstring saying the reads are public, the API inventory kept
# declaring auth "none", and PrinterInstallerMap.vue kept its comment
# explaining why. Four places agreed and the code did not.
#
# Restored rather than amending the ADR: blueprint paths and level names are
# not secrets, the asset positions on them already render on public kiosk
# pages, and the feature the rule exists for is broken without it. These three
# are registered in tests/test_core/test_public_read_surface.py.
# =============================================================================
@maplevels_bp.route('', methods=['GET'])
@require_permission('assets.view')
@jwt_required(optional=True)
def list_levels():
"""Every active level, grouped by building, in display order.
@@ -74,7 +91,7 @@ def list_levels():
@maplevels_bp.route('/<int:levelid>', methods=['GET'])
@require_permission('assets.view')
@jwt_required(optional=True)
def get_level(levelid):
level = db.session.get(MapLevel, levelid)
if not level or not level.isactive:
@@ -84,7 +101,7 @@ def get_level(levelid):
@maplevels_bp.route('/<int:levelid>/blueprint/<path:filename>', methods=['GET'])
@require_permission('assets.view')
@jwt_required(optional=True)
def serve_blueprint(filename, levelid=None):
"""Serve a level's blueprint image. Public, for the same reason as above."""
return send_upload(_blueprint_dir(), filename)
@@ -160,9 +177,17 @@ def create_level():
data = request.get_json() or {}
name = (data.get('levelname') or '').strip()
buildingid = data.get('buildingid')
# Names the fields this route actually reads. It said 'buildingname',
# which this handler never looks at, so somebody who omitted the level name
# was told to supply something else entirely.
if not name or not buildingid:
return error_response(ErrorCodes.VALIDATION_ERROR,
'buildingname and buildingid are required')
missing = [field for field, value in
(('levelname', name), ('buildingid', buildingid))
if not value]
return error_response(
ErrorCodes.VALIDATION_ERROR,
'%s %s required' % (' and '.join(missing),
'is' if len(missing) == 1 else 'are'))
if not db.session.get(Building, buildingid):
return error_response(ErrorCodes.NOT_FOUND, 'No such building',
http_code=404)

View File

@@ -235,7 +235,6 @@ def upload_model_image(model_id: int):
@models_bp.route('/image/<path:filename>', methods=['GET'])
@require_permission('assets.view')
def serve_model_image(filename):
"""Serve an uploaded model image (public - asset detail pages read it)."""
return send_upload(_modelimage_dir(), filename)

View File

@@ -13,6 +13,7 @@ from shopdb.core.models import (
)
from shopdb.utils.responses import success_response, error_response, ErrorCodes
from shopdb.utils.authz import require_permission
from shopdb.utils.queryargs import intarg
reports_bp = Blueprint('reports', __name__)
@@ -62,8 +63,8 @@ def machines_by_type():
)
# Business unit filter
if bu_id := request.args.get('businessunitid'):
query = query.filter(Asset.businessunitid == int(bu_id))
if (bu_id := intarg('businessunitid')) is not None:
query = query.filter(Asset.businessunitid == bu_id)
# Dualpath single-machine collapse: exclude the secondary bay so a dual-bay
# pair counts once per type bucket. Gated on the site setting (default on).
@@ -128,12 +129,12 @@ def assets_by_status():
)
# Asset type filter
if type_id := request.args.get('assettypeid'):
query = query.filter(Asset.assettypeid == int(type_id))
if (type_id := intarg('assettypeid')) is not None:
query = query.filter(Asset.assettypeid == type_id)
# Business unit filter
if bu_id := request.args.get('businessunitid'):
query = query.filter(Asset.businessunitid == int(bu_id))
if (bu_id := intarg('businessunitid')) is not None:
query = query.filter(Asset.businessunitid == bu_id)
query = query.group_by(
AssetStatus.statusid,
@@ -175,7 +176,7 @@ def kb_popularity():
- limit: Number of results (default 20)
- format: 'json' (default) or 'csv'
"""
limit = min(int(request.args.get('limit', 20)), 100)
limit = min(intarg('limit', 20), 100)
# Knowledge Base is a plugin; degrade gracefully if it is not installed.
try:
@@ -362,10 +363,10 @@ def asset_inventory():
AssetType.isactive == True
)
if bu_id := request.args.get('businessunitid'):
by_type = by_type.filter(Asset.businessunitid == int(bu_id))
if loc_id := request.args.get('locationid'):
by_type = by_type.filter(Asset.locationid == int(loc_id))
if (bu_id := intarg('businessunitid')) is not None:
by_type = by_type.filter(Asset.businessunitid == bu_id)
if (loc_id := intarg('locationid')) is not None:
by_type = by_type.filter(Asset.locationid == loc_id)
by_type = by_type.group_by(AssetType.assettype).all()
@@ -380,10 +381,10 @@ def asset_inventory():
AssetStatus.isactive == True
)
if bu_id := request.args.get('businessunitid'):
by_status = by_status.filter(Asset.businessunitid == int(bu_id))
if loc_id := request.args.get('locationid'):
by_status = by_status.filter(Asset.locationid == int(loc_id))
if (bu_id := intarg('businessunitid')) is not None:
by_status = by_status.filter(Asset.businessunitid == bu_id)
if (loc_id := intarg('locationid')) is not None:
by_status = by_status.filter(Asset.locationid == loc_id)
by_status = by_status.group_by(AssetStatus.status, AssetStatus.color).all()
@@ -398,8 +399,8 @@ def asset_inventory():
Location.isactive == True
)
if bu_id := request.args.get('businessunitid'):
by_location = by_location.filter(Asset.businessunitid == int(bu_id))
if (bu_id := intarg('businessunitid')) is not None:
by_location = by_location.filter(Asset.businessunitid == bu_id)
by_location = by_location.group_by(Location.locationname).all()

View File

@@ -244,6 +244,18 @@ def _get_asset_result(asset, query, relevance=None):
if relevance is None:
relevance = 15
# A retired asset never outranks a live one, whatever it matched on: the
# person searching almost always wants the machine that is still on the
# floor. Flagged too, so the result can say so rather than looking like any
# other hit.
# getattr, because this builder is also called with lightweight
# stand-ins that carry only the fields a result needs. An object that does
# not say is treated as live, which is the safe direction: a live asset
# ranked correctly beats a live asset buried as retired.
retired = getattr(asset, 'isactive', True) is False
if retired:
relevance = max(1, relevance - 60)
return {
'type': asset_type_name,
'id': result_id,
@@ -251,7 +263,8 @@ def _get_asset_result(asset, query, relevance=None):
'subtitle': subtitle,
'location': location_name,
'url': url,
'relevance': relevance
'relevance': relevance,
'retired': retired
}
@@ -499,11 +512,17 @@ def _search_assets(query, search_term):
joinedload(Asset.assettype),
joinedload(Asset.location),
).filter(
Asset.isactive == True,
# Retired assets are INCLUDED, and ranked below live ones. Retire
# is a soft delete whose confirm dialog promises the record can be
# restored - and the one place somebody looks for a machine they
# retired last month is the search box. Excluding them meant the
# record existed, was reachable by URL, and could not be found by
# anybody who did not already know where it was.
_word_match(query, Asset.assetnumber, Asset.name,
Asset.serialnumber, Asset.notes,
Asset.gaugelabreference, Asset.maintenancereference)
).order_by(Asset.assetnumber, Asset.assetid).limit(15).all()
).order_by(Asset.isactive.desc(), Asset.assetnumber,
Asset.assetid).limit(15).all()
for asset in assets:
relevance = 15

View File

@@ -811,6 +811,34 @@ def update_setting(key: str):
if _is_secret(key) and value == SECRET_MASK:
return success_response(_serialize_setting(setting), message='Setting unchanged')
# The declared valuetype is a promise about what every reader will get, and
# nothing checked it: "A6-not-a-number" was stored verbatim on an integer
# key and every consumer then read 0, because Setting.get_typed_value
# swallows the ValueError. The setting looked set, the behaviour was the
# default, and nothing said so.
valuetype = (setting.valuetype or 'string').lower()
if value is not None and not isinstance(value, bool):
text = str(value).strip()
if valuetype == 'integer' and text:
try:
int(text)
except ValueError:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'%s is a whole-number setting (got %r)' % (key, value))
elif valuetype == 'float' and text:
try:
float(text)
except ValueError:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'%s is a number setting (got %r)' % (key, value))
elif valuetype == 'boolean' and text:
if text.lower() not in ('true', 'false', '1', '0', 'yes', 'no'):
return error_response(
ErrorCodes.VALIDATION_ERROR,
'%s is a true/false setting (got %r)' % (key, value))
# Convert value to string for storage
if isinstance(value, bool):
setting.value = 'true' if value else 'false'
@@ -1460,14 +1488,13 @@ def build_default_settings():
'Incoming Webhook), adaptivecard (Teams Workflow), '
'or json (generic {title,text})'
},
# Audit log settings
{
'key': 'audit_retention_days',
'value': '90',
'valuetype': 'integer',
'category': 'audit',
'description': 'Number of days to retain audit logs (0 = keep forever)'
},
# Audit log retention lives at 'audit_retentiondays' (category site),
# defined above and read by shopdb/core/services/auditretention.py. A
# second key 'audit_retention_days' was seeded here with a different
# default and a different category, and the Audit & Logging page wrote
# to THAT one - so the page saved a value, read it back, and nothing
# ever consulted it. Removed rather than aliased: two keys for one
# setting is how this happened.
# Authentication settings
{
'key': 'saml_enabled',

View File

@@ -187,9 +187,28 @@ def update_user(userid: int):
user.failedlogins = 0
changes['unlocked'] = {'old': True, 'new': False}
# Password change
# Password change.
#
# An ADMIN may set one here - that is how a temporary password is issued,
# and mustchangepassword forces the holder to replace it at next login.
# Somebody changing their OWN password may not: this route asks for no
# current password and enforces no length, so it was a way around
# /api/auth/change-password, which requires both. The two rules have to
# agree or the weaker one is the real one.
if 'password' in data and data['password']:
if not current_user.hasrole('admin'):
return error_response(
ErrorCodes.FORBIDDEN,
'Change your own password at /api/auth/change-password, which '
'requires your current one.',
http_code=403)
if len(data['password']) < 8:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Password must be at least 8 characters')
user.passwordhash = generate_password_hash(data['password'])
# Issued by somebody else, so it is temporary by definition.
user.mustchangepassword = True
changes['password'] = {'old': '***', 'new': '***'}
if changes:

View File

@@ -256,6 +256,24 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
elif hasattr(self, 'measuringtool') and self.measuringtool:
result['pluginid'] = self.measuringtool.measuringtoolid
# What this asset OWNS, before any inheritance is merged in below.
#
# The merge is right for a reader: a detail page, a list and the map all
# want the resolved location, whoever it came from. It is wrong for a
# FORM, which reads the same payload and cannot tell an owned value
# from a derived one - so the PC form pre-selected an inherited
# location, the operator saved something else entirely, and the
# inherited value was written back as the record's own. The asset then
# stopped following its parent, silently, on an edit that never touched
# location.
#
# Always emitted, even when nothing is inherited, so a client can bind
# to these without checking whether the merge happened.
result['ownlocationid'] = self.locationid
result['ownmapx'] = self.mapx
result['ownmapy'] = self.mapy
result['ownlevelid'] = self.levelid
# Include inherited location if this asset has no location data
if include_inherited_location:
inherited = self.get_inherited_location()

View File

@@ -1,159 +1,192 @@
"""Audit log model for tracking changes."""
from datetime import datetime, timezone
from shopdb.extensions import db
def _utcnow():
# naive UTC for DB columns (stored without tzinfo)
return datetime.now(timezone.utc).replace(tzinfo=None)
class AuditLog(db.Model):
"""
Audit log for tracking user actions.
Records who did what, when, from where, and what changed.
"""
__tablename__ = 'auditlogs'
auditlogid = db.Column(db.Integer, primary_key=True)
# Who
userid = db.Column(db.Integer, db.ForeignKey('users.userid'), nullable=True)
username = db.Column(db.String(100), nullable=True) # Denormalized for history
# When
timestamp = db.Column(db.DateTime, default=_utcnow, nullable=False, index=True)
# Where (client info)
ipaddress = db.Column(db.String(45), nullable=True) # IPv6 max length
useragent = db.Column(db.String(255), nullable=True)
# What
action = db.Column(db.String(20), nullable=False, index=True) # created, updated, deleted
entitytype = db.Column(db.String(50), nullable=False, index=True) # Asset, Printer, Setting, etc.
entityid = db.Column(db.Integer, nullable=True) # ID of the affected record
entityname = db.Column(db.String(255), nullable=True) # Human-readable identifier
# Changes (stored as JSON text)
_changes = db.Column('changes', db.Text, nullable=True) # {"field": {"old": x, "new": y}, ...}
@property
def changes(self):
"""Get changes as dict."""
if self._changes:
import json
try:
return json.loads(self._changes)
except (json.JSONDecodeError, TypeError):
return None
return None
@changes.setter
def changes(self, value):
"""Set changes from dict."""
if value is not None:
import json
self._changes = json.dumps(value)
else:
self._changes = None
# Additional context
details = db.Column(db.Text, nullable=True) # Optional description
# Relationship
user = db.relationship('User', backref=db.backref('auditlogs', lazy='dynamic'))
def to_dict(self):
return {
'auditlogid': self.auditlogid,
'userid': self.userid,
'username': self.username,
'timestamp': self.timestamp.isoformat() + 'Z' if self.timestamp else None,
'ipaddress': self.ipaddress,
'useragent': self.useragent,
'action': self.action,
'entitytype': self.entitytype,
'entityid': self.entityid,
'entityname': self.entityname,
'changes': self.changes,
'details': self.details
}
@classmethod
def log(cls, action: str, entitytype: str, entityid: int = None,
entityname: str = None, changes: dict = None, details: str = None,
user=None, request=None):
"""
Create an audit log entry.
Args:
action: 'created', 'updated', 'deleted'
entitytype: Type of entity (e.g., 'Asset', 'Printer', 'Setting')
entityid: ID of the affected record
entityname: Human-readable name/identifier
changes: Dict of field changes {"field": {"old": x, "new": y}}
details: Optional description
user: Current user object (or will try to get from flask-jwt-extended)
request: Flask request object (or will try to get current request)
"""
from flask import request as flask_request
from flask_jwt_extended import current_user, verify_jwt_in_request
# Get user info
if user is None:
try:
verify_jwt_in_request(optional=True)
user = current_user
except:
pass
userid = user.userid if user else None
username = user.username if user else None
# Get request info
request_obj = request or flask_request
ipaddress = None
useragent = None
if request_obj:
# First-hop client IP, port stripped (ARR forwards clientip:port).
from shopdb.utils.clientip import client_ip
ipaddress = client_ip(request_obj)
useragent = request_obj.headers.get('User-Agent', '')[:255]
entry = cls(
userid=userid,
username=username,
ipaddress=ipaddress,
useragent=useragent,
action=action,
entitytype=entitytype,
entityid=entityid,
entityname=entityname,
changes=changes,
details=details
)
db.session.add(entry)
# Don't commit here - let the caller handle transaction
return entry
@classmethod
def log_create(cls, entitytype: str, entity, name_field: str = 'name'):
"""Log a create action."""
entityname = getattr(entity, name_field, None) or str(entity)
entityid = getattr(entity, f'{entitytype.lower()}id', None) or getattr(entity, 'id', None)
return cls.log('created', entitytype, entityid=entityid, entityname=entityname)
@classmethod
def log_update(cls, entitytype: str, entity, changes: dict, name_field: str = 'name'):
"""Log an update action with changes."""
entityname = getattr(entity, name_field, None) or str(entity)
entityid = getattr(entity, f'{entitytype.lower()}id', None) or getattr(entity, 'id', None)
return cls.log('updated', entitytype, entityid=entityid, entityname=entityname, changes=changes)
@classmethod
def log_delete(cls, entitytype: str, entityid: int, entityname: str = None):
"""Log a delete action."""
return cls.log('deleted', entitytype, entityid=entityid, entityname=entityname)
"""Audit log model for tracking changes."""
from datetime import datetime, timezone
from shopdb.extensions import db
def _utcnow():
# naive UTC for DB columns (stored without tzinfo)
return datetime.now(timezone.utc).replace(tzinfo=None)
def _jsonsafe(value):
"""Last-resort encoder for a value the audit log was handed.
Dates and datetimes become ISO strings, Decimals become floats, and
anything else becomes its repr rather than an exception. An audit entry
that reads a little awkwardly is worth more than a failed write: the
alternative, and what happened, is losing the change AND the record of it.
"""
from datetime import date, datetime, time
from decimal import Decimal
if isinstance(value, (datetime, date, time)):
return value.isoformat()
if isinstance(value, Decimal):
return float(value)
if isinstance(value, (set, frozenset)):
return sorted(str(item) for item in value)
if isinstance(value, bytes):
return value.decode('utf-8', 'replace')
return repr(value)
class AuditLog(db.Model):
"""
Audit log for tracking user actions.
Records who did what, when, from where, and what changed.
"""
__tablename__ = 'auditlogs'
auditlogid = db.Column(db.Integer, primary_key=True)
# Who
userid = db.Column(db.Integer, db.ForeignKey('users.userid'), nullable=True)
username = db.Column(db.String(100), nullable=True) # Denormalized for history
# When
timestamp = db.Column(db.DateTime, default=_utcnow, nullable=False, index=True)
# Where (client info)
ipaddress = db.Column(db.String(45), nullable=True) # IPv6 max length
useragent = db.Column(db.String(255), nullable=True)
# What
action = db.Column(db.String(20), nullable=False, index=True) # created, updated, deleted
entitytype = db.Column(db.String(50), nullable=False, index=True) # Asset, Printer, Setting, etc.
entityid = db.Column(db.Integer, nullable=True) # ID of the affected record
entityname = db.Column(db.String(255), nullable=True) # Human-readable identifier
# Changes (stored as JSON text)
_changes = db.Column('changes', db.Text, nullable=True) # {"field": {"old": x, "new": y}, ...}
@property
def changes(self):
"""Get changes as dict."""
if self._changes:
import json
try:
return json.loads(self._changes)
except (json.JSONDecodeError, TypeError):
return None
return None
@changes.setter
def changes(self, value):
"""Set changes from dict.
Serialised with a fallback for the types a model actually holds. A bare
json.dumps raises on a date, a datetime or a Decimal, and the caller is
a route building {'old': getattr(row, field)} straight off the model -
so the moment an audited field held a date, the whole request died.
That is not hypothetical: PUT /api/machines/<id> returned 500 and rolled
the edit back for ANY machine with a maintenance date recorded,
including a rename that never touched the date. The audit log refusing
to record a change must not be what stops the change.
"""
if value is not None:
import json
self._changes = json.dumps(value, default=_jsonsafe)
else:
self._changes = None
# Additional context
details = db.Column(db.Text, nullable=True) # Optional description
# Relationship
user = db.relationship('User', backref=db.backref('auditlogs', lazy='dynamic'))
def to_dict(self):
return {
'auditlogid': self.auditlogid,
'userid': self.userid,
'username': self.username,
'timestamp': self.timestamp.isoformat() + 'Z' if self.timestamp else None,
'ipaddress': self.ipaddress,
'useragent': self.useragent,
'action': self.action,
'entitytype': self.entitytype,
'entityid': self.entityid,
'entityname': self.entityname,
'changes': self.changes,
'details': self.details
}
@classmethod
def log(cls, action: str, entitytype: str, entityid: int = None,
entityname: str = None, changes: dict = None, details: str = None,
user=None, request=None):
"""
Create an audit log entry.
Args:
action: 'created', 'updated', 'deleted'
entitytype: Type of entity (e.g., 'Asset', 'Printer', 'Setting')
entityid: ID of the affected record
entityname: Human-readable name/identifier
changes: Dict of field changes {"field": {"old": x, "new": y}}
details: Optional description
user: Current user object (or will try to get from flask-jwt-extended)
request: Flask request object (or will try to get current request)
"""
from flask import request as flask_request
from flask_jwt_extended import current_user, verify_jwt_in_request
# Get user info
if user is None:
try:
verify_jwt_in_request(optional=True)
user = current_user
except:
pass
userid = user.userid if user else None
username = user.username if user else None
# Get request info
request_obj = request or flask_request
ipaddress = None
useragent = None
if request_obj:
# First-hop client IP, port stripped (ARR forwards clientip:port).
from shopdb.utils.clientip import client_ip
ipaddress = client_ip(request_obj)
useragent = request_obj.headers.get('User-Agent', '')[:255]
entry = cls(
userid=userid,
username=username,
ipaddress=ipaddress,
useragent=useragent,
action=action,
entitytype=entitytype,
entityid=entityid,
entityname=entityname,
changes=changes,
details=details
)
db.session.add(entry)
# Don't commit here - let the caller handle transaction
return entry
@classmethod
def log_create(cls, entitytype: str, entity, name_field: str = 'name'):
"""Log a create action."""
entityname = getattr(entity, name_field, None) or str(entity)
entityid = getattr(entity, f'{entitytype.lower()}id', None) or getattr(entity, 'id', None)
return cls.log('created', entitytype, entityid=entityid, entityname=entityname)
@classmethod
def log_update(cls, entitytype: str, entity, changes: dict, name_field: str = 'name'):
"""Log an update action with changes."""
entityname = getattr(entity, name_field, None) or str(entity)
entityid = getattr(entity, f'{entitytype.lower()}id', None) or getattr(entity, 'id', None)
return cls.log('updated', entitytype, entityid=entityid, entityname=entityname, changes=changes)
@classmethod
def log_delete(cls, entitytype: str, entityid: int, entityname: str = None):
"""Log a delete action."""
return cls.log('deleted', entitytype, entityid=entityid, entityname=entityname)

View File

@@ -1,6 +1,6 @@
"""Base model class with common fields."""
from datetime import datetime, timezone
from datetime import date, datetime, timezone
from shopdb.extensions import db
@@ -63,6 +63,13 @@ class BaseModel(db.Model):
value = getattr(self, c.name)
if isinstance(value, datetime):
value = value.isoformat() + 'Z'
elif isinstance(value, date):
# A date, not an instant: no time and no timezone marker. Left
# to Flask's encoder it became an HTTP-date
# ("Thu, 15 Jan 2026 00:00:00 GMT"), which a browser parses
# back into a moment and can render as the day before.
# datetime is a subclass of date, so this must stay second.
value = value.isoformat()
result[c.name] = value
return result

View File

@@ -102,14 +102,27 @@ def caller_holds(permission_name: str) -> bool:
Guests are included on purpose: a signed-out visitor holds whatever the
guest role was granted, so the answer must come from the same place
require_permission gets it.
Scoped personal tokens are included for the same reason, and this is the
half that was missing: the answer has to be about the CALLER, and a scoped
token is a narrower caller than its owner. Reading the owner's permissions
let a token scoped to endpoints.command queue a remote restart, because the
elevated check asked the owner rather than the token - the one bypass
require_permission exists to prevent, reintroduced by a route that did its
own check.
"""
user = currentuser()
if user is None:
return guestholds(permission_name)
try:
return bool(user.haspermission(permission_name))
if not user.haspermission(permission_name):
return False
patscopes = get_jwt().get('patscopes')
except Exception:
return False
if patscopes is not None:
return permission_name in patscopes
return True
def require_permission(permission_name: str):

70
shopdb/utils/badges.py Normal file
View File

@@ -0,0 +1,70 @@
"""What a scanned badge looks like, in one place.
Two kiosks read the same physical badges and disagreed about them. The parts
kiosk understood both shapes a scanner emits; the USB kiosk took the string
raw and compared it to an SSO, so the wrapped form was simply "not recognized".
An operator whose keyboard-wedge scanner emits `0270015376BZ` could take a
3D-printed part and could not check out a drive - with the same badge, at two
cabinets a few metres apart.
The shapes:
270015376 the SSO as printed, what a keypad entry looks like
0270015376BZ the PayNo wrapper some scanners emit around it
Both mean the same person. Nothing else is a badge - and in particular a string
of NON-ASCII digits is not, however much it looks like one: Arabic-Indic digits
pass Python's str.isdigit(), and a badge written in them was accepted, stored as
the employee's SSO, and joined to nothing afterwards.
This lives in core rather than in either plugin because a plugin may not import
another plugin, and because a badge is a property of the site, not of a
cabinet.
"""
import re
# The PayNo wrapper: a leading zero, the digits, and a BZ suffix.
PAYNO_BADGE = re.compile(r'^0(\d+)BZ$', re.IGNORECASE)
# ASCII digits only. str.isdigit() is true for Arabic-Indic and other decimal
# forms, and int() happily parses them, so a badge in those characters resolved
# to a real employee and was then written back in a form nothing else matches.
ASCII_DIGITS = re.compile(r'^[0-9]+$')
class BadgeShapeError(ValueError):
"""The scanned string is not a badge this site recognises."""
def normalise_badge(raw):
"""Return the plain SSO digits for a scanned badge.
Accepts the bare number and the PayNo wrapper, and raises BadgeShapeError
for anything else. The caller decides what to do about a badge that is
well-formed but matches no employee - that is a directory question, not a
shape one.
"""
badge = (raw or '').strip()
if not badge:
raise BadgeShapeError('Scan or enter a badge')
if ASCII_DIGITS.match(badge):
return badge
match = PAYNO_BADGE.match(badge)
if match and ASCII_DIGITS.match(match.group(1)):
return match.group(1)
raise BadgeShapeError('Unrecognized badge format')
def badge_kind(raw):
"""'sso' or 'payno' for a recognised badge; raises for anything else.
The external HR lookup searches a different column for each, so the shape
survives normalisation for that one caller.
"""
badge = (raw or '').strip()
if ASCII_DIGITS.match(badge):
return 'sso'
if PAYNO_BADGE.match(badge):
return 'payno'
raise BadgeShapeError('Unrecognized badge format')

View File

@@ -0,0 +1,82 @@
"""Parsing a date out of a request body, strictly.
WHY THIS EXISTS
Two plugins carried an identical private `_parse_date` that swallowed the
error:
try:
return datetime.strptime(str(value)[:10], '%Y-%m-%d').date()
except (ValueError, TypeError):
return None
So an unparseable date and a deliberately cleared one produced the same
result, and the write went ahead. `PUT /api/warranty/<id>` with enddate
'not-a-date' answered 200 "Warranty updated" and stored NULL - the warranty
then vanished from every expiry surface, silently, on the strength of a typo.
The measuring-tool calibration date did the same thing and dropped the tool out
of the calibration report.
The rule here: an EXPLICIT empty value clears the field, and anything that is
not a date is refused. Those are different intents and they get different
answers. Callers turn `DateValueError` into a 400 naming the field, so the
person who mistyped it finds out at the point of the mistake rather than at the
next audit.
Accepted forms are deliberately narrow - 'YYYY-MM-DD', and the ISO-8601
datetime forms whose leading ten characters are a date, since that is what the
frontend's date inputs and every vendor API in the tree emit. Anything looser
is a guess about what somebody meant.
"""
from datetime import date, datetime
class DateValueError(ValueError):
"""A supplied value was neither empty nor a date this accepts.
Carries the field name so the error message can say which one, which is the
whole point of refusing rather than nulling.
"""
def __init__(self, field, value):
self.field = field
self.value = value
super().__init__(
'%s must be a date as YYYY-MM-DD (got %r)' % (field, value))
def parse_date(value, field='date'):
"""Return a date, or None for an explicitly empty value.
Raises DateValueError for anything else. None and '' mean "clear it";
they are the only values that produce None.
"""
if value is None:
return None
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
if not isinstance(value, str):
# A number or a list is not a mistyped date, it is the wrong type.
raise DateValueError(field, value)
text = value.strip()
if not text:
return None
try:
return datetime.strptime(text[:10], '%Y-%m-%d').date()
except ValueError:
raise DateValueError(field, value)
def parse_date_fields(data, fields):
"""Parse several date fields out of a payload at once.
Only keys PRESENT in the payload are returned, so a caller can tell "not
mentioned" (leave it alone) from "sent as empty" (clear it) - a distinction
a plain `data.get(...)` throws away, and the reason the alias-clearing bug
existed on the PC form.
"""
return {field: parse_date(data[field], field)
for field in fields if field in data}

64
shopdb/utils/queryargs.py Normal file
View File

@@ -0,0 +1,64 @@
"""Reading a typed value out of the query string, without a 500.
WHY THIS EXISTS
List endpoints filtered like this:
if type_id := request.args.get('typeid'):
query = query.filter(Machine.machinetypeid == int(type_id))
`int('abc')` raises, nothing catches it, and the caller gets
500 INTERNAL_ERROR with a traceback in the log. A wrong query argument is the
caller's mistake and deserves a 400 saying which argument; a 500 says the server
is broken and sends whoever is reading the log looking for a fault that is not
there. The feature test run found the same shape in six places across five
plugins, which makes it a property of how list routes are written rather than of
any one route.
The helpers raise QueryArgError. Routes turn it into a 400 - or, more usually,
nothing does anything at all, because the app-level handler in
shopdb/__init__.py answers it. That is the point: a route should not have to
remember.
Deliberately NOT the same policy as get_pagination_params, which falls back to
a default for a bad page number. A nonsense page is harmless and the caller
still wants their list; a nonsense FILTER is not, because silently ignoring it
returns rows the caller believes they excluded.
"""
from flask import request
class QueryArgError(ValueError):
"""A query argument was present but not of the type the route needs."""
def __init__(self, name, value, expected='a whole number'):
self.name = name
self.value = value
super().__init__('%s must be %s (got %r)' % (name, expected, value))
def intarg(name, default=None, request_obj=None):
"""Read an integer query argument.
Absent or empty -> default. Present but not an integer -> QueryArgError,
which the app answers as a 400 naming the argument.
"""
source = request_obj if request_obj is not None else request
raw = source.args.get(name)
if raw is None or raw == '':
return default
try:
return int(raw)
except (TypeError, ValueError):
raise QueryArgError(name, raw)
def intargs(*names, request_obj=None):
"""Read several integer arguments at once; returns a dict of those present."""
found = {}
for name in names:
value = intarg(name, request_obj=request_obj)
if value is not None:
found[name] = value
return found

View File

@@ -417,3 +417,45 @@ def test_update_scopes_ceiling_enforced(client, db):
headers=headers)
assert response.status_code == 400
assert 'applications.delete' in response.get_json()['data']['error']['message']
# --- a route that does its own permission check --------------------------
#
# require_permission suspends the admin bypass for a scoped token, and the
# tests above pin that. What they could not see is a route that checks a
# SECOND permission by hand: the endpoints command route asked
# current_user.haspermission(elevated), which reads the OWNER. A token scoped
# to endpoints.command alone could therefore queue system_restart, run_script
# and update_app on a shop-floor PC - the exact bypass this file exists to
# prevent, reintroduced one route at a time.
#
# The fix is in caller_holds, so this tests the helper rather than only the one
# route: it is the answer every hand-rolled check should be using.
def test_a_scoped_token_cannot_pass_a_hand_rolled_permission_check(
client, db, admin_user, auth_headers):
"""The secure-send route checks tools.secrets itself rather than through
require_permission. A scoped token whose owner holds that permission must
still be refused, because the caller is the token."""
secret = _create_token(
client, auth_headers,
scopes=['applications.create']).get_json()['data']['secret']
refused = client.post('/api/tools/secrets', json={'content': 'hello'},
headers=_pat_headers(secret))
assert refused.status_code == 403, refused.get_json()
def test_an_unscoped_token_still_passes_that_check(client, db, admin_user,
auth_headers):
"""The narrowing applies ONLY to scoped tokens - an unscoped one is the
person, and must keep working."""
secret = _create_token(
client, auth_headers, scopes=None).get_json()['data']['secret']
allowed = client.post('/api/tools/secrets', json={'content': 'hello'},
headers=_pat_headers(secret))
# Not 403 is the whole assertion: the payload is deliberately incomplete
# (secure send wants ciphertext and iv), so a 400 proves the request got
# PAST the permission check, which is what this is about.
assert allowed.status_code != 403, allowed.get_json()

View File

@@ -40,3 +40,53 @@ def test_version_delivery_metadata_defaults_to_null(client, db, auth_headers):
data = resp.get_json()['data']
assert data['sha256'] is None
assert data['silentargs'] is None
# --- a digest is a claim something checks against ---------------------------
#
# sha256 and releasedate were stored raw. An over-long digest was silently
# truncated to the column width and came back as 64 plausible hex characters -
# indistinguishable from a real SHA-256, and wrong. A malformed releasedate
# reached MySQL, which wrote 0000-00-00 and then broke every read of that
# application, with no API way to remove the row.
def _app(client, headers, name='Digest Test App'):
created = client.post('/api/applications', headers=headers,
json={'appname': name})
assert created.status_code in (200, 201), created.get_json()
data = created.get_json()['data']
return data.get('appid') or data.get('applicationid')
def test_a_digest_that_is_not_a_sha256_is_refused(client, db, auth_headers):
appid = _app(client, auth_headers, 'Digest refuse app')
for bad in ('c' * 100, 'nothex' * 10, 'abc', 'C' * 63):
response = client.post('/api/applications/%d/versions' % appid,
headers=auth_headers,
json={'version': 'v-%s' % bad[:4],
'sha256': bad})
assert response.status_code == 400, (bad, response.get_json())
def test_a_real_digest_is_accepted_and_lowercased(client, db, auth_headers):
appid = _app(client, auth_headers, 'Digest accept app')
digest = 'A' * 64
response = client.post('/api/applications/%d/versions' % appid,
headers=auth_headers,
json={'version': 'v1', 'sha256': digest})
assert response.status_code in (200, 201), response.get_json()
assert response.get_json()['data']['sha256'] == 'a' * 64
def test_a_malformed_release_date_is_refused_and_nothing_is_committed(
client, db, auth_headers):
from shopdb.core.models import AppVersion
appid = _app(client, auth_headers, 'Date refuse app')
response = client.post('/api/applications/%d/versions' % appid,
headers=auth_headers,
json={'version': 'poison-1',
'releasedate': 'tomorrow'})
assert response.status_code == 400, response.get_json()
assert AppVersion.query.filter_by(version='poison-1').first() is None, \
'a refused version must not be committed'

View File

@@ -0,0 +1,99 @@
"""One setting, one key.
Audit retention had two: shopdb/core/services/auditretention.py reads
`audit_retentiondays`, and the Audit & Logging settings page wrote
`audit_retention_days`. The page saved, reloaded, showed the value back, and
nothing ever consulted it - retention stayed off while the screen said it was
on. A settings page that saves a key nothing reads is worse than a missing
feature, because it tells an administrator the site is configured when it is
not.
"""
import json
import re
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
def _seeded_keys():
"""Every setting key in the DEFAULTS list, read statically."""
source = (REPO / 'shopdb' / 'core' / 'api' / 'settings.py').read_text()
return re.findall(r"^\s*'key': '([a-z0-9_]+)'", source, re.M)
def test_only_one_audit_retention_key_is_seeded():
keys = _seeded_keys()
retention = sorted({key for key in keys if 'retention' in key})
assert retention == ['audit_retentiondays'], retention
def test_the_service_and_the_settings_pages_use_the_same_key():
"""The three places that mention retention must agree: the service that
reads it, and both settings pages that write it."""
service = (REPO / 'shopdb' / 'core' / 'services' / 'auditretention.py').read_text()
assert "audit_retentiondays" in service
assert "audit_retention_days" not in service
for page in ('AuditSettings.vue', 'SiteSettings.vue'):
text = (REPO / 'frontend' / 'src' / 'views' / 'settings' / page).read_text()
if 'retention' not in text.lower():
continue
assert 'audit_retention_days' not in text, page
assert 'audit_retentiondays' in text, page
def test_the_retention_setting_actually_drives_the_report(client, db,
auth_headers):
"""End to end: set it through the API the page uses, and read it back from
the endpoint that decides what a purge would remove."""
saved = client.put('/api/settings/audit_retentiondays',
json={'value': 30}, headers=auth_headers)
assert saved.status_code == 200, saved.get_json()
report = client.get('/api/auditlogs/retention', headers=auth_headers)
assert report.status_code == 200, report.get_json()
assert report.get_json()['data']['retentiondays'] == 30, \
'the setting the page writes must be the one the service reads'
# --- a declared valuetype is a promise to every reader -----------------------
#
# PUT /api/settings/<key> stored whatever arrived. "A6-not-a-number" went into
# an integer key verbatim, and every consumer then read 0, because
# Setting.get_typed_value swallows the ValueError. The setting looked set, the
# behaviour was the default, and nothing said so.
def test_an_integer_setting_refuses_something_that_is_not_a_number(
client, db, auth_headers):
refused = client.put('/api/settings/audit_retentiondays',
json={'value': 'not-a-number'}, headers=auth_headers)
assert refused.status_code == 400, refused.get_json()
assert 'whole-number' in refused.get_json()['data']['error']['message']
# And the old value survives, rather than being replaced by a 0 nobody
# asked for.
reading = client.get('/api/settings/audit_retentiondays',
headers=auth_headers)
assert reading.status_code == 200
assert str(reading.get_json()['data']['value']) != 'not-a-number'
def test_an_integer_setting_still_accepts_a_number(client, db, auth_headers):
accepted = client.put('/api/settings/audit_retentiondays',
json={'value': 45}, headers=auth_headers)
assert accepted.status_code == 200, accepted.get_json()
def test_a_boolean_setting_refuses_a_word_that_is_not_true_or_false(
client, db, auth_headers):
refused = client.put('/api/settings/securesend_allow_guests',
json={'value': 'maybe'}, headers=auth_headers)
assert refused.status_code == 400, refused.get_json()
def test_a_boolean_setting_accepts_the_usual_spellings(client, db,
auth_headers):
for value in (True, False, 'true', 'false', '1', '0'):
response = client.put('/api/settings/securesend_allow_guests',
json={'value': value}, headers=auth_headers)
assert response.status_code == 200, (value, response.get_json())

View File

@@ -0,0 +1,120 @@
"""No list route may answer a nonsense filter with a 500.
THE GATE, not just the six fixes
The feature test run found `int(request.args.get(...))` with nothing catching
the ValueError in six routes across five plugins - machines, network, printers,
knowledgebase, computers and the asset core. Fixing six sites does not stop a
seventh: the pattern is how a filter gets written, and it is invisible in review
because the happy path works.
So this sweeps every registered GET route that takes no path parameters and
sends each of them a set of arguments that are not what any filter wants. The
contract is narrow on purpose:
- a 5xx is ALWAYS a failure. The caller sent a bad argument; that is a 400.
- a 400 is the intended answer.
- a 200 is acceptable: a route is entitled to ignore an argument it does not
know about, and most of these routes will not have the argument at all.
- a 401/403/404 is acceptable: this test is about argument handling, not
about authorization.
Run as an admin, because a route that refuses everyone tests nothing.
"""
import pytest
# Names taken from the filters the failing routes actually declared, plus the
# generic shapes a client sends. Values chosen to break a bare int(), a bare
# float(), and a naive split.
BAD_ARGS = [
('typeid', 'abc'),
('type_id', 'abc'),
('assettypeid', '1; DROP TABLE assets'),
('vendorid', 'null'),
('locationid', '1.5'),
('businessunitid', ''),
('appid', 'abc'),
('statusid', 'NaN'),
('modelid', '-'),
('limit', 'lots'),
('page', 'abc'),
('perpage', 'abc'),
]
# Routes that are not list endpoints and whose GET does real work with side
# effects or external calls. Excluded by PREFIX, and each one says why.
SKIP_PREFIXES = (
'/api/collector/', # needs the collector key; fail-closed by design
'/api/endpoints/', # agent wire protocol, same key
'/api/backups/run', # kicks off work rather than reading
)
# Skipped by EXACT path, not prefix, so the rest of the plugin stays covered.
SKIP_EXACT = {
# These four reach the EXTERNAL cmmc_usb database. Their 500 here is "no
# such database in the test environment", a different fault from the one
# this gate is about, and the list is the complete set - every usb GET
# route whose handler calls cmmc_usb_connection. The self-hosted USB
# surface is covered by test_usb_kiosk.py.
'/api/usb',
'/api/usb/checkouts',
'/api/usb/checkouts/active',
'/api/usb/reports/audit',
}
def _listish_routes(app):
routes = []
for rule in app.url_map.iter_rules():
path = str(rule.rule)
if not path.startswith('/api/') or '<' in path:
continue
if 'GET' not in (rule.methods or ()):
continue
if path in SKIP_EXACT:
continue
if any(path.startswith(prefix) for prefix in SKIP_PREFIXES):
continue
routes.append(path)
return sorted(set(routes))
def test_the_sweep_actually_covers_something(app):
"""A gate that silently matches nothing passes forever. This one has been
known to cover 90+ routes; if it drops to a handful, the filter above is
wrong rather than the app being small."""
assert len(_listish_routes(app)) > 40
def test_no_route_answers_a_nonsense_filter_with_a_server_error(
client, auth_headers, app):
failures = []
for path in _listish_routes(app):
for name, value in BAD_ARGS:
response = client.get('%s?%s=%s' % (path, name, value),
headers=auth_headers)
if response.status_code >= 500:
failures.append('%s?%s=%s -> %d' % (path, name, value,
response.status_code))
assert not failures, (
'a bad query argument is a 400, never a 500:\n '
+ '\n '.join(failures[:40]))
@pytest.mark.parametrize('value', ['abc', '', '1.5', 'null'])
def test_a_bad_type_filter_is_a_400_that_names_the_argument(
client, auth_headers, value):
"""The routes the run actually caught. An empty value is the exception:
absent and empty both mean 'no filter', which is what a cleared dropdown
sends."""
for path in ('/api/machines', '/api/computers', '/api/network',
'/api/printers', '/api/assets'):
response = client.get('%s?typeid=%s' % (path, value),
headers=auth_headers)
if value == '':
assert response.status_code == 200, (path, response.get_json())
continue
assert response.status_code == 400, (path, response.status_code)
body = response.get_json()
assert body['data']['error']['code'] == 'VALIDATION_ERROR'
assert 'typeid' in body['data']['error']['message']

View File

@@ -0,0 +1,80 @@
"""Two ways to change a credential must not disagree.
Both of these were found by the 2026-09-03 run, and both are the same shape: a
second route that reached the same state with fewer rules than the route
designed for it.
* PUT /api/users/<self> accepted a `password` field with no current-password
proof and no length rule, while /api/auth/change-password requires both.
The weaker route is the real rule, so the stronger one was decoration.
* PUT /api/apitokens/<id> {"isactive": true} un-revoked a token, and the
ORIGINAL secret worked again. "Revoked" meant "paused until somebody flips
it back" - for a credential whose secret is, by the act of revoking it,
assumed to be out.
"""
def _token(client, headers, name='cred-test'):
created = client.post('/api/apitokens', headers=headers,
json={'name': name})
assert created.status_code in (200, 201), created.get_json()
return created.get_json()['data']
def test_a_user_cannot_change_their_own_password_through_the_users_route(
client, db, member_headers, member_user):
"""The route that asks for no current password must refuse the change, not
perform it."""
response = client.put('/api/users/%d' % member_user.userid,
headers=member_headers, json={'password': 'brandnew1'})
assert response.status_code == 403, response.get_json()
assert 'change-password' in response.get_json()['data']['error']['message']
def test_an_admin_may_still_issue_a_temporary_password(client, db,
auth_headers,
member_user):
"""Issuing one is how a locked-out user gets back in - and it is temporary
by definition, so the holder must replace it at next login."""
response = client.put('/api/users/%d' % member_user.userid,
headers=auth_headers, json={'password': 'issued12345'})
assert response.status_code == 200, response.get_json()
db.session.refresh(member_user)
assert member_user.mustchangepassword is True
def test_an_admin_issued_password_still_has_to_be_long_enough(client, db,
auth_headers,
member_user):
response = client.put('/api/users/%d' % member_user.userid,
headers=auth_headers, json={'password': 'short'})
assert response.status_code == 400, response.get_json()
def test_a_revoked_token_cannot_be_brought_back(client, db, auth_headers):
token = _token(client, auth_headers, name='cred-revoke')
tokenid = token['tokenid']
secret = token['secret']
assert client.delete('/api/apitokens/%d' % tokenid,
headers=auth_headers).status_code == 200
refused = client.put('/api/apitokens/%d' % tokenid, headers=auth_headers,
json={'isactive': True})
assert refused.status_code == 400, refused.get_json()
assert 'new token' in refused.get_json()['data']['error']['message']
# And the original secret stays dead, which is the point.
denied = client.get('/api/assets',
headers={'Authorization': 'Bearer %s' % secret})
assert denied.status_code == 401
def test_a_live_token_can_still_be_deactivated_through_the_same_route(
client, db, auth_headers):
"""One-way, not frozen: turning one OFF here still works."""
token = _token(client, auth_headers, name='cred-deactivate')
response = client.put('/api/apitokens/%d' % token['tokenid'],
headers=auth_headers, json={'isactive': False})
assert response.status_code == 200, response.get_json()
assert response.get_json()['data']['isactive'] is False

View File

@@ -24,3 +24,84 @@ def test_search_degrades_when_directory_unreachable(client, db, auth_headers):
resp = client.get('/api/employees/search?q=smith', headers=auth_headers)
assert resp.status_code == 500
assert resp.get_json()['status'] == 'error'
# --- a full name has to find the person -------------------------------------
#
# Reported from the floor: somebody types the employee's whole name into the
# notification/recognition picker and presses Enter instead of clicking the
# suggestion, and the entry does not pull from the employee database.
#
# Two causes, one symptom. The search matched the whole query against first
# name OR last name, so "Dana Reyes" matched nothing - no single column holds
# that string - and with no suggestion to click, the form turned the typed text
# into a free-text label with no SSO: no photo, no directory link, and a
# recognition the person it is about cannot be found by.
def _seed(db, first, last, sso):
"""One directory employee, in self-hosted mode.
The mode matters: without it the search goes to the external HR database,
which does not exist here and answers 500.
"""
from shopdb.core.models import Setting
from plugins.employees.models import DirectoryEmployee
row = Setting.query.filter_by(key='employee_directory_mode').first()
if row is None:
db.session.add(Setting(key='employee_directory_mode',
value='selfhosted', valuetype='string',
category='site'))
else:
row.value = 'selfhosted'
db.session.add(DirectoryEmployee(sso=sso, firstname=first, lastname=last))
db.session.commit()
def test_a_full_name_finds_the_employee(client, db, auth_headers):
_seed(db, 'Dana', 'Reyes', 990001)
response = client.get('/api/employees/search?q=Dana%20Reyes',
headers=auth_headers)
assert response.status_code == 200, response.get_json()
rows = response.get_json()['data']
assert [str(r['SSO']) for r in rows] == ['990001']
def test_the_words_may_be_in_either_order(client, db, auth_headers):
"""A directory that lists "Reyes, Dana" trains people to type it that way."""
_seed(db, 'Dana', 'Reyes', 990002)
rows = client.get('/api/employees/search?q=Reyes%20Dana',
headers=auth_headers).get_json()['data']
assert [str(r['SSO']) for r in rows] == ['990002']
def test_every_word_must_match_so_a_full_name_is_not_a_broad_search(
client, db, auth_headers):
""""Dana Reyes" must not also return Sam Reyes - that is what makes the
result unambiguous enough to select automatically."""
_seed(db, 'Dana', 'Reyes', 990003)
_seed(db, 'Sam', 'Reyes', 990004)
rows = client.get('/api/employees/search?q=Dana%20Reyes',
headers=auth_headers).get_json()['data']
assert [str(r['SSO']) for r in rows] == ['990003']
def test_a_single_word_still_searches_broadly(client, db, auth_headers):
"""One word is a search, not a name: it must still find every match."""
_seed(db, 'Dana', 'Reyes', 990005)
_seed(db, 'Sam', 'Reyes', 990006)
rows = client.get('/api/employees/search?q=Reyes',
headers=auth_headers).get_json()['data']
assert len(rows) == 2
def test_an_sso_still_resolves(client, db, auth_headers):
_seed(db, 'Dana', 'Reyes', 990007)
rows = client.get('/api/employees/search?q=990007',
headers=auth_headers).get_json()['data']
assert [str(r['SSO']) for r in rows] == ['990007']

View File

@@ -0,0 +1,66 @@
"""Every /api/ error answers in the documented envelope.
the API-REFERENCE wiki page says an error is
{"status":"error","data":{"error":{"code","message"}},"meta":{...}}. Three
statuses came straight from Werkzeug as HTML instead:
405 a wrong method - and on a route the caller cannot otherwise see, the
405 also CONFIRMED the path exists, so a gated kiosk route meant to
read as absent announced itself by refusing the wrong verb
415 a body sent without Content-Type: application/json
400 a body that is not parseable JSON
A client written against the contract gets a page of markup with no error code,
which is indistinguishable from the server having fallen over.
Outside /api/ the framework's own behaviour is left alone: that is the SPA, and
it has its own catch-all.
"""
def _is_envelope(response):
body = response.get_json(silent=True)
if not body:
return False
return (body.get('status') == 'error'
and 'error' in body.get('data', {})
and 'code' in body['data']['error']
and 'message' in body['data']['error']
and 'meta' in body)
def test_a_wrong_method_answers_in_the_envelope(client):
response = client.delete('/api/assets/types')
assert response.status_code == 405
assert response.content_type.startswith('application/json'), \
response.content_type
assert _is_envelope(response), response.get_data(as_text=True)[:200]
def test_a_body_with_the_wrong_content_type_answers_in_the_envelope(client):
response = client.post('/api/auth/login', data='not json',
content_type='text/plain')
assert response.status_code == 415
assert _is_envelope(response), response.get_data(as_text=True)[:200]
def test_a_malformed_json_body_answers_in_the_envelope(client):
response = client.post('/api/auth/login', data='{"username": ',
content_type='application/json')
assert response.status_code == 400
assert _is_envelope(response), response.get_data(as_text=True)[:200]
def test_a_missing_api_route_still_answers_in_the_envelope(client):
response = client.get('/api/nothing-here-at-all')
assert response.status_code == 404
assert _is_envelope(response)
def test_the_spa_catch_all_is_untouched(client):
"""A browser asking for an app route must still get the app, not JSON.
Enveloping everything would have broken deep links."""
response = client.get('/pcs/1')
assert response.status_code in (200, 404)
if response.status_code == 200:
assert not response.content_type.startswith('application/json')

View File

@@ -290,3 +290,35 @@ def test_capabilities_does_not_report_what_the_guest_role_holds(client, db,
grant(db, guestrole, 'assets.view')
body = client.get('/api/dashboard/capabilities').get_json()['data']
assert set(body) == {'cansearch'}
# --- a grant nobody reads is worse than no grant ----------------------------
#
# tools.secrets was declared guest-grantable, so the role grid offered it and
# stored it, and the secure-send route never consulted the guest ROLE - it
# decides on securesend_allow_guests, which fails closed and carries the rate
# limit. Ticking the box changed nothing in either direction, so the screen and
# the behaviour disagreed. One decision, one mechanism.
def test_tools_secrets_is_not_offered_as_a_guest_grant(app):
from shopdb.core.models import guest_grantable_permissions
with app.app_context():
grantable = guest_grantable_permissions()
assert 'tools.secrets' not in grantable
assert 'tools.view' in grantable, \
'the nav entry gate is a real guest grant and must stay'
def test_the_guest_role_still_saves_when_it_holds_only_grantable_permissions(
client, db, auth_headers, guestrole):
"""The deadlock guard: removing something from the grantable set must not
stop the guest role being saved (see 723a4ff)."""
from shopdb.core.models import Role
guest = Role.query.filter_by(rolename=Role.GUEST).first()
assert guest is not None
saved = client.put('/api/users/roles/%d' % guest.roleid, headers=auth_headers,
json={'permissions': ['tools.view']})
assert saved.status_code == 200, saved.get_json()

View File

@@ -75,3 +75,73 @@ def test_per_type_identifier_seed_count(client, db, auth_headers):
}
assert expected <= keys
assert len(expected) == len(IDENTIFIER_LABELS) * len(IDENTIFIER_ASSETTYPES)
# --- owned location versus inherited location -------------------------------
#
# Asset.to_dict merges an inherited location into locationid/mapx/mapy/levelid
# when the asset has none of its own. That is right for a reader - a detail
# page, a list and the map all want the resolved location - and wrong for a
# FORM, which reads the same payload and cannot tell an owned value from a
# derived one. The PC form pre-selected an inherited location and saved it back
# as the record's own, so an edit that never touched location silently stopped
# the PC following its parent.
def test_an_asset_payload_says_what_it_owns_as_well_as_what_it_resolves_to(
db, app):
from shopdb.core.models import Asset, AssetType, Location, RelationshipType
from shopdb.core.models.relationship import AssetRelationship
with app.app_context():
assettype = AssetType(assettype='machine')
location = Location(locationname='Bay 7')
db.session.add_all([assettype, location])
db.session.flush()
parent = Asset(assetnumber='OWN-PARENT', assettypeid=assettype.assettypeid,
locationid=location.locationid, mapx=10, mapy=20)
child = Asset(assetnumber='OWN-CHILD', assettypeid=assettype.assettypeid)
db.session.add_all([parent, child])
db.session.flush()
controls = RelationshipType.query.filter_by(
relationshiptype='controls').first()
if controls is None:
controls = RelationshipType(relationshiptype='controls',
isdirectional=True)
db.session.add(controls)
db.session.flush()
db.session.add(AssetRelationship(
sourceassetid=child.assetid, targetassetid=parent.assetid,
relationshiptypeid=controls.relationshiptypeid))
db.session.commit()
payload = child.to_dict()
# What it OWNS: nothing. That is the value a form must bind to.
assert payload['ownlocationid'] is None
assert payload['ownmapx'] is None
assert payload['ownmapy'] is None
assert payload['ownlevelid'] is None
def test_the_owned_values_are_present_even_with_nothing_inherited(db, app):
"""A client binds to them unconditionally, so they cannot appear only when
inheritance happens to be in play."""
from shopdb.core.models import Asset, AssetType, Location
with app.app_context():
assettype = AssetType(assettype='machine')
location = Location(locationname='Bay 9')
db.session.add_all([assettype, location])
db.session.flush()
asset = Asset(assetnumber='OWN-PLAIN', assettypeid=assettype.assettypeid,
locationid=location.locationid, mapx=5, mapy=6)
db.session.add(asset)
db.session.commit()
payload = asset.to_dict()
assert payload['ownlocationid'] == payload['locationid']
assert payload['ownmapx'] == 5
assert payload['ownmapy'] == 6

View File

@@ -0,0 +1,56 @@
"""The floor-map level reads answer a caller with no account.
ADR-017 rule 6 says so, docs/api-inventory.json declares auth "none" for all
three, PrinterInstallerMap.vue carries a comment explaining why, and the module
docstring in shopdb/core/api/maplevels.py says the reads are public. The code
disagreed with all four: afce2437 swept 165 genuinely-open GET routes behind
require_permission and caught these in the net, so the anonymous installer map
drew no floor plan.
Nothing leaked - it failed closed - but a feature was off for a fortnight while
every document said it was on. test_public_read_surface.py checks that a public
route is REGISTERED as public; this checks that it actually answers, which is
the part that was broken.
"""
def _level(db):
from shopdb.core.models.maplevel import Building, MapLevel
building = Building(buildingname='Public Works', sortorder=1)
db.session.add(building)
db.session.flush()
level = MapLevel(buildingid=building.buildingid, levelname='Ground',
sortorder=1, blueprintlight='floorplan.svg')
db.session.add(level)
db.session.commit()
return level.levelid
def test_the_level_list_answers_an_anonymous_caller(client, db):
levelid = _level(db)
response = client.get('/api/maplevels')
assert response.status_code == 200, response.get_json()
payload = response.get_json()['data']
names = [level['levelname']
for building in payload['buildings']
for level in building['levels']]
assert 'Ground' in names
assert levelid
def test_one_level_answers_an_anonymous_caller(client, db):
levelid = _level(db)
response = client.get('/api/maplevels/%d' % levelid)
assert response.status_code == 200, response.get_json()
assert response.get_json()['data']['levelname'] == 'Ground'
def test_writes_are_still_gated(client, db):
"""The reads going back to public must not take the writes with them."""
levelid = _level(db)
assert client.post('/api/maplevels/buildings',
json={'buildingname': 'Sneaky'}).status_code in (401, 403)
assert client.put('/api/maplevels/%d' % levelid,
json={'levelname': 'Renamed'}).status_code in (401, 403)
assert client.delete('/api/maplevels/%d' % levelid).status_code in (401, 403)

View File

@@ -0,0 +1,79 @@
"""No client may ask for a page bigger than the server will give.
MAX_PAGE_SIZE caps perpage at 100. The cap is deliberate, and it is applied
SILENTLY: a request for perpage=500 returns the first 100 rows with a 200, so
nothing anywhere reports a problem. A picker built that way shows the first 100
options and drops the rest, and the person using it has no way to tell - the
missing vendor simply is not in the list.
That shape has been found and fixed more than once, which is what makes it worth
a gate rather than a comment. Paging through is what fetchAllPages/listAll are
for.
The check reads the frontend sources rather than the running app, because the
defect is in what the caller ASKS for: the response is a valid 200 either way,
so no runtime assertion can see it.
"""
import re
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
# Where a client is written. The staged copy under frontend/src/.plugins-staged
# is generated from plugins/*/frontend at build time, so checking the source
# avoids reporting the same line twice.
SOURCE_DIRS = [
REPO / 'frontend' / 'src',
*sorted((REPO / 'plugins').glob('*/frontend')),
]
SKIP_PARTS = {'.plugins-staged', 'node_modules', 'dist'}
# perpage: <number>, in an object literal or a query string.
PERPAGE = re.compile(r'perpage[\'"]?\s*[:=]\s*[\'"]?(\d+)', re.IGNORECASE)
MAX_PAGE_SIZE = 100
def _sources():
for root in SOURCE_DIRS:
if not root.exists():
continue
for path in root.rglob('*'):
if path.suffix not in ('.vue', '.js'):
continue
if set(path.parts) & SKIP_PARTS:
continue
if path.name.endswith('.spec.js'):
continue
yield path
def test_the_sweep_actually_reads_something():
"""A gate that matches nothing passes forever."""
files = list(_sources())
assert len(files) > 100, len(files)
assert any(PERPAGE.search(path.read_text()) for path in files), \
'no perpage usage found at all - the pattern is probably wrong'
def test_no_client_asks_for_more_rows_than_the_server_returns():
offenders = []
for path in _sources():
for lineno, line in enumerate(path.read_text().splitlines(), start=1):
# A comment explaining the cap is not a caller asking for 1000
# rows. Crude but sufficient: this is a line-oriented check over
# source that follows one style.
stripped = line.strip()
if stripped.startswith('//') or stripped.startswith('*'):
continue
for match in PERPAGE.finditer(line):
if int(match.group(1)) > MAX_PAGE_SIZE:
offenders.append('%s:%d %s'
% (path.relative_to(REPO), lineno,
line.strip()[:90]))
assert not offenders, (
'perpage is capped at %d server-side and the excess is dropped '
'silently, so these show a truncated list with no error. Use listAll() '
'/ fetchAllPages() instead:\n %s'
% (MAX_PAGE_SIZE, '\n '.join(offenders)))

View File

@@ -44,6 +44,12 @@ PUBLIC_ENDPOINTS = {
'settings.serve_branding_logo',
'settings.serve_map_blueprint',
'employees.serve_employee_photo',
'applications.serve_application_image',
# Same reasoning: a machine detail page renders the catalog model's photo
# with <img :src="withBase(imageurl)">, which sends no Authorization
# header. A gate here does not protect the file, it only guarantees the
# photo never appears.
'models.serve_model_image',
# API documentation: shapes, not data. The docs page loads its own script.
'docs.docs_index', 'docs.openapi_spec', 'docs.redoc_js', 'docs.llms_txt',
@@ -59,6 +65,13 @@ PUBLIC_ENDPOINTS = {
'notifications.get_employee_recognitions',
'slides.feed', 'slides.serve_image',
# The floor-map levels and their blueprints. ADR-017 rule 6: the printer
# installer map runs optional-auth before anyone logs in and needs a
# blueprint to draw on. Names and image paths, not asset data - and the
# asset positions that go on top already render on public kiosk pages.
'maplevels.list_levels', 'maplevels.get_level',
'maplevels.serve_blueprint',
# Parts kiosk: one item by its code, and that item's picture.
# kiosk_item takes no JWT but is NOT open: same kiosk flag + device token
# as the write. Gating only the write would have left the enumeration

View File

@@ -0,0 +1,69 @@
"""A retired asset has to be findable, and must never outrank a live one.
Retire is a soft delete: the confirm dialog promises history is kept and the
record can be restored. Global search excluded inactive assets, so the record
existed, was reachable by URL, and could not be found by anybody who did not
already know where it was - including whoever wanted to restore it.
Including them is only safe with the ordering: somebody searching a machine
number almost always wants the machine still on the floor, so a retired match
is pushed below every live one and flagged, rather than being mixed in
indistinguishably.
"""
import pytest
@pytest.fixture
def assets(db):
from shopdb.core.models import Asset, AssetType
assettype = AssetType(assettype='machine')
db.session.add(assettype)
db.session.flush()
live = Asset(assetnumber='SR-1000', name='Live grinder',
assettypeid=assettype.assettypeid)
gone = Asset(assetnumber='SR-1001', name='Retired grinder',
assettypeid=assettype.assettypeid, isactive=False)
db.session.add_all([live, gone])
db.session.commit()
return live, gone
def _search(client, headers, term):
response = client.get('/api/search?q=%s' % term, headers=headers)
assert response.status_code == 200, response.get_json()
payload = response.get_json()['data']
return payload.get('results', payload if isinstance(payload, list) else [])
def test_a_retired_asset_can_be_found(client, db, auth_headers, assets):
_live, gone = assets
titles = [r['title'] for r in _search(client, auth_headers, 'SR-1001')]
assert any('Retired grinder' in title or 'SR-1001' in title
for title in titles), titles
def test_a_retired_result_says_it_is_retired(client, db, auth_headers, assets):
results = _search(client, auth_headers, 'SR-1001')
match = next(r for r in results
if 'SR-1001' in (r['title'] + (r.get('subtitle') or '')))
assert match['retired'] is True
def test_a_live_asset_outranks_a_retired_one(client, db, auth_headers, assets):
"""Both match 'grinder'; the one still on the floor comes first."""
results = [r for r in _search(client, auth_headers, 'grinder')
if r.get('retired') is not None]
assert len(results) >= 2, results
live = next(r for r in results if not r['retired'])
retired = next(r for r in results if r['retired'])
assert live['relevance'] > retired['relevance'], (live, retired)
def test_a_live_asset_is_not_flagged(client, db, auth_headers, assets):
results = _search(client, auth_headers, 'SR-1000')
match = next(r for r in results if 'SR-1000' in (r['title'] + (r.get('subtitle') or '')))
assert match['retired'] is False

View File

@@ -0,0 +1,140 @@
"""A write the database refuses is the CALLER's error, not a server fault.
The engine now runs with STRICT_TRANS_TABLES (see
shopdb/config.py: SQLALCHEMY_ENGINE_OPTIONS). Before that, MySQL silently
adjusted a write it could not store: a 257-character username became the first
100 characters, an unparseable date became '0000-00-00' and then broke every
read of that row, an over-long asset number was cut at 50 and collided with a
different one. All of them answered 200 or 201, because nothing in the stack
knew the value had changed.
Strict mode turns those into errors, which is the point - but an uncaught
DataError is a 500, and a 500 says "the server is broken" when the truth is
"that value does not fit". This pins the mapping.
These tests raise the error directly rather than through a column, because the
suite runs on SQLite, which does not enforce VARCHAR length at all: there is no
value that would trigger it here. The behaviour against the real engine was
checked by hand on MySQL 5.6 - `POST /api/users` with a 257-character username
returns 400 "That value is too long for username." and stores nothing, where it
previously stored a truncated row.
"""
import pytest
from sqlalchemy.exc import DataError
from shopdb import _column_from_data_error
# A private app, because routes can only be mounted before the first request
# and the session-wide one has already served plenty.
@pytest.fixture(scope='module')
def raising_app():
from shopdb import create_app
application = create_app('testing')
application.add_url_rule(
'/__test__/raise', 'raise_data_error',
lambda: (_ for _ in ()).throw(
_data_error(application.config['TEST_DATA_ERROR_MESSAGE'])))
return application
def _raise(app, message):
"""Answer a request whose route raises DataError, so the app's own handler
is what produces the response."""
app.config['TEST_DATA_ERROR_MESSAGE'] = message
return app.test_client().get('/__test__/raise')
def _data_error(message):
return DataError('INSERT INTO x', {}, Exception(message))
def test_a_value_too_long_is_a_400_naming_the_column(raising_app):
response = _raise(
raising_app,
"(1406, \"Data too long for column 'username' at row 1\")")
assert response.status_code == 400
body = response.get_json()
assert body['data']['error']['code'] == 'VALIDATION_ERROR'
assert 'too long' in body['data']['error']['message']
assert 'username' in body['data']['error']['message']
def test_a_bad_date_is_a_400_naming_the_column(raising_app):
response = _raise(
raising_app,
"(1292, \"Incorrect date value: 'not-a-date' for column "
"'releasedate' at row 1\")")
assert response.status_code == 400
assert 'releasedate' in response.get_json()['data']['error']['message']
def test_an_unrecognised_data_error_is_still_a_400_not_a_500(raising_app):
"""The point is the CLASS of answer. A message shape nobody anticipated
must not fall back to 'internal server error'."""
response = _raise(raising_app, '(9999, "something new")')
assert response.status_code == 400
assert response.get_json()['data']['error']['code'] == 'VALIDATION_ERROR'
@pytest.mark.parametrize('message,expected', [
("Data too long for column 'assetnumber' at row 1", 'assetnumber'),
("Incorrect date value: 'x' for column 'enddate' at row 1", 'enddate'),
('no column mentioned at all', None),
('', None),
(None, None),
])
def test_the_column_name_is_pulled_out_when_the_engine_names_one(message,
expected):
assert _column_from_data_error(message) == expected
def test_the_strict_sql_mode_is_actually_set_on_the_mysql_connection():
"""The whole class of fix rests on this one connect_arg, and it is exactly
the kind of line a later edit drops without noticing."""
from shopdb.config import Config
connect_args = Config.SQLALCHEMY_ENGINE_OPTIONS['connect_args']
assert 'STRICT_TRANS_TABLES' in connect_args['init_command']
# --- the audit log must never be what fails the write ------------------------
def test_audit_changes_serialise_the_types_a_model_actually_holds():
"""A route builds {'old': getattr(row, field)} straight off the model, so
the changes dict carries dates, datetimes and Decimals. json.dumps raises
on all three, and the exception took the whole request down: PUT
/api/machines/<id> 500d and rolled back for any machine with a maintenance
date, including a rename that never touched it."""
import datetime
import decimal
from shopdb.core.models.auditlog import AuditLog
entry = AuditLog()
entry.changes = {
'nextmaintenancedate': {'old': datetime.date(2026, 12, 15),
'new': '2027-01-05'},
'lastseen': {'old': datetime.datetime(2026, 1, 2, 3, 4, 5),
'new': None},
'cost': {'old': decimal.Decimal('12.50'), 'new': 13},
}
stored = entry.changes
assert stored['nextmaintenancedate']['old'] == '2026-12-15'
assert stored['lastseen']['old'] == '2026-01-02T03:04:05'
assert stored['cost']['old'] == 12.5
def test_an_unserialisable_value_is_recorded_rather_than_raising():
"""An audit entry that reads awkwardly beats a failed write: the
alternative is losing the change AND the record of it."""
from shopdb.core.models.auditlog import AuditLog
class Odd:
def __repr__(self):
return '<Odd value>'
entry = AuditLog()
entry.changes = {'thing': {'old': Odd(), 'new': None}}
assert entry.changes['thing']['old'] == '<Odd value>'

View File

@@ -72,8 +72,9 @@ EXPECTED_HEAD_REVISION['machines'] = 'machines0002rename'
EXPECTED_HEAD_REVISION['employees'] = 'employees0002photo'
# usb drops the dead usbcheckouts.machineid column on top of its anchor, then
# adds scannedviruses so an imported external history keeps the scan result -
# the field a removable-media audit actually asks about.
EXPECTED_HEAD_REVISION['usb'] = 'usb0003scanfields'
# the field a removable-media audit actually asks about - and finally records
# who handed a drive back, which is not always who took it out.
EXPECTED_HEAD_REVISION['usb'] = 'usb0004checkinidentity'
# network links a device to a catalog model, which is where its photo comes
# from - machines, PCs and printers already had that link.
EXPECTED_HEAD_REVISION['network'] = 'network0003prefix'

Some files were not shown because too many files have changed in this diff Show More