Merge remote-tracking branch 'origin/wj' into wilm
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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)">×</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">×</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)">×</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">×</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
56
frontend/src/composables/assetTypeIds.js
Normal file
56
frontend/src/composables/assetTypeIds.js
Normal 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 }
|
||||
94
frontend/src/composables/assetTypeIds.spec.js
Normal file
94
frontend/src/composables/assetTypeIds.spec.js
Normal 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()
|
||||
})
|
||||
})
|
||||
55
frontend/src/composables/employeeResolve.js
Normal file
55
frontend/src/composables/employeeResolve.js
Normal 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 }
|
||||
81
frontend/src/composables/employeeResolve.spec.js
Normal file
81
frontend/src/composables/employeeResolve.spec.js
Normal 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({})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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 > Site & 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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
95
migrations/versions/7d39_audit_retention_key_merge.py
Normal file
95
migrations/versions/7d39_audit_retention_key_merge.py
Normal 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.
|
||||
"""
|
||||
@@ -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)
|
||||
56
migrations/versions/7d41_drop_guest_tools_secrets.py
Normal file
56
migrations/versions/7d41_drop_guest_tools_secrets.py
Normal 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.
|
||||
"""
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 || []
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 || []
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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 > 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 > 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);
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">↑</button>
|
||||
<button class="btn btn-sm btn-secondary" :disabled="idx === slides.length - 1" @click="move(idx, 1)" title="Move down">↓</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;
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
59
plugins/usb/migrations/versions/0004_checkin_identity.py
Normal file
59
plugins/usb/migrations/versions/0004_checkin_identity.py
Normal 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)
|
||||
@@ -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')
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
70
shopdb/utils/badges.py
Normal 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')
|
||||
82
shopdb/utils/datevalues.py
Normal file
82
shopdb/utils/datevalues.py
Normal 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
64
shopdb/utils/queryargs.py
Normal 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
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
99
tests/test_core/test_audit_retention_key.py
Normal file
99
tests/test_core/test_audit_retention_key.py
Normal 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())
|
||||
120
tests/test_core/test_bad_query_args.py
Normal file
120
tests/test_core/test_bad_query_args.py
Normal 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']
|
||||
80
tests/test_core/test_credential_paths.py
Normal file
80
tests/test_core/test_credential_paths.py
Normal 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
|
||||
@@ -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']
|
||||
|
||||
66
tests/test_core/test_error_envelope.py
Normal file
66
tests/test_core/test_error_envelope.py
Normal 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')
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
56
tests/test_core/test_maplevels_public.py
Normal file
56
tests/test_core/test_maplevels_public.py
Normal 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)
|
||||
79
tests/test_core/test_perpage_cap.py
Normal file
79
tests/test_core/test_perpage_cap.py
Normal 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)))
|
||||
@@ -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
|
||||
|
||||
69
tests/test_core/test_search_retired.py
Normal file
69
tests/test_core/test_search_retired.py
Normal 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
|
||||
140
tests/test_core/test_strict_write_errors.py
Normal file
140
tests/test_core/test_strict_write_errors.py
Normal 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>'
|
||||
@@ -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
Reference in New Issue
Block a user