Merge remote-tracking branch 'origin/wj' into wilm
This commit is contained in:
@@ -405,6 +405,12 @@ export const vendorsApi = {
|
|||||||
list(params = {}) {
|
list(params = {}) {
|
||||||
return api.get('/vendors', { 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) {
|
get(id) {
|
||||||
return api.get(`/vendors/${id}`)
|
return api.get(`/vendors/${id}`)
|
||||||
},
|
},
|
||||||
@@ -667,6 +673,10 @@ export const businessunitsApi = {
|
|||||||
list(params = {}) {
|
list(params = {}) {
|
||||||
return api.get('/businessunits', { 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) {
|
get(id) {
|
||||||
return api.get(`/businessunits/${id}`)
|
return api.get(`/businessunits/${id}`)
|
||||||
},
|
},
|
||||||
@@ -1306,6 +1316,11 @@ export const slidesApi = {
|
|||||||
},
|
},
|
||||||
remove(surface, files) {
|
remove(surface, files) {
|
||||||
return api.post(`/slides/${surface}/delete`, { 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;
|
margin: 0 0 1rem 0;
|
||||||
line-height: 1.5;
|
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 {
|
.settings-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
|||||||
@@ -48,6 +48,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { employeesApi } from '../api'
|
import { employeesApi } from '../api'
|
||||||
|
import { resolveEmployeeName } from '../composables/employeeResolve'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: { type: [Object, Array], default: null },
|
modelValue: { type: [Object, Array], default: null },
|
||||||
@@ -117,12 +118,32 @@ function selectEmployee(emp) {
|
|||||||
results.value = []
|
results.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
function addCustom() {
|
async function addCustom() {
|
||||||
if (!props.allowCustom) return
|
|
||||||
|
|
||||||
const name = searchQuery.value.trim()
|
const name = searchQuery.value.trim()
|
||||||
if (!name) return
|
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 = {
|
const employee = {
|
||||||
sso: `NAME:${name}`,
|
sso: `NAME:${name}`,
|
||||||
name: name
|
name: name
|
||||||
|
|||||||
@@ -47,7 +47,20 @@ const props = defineProps({
|
|||||||
|
|
||||||
defineEmits(['update:page', 'update:perPage'])
|
defineEmits(['update:page', 'update:perPage'])
|
||||||
|
|
||||||
const perPageOptions = [10, 20, 50, 100]
|
// 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 visiblePages = computed(() => {
|
||||||
const total = props.totalPages
|
const total = props.totalPages
|
||||||
|
|||||||
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.location" class="result-location">{{ result.location }}</span>
|
||||||
<span v-if="result.ticketnumber" class="result-ticket">{{ result.ticketnumber }}</span>
|
<span v-if="result.ticketnumber" class="result-ticket">{{ result.ticketnumber }}</span>
|
||||||
<span v-if="result.iscurrent" class="badge badge-success">Active</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>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -8,8 +8,10 @@
|
|||||||
<div class="setting-group">
|
<div class="setting-group">
|
||||||
<h3>Audit Log Retention</h3>
|
<h3>Audit Log Retention</h3>
|
||||||
<p class="setting-description">
|
<p class="setting-description">
|
||||||
Configure how long audit logs are retained. Older logs will be automatically purged.
|
How long audit entries are kept. 0 keeps everything forever, which is
|
||||||
Set to 0 to keep logs indefinitely.
|
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>
|
</p>
|
||||||
|
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
@@ -17,13 +19,13 @@
|
|||||||
<span>Retention Period (days)</span>
|
<span>Retention Period (days)</span>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
v-model="settings.audit_retention_days"
|
v-model="settings.audit_retentiondays"
|
||||||
min="0"
|
min="0"
|
||||||
placeholder="90"
|
placeholder="0"
|
||||||
@blur="saveSetting('audit_retention_days', settings.audit_retention_days)"
|
@blur="saveSetting('audit_retentiondays', settings.audit_retentiondays)"
|
||||||
:disabled="saving"
|
:disabled="saving"
|
||||||
>
|
>
|
||||||
<small class="input-hint">0 = keep forever, recommended: 90 days</small>
|
<small class="input-hint">0 = keep forever</small>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,16 @@
|
|||||||
<div class="section-card">
|
<div class="section-card">
|
||||||
<div class="setting-group">
|
<div class="setting-group">
|
||||||
<h3>SAML Single Sign-On</h3>
|
<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">
|
<p class="setting-description">
|
||||||
Enable SAML SSO for enterprise authentication. Users can sign in using your organization's
|
When it ships, users will sign in through your organization's identity
|
||||||
identity provider (Azure AD, Okta, etc.). Local login can remain enabled as a fallback.
|
provider (Azure AD, Okta, etc.), with local login available as a
|
||||||
|
fallback.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
@@ -82,19 +89,26 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</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">
|
<div class="setting-row">
|
||||||
<label class="toggle-label">
|
<label class="toggle-label">
|
||||||
<span>Allow Local Login</span>
|
<span>Allow Local Login</span>
|
||||||
<button
|
<button
|
||||||
class="toggle-btn"
|
class="toggle-btn active"
|
||||||
:class="{ active: settings.saml_allow_local_login }"
|
disabled
|
||||||
@click="toggleSetting('saml_allow_local_login')"
|
title="Local login cannot be turned off until SAML sign-in exists"
|
||||||
:disabled="saving"
|
|
||||||
>
|
>
|
||||||
<span class="toggle-slider"></span>
|
<span class="toggle-slider"></span>
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</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>
|
||||||
|
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
@@ -148,15 +162,20 @@ const {
|
|||||||
// Per-page connection status (stays with the page, not the composable).
|
// Per-page connection status (stays with the page, not the composable).
|
||||||
const samlStatus = computed(() => {
|
const samlStatus = computed(() => {
|
||||||
if (!settings.saml_enabled) return 'inactive'
|
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'
|
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(() => {
|
const samlMessage = computed(() => {
|
||||||
if (!settings.saml_enabled) return 'Disabled - using local authentication only'
|
if (!settings.saml_enabled) return 'Off - local accounts only'
|
||||||
if (!settings.saml_idp_metadata_url) return 'IdP metadata URL not configured'
|
if (!settings.saml_idp_metadata_url) return 'IdP metadata URL not set'
|
||||||
if (!settings.saml_entity_id) return 'Entity ID not configured'
|
if (!settings.saml_entity_id) return 'Entity ID not set'
|
||||||
return 'Configured - SAML login available'
|
return 'Settings saved - waiting on SAML support, local accounts still in use'
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(loadSettings)
|
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()
|
revisions = query.order_by(BackupRevision.backuprevisionid.desc()).all()
|
||||||
|
|
||||||
# "Latest" is per kind, not per asset, so an unfiltered listing still marks
|
# "Latest" is per kind AND per source chain. Per kind alone was wrong on
|
||||||
# the current revision of each kind correctly.
|
# 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()
|
seen = set()
|
||||||
out = []
|
out = []
|
||||||
for revision in revisions:
|
for revision in revisions:
|
||||||
islatest = revision.backupkind not in seen
|
chain = (revision.backupkind,
|
||||||
seen.add(revision.backupkind)
|
(revision.sourcehostname or '').strip().lower())
|
||||||
|
islatest = chain not in seen
|
||||||
|
seen.add(chain)
|
||||||
out.append(_summary(revision, islatest=islatest))
|
out.append(_summary(revision, islatest=islatest))
|
||||||
return success_response(out)
|
return success_response(out)
|
||||||
|
|
||||||
@@ -297,6 +309,23 @@ def diff_revision(backuprevisionid):
|
|||||||
againstid = request.args.get('against', type=int)
|
againstid = request.args.get('against', type=int)
|
||||||
if againstid:
|
if againstid:
|
||||||
other = db.session.get(BackupRevision, 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:
|
else:
|
||||||
# The previous revision FROM THE SAME PC. Several PCs can share a
|
# The previous revision FROM THE SAME PC. Several PCs can share a
|
||||||
# machine number, and diffing across them reported one device's COM port
|
# 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 ..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__)
|
computers_bp = Blueprint('computers', __name__)
|
||||||
|
|
||||||
@@ -258,13 +258,42 @@ def _computer_access_links(comp, domain=None):
|
|||||||
return links
|
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):
|
def _sync_access_methods(comp, data):
|
||||||
"""Replace a computer's enabled protocols from data['accessmethods'] (a list
|
"""Replace a computer's enabled protocols from data['accessmethods'] (a list
|
||||||
of {protocolid, portoverride?}). No-op if the key is absent, so callers that
|
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:
|
if 'accessmethods' not in data:
|
||||||
return
|
return None
|
||||||
desired = data.get('accessmethods') or []
|
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()
|
ComputerAccess.query.filter_by(computerid=comp.computerid).delete()
|
||||||
seen = set()
|
seen = set()
|
||||||
for m in desired:
|
for m in desired:
|
||||||
@@ -287,6 +316,13 @@ def _sync_access_methods(comp, data):
|
|||||||
isactive=True,
|
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
|
# Computers CRUD
|
||||||
@@ -380,20 +416,20 @@ def list_computers():
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Computer type filter
|
# Computer type filter
|
||||||
if type_id := request.args.get('typeid', request.args.get('type_id')):
|
if (type_id := intarg('typeid', intarg('type_id'))) is not None:
|
||||||
query = query.filter(Computer.computertypeid == int(type_id))
|
query = query.filter(Computer.computertypeid == type_id)
|
||||||
|
|
||||||
# OS filter
|
# OS filter
|
||||||
if os_id := request.args.get('osid', request.args.get('os_id')):
|
if (os_id := intarg('osid', intarg('os_id'))) is not None:
|
||||||
query = query.filter(Computer.osid == int(os_id))
|
query = query.filter(Computer.osid == os_id)
|
||||||
|
|
||||||
# Location filter
|
# Location filter
|
||||||
if location_id := request.args.get('locationid', request.args.get('location_id')):
|
if (location_id := intarg('locationid', intarg('location_id'))) is not None:
|
||||||
query = query.filter(Asset.locationid == int(location_id))
|
query = query.filter(Asset.locationid == location_id)
|
||||||
|
|
||||||
# Business unit filter
|
# Business unit filter
|
||||||
if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')):
|
if (bu_id := intarg('businessunitid', intarg('businessunit_id'))) is not None:
|
||||||
query = query.filter(Asset.businessunitid == int(bu_id))
|
query = query.filter(Asset.businessunitid == bu_id)
|
||||||
|
|
||||||
# Shopfloor filter (by the Shopfloor computer type)
|
# Shopfloor filter (by the Shopfloor computer type)
|
||||||
if shopfloor := request.args.get('shopfloor'):
|
if shopfloor := request.args.get('shopfloor'):
|
||||||
@@ -587,7 +623,7 @@ def create_computer():
|
|||||||
|
|
||||||
# Optional primary IP communication
|
# Optional primary IP communication
|
||||||
if data.get('ipaddress'):
|
if data.get('ipaddress'):
|
||||||
ip_comtype = CommunicationType.query.filter_by(comtype='IP').first()
|
ip_comtype = _ip_comtype()
|
||||||
if ip_comtype:
|
if ip_comtype:
|
||||||
db.session.add(Communication(
|
db.session.add(Communication(
|
||||||
assetid=asset.assetid,
|
assetid=asset.assetid,
|
||||||
@@ -596,7 +632,9 @@ def create_computer():
|
|||||||
isprimary=True,
|
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)
|
_sync_access_methods(comp, data)
|
||||||
|
|
||||||
# Preserve legacy timestamps in import mode (no-op otherwise)
|
# 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()
|
ip = (data.get('ipaddress') or '').strip()
|
||||||
primary = Communication.query.filter_by(
|
primary = Communication.query.filter_by(
|
||||||
assetid=asset.assetid, isprimary=True).first()
|
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 ip:
|
||||||
if primary:
|
if primary:
|
||||||
primary.ipaddress = ip
|
primary.ipaddress = ip
|
||||||
else:
|
else:
|
||||||
ip_comtype = CommunicationType.query.filter_by(comtype='IP').first()
|
|
||||||
if ip_comtype:
|
|
||||||
db.session.add(Communication(
|
db.session.add(Communication(
|
||||||
assetid=asset.assetid, comtypeid=ip_comtype.comtypeid,
|
assetid=asset.assetid,
|
||||||
|
comtypeid=_ip_comtype().comtypeid,
|
||||||
ipaddress=ip, isprimary=True))
|
ipaddress=ip, isprimary=True))
|
||||||
elif primary:
|
elif primary:
|
||||||
primary.ipaddress = None
|
primary.ipaddress = None
|
||||||
|
|
||||||
# Remote-access protocols
|
# 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
|
# Audit log if there were changes
|
||||||
if changes:
|
if changes:
|
||||||
|
|||||||
@@ -2,7 +2,14 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h2>Site Policies</h2>
|
<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>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -110,6 +117,7 @@ const toast = useToast()
|
|||||||
const policies = ref([])
|
const policies = ref([])
|
||||||
const configPolicies = ref([])
|
const configPolicies = ref([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
const featureOff = ref(false)
|
||||||
|
|
||||||
const showModal = ref(false)
|
const showModal = ref(false)
|
||||||
const editing = ref(null)
|
const editing = ref(null)
|
||||||
@@ -135,6 +143,10 @@ async function loadData() {
|
|||||||
configPolicies.value = config.data.data || []
|
configPolicies.value = config.data.data || []
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading business policies:', 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 {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -214,6 +226,13 @@ async function remove(p) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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; }
|
.item-row { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
|
||||||
.order-badge {
|
.order-badge {
|
||||||
display: inline-flex; align-items: center; justify-content: center;
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
|||||||
@@ -2,7 +2,14 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h2>Configuration Policies</h2>
|
<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>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -123,6 +130,7 @@ const apps = ref([])
|
|||||||
const actionCatalog = ref([])
|
const actionCatalog = ref([])
|
||||||
const monitors = ref([])
|
const monitors = ref([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
const featureOff = ref(false)
|
||||||
|
|
||||||
const showModal = ref(false)
|
const showModal = ref(false)
|
||||||
const editing = ref(null)
|
const editing = ref(null)
|
||||||
@@ -145,8 +153,8 @@ async function loadData() {
|
|||||||
// 500'd, which looked like data loss.
|
// 500'd, which looked like data loss.
|
||||||
const [pol, types, appList, actions, monitorList] = await Promise.allSettled([
|
const [pol, types, appList, actions, monitorList] = await Promise.allSettled([
|
||||||
computersApi.policies.configuration.list(),
|
computersApi.policies.configuration.list(),
|
||||||
computersApi.types.list({ perpage: 200 }),
|
computersApi.types.listAll(),
|
||||||
applicationsApi.listAll({ perpage: 200 }),
|
applicationsApi.listAll(),
|
||||||
computersApi.policies.actions(),
|
computersApi.policies.actions(),
|
||||||
endpointsApi.monitors.list()
|
endpointsApi.monitors.list()
|
||||||
])
|
])
|
||||||
@@ -155,8 +163,13 @@ async function loadData() {
|
|||||||
|
|
||||||
if (pol.status === 'fulfilled') {
|
if (pol.status === 'fulfilled') {
|
||||||
policies.value = pol.value.data.data || []
|
policies.value = pol.value.data.data || []
|
||||||
|
featureOff.value = false
|
||||||
} else {
|
} 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)
|
console.error('Error loading policies:', pol.reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,6 +297,13 @@ async function remove(p) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.modal-lg {
|
||||||
max-width: 760px;
|
max-width: 760px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,21 @@
|
|||||||
<div class="header-actions">
|
<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="`/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>
|
<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>
|
<router-link :to="returnTo" class="btn btn-secondary">{{ returnLabel }}</router-link>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<div v-if="loading" class="loading">Loading...</div>
|
||||||
|
|
||||||
<template v-else-if="computer">
|
<template v-else-if="computer">
|
||||||
@@ -644,6 +654,21 @@ async function confirmRestart() {
|
|||||||
// Retire, not delete: the server deactivates the asset, so the PC
|
// Retire, not delete: the server deactivates the asset, so the PC
|
||||||
// leaves every list and report while its history and relationships stay put.
|
// leaves every list and report while its history and relationships stay put.
|
||||||
// Reversible by an admin setting isactive back to true.
|
// 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() {
|
async function confirmRetire() {
|
||||||
const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
|
const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
|
||||||
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
|
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
|
||||||
@@ -660,6 +685,10 @@ async function confirmRetire() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.retired-banner {
|
||||||
|
border-left: 3px solid var(--warning);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
.portal-link {
|
.portal-link {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
margin-left: 0.5rem;
|
margin-left: 0.5rem;
|
||||||
|
|||||||
@@ -143,6 +143,10 @@
|
|||||||
v-model="form.locationid"
|
v-model="form.locationid"
|
||||||
class="form-control"
|
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 value="">Select location...</option>
|
||||||
<option
|
<option
|
||||||
v-for="l in locations"
|
v-for="l in locations"
|
||||||
@@ -152,6 +156,33 @@
|
|||||||
{{ l.locationname }}
|
{{ l.locationname }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -364,7 +395,7 @@
|
|||||||
import { ref, onMounted, computed, watch } from 'vue'
|
import { ref, onMounted, computed, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { computersApi, assetsApi, vendorsApi, locationsApi, modelsApi, operatingsystemsApi,
|
import { computersApi, assetsApi, vendorsApi, locationsApi, modelsApi, operatingsystemsApi,
|
||||||
printersApi, relationshipTypesApi } from '@/api'
|
printersApi, relationshipTypesApi, businessunitsApi } from '@/api'
|
||||||
import ShopFloorMap from '@/components/ShopFloorMap.vue'
|
import ShopFloorMap from '@/components/ShopFloorMap.vue'
|
||||||
import { loadMapConfig, levelOptions, levelName, state as mapConfig }
|
import { loadMapConfig, levelOptions, levelName, state as mapConfig }
|
||||||
from '@/composables/mapConfig'
|
from '@/composables/mapConfig'
|
||||||
@@ -373,6 +404,7 @@ import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
|
|||||||
import PrinterAssignmentPicker from '@/components/PrinterAssignmentPicker.vue'
|
import PrinterAssignmentPicker from '@/components/PrinterAssignmentPicker.vue'
|
||||||
import { currentTheme } from '@/stores/theme'
|
import { currentTheme } from '@/stores/theme'
|
||||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||||
|
import { loadAssetTypeIds, assetTypeId } from '@/composables/assetTypeIds'
|
||||||
import { isPluginEnabled, loadEnabledPlugins } from '@/composables/enabledPlugins'
|
import { isPluginEnabled, loadEnabledPlugins } from '@/composables/enabledPlugins'
|
||||||
import { useToast } from '@/composables/toast'
|
import { useToast } from '@/composables/toast'
|
||||||
import { apiError } from '@/utils/apiError'
|
import { apiError } from '@/utils/apiError'
|
||||||
@@ -390,13 +422,20 @@ const isEdit = computed(() => !!route.params.id)
|
|||||||
// falls back to the PCs list.
|
// falls back to the PCs list.
|
||||||
const returnTo = computed(() => (route.query.from === 'fleet' ? '/endpoints' : '/pcs'))
|
const returnTo = computed(() => (route.query.from === 'fleet' ? '/endpoints' : '/pcs'))
|
||||||
|
|
||||||
// Seeded asset-type id for computers (see /api/assets/types). Custom-field
|
// Resolved by NAME at load, never hardcoded: the id comes from seed
|
||||||
// values are keyed by the underlying asset id, captured on load / create.
|
// order, so the constant that used to live here pointed at a different
|
||||||
const COMPUTER_ASSETTYPEID = 2
|
// 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 customFieldsRef = ref(null)
|
||||||
const printerPickerRef = ref(null)
|
const printerPickerRef = ref(null)
|
||||||
const currentAssetId = 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
|
// PC Number (assetnumber) defaults to the serial number while the user hasn't
|
||||||
// typed their own. Editable; only auto-fills on a new PC.
|
// typed their own. Editable; only auto-fills on a new PC.
|
||||||
const manualPcNumber = ref(false)
|
const manualPcNumber = ref(false)
|
||||||
@@ -470,6 +509,7 @@ function setProtocolPort(protocolid, value) {
|
|||||||
const vendors = ref([])
|
const vendors = ref([])
|
||||||
const models = ref([])
|
const models = ref([])
|
||||||
const locations = ref([])
|
const locations = ref([])
|
||||||
|
const businessunits = ref([])
|
||||||
const operatingsystems = ref([])
|
const operatingsystems = ref([])
|
||||||
|
|
||||||
|
|
||||||
@@ -519,17 +559,20 @@ const filteredVendors = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
await loadAssetTypeIds()
|
||||||
try {
|
try {
|
||||||
// Load reference data
|
// Load reference data
|
||||||
// perpage 100 so dropdowns aren't truncated to the default 20-row page
|
// 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 }),
|
computersApi.types.list({ perpage: 100 }),
|
||||||
assetsApi.statuses.list(),
|
assetsApi.statuses.list(),
|
||||||
vendorsApi.list({ perpage: 100 }),
|
vendorsApi.listAll(),
|
||||||
modelsApi.listAll({ category: 'PC' }), // backend caps perpage at 100; page through all
|
modelsApi.listAll({ category: 'PC' }), // backend caps perpage at 100; page through all
|
||||||
locationsApi.listAll(), // backend caps perpage at 100; page through all
|
locationsApi.listAll(), // backend caps perpage at 100; page through all
|
||||||
operatingsystemsApi.list({ perpage: 100 }),
|
operatingsystemsApi.list({ perpage: 100 }),
|
||||||
computersApi.protocols.list(),
|
computersApi.protocols.list(),
|
||||||
|
businessunitsApi.listAll(),
|
||||||
// Handles its own failure: a site without printers still gets a form.
|
// Handles its own failure: a site without printers still gets a form.
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -540,6 +583,7 @@ onMounted(async () => {
|
|||||||
locations.value = locRes || []
|
locations.value = locRes || []
|
||||||
operatingsystems.value = osRes.data.data || []
|
operatingsystems.value = osRes.data.data || []
|
||||||
protocols.value = protoRes.data.data || []
|
protocols.value = protoRes.data.data || []
|
||||||
|
businessunits.value = allBusinessUnits || []
|
||||||
|
|
||||||
// Load PC if editing (asset-based shape: extension under pc.computer)
|
// Load PC if editing (asset-based shape: extension under pc.computer)
|
||||||
if (isEdit.value) {
|
if (isEdit.value) {
|
||||||
@@ -550,6 +594,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
const primaryComm = pc.communications?.find(c => c.isprimary) || pc.communications?.[0]
|
const primaryComm = pc.communications?.find(c => c.isprimary) || pc.communications?.[0]
|
||||||
|
|
||||||
|
inheritedLocation.value = pc.inheritedlocation || null
|
||||||
form.value = {
|
form.value = {
|
||||||
machinenumber: pc.assetnumber || '',
|
machinenumber: pc.assetnumber || '',
|
||||||
alias: pc.name && pc.name.toUpperCase() !== 'NONE' ? pc.name : '',
|
alias: pc.name && pc.name.toUpperCase() !== 'NONE' ? pc.name : '',
|
||||||
@@ -561,7 +606,12 @@ onMounted(async () => {
|
|||||||
statusid: pc.statusid || '',
|
statusid: pc.statusid || '',
|
||||||
vendorid: ext.vendorid || '',
|
vendorid: ext.vendorid || '',
|
||||||
modelnumberid: ext.modelnumberid || '',
|
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 || '',
|
osid: ext.osid || '',
|
||||||
loggedinuser: ext.loggedinuser || '',
|
loggedinuser: ext.loggedinuser || '',
|
||||||
azuredeviceid: ext.azuredeviceid || '',
|
azuredeviceid: ext.azuredeviceid || '',
|
||||||
@@ -572,9 +622,9 @@ onMounted(async () => {
|
|||||||
portoverride: a.portoverride ?? null
|
portoverride: a.portoverride ?? null
|
||||||
})),
|
})),
|
||||||
notes: pc.notes || '',
|
notes: pc.notes || '',
|
||||||
mapx: pc.mapx ?? null,
|
mapx: pc.ownmapx ?? null,
|
||||||
mapy: pc.mapy ?? null,
|
mapy: pc.ownmapy ?? null,
|
||||||
levelid: pc.levelid ?? null,
|
levelid: pc.ownlevelid ?? null,
|
||||||
ipaddress: primaryComm?.ipaddress || ''
|
ipaddress: primaryComm?.ipaddress || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -640,6 +690,7 @@ async function savePC() {
|
|||||||
vendorid: form.value.vendorid || null,
|
vendorid: form.value.vendorid || null,
|
||||||
modelnumberid: form.value.modelnumberid || null,
|
modelnumberid: form.value.modelnumberid || null,
|
||||||
locationid: form.value.locationid || null,
|
locationid: form.value.locationid || null,
|
||||||
|
businessunitid: form.value.businessunitid || null,
|
||||||
osid: form.value.osid || null,
|
osid: form.value.osid || null,
|
||||||
loggedinuser: form.value.loggedinuser || null,
|
loggedinuser: form.value.loggedinuser || null,
|
||||||
azuredeviceid: form.value.azuredeviceid || null,
|
azuredeviceid: form.value.azuredeviceid || null,
|
||||||
@@ -649,10 +700,22 @@ async function savePC() {
|
|||||||
notes: form.value.notes || null,
|
notes: form.value.notes || null,
|
||||||
ipaddress: form.value.ipaddress || null,
|
ipaddress: form.value.ipaddress || null,
|
||||||
mapx: form.value.mapx,
|
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
|
// An emptied alias has to be SENT, or it cannot be cleared: the payload
|
||||||
if (form.value.alias) {
|
// 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
|
payload.name = form.value.alias
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ onMounted(() => loadData())
|
|||||||
async function loadData() {
|
async function loadData() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const response = await computersApi.types.list({ perpage: 200, active: false })
|
const response = await computersApi.types.listAll({ active: false })
|
||||||
pcTypes.value = response.data.data || []
|
pcTypes.value = response.data.data || []
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading PC types:', err)
|
console.error('Error loading PC types:', err)
|
||||||
|
|||||||
@@ -15,7 +15,14 @@
|
|||||||
placeholder="Search computers..."
|
placeholder="Search computers..."
|
||||||
@input="debouncedSearch"
|
@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 class="card">
|
||||||
<div v-if="loading" class="loading">Loading...</div>
|
<div v-if="loading" class="loading">Loading...</div>
|
||||||
@@ -65,7 +72,15 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr v-if="computers.length === 0">
|
<tr v-if="computers.length === 0">
|
||||||
<td colspan="8" style="text-align: center; color: var(--text-light);">
|
<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" />
|
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -106,6 +121,9 @@ function applySuggestion(word) {
|
|||||||
setSearch(word)
|
setSearch(word)
|
||||||
loadComputers()
|
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 totalPages = ref(1)
|
||||||
const perPage = ref(20)
|
const perPage = ref(20)
|
||||||
|
|
||||||
@@ -123,6 +141,8 @@ async function loadComputers() {
|
|||||||
perpage: perPage.value
|
perpage: perPage.value
|
||||||
}
|
}
|
||||||
if (search.value) params.search = search.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)
|
const response = await computersApi.list(params)
|
||||||
computers.value = response.data.data || []
|
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
|
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading computers:', error)
|
console.error('Error loading computers:', error)
|
||||||
|
denied.value = error?.response?.status === 403
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from shopdb.api import (
|
|||||||
employee_connection,
|
employee_connection,
|
||||||
require_role,
|
require_role,
|
||||||
)
|
)
|
||||||
from shopdb.api import Setting
|
from shopdb.api import Setting, intarg
|
||||||
from shopdb.api import send_upload
|
from shopdb.api import send_upload
|
||||||
|
|
||||||
from ..models import DirectoryEmployee
|
from ..models import DirectoryEmployee
|
||||||
@@ -287,7 +287,7 @@ def search_employees():
|
|||||||
- limit: Max results (default 10)
|
- limit: Max results (default 10)
|
||||||
"""
|
"""
|
||||||
query = request.args.get('q', '').strip()
|
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:
|
if len(query) < 2:
|
||||||
return error_response(
|
return error_response(
|
||||||
@@ -295,12 +295,31 @@ def search_employees():
|
|||||||
'Search query must be at least 2 characters'
|
'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():
|
if _selfhosted():
|
||||||
|
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}%'
|
term = f'%{query}%'
|
||||||
rows = (DirectoryEmployee.query
|
rows = rows.filter(db.or_(
|
||||||
.filter(db.or_(DirectoryEmployee.firstname.ilike(term),
|
DirectoryEmployee.firstname.ilike(term),
|
||||||
DirectoryEmployee.lastname.ilike(term),
|
DirectoryEmployee.lastname.ilike(term),
|
||||||
db.cast(DirectoryEmployee.sso, db.String).ilike(term)))
|
db.cast(DirectoryEmployee.sso, db.String).ilike(term)))
|
||||||
|
rows = (rows
|
||||||
.order_by(DirectoryEmployee.lastname, DirectoryEmployee.firstname)
|
.order_by(DirectoryEmployee.lastname, DirectoryEmployee.firstname)
|
||||||
.limit(limit).all())
|
.limit(limit).all())
|
||||||
return success_response([_with_photo_url(e.to_dict()) for e in rows])
|
return success_response([_with_photo_url(e.to_dict()) for e in rows])
|
||||||
@@ -308,6 +327,23 @@ def search_employees():
|
|||||||
try:
|
try:
|
||||||
conn = employee_connection()
|
conn = employee_connection()
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
|
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'''
|
cur.execute(f'''
|
||||||
SELECT {_FIELDS}
|
SELECT {_FIELDS}
|
||||||
FROM employees
|
FROM employees
|
||||||
|
|||||||
@@ -1376,8 +1376,13 @@ def enqueue_endpoint_command(asset_id: int):
|
|||||||
# Destructive actions (for example system_restart) demand a stricter grant
|
# Destructive actions (for example system_restart) demand a stricter grant
|
||||||
# ON TOP OF endpoints.command, so a remote reboot cannot ride the routine
|
# ON TOP OF endpoints.command, so a remote reboot cannot ride the routine
|
||||||
# toggle permission.
|
# 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)
|
elevated = elevated_permission_for(action)
|
||||||
if elevated and not current_user.haspermission(elevated):
|
if elevated and not caller_holds(elevated):
|
||||||
return error_response(
|
return error_response(
|
||||||
ErrorCodes.FORBIDDEN,
|
ErrorCodes.FORBIDDEN,
|
||||||
f'Action {action!r} requires the {elevated} permission',
|
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 json
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional, Type
|
from typing import Dict, List, Optional, Type
|
||||||
|
|
||||||
@@ -56,18 +56,35 @@ KNOWN_PAYLOAD_KEYS = frozenset({
|
|||||||
|
|
||||||
|
|
||||||
def _parse_dt(value):
|
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:
|
if not value:
|
||||||
return None
|
return None
|
||||||
if isinstance(value, datetime):
|
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')
|
text = str(value).strip().replace('Z', '+00:00')
|
||||||
try:
|
try:
|
||||||
parsed = datetime.fromisoformat(text)
|
parsed = datetime.fromisoformat(text)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None
|
return None
|
||||||
if parsed.tzinfo is not 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
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -454,6 +454,15 @@ def create_scope():
|
|||||||
if not scopename:
|
if not scopename:
|
||||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
'scopename is required', http_code=400)
|
'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:
|
if phase not in PHASES:
|
||||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
f'phase must be one of {PHASES}', http_code=400)
|
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 hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
@@ -532,6 +533,30 @@ def store_inline_payload(entry, filename, contenttype, rawbytes):
|
|||||||
return payload
|
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):
|
def export_scope_to_share(scopename, phase, shareroot):
|
||||||
"""Write a scope's current published JSON to the share, backing up the old
|
"""Write a scope's current published JSON to the share, backing up the old
|
||||||
file to _meta/history first. Returns the written path."""
|
file to _meta/history first. Returns the written path."""
|
||||||
@@ -546,7 +571,21 @@ def export_scope_to_share(scopename, phase, shareroot):
|
|||||||
if phase == 'preinstall':
|
if phase == 'preinstall':
|
||||||
target = os.path.join(shareroot, 'preinstall.json')
|
target = os.path.join(shareroot, 'preinstall.json')
|
||||||
else:
|
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')
|
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)
|
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||||||
|
|
||||||
if os.path.isfile(target):
|
if os.path.isfile(target):
|
||||||
|
|||||||
@@ -503,8 +503,19 @@ def restore_revision(guideid, revisionid):
|
|||||||
'You do not have permission to edit this guide',
|
'You do not have permission to edit this guide',
|
||||||
http_code=403)
|
http_code=403)
|
||||||
|
|
||||||
|
before = guide.revision
|
||||||
authoring.restore(guide, revision, userid=_userid())
|
authoring.restore(guide, revision, userid=_userid())
|
||||||
db.session.commit()
|
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),
|
return success_response(guide.to_dict(includeblocks=True),
|
||||||
message='Restored version {} as a new draft version'
|
message='Restored version {} as a new draft version'
|
||||||
.format(revision.revision))
|
.format(revision.revision))
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from flask import Blueprint, request
|
|||||||
from flask_jwt_extended import jwt_required
|
from flask_jwt_extended import jwt_required
|
||||||
|
|
||||||
from shopdb.api import (
|
from shopdb.api import (
|
||||||
|
intarg,
|
||||||
db,
|
db,
|
||||||
Application,
|
Application,
|
||||||
success_response,
|
success_response,
|
||||||
@@ -16,7 +17,7 @@ from shopdb.api import (
|
|||||||
|
|
||||||
from ..models import KnowledgeBase
|
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__)
|
knowledgebase_bp = Blueprint('knowledgebase', __name__)
|
||||||
|
|
||||||
@@ -91,8 +92,8 @@ def list_articles():
|
|||||||
query = query.filter(_search_clause(search))
|
query = query.filter(_search_clause(search))
|
||||||
|
|
||||||
# Filter by topic/application
|
# Filter by topic/application
|
||||||
if appid := request.args.get('appid'):
|
if (appid := intarg('appid')) is not None:
|
||||||
query = query.filter(KnowledgeBase.appid == int(appid))
|
query = query.filter(KnowledgeBase.appid == appid)
|
||||||
|
|
||||||
# Exact-match natural-key lookups for idempotent import. linkurl is the
|
# Exact-match natural-key lookups for idempotent import. linkurl is the
|
||||||
# stable natural key; shortdescription (the title) is offered as a fallback.
|
# 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 ..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__)
|
machines_bp = Blueprint('machines', __name__)
|
||||||
|
|
||||||
@@ -197,20 +198,20 @@ def list_machines():
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Machine type filter
|
# Machine type filter
|
||||||
if type_id := request.args.get('typeid', request.args.get('type_id')):
|
if (type_id := intarg('typeid', intarg('type_id'))) is not None:
|
||||||
query = query.filter(Machine.machinetypeid == int(type_id))
|
query = query.filter(Machine.machinetypeid == type_id)
|
||||||
|
|
||||||
# Vendor filter
|
# Vendor filter
|
||||||
if vendor_id := request.args.get('vendorid', request.args.get('vendor_id')):
|
if (vendor_id := intarg('vendorid', intarg('vendor_id'))) is not None:
|
||||||
query = query.filter(Machine.vendorid == int(vendor_id))
|
query = query.filter(Machine.vendorid == vendor_id)
|
||||||
|
|
||||||
# Location filter
|
# Location filter
|
||||||
if location_id := request.args.get('locationid', request.args.get('location_id')):
|
if (location_id := intarg('locationid', intarg('location_id'))) is not None:
|
||||||
query = query.filter(Asset.locationid == int(location_id))
|
query = query.filter(Asset.locationid == location_id)
|
||||||
|
|
||||||
# Business unit filter
|
# Business unit filter
|
||||||
if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')):
|
if (bu_id := intarg('businessunitid', intarg('businessunit_id'))) is not None:
|
||||||
query = query.filter(Asset.businessunitid == int(bu_id))
|
query = query.filter(Asset.businessunitid == bu_id)
|
||||||
|
|
||||||
# Sorting
|
# Sorting
|
||||||
sort_by = request.args.get('sort', 'assetnumber')
|
sort_by = request.args.get('sort', 'assetnumber')
|
||||||
@@ -336,6 +337,14 @@ def create_machine():
|
|||||||
- mapx, mapy, notes
|
- mapx, mapy, notes
|
||||||
"""
|
"""
|
||||||
data = request.get_json()
|
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:
|
if not data:
|
||||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
||||||
@@ -388,8 +397,8 @@ def create_machine():
|
|||||||
modelnumberid=data.get('modelnumberid'),
|
modelnumberid=data.get('modelnumberid'),
|
||||||
requiresmanualconfig=data.get('requiresmanualconfig', False),
|
requiresmanualconfig=data.get('requiresmanualconfig', False),
|
||||||
islocationonly=data.get('islocationonly', False),
|
islocationonly=data.get('islocationonly', False),
|
||||||
lastmaintenancedate=data.get('lastmaintenancedate'),
|
lastmaintenancedate=maintenancedates.get('lastmaintenancedate'),
|
||||||
nextmaintenancedate=data.get('nextmaintenancedate'),
|
nextmaintenancedate=maintenancedates.get('nextmaintenancedate'),
|
||||||
maintenanceintervaldays=data.get('maintenanceintervaldays'),
|
maintenanceintervaldays=data.get('maintenanceintervaldays'),
|
||||||
controllervendorid=data.get('controllervendorid'),
|
controllervendorid=data.get('controllervendorid'),
|
||||||
controllermodelid=data.get('controllermodelid')
|
controllermodelid=data.get('controllermodelid')
|
||||||
@@ -427,6 +436,14 @@ def update_machine(machine_id: int):
|
|||||||
)
|
)
|
||||||
|
|
||||||
data = request.get_json()
|
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:
|
if not data:
|
||||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
||||||
|
|
||||||
@@ -471,7 +488,7 @@ def update_machine(machine_id: int):
|
|||||||
# Update machine fields
|
# Update machine fields
|
||||||
machine_fields = ['machinetypeid', 'vendorid', 'modelnumberid',
|
machine_fields = ['machinetypeid', 'vendorid', 'modelnumberid',
|
||||||
'requiresmanualconfig', 'islocationonly',
|
'requiresmanualconfig', 'islocationonly',
|
||||||
'lastmaintenancedate', 'nextmaintenancedate', 'maintenanceintervaldays',
|
'maintenanceintervaldays',
|
||||||
'controllervendorid', 'controllermodelid']
|
'controllervendorid', 'controllermodelid']
|
||||||
for key in machine_fields:
|
for key in machine_fields:
|
||||||
if key in data:
|
if key in data:
|
||||||
@@ -481,6 +498,14 @@ def update_machine(machine_id: int):
|
|||||||
changes[key] = {'old': old_val, 'new': new_val}
|
changes[key] = {'old': old_val, 'new': new_val}
|
||||||
setattr(mach, key, data[key])
|
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
|
# Audit log if there were changes
|
||||||
if changes:
|
if changes:
|
||||||
AuditLog.log('updated', 'Machine', entityid=mach.machineid,
|
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_query = by_type_query.filter(Asset.assetid.notin_(secondaryassetids))
|
||||||
by_type = by_type_query.group_by(MachineType.machinetype).all()
|
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
|
from shopdb.api import AssetStatus
|
||||||
by_status = db.session.query(
|
by_status_query = db.session.query(
|
||||||
AssetStatus.status,
|
AssetStatus.status,
|
||||||
db.func.count(Machine.machineid)
|
db.func.count(Machine.machineid)
|
||||||
).join(Asset, Asset.assetid == Machine.assetid
|
).join(Asset, Asset.assetid == Machine.assetid
|
||||||
).join(AssetStatus, AssetStatus.statusid == Asset.statusid
|
).join(AssetStatus, AssetStatus.statusid == Asset.statusid
|
||||||
).filter(Asset.isactive == True
|
).filter(Asset.isactive == True)
|
||||||
).group_by(AssetStatus.status
|
if secondaryassetids:
|
||||||
).all()
|
by_status_query = by_status_query.filter(
|
||||||
|
Asset.assetid.notin_(secondaryassetids))
|
||||||
|
by_status = by_status_query.group_by(AssetStatus.status).all()
|
||||||
|
|
||||||
return success_response({
|
return success_response({
|
||||||
'total': total,
|
'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">
|
<router-link :to="`/print/asset-label/machine/${machine?.machine?.machineid}`" class="btn btn-secondary" v-if="machine" target="_blank">
|
||||||
Print Label
|
Print Label
|
||||||
</router-link>
|
</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
|
Edit
|
||||||
</router-link>
|
</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>
|
<router-link to="/machines" class="btn btn-secondary">Back to List</router-link>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<div v-if="loading" class="loading">Loading...</div>
|
||||||
|
|
||||||
<template v-else-if="machine">
|
<template v-else-if="machine">
|
||||||
@@ -295,7 +305,7 @@ const { heroWarranty, warrantyDate } = useWarrantyBadge(() => machine.value?.ass
|
|||||||
|
|
||||||
// relationships render via the shared AssetRelationships card
|
// relationships render via the shared AssetRelationships card
|
||||||
|
|
||||||
onMounted(async () => {
|
async function loadMachine() {
|
||||||
try {
|
try {
|
||||||
const response = await machinesApi.get(route.params.id)
|
const response = await machinesApi.get(route.params.id)
|
||||||
machine.value = response.data.data
|
machine.value = response.data.data
|
||||||
@@ -305,7 +315,9 @@ onMounted(async () => {
|
|||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
onMounted(loadMachine)
|
||||||
|
|
||||||
function getStatusClass(status) {
|
function getStatusClass(status) {
|
||||||
if (!status) return 'badge-info'
|
if (!status) return 'badge-info'
|
||||||
@@ -324,6 +336,20 @@ function formatDate(dateStr) {
|
|||||||
// Retire, not delete: the server deactivates the asset, so the machine
|
// Retire, not delete: the server deactivates the asset, so the machine
|
||||||
// leaves every list and report while its history and relationships stay put.
|
// leaves every list and report while its history and relationships stay put.
|
||||||
// Reversible by an admin setting isactive back to true.
|
// 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() {
|
async function confirmRetire() {
|
||||||
const label = machine.value?.assetnumber || machine.value?.name || 'this machine'
|
const label = machine.value?.assetnumber || machine.value?.name || 'this machine'
|
||||||
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
|
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
|
||||||
@@ -340,6 +366,10 @@ async function confirmRetire() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.retired-banner {
|
||||||
|
border-left: 3px solid var(--warning);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
.mono {
|
.mono {
|
||||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -416,6 +416,7 @@ import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
|
|||||||
import PrinterAssignmentPicker from '@/components/PrinterAssignmentPicker.vue'
|
import PrinterAssignmentPicker from '@/components/PrinterAssignmentPicker.vue'
|
||||||
import { currentTheme } from '@/stores/theme'
|
import { currentTheme } from '@/stores/theme'
|
||||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||||
|
import { loadAssetTypeIds, assetTypeId } from '@/composables/assetTypeIds'
|
||||||
import { apiError } from '@/utils/apiError'
|
import { apiError } from '@/utils/apiError'
|
||||||
import { planControllingPcChange } from './controllingPc'
|
import { planControllingPcChange } from './controllingPc'
|
||||||
|
|
||||||
@@ -426,8 +427,11 @@ const router = useRouter()
|
|||||||
|
|
||||||
const isEdit = computed(() => !!route.params.id)
|
const isEdit = computed(() => !!route.params.id)
|
||||||
|
|
||||||
// Seeded asset-type id for machines (see /api/assets/types).
|
// Resolved by NAME at load, never hardcoded: the id comes from seed
|
||||||
const MACHINE_ASSETTYPEID = 1
|
// 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 customFieldsRef = ref(null)
|
||||||
const printerPickerRef = ref(null)
|
const printerPickerRef = ref(null)
|
||||||
const currentAssetId = ref(null)
|
const currentAssetId = ref(null)
|
||||||
@@ -571,15 +575,16 @@ watch(() => form.value.controllervendorid, (newVal, oldVal) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
await loadAssetTypeIds()
|
||||||
try {
|
try {
|
||||||
// Load reference data in parallel
|
// Load reference data in parallel
|
||||||
const [typesRes, statusRes, vendorRes, locRes, allModels, buRes, pcsRes, relTypesRes] = await Promise.all([
|
const [typesRes, statusRes, vendorRes, locRes, allModels, buRes, pcsRes, relTypesRes] = await Promise.all([
|
||||||
machinesApi.types.list(),
|
machinesApi.types.list(),
|
||||||
assetsApi.statuses.list(),
|
assetsApi.statuses.list(),
|
||||||
vendorsApi.list({ perpage: 500 }),
|
vendorsApi.listAll(),
|
||||||
locationsApi.listAll(), // backend caps perpage at 100; page through all
|
locationsApi.listAll(), // backend caps perpage at 100; page through all
|
||||||
modelsApi.listAll({ category: 'Equipment' }), // 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
|
computersApi.listAll(), // backend caps perpage at 100; page through all
|
||||||
assetsApi.types.list() // Used for relationship types, will fix below
|
assetsApi.types.list() // Used for relationship types, will fix below
|
||||||
])
|
])
|
||||||
@@ -763,7 +768,13 @@ async function saveMachine() {
|
|||||||
maintenanceintervaldays: form.value.maintenanceintervaldays || null,
|
maintenanceintervaldays: form.value.maintenanceintervaldays || null,
|
||||||
notes: form.value.notes || null,
|
notes: form.value.notes || null,
|
||||||
mapx: form.value.mapx,
|
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
|
let savedMachine
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ onMounted(loadData)
|
|||||||
async function loadData() {
|
async function loadData() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
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 || []
|
items.value = response.data.data || []
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading machine types:', err)
|
console.error('Error loading machine types:', err)
|
||||||
|
|||||||
@@ -15,6 +15,14 @@
|
|||||||
placeholder="Search machines..."
|
placeholder="Search machines..."
|
||||||
@input="debouncedSearch"
|
@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>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -41,6 +49,7 @@
|
|||||||
@click="item.machine?.machineid && $router.push(`/machines/${item.machine.machineid}`)">
|
@click="item.machine?.machineid && $router.push(`/machines/${item.machine.machineid}`)">
|
||||||
<td>
|
<td>
|
||||||
{{ item.assetnumber }}<template v-if="item.dualpathpartner"> / {{ item.dualpathpartner.assetnumber }}</template>
|
{{ 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>
|
||||||
<td>{{ item.name || '-' }}</td>
|
<td>{{ item.name || '-' }}</td>
|
||||||
<td class="mono">{{ item.serialnumber || '-' }}</td>
|
<td class="mono">{{ item.serialnumber || '-' }}</td>
|
||||||
@@ -122,6 +131,7 @@ function applySuggestion(word) {
|
|||||||
// search page documents. Load directly.
|
// search page documents. Load directly.
|
||||||
loadMachines()
|
loadMachines()
|
||||||
}
|
}
|
||||||
|
const showRetired = ref(false)
|
||||||
const totalPages = ref(1)
|
const totalPages = ref(1)
|
||||||
const perPage = ref(20)
|
const perPage = ref(20)
|
||||||
|
|
||||||
@@ -139,6 +149,8 @@ async function loadMachines() {
|
|||||||
perpage: perPage.value
|
perpage: perPage.value
|
||||||
}
|
}
|
||||||
if (search.value) params.search = search.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)
|
const response = await machinesApi.list(params)
|
||||||
machines.value = response.data.data || []
|
machines.value = response.data.data || []
|
||||||
|
|||||||
@@ -73,8 +73,12 @@ class Machine(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Maintenance tracking
|
# Maintenance tracking
|
||||||
lastmaintenancedate = db.Column(db.DateTime, nullable=True)
|
# Dates, not datetimes: there is no time in a maintenance date, and a
|
||||||
nextmaintenancedate = db.Column(db.DateTime, nullable=True)
|
# 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)
|
maintenanceintervaldays = db.Column(db.Integer, nullable=True)
|
||||||
|
|
||||||
# Controller info (for CNC machines)
|
# Controller info (for CNC machines)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from shopdb.api import (
|
|||||||
success_response, error_response, paginated_response, ErrorCodes,
|
success_response, error_response, paginated_response, ErrorCodes,
|
||||||
get_pagination_params, paginate_query,
|
get_pagination_params, paginate_query,
|
||||||
require_permission, apply_import_timestamps,
|
require_permission, apply_import_timestamps,
|
||||||
|
DateValueError, parse_date_fields,
|
||||||
)
|
)
|
||||||
|
|
||||||
from ..models import MeasuringTool, MeasuringToolType, derive_status, STATUS_COLORS
|
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__)
|
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):
|
def _merged(tool, today=None):
|
||||||
"""Asset core dict with the measuringtools extension nested under 'measuringtool'."""
|
"""Asset core dict with the measuringtools extension nested under 'measuringtool'."""
|
||||||
result = tool.asset.to_dict() if tool.asset else {}
|
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.filter(Asset.statusid == status_id)
|
||||||
|
|
||||||
query = query.order_by(Asset.assetnumber)
|
query = query.order_by(Asset.assetnumber)
|
||||||
items, total = paginate_query(query, page, per_page)
|
|
||||||
|
|
||||||
today = date.today()
|
today = date.today()
|
||||||
data = [_merged(tool, today) for tool in items]
|
|
||||||
|
|
||||||
# Calibration status is derived, so it cannot be a SQL filter - apply it
|
# Calibration status is DERIVED (see derive_status), so it cannot be a SQL
|
||||||
# to the built rows. Filtering post-pagination is acceptable here because
|
# filter. It used to be applied to the rows of the current page, which
|
||||||
# the dataset is a gage lab's worth of tools, not a fleet.
|
# 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')
|
calibrationstatus = request.args.get('calibrationstatus')
|
||||||
if calibrationstatus:
|
if calibrationstatus:
|
||||||
data = [d for d in data
|
matched = [_merged(tool, today) for tool in query.all()]
|
||||||
if d['measuringtool']['calibrationstatus'] == calibrationstatus]
|
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)
|
return paginated_response(data, page, per_page, total)
|
||||||
|
|
||||||
|
|
||||||
@@ -240,6 +244,12 @@ def _asset_type_id():
|
|||||||
def create_tool():
|
def create_tool():
|
||||||
"""Create a measuring tool: one Asset core row + one extension row."""
|
"""Create a measuring tool: one Asset core row + one extension row."""
|
||||||
data = request.get_json() or {}
|
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'):
|
if not data.get('assetnumber'):
|
||||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'assetnumber is required')
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'assetnumber is required')
|
||||||
if Asset.query.filter_by(assetnumber=data['assetnumber']).first():
|
if Asset.query.filter_by(assetnumber=data['assetnumber']).first():
|
||||||
@@ -275,8 +285,8 @@ def create_tool():
|
|||||||
assetid=asset.assetid,
|
assetid=asset.assetid,
|
||||||
measuringtooltypeid=data.get('measuringtooltypeid'),
|
measuringtooltypeid=data.get('measuringtooltypeid'),
|
||||||
calibrationintervaldays=data.get('calibrationintervaldays'),
|
calibrationintervaldays=data.get('calibrationintervaldays'),
|
||||||
lastcalibrationdate=_parse_date(data.get('lastcalibrationdate')),
|
lastcalibrationdate=dates.get('lastcalibrationdate'),
|
||||||
nextcalibrationdate=_parse_date(data.get('nextcalibrationdate')),
|
nextcalibrationdate=dates.get('nextcalibrationdate'),
|
||||||
calibrationprovider=(data.get('calibrationprovider') or '').strip() or None,
|
calibrationprovider=(data.get('calibrationprovider') or '').strip() or None,
|
||||||
notes=(data.get('notes') 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,
|
return error_response(ErrorCodes.NOT_FOUND,
|
||||||
f'Measuring tool {tool_id} not found', http_code=404)
|
f'Measuring tool {tool_id} not found', http_code=404)
|
||||||
data = request.get_json() or {}
|
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
|
asset = tool.asset
|
||||||
|
|
||||||
# An UPDATE may not blank the asset number. Create validates it and the
|
# 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]:
|
if getattr(tool, key) != data[key]:
|
||||||
changes[key] = {'old': getattr(tool, key), 'new': data[key]}
|
changes[key] = {'old': getattr(tool, key), 'new': data[key]}
|
||||||
setattr(tool, key, data[key])
|
setattr(tool, key, data[key])
|
||||||
for key in ('lastcalibrationdate', 'nextcalibrationdate'):
|
# Refused, not nulled: a mistyped calibration date used to store NULL and
|
||||||
if key in data:
|
# answer 200, which flipped the tool to 'unknown' and dropped it out of the
|
||||||
setattr(tool, key, _parse_date(data[key]))
|
# calibration report and the map overlay.
|
||||||
|
for key, parsed in dates.items():
|
||||||
|
setattr(tool, key, parsed)
|
||||||
|
|
||||||
if changes:
|
if changes:
|
||||||
AuditLog.log('updated', 'MeasuringTool', entityid=tool.measuringtoolid,
|
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">
|
<router-link :to="`/measuringtools/${tool?.measuringtool?.measuringtoolid}/edit`" class="btn btn-primary" v-if="tool">
|
||||||
Edit
|
Edit
|
||||||
</router-link>
|
</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>
|
<router-link to="/measuringtools" class="btn btn-secondary">Back to List</router-link>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<div v-if="loading" class="loading">Loading...</div>
|
||||||
|
|
||||||
<template v-else-if="tool">
|
<template v-else-if="tool">
|
||||||
@@ -181,7 +191,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { colorStyle } from '@/utils/colorStyle'
|
import { colorStyle } from '@/utils/colorStyle'
|
||||||
import { measuringtoolsApi } from '@/api'
|
import { measuringtoolsApi } from '@/api'
|
||||||
@@ -227,6 +237,21 @@ onMounted(async () => {
|
|||||||
// Retire, not delete: the server deactivates the asset, so the measuring tool
|
// Retire, not delete: the server deactivates the asset, so the measuring tool
|
||||||
// leaves every list and report while its history and relationships stay put.
|
// leaves every list and report while its history and relationships stay put.
|
||||||
// Reversible by an admin setting isactive back to true.
|
// 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() {
|
async function confirmRetire() {
|
||||||
const label = tool.value?.assetnumber || tool.value?.name || 'this measuring tool'
|
const label = tool.value?.assetnumber || tool.value?.name || 'this measuring tool'
|
||||||
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
|
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
|
||||||
@@ -243,6 +268,10 @@ async function confirmRetire() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.retired-banner {
|
||||||
|
border-left: 3px solid var(--warning);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
.mono {
|
.mono {
|
||||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,10 +179,10 @@ const businessunits = ref([])
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const [typesResponse, statusResponse, locResponse, buResponse, assetTypesResponse] = await Promise.all([
|
const [typesResponse, statusResponse, locResponse, buResponse, assetTypesResponse] = await Promise.all([
|
||||||
measuringtoolsApi.types.list({ perpage: 200 }),
|
measuringtoolsApi.types.list({ perpage: 100 }),
|
||||||
assetsApi.statuses.list(),
|
assetsApi.statuses.list(),
|
||||||
locationsApi.listAll(), // backend caps perpage at 100; page through all
|
locationsApi.listAll(), // backend caps perpage at 100; page through all
|
||||||
businessunitsApi.list({ perpage: 500 }),
|
businessunitsApi.listAll(),
|
||||||
assetsApi.types.list(),
|
assetsApi.types.list(),
|
||||||
])
|
])
|
||||||
types.value = typesResponse.data.data || []
|
types.value = typesResponse.data.data || []
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ onMounted(() => loadData())
|
|||||||
async function loadData() {
|
async function loadData() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
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 || []
|
types.value = response.data.data || []
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading measuring tool types:', err)
|
console.error('Error loading measuring tool types:', err)
|
||||||
|
|||||||
@@ -28,7 +28,14 @@
|
|||||||
<option value="current">Current</option>
|
<option value="current">Current</option>
|
||||||
<option value="unknown">Unknown</option>
|
<option value="unknown">Unknown</option>
|
||||||
</select>
|
</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 class="card">
|
||||||
<div v-if="loading" class="loading">Loading...</div>
|
<div v-if="loading" class="loading">Loading...</div>
|
||||||
@@ -115,6 +122,7 @@ function applySuggestion(word) {
|
|||||||
}
|
}
|
||||||
const typeid = ref('')
|
const typeid = ref('')
|
||||||
const calibrationstatus = ref('')
|
const calibrationstatus = ref('')
|
||||||
|
const showRetired = ref(false)
|
||||||
const totalPages = ref(1)
|
const totalPages = ref(1)
|
||||||
const perPage = ref(20)
|
const perPage = ref(20)
|
||||||
|
|
||||||
@@ -126,7 +134,7 @@ function formatDate(d) { return new Date(d + 'T00:00:00').toLocaleDateString() }
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const typesResponse = await measuringtoolsApi.types.list({ perpage: 200 })
|
const typesResponse = await measuringtoolsApi.types.list({ perpage: 100 })
|
||||||
types.value = typesResponse.data.data || []
|
types.value = typesResponse.data.data || []
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading types:', err)
|
console.error('Error loading types:', err)
|
||||||
@@ -139,6 +147,8 @@ async function loadTools() {
|
|||||||
try {
|
try {
|
||||||
const params = { page: page.value, perpage: perPage.value }
|
const params = { page: page.value, perpage: perPage.value }
|
||||||
if (search.value) params.search = search.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 (typeid.value) params.typeid = typeid.value
|
||||||
if (calibrationstatus.value) params.calibrationstatus = calibrationstatus.value
|
if (calibrationstatus.value) params.calibrationstatus = calibrationstatus.value
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,14 @@
|
|||||||
from flask import Blueprint, request
|
from flask import Blueprint, request
|
||||||
from flask_jwt_extended import jwt_required
|
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 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 ..services.assetnumbers import generate_for_type
|
||||||
from ..models import NetworkDevice, NetworkDeviceType, Subnet, VLAN
|
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__)
|
network_bp = Blueprint('network', __name__)
|
||||||
|
|
||||||
@@ -232,20 +234,20 @@ def list_network_devices():
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Type filter
|
# Type filter
|
||||||
if type_id := request.args.get('typeid', request.args.get('type_id')):
|
if (type_id := intarg('typeid', intarg('type_id'))) is not None:
|
||||||
query = query.filter(NetworkDevice.networkdevicetypeid == int(type_id))
|
query = query.filter(NetworkDevice.networkdevicetypeid == type_id)
|
||||||
|
|
||||||
# Vendor filter
|
# Vendor filter
|
||||||
if vendor_id := request.args.get('vendorid', request.args.get('vendor_id')):
|
if (vendor_id := intarg('vendorid', intarg('vendor_id'))) is not None:
|
||||||
query = query.filter(NetworkDevice.vendorid == int(vendor_id))
|
query = query.filter(NetworkDevice.vendorid == vendor_id)
|
||||||
|
|
||||||
# Location filter
|
# Location filter
|
||||||
if location_id := request.args.get('locationid', request.args.get('location_id')):
|
if (location_id := intarg('locationid', intarg('location_id'))) is not None:
|
||||||
query = query.filter(Asset.locationid == int(location_id))
|
query = query.filter(Asset.locationid == location_id)
|
||||||
|
|
||||||
# Business unit filter
|
# Business unit filter
|
||||||
if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')):
|
if (bu_id := intarg('businessunitid', intarg('businessunit_id'))) is not None:
|
||||||
query = query.filter(Asset.businessunitid == int(bu_id))
|
query = query.filter(Asset.businessunitid == bu_id)
|
||||||
|
|
||||||
# PoE filter
|
# PoE filter
|
||||||
if poe := request.args.get('poe'):
|
if poe := request.args.get('poe'):
|
||||||
@@ -697,11 +699,27 @@ def create_vlan():
|
|||||||
if not data.get('name'):
|
if not data.get('name'):
|
||||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required')
|
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
|
# 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(
|
return error_response(
|
||||||
ErrorCodes.CONFLICT,
|
ErrorCodes.CONFLICT,
|
||||||
f"VLAN {data['vlannumber']} already exists",
|
f"VLAN {vlannumber} already exists",
|
||||||
http_code=409
|
http_code=409
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -784,10 +802,14 @@ def delete_vlan(vlan_id: int):
|
|||||||
|
|
||||||
# Check if VLAN has associated subnets
|
# Check if VLAN has associated subnets
|
||||||
if vlan.subnets.filter_by(isactive=True).count() > 0:
|
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(
|
return error_response(
|
||||||
ErrorCodes.VALIDATION_ERROR,
|
ErrorCodes.CONFLICT,
|
||||||
'Cannot delete VLAN with associated subnets',
|
'Cannot delete VLAN with associated subnets',
|
||||||
http_code=400
|
http_code=409
|
||||||
)
|
)
|
||||||
|
|
||||||
vlan.isactive = False
|
vlan.isactive = False
|
||||||
@@ -827,12 +849,12 @@ def list_subnets():
|
|||||||
)
|
)
|
||||||
|
|
||||||
# VLAN filter
|
# VLAN filter
|
||||||
if vlan_id := request.args.get('vlanid'):
|
if (vlan_id := intarg('vlanid')) is not None:
|
||||||
query = query.filter(Subnet.vlanid == int(vlan_id))
|
query = query.filter(Subnet.vlanid == vlan_id)
|
||||||
|
|
||||||
# Location filter
|
# Location filter
|
||||||
if location_id := request.args.get('locationid'):
|
if (location_id := intarg('locationid')) is not None:
|
||||||
query = query.filter(Subnet.locationid == int(location_id))
|
query = query.filter(Subnet.locationid == location_id)
|
||||||
|
|
||||||
# Type filter
|
# Type filter
|
||||||
if subnet_type := request.args.get('type'):
|
if subnet_type := request.args.get('type'):
|
||||||
@@ -936,10 +958,30 @@ def create_subnet():
|
|||||||
if not data.get('name'):
|
if not data.get('name'):
|
||||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required')
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required')
|
||||||
|
|
||||||
# Validate CIDR format (basic check)
|
# Parsed, not eyeballed for a slash. 'notanip/xx' passed the old check and
|
||||||
cidr = data['cidr']
|
# 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:
|
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
|
# Check for duplicate CIDR
|
||||||
if Subnet.query.filter_by(cidr=cidr).first():
|
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 Modal from '@/components/Modal.vue'
|
||||||
import { currentTheme } from '@/stores/theme'
|
import { currentTheme } from '@/stores/theme'
|
||||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||||
|
import { loadAssetTypeIds, assetTypeId } from '@/composables/assetTypeIds'
|
||||||
import { apiError } from '@/utils/apiError'
|
import { apiError } from '@/utils/apiError'
|
||||||
|
|
||||||
const { isEnabled } = useIdentifierFlags()
|
const { isEnabled } = useIdentifierFlags()
|
||||||
@@ -350,8 +351,11 @@ const router = useRouter()
|
|||||||
const deviceId = route.params.id
|
const deviceId = route.params.id
|
||||||
const isEdit = computed(() => !!deviceId)
|
const isEdit = computed(() => !!deviceId)
|
||||||
|
|
||||||
// Seeded asset-type id for network devices (see /api/assets/types).
|
// Resolved by NAME at load, never hardcoded: the id comes from seed
|
||||||
const NETWORK_ASSETTYPEID = 3
|
// 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 customFieldsRef = ref(null)
|
||||||
const currentAssetId = ref(null)
|
const currentAssetId = ref(null)
|
||||||
|
|
||||||
@@ -424,6 +428,7 @@ const saving = ref(false)
|
|||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
await loadAssetTypeIds()
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
loadDeviceTypes(),
|
loadDeviceTypes(),
|
||||||
loadVendors(),
|
loadVendors(),
|
||||||
|
|||||||
@@ -45,7 +45,14 @@
|
|||||||
{{ loc.locationname }}
|
{{ loc.locationname }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</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 class="card">
|
||||||
<div v-if="loading" class="loading">Loading...</div>
|
<div v-if="loading" class="loading">Loading...</div>
|
||||||
@@ -143,6 +150,7 @@ function applySuggestion(word) {
|
|||||||
const selectedType = ref(null)
|
const selectedType = ref(null)
|
||||||
const vendorFilter = ref('')
|
const vendorFilter = ref('')
|
||||||
const locationFilter = ref('')
|
const locationFilter = ref('')
|
||||||
|
const showRetired = ref(false)
|
||||||
const totalPages = ref(1)
|
const totalPages = ref(1)
|
||||||
const perPage = ref(25)
|
const perPage = ref(25)
|
||||||
const totalCount = ref(0)
|
const totalCount = ref(0)
|
||||||
@@ -225,6 +233,8 @@ async function loadDevices() {
|
|||||||
perpage: perPage.value
|
perpage: perPage.value
|
||||||
}
|
}
|
||||||
if (search.value) params.search = search.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 (selectedType.value) params.typeid = selectedType.value
|
||||||
if (vendorFilter.value) params.vendorid = vendorFilter.value
|
if (vendorFilter.value) params.vendorid = vendorFilter.value
|
||||||
if (locationFilter.value) params.locationid = locationFilter.value
|
if (locationFilter.value) params.locationid = locationFilter.value
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ onMounted(loadData)
|
|||||||
async function loadData() {
|
async function loadData() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
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 || []
|
items.value = response.data.data || []
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading network device types:', 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 import Notification, NotificationType
|
||||||
from ..models.notification import _utc_iso
|
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__)
|
notifications_bp = Blueprint('notifications', __name__)
|
||||||
|
|
||||||
@@ -355,8 +355,8 @@ def list_notifications():
|
|||||||
query = query.filter(Notification.isactive == True)
|
query = query.filter(Notification.isactive == True)
|
||||||
|
|
||||||
# Type filter
|
# Type filter
|
||||||
if type_id := request.args.get('typeid', request.args.get('type_id')):
|
if (type_id := intarg('typeid', intarg('type_id'))) is not None:
|
||||||
query = query.filter(Notification.notificationtypeid == int(type_id))
|
query = query.filter(Notification.notificationtypeid == type_id)
|
||||||
|
|
||||||
# Exact-match lookup for idempotent import. Notifications have no strong
|
# Exact-match lookup for idempotent import. Notifications have no strong
|
||||||
# natural key; ticketnumber is the best available when a ticket is set.
|
# natural key; ticketnumber is the best available when a ticket is set.
|
||||||
|
|||||||
@@ -244,6 +244,7 @@
|
|||||||
import { ref, onMounted, computed, watch } from 'vue'
|
import { ref, onMounted, computed, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { notificationsApi, applicationsApi, businessUnitsApi, employeesApi, settingsApi } from '@/api'
|
import { notificationsApi, applicationsApi, businessUnitsApi, employeesApi, settingsApi } from '@/api'
|
||||||
|
import { resolveEmployeeName, fullname } from '@/composables/employeeResolve'
|
||||||
import { apiError } from '@/utils/apiError'
|
import { apiError } from '@/utils/apiError'
|
||||||
import { zonedInputFromUtc, utcFromZonedInput, DEFAULT_TZ } from '@/utils/datetime'
|
import { zonedInputFromUtc, utcFromZonedInput, DEFAULT_TZ } from '@/utils/datetime'
|
||||||
|
|
||||||
@@ -459,6 +460,30 @@ async function addCustomEmployee() {
|
|||||||
}
|
}
|
||||||
selectedEmployees.value.push({ sso: token, name })
|
selectedEmployees.value.push({ sso: token, name })
|
||||||
} else {
|
} 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}`
|
const key = `NAME:${token}`
|
||||||
if (selectedEmployees.value.some(e => e.sso === key)) continue
|
if (selectedEmployees.value.some(e => e.sso === key)) continue
|
||||||
selectedEmployees.value.push({ sso: key, name: token })
|
selectedEmployees.value.push({ sso: key, name: token })
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ def get_item(item_id: int):
|
|||||||
|
|
||||||
import glob
|
import glob
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
@@ -257,6 +258,52 @@ from ..models import PrintedItemTransaction
|
|||||||
from ..services.badges import BadgeError, resolve_badge
|
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,
|
def _ledger_write(item, transactiontype, quantitychange, sso, name, reason=None,
|
||||||
revision=None):
|
revision=None):
|
||||||
"""Append a ledger row and move the cached quantity in ONE commit.
|
"""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
|
alert when this write CROSSES the item's threshold downward - crossing
|
||||||
(not being below) is the natural debounce: one alert per depletion, and
|
(not being below) is the natural debounce: one alert per depletion, and
|
||||||
restocking above the threshold rearms it.
|
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
|
# Derived from what the write ACTUALLY produced, not from what this request
|
||||||
item.quantityonhand += quantitychange
|
# 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(
|
db.session.add(PrintedItemTransaction(
|
||||||
printeditemid=item.printeditemid,
|
printeditemid=item.printeditemid,
|
||||||
transactiontype=transactiontype,
|
transactiontype=transactiontype,
|
||||||
@@ -427,7 +482,13 @@ def adjust_item(item_id: int):
|
|||||||
except BadgeError as badge_error:
|
except BadgeError as badge_error:
|
||||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(badge_error),
|
return error_response(ErrorCodes.VALIDATION_ERROR, str(badge_error),
|
||||||
http_code=422)
|
http_code=422)
|
||||||
|
try:
|
||||||
_ledger_write(item, 'adjust', quantitychange, sso, name, reason=reason)
|
_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')
|
return success_response(item.to_dict(), message='Stock adjusted')
|
||||||
|
|
||||||
|
|
||||||
@@ -500,8 +561,14 @@ def _kiosk_find_item(itemcode):
|
|||||||
matches = []
|
matches = []
|
||||||
for candidate in PrintedItem.query.filter_by(isactive=True).all():
|
for candidate in PrintedItem.query.filter_by(isactive=True).all():
|
||||||
for value in (candidate.itemcode, candidate.gagelabtag):
|
for value in (candidate.itemcode, candidate.gagelabtag):
|
||||||
tail = ''.join(ch for ch in (value or '') if ch.isdigit())
|
# The TRAILING digit run, not every digit in the string. A
|
||||||
if tail and int(tail) == wanted:
|
# 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)
|
matches.append(candidate)
|
||||||
break
|
break
|
||||||
if len(matches) == 1:
|
if len(matches) == 1:
|
||||||
@@ -579,7 +646,16 @@ def kiosk_take():
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
revision = None
|
revision = None
|
||||||
|
|
||||||
|
try:
|
||||||
_ledger_write(item, 'take', -quantity, sso, name, revision=revision)
|
_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(),
|
return success_response(item.to_dict(),
|
||||||
message=f'Took {quantity}, {item.quantityonhand} left')
|
message=f'Took {quantity}, {item.quantityonhand} left')
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,31 @@
|
|||||||
</button>
|
</button>
|
||||||
</header>
|
</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 -->
|
<!-- 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>
|
<p class="kiosk-prompt">Scan the barcode on the bin</p>
|
||||||
<!-- one visible field: keyboard-wedge scanners, a plugged-in keyboard or
|
<!-- one visible field: keyboard-wedge scanners, a plugged-in keyboard or
|
||||||
numpad, and the on-screen keypad all type into it. -->
|
numpad, and the on-screen keypad all type into it. -->
|
||||||
@@ -108,6 +129,13 @@ const item = ref(null)
|
|||||||
const badge = ref('')
|
const badge = ref('')
|
||||||
const quantity = ref('')
|
const quantity = ref('')
|
||||||
const error = 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 doneMessage = ref('')
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const manualCode = ref('')
|
const manualCode = ref('')
|
||||||
@@ -118,6 +146,14 @@ let resetTimer = null
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
claimKioskToken(KIOSK_TOKEN_KEYS['/printedparts/kiosk/'])
|
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
|
// 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
|
// label format is a property of the site's physical tags, not something that
|
||||||
// changes during a shift.
|
// changes during a shift.
|
||||||
@@ -153,11 +189,17 @@ async function lookupItem(itemcode) {
|
|||||||
try {
|
try {
|
||||||
const response = await printedpartsApi.kioskItem(code)
|
const response = await printedpartsApi.kioskItem(code)
|
||||||
item.value = response.data.data
|
item.value = response.data.data
|
||||||
|
misses.value = 0
|
||||||
step.value = 'badge'
|
step.value = 'badge'
|
||||||
manualCode.value = ''
|
manualCode.value = ''
|
||||||
} catch (lookupError) {
|
} catch (lookupError) {
|
||||||
error.value = lookupError.response?.data?.data?.error?.message ||
|
error.value = lookupError.response?.data?.data?.error?.message ||
|
||||||
'No part matches that barcode'
|
'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()
|
focusEntry()
|
||||||
}
|
}
|
||||||
@@ -215,6 +257,14 @@ function reset() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.kiosk-unprovisioned {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.kiosk-hint {
|
||||||
|
color: var(--text-light);
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
}
|
||||||
.parts-kiosk {
|
.parts-kiosk {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
|
|||||||
@@ -36,16 +36,24 @@ class BadgeError(ValueError):
|
|||||||
|
|
||||||
|
|
||||||
def _parse_badge(badge):
|
def _parse_badge(badge):
|
||||||
"""Return ('sso'|'payno', digits) or raise BadgeError on unknown shape."""
|
"""Return ('sso'|'payno', digits) or raise BadgeError on unknown shape.
|
||||||
badge = (badge or '').strip()
|
|
||||||
if not badge:
|
The shapes themselves live in shopdb.api (normalise_badge), because the USB
|
||||||
raise BadgeError('Scan or enter a badge')
|
kiosk reads the same physical badges and used to disagree: it took the
|
||||||
if badge.isdigit():
|
string raw, so the PayNo wrapper resolved to nobody and an operator whose
|
||||||
return 'sso', badge
|
scanner emits it could take a part and not check out a drive.
|
||||||
match = _PAYNO_BADGE.match(badge)
|
|
||||||
if match:
|
That also closed a subtler hole. This used str.isdigit(), which is true for
|
||||||
return 'payno', match.group(1)
|
Arabic-Indic and other decimal forms, so a badge written in them was
|
||||||
raise BadgeError('Unrecognized badge format')
|
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):
|
def _selfhosted_lookup(digits):
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import re
|
|||||||
from flask import Blueprint, request, Response, current_app
|
from flask import Blueprint, request, Response, current_app
|
||||||
from flask_jwt_extended import jwt_required
|
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 import Printer, PrinterType, ModelSupply, PrinterDriver, PrinterObservedQueue
|
||||||
from ..models.model_supply import SUPPLY_TYPES, SUPPLY_COLORS, CAPACITY_TIERS
|
from ..models.model_supply import SUPPLY_TYPES, SUPPLY_COLORS, CAPACITY_TIERS
|
||||||
@@ -163,10 +163,20 @@ def create_driver():
|
|||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
if not (data.get('name') and data.get('location')):
|
if not (data.get('name') and data.get('location')):
|
||||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'name and location are required')
|
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(
|
d = PrinterDriver(
|
||||||
name=data['name'],
|
name=data['name'],
|
||||||
location=data['location'],
|
location=data['location'],
|
||||||
description=data.get('description'),
|
description=data.get('description'),
|
||||||
|
drivername=(data.get('drivername') or '').strip() or None,
|
||||||
|
vendorid=data.get('vendorid') or None,
|
||||||
modelnumberid=data.get('modelnumberid') or None,
|
modelnumberid=data.get('modelnumberid') or None,
|
||||||
isactive=data.get('isactive', True),
|
isactive=data.get('isactive', True),
|
||||||
)
|
)
|
||||||
@@ -185,8 +195,11 @@ def update_driver(driver_id):
|
|||||||
for key in ('name', 'location', 'description', 'isactive'):
|
for key in ('name', 'location', 'description', 'isactive'):
|
||||||
if key in data:
|
if key in data:
|
||||||
setattr(d, key, data[key])
|
setattr(d, key, data[key])
|
||||||
if 'modelnumberid' in data:
|
if 'drivername' in data:
|
||||||
d.modelnumberid = data['modelnumberid'] or None
|
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()
|
db.session.commit()
|
||||||
return success_response(d.to_dict(), message='Driver updated')
|
return success_response(d.to_dict(), message='Driver updated')
|
||||||
|
|
||||||
@@ -261,20 +274,20 @@ def list_printers():
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Type filter
|
# Type filter
|
||||||
if typeid := request.args.get('typeid', request.args.get('type_id')):
|
if (typeid := intarg('typeid', intarg('type_id'))) is not None:
|
||||||
query = query.filter(Printer.printertypeid == int(typeid))
|
query = query.filter(Printer.printertypeid == typeid)
|
||||||
|
|
||||||
# Vendor filter
|
# Vendor filter
|
||||||
if vendor_id := request.args.get('vendorid', request.args.get('vendor_id')):
|
if (vendor_id := intarg('vendorid', intarg('vendor_id'))) is not None:
|
||||||
query = query.filter(Printer.vendorid == int(vendor_id))
|
query = query.filter(Printer.vendorid == vendor_id)
|
||||||
|
|
||||||
# Location filter
|
# Location filter
|
||||||
if location_id := request.args.get('locationid', request.args.get('location_id')):
|
if (location_id := intarg('locationid', intarg('location_id'))) is not None:
|
||||||
query = query.filter(Asset.locationid == int(location_id))
|
query = query.filter(Asset.locationid == location_id)
|
||||||
|
|
||||||
# Business unit filter
|
# Business unit filter
|
||||||
if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')):
|
if (bu_id := intarg('businessunitid', intarg('businessunit_id'))) is not None:
|
||||||
query = query.filter(Asset.businessunitid == int(bu_id))
|
query = query.filter(Asset.businessunitid == bu_id)
|
||||||
|
|
||||||
# Sorting
|
# Sorting
|
||||||
sort_by = request.args.get('sort', 'hostname')
|
sort_by = request.args.get('sort', 'hostname')
|
||||||
@@ -2316,15 +2329,28 @@ def printer_lookup():
|
|||||||
'Provide ip or fqdn'
|
'Provide ip or fqdn'
|
||||||
)
|
)
|
||||||
|
|
||||||
# match the IP against any active printer communication
|
# An IP matches a communication row; an FQDN matches the printer's
|
||||||
row = (
|
# 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)
|
db.session.query(Printer, Asset)
|
||||||
.join(Asset, Asset.assetid == Printer.assetid)
|
.join(Asset, Asset.assetid == Printer.assetid)
|
||||||
.join(Communication, Communication.assetid == Asset.assetid)
|
|
||||||
.filter(Asset.isactive == True)
|
.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:
|
if not row:
|
||||||
return error_response(
|
return error_response(
|
||||||
@@ -2481,8 +2507,8 @@ def list_supply_models():
|
|||||||
|
|
||||||
if search := request.args.get('search'):
|
if search := request.args.get('search'):
|
||||||
query = query.filter(Model.modelnumber.ilike(f'%{search}%'))
|
query = query.filter(Model.modelnumber.ilike(f'%{search}%'))
|
||||||
if vendor_id := request.args.get('vendorid', request.args.get('vendor_id')):
|
if (vendor_id := intarg('vendorid', intarg('vendor_id'))) is not None:
|
||||||
query = query.filter(Model.vendorid == int(vendor_id))
|
query = query.filter(Model.vendorid == vendor_id)
|
||||||
if request.args.get('withsupplies', '').lower() == 'true':
|
if request.args.get('withsupplies', '').lower() == 'true':
|
||||||
query = query.having(supplycount > 0)
|
query = query.having(supplycount > 0)
|
||||||
|
|
||||||
@@ -2755,7 +2781,7 @@ def supplies_forecast():
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
days = max(1, min(365, int(request.args.get('days', 90))))
|
days = max(1, min(365, intarg('days', 90)))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
days = 90
|
days = 90
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,21 @@
|
|||||||
Print Label
|
Print Label
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link :to="`/printers/${$route.params.id}/edit`" class="btn btn-primary">Edit</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>
|
<router-link to="/printers" class="btn btn-secondary">Back to List</router-link>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<div v-if="loading" class="loading">Loading...</div>
|
||||||
|
|
||||||
<template v-else-if="printer">
|
<template v-else-if="printer">
|
||||||
@@ -306,7 +316,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { printersApi } from '@/api'
|
import { printersApi } from '@/api'
|
||||||
import { withBase } from '@/utils/basePath'
|
import { withBase } from '@/utils/basePath'
|
||||||
@@ -406,6 +416,21 @@ function formatDate(dateStr) {
|
|||||||
// Retire, not delete: the server deactivates the asset, so the printer
|
// Retire, not delete: the server deactivates the asset, so the printer
|
||||||
// leaves every list and report while its history and relationships stay put.
|
// leaves every list and report while its history and relationships stay put.
|
||||||
// Reversible by an admin setting isactive back to true.
|
// 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() {
|
async function confirmRetire() {
|
||||||
const label = displayTitle.value || 'this printer'
|
const label = displayTitle.value || 'this printer'
|
||||||
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
|
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
|
||||||
@@ -422,6 +447,10 @@ async function confirmRetire() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.retired-banner {
|
||||||
|
border-left: 3px solid var(--warning);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
/* Printer-specific styles - shared styles are in global style.css */
|
/* Printer-specific styles - shared styles are in global style.css */
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
|
|||||||
@@ -61,6 +61,26 @@
|
|||||||
<label>Name *</label>
|
<label>Name *</label>
|
||||||
<input v-model="form.name" type="text" class="form-control" maxlength="150" required />
|
<input v-model="form.name" type="text" class="form-control" maxlength="150" required />
|
||||||
</div>
|
</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">
|
<div class="form-group">
|
||||||
<label>Printer Model <span class="hint">(links driver to a model so it shows on matching printers)</span></label>
|
<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">
|
<select v-model="form.modelnumberid" class="form-control">
|
||||||
@@ -96,13 +116,14 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { printersApi } from '@/api'
|
import { printersApi, vendorsApi } from '@/api'
|
||||||
import { useToast } from '@/composables/toast'
|
import { useToast } from '@/composables/toast'
|
||||||
import { apiError } from '@/utils/apiError'
|
import { apiError } from '@/utils/apiError'
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
const items = ref([])
|
const items = ref([])
|
||||||
const models = ref([])
|
const models = ref([])
|
||||||
|
const vendors = ref([])
|
||||||
const showInactive = ref(false)
|
const showInactive = ref(false)
|
||||||
const visibleItems = computed(() => showInactive.value ? items.value : items.value.filter(x => x.isactive !== false))
|
const visibleItems = computed(() => showInactive.value ? items.value : items.value.filter(x => x.isactive !== false))
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
@@ -110,13 +131,13 @@ const showModal = ref(false)
|
|||||||
const editing = ref(null)
|
const editing = ref(null)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const error = ref('')
|
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) {
|
function isHttp(loc) {
|
||||||
return typeof loc === 'string' && /^https?:\/\//i.test(loc)
|
return typeof loc === 'string' && /^https?:\/\//i.test(loc)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => { loadData(); loadModels() })
|
onMounted(() => { loadData(); loadModels(); loadVendors() })
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
loading.value = true
|
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) {
|
function openModal(item = null) {
|
||||||
editing.value = item
|
editing.value = item
|
||||||
form.value = item
|
form.value = item
|
||||||
? { name: item.name || '', location: item.location || '', description: item.description || '', modelnumberid: item.modelnumberid || null, isactive: item.isactive !== false }
|
? { 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: '', modelnumberid: null, isactive: true }
|
: { name: '', location: '', description: '', drivername: '', vendorid: null, modelnumberid: null, isactive: true }
|
||||||
error.value = ''
|
error.value = ''
|
||||||
showModal.value = true
|
showModal.value = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -336,6 +336,7 @@ import Modal from '@/components/Modal.vue'
|
|||||||
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
|
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
|
||||||
import { currentTheme } from '@/stores/theme'
|
import { currentTheme } from '@/stores/theme'
|
||||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||||
|
import { loadAssetTypeIds, assetTypeId } from '@/composables/assetTypeIds'
|
||||||
import { apiError } from '@/utils/apiError'
|
import { apiError } from '@/utils/apiError'
|
||||||
import { getPrinterHostnameTemplate } from '@/utils/siteSettings'
|
import { getPrinterHostnameTemplate } from '@/utils/siteSettings'
|
||||||
|
|
||||||
@@ -346,8 +347,11 @@ const router = useRouter()
|
|||||||
|
|
||||||
const isEdit = computed(() => !!route.params.id)
|
const isEdit = computed(() => !!route.params.id)
|
||||||
|
|
||||||
// Seeded asset-type id for printers (see /api/assets/types).
|
// Resolved by NAME at load, never hardcoded: the id comes from seed
|
||||||
const PRINTER_ASSETTYPEID = 4
|
// 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 customFieldsRef = ref(null)
|
||||||
const currentAssetId = ref(null)
|
const currentAssetId = ref(null)
|
||||||
const manualHostname = ref(false)
|
const manualHostname = ref(false)
|
||||||
@@ -542,6 +546,7 @@ function onWindowsNameInput() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
await loadAssetTypeIds()
|
||||||
try {
|
try {
|
||||||
// Load reference data (models paged in full via listAll, see api/index.js)
|
// 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
|
// perpage 100 so dropdowns aren't truncated to the default 20-row page
|
||||||
@@ -686,7 +691,13 @@ async function savePrinter() {
|
|||||||
isduplex: !!form.value.isduplex,
|
isduplex: !!form.value.isduplex,
|
||||||
isnetwork: !!form.value.isnetwork,
|
isnetwork: !!form.value.isnetwork,
|
||||||
mapx: form.value.mapx,
|
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
|
// only set the display name when an alias is given, so we don't clobber it
|
||||||
if (form.value.alias) {
|
if (form.value.alias) {
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ onMounted(loadData)
|
|||||||
async function loadData() {
|
async function loadData() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
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 || []
|
items.value = response.data.data || []
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading printer types:', err)
|
console.error('Error loading printer types:', err)
|
||||||
|
|||||||
@@ -25,7 +25,14 @@
|
|||||||
{{ pt.printertype }}
|
{{ pt.printertype }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</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 class="card">
|
||||||
<div v-if="loading" class="loading">Loading...</div>
|
<div v-if="loading" class="loading">Loading...</div>
|
||||||
@@ -111,6 +118,7 @@ function applySuggestion(word) {
|
|||||||
// search page documents. Load directly.
|
// search page documents. Load directly.
|
||||||
loadPrinters()
|
loadPrinters()
|
||||||
}
|
}
|
||||||
|
const showRetired = ref(false)
|
||||||
const totalPages = ref(1)
|
const totalPages = ref(1)
|
||||||
const perPage = ref(20)
|
const perPage = ref(20)
|
||||||
|
|
||||||
@@ -134,6 +142,8 @@ async function loadPrinters() {
|
|||||||
perpage: perPage.value
|
perpage: perPage.value
|
||||||
}
|
}
|
||||||
if (search.value) params.search = search.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
|
if (typeFilter.value) params.typeid = typeFilter.value
|
||||||
|
|
||||||
const response = await printersApi.list(params)
|
const response = await printersApi.list(params)
|
||||||
|
|||||||
@@ -17,8 +17,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<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 -->
|
<!-- Summary Bar -->
|
||||||
<div class="summary-bar">
|
<div v-if="!unavailable" class="summary-bar">
|
||||||
<div class="summary-stat card">
|
<div class="summary-stat card">
|
||||||
<div class="stat-value">{{ summary.total_checked }}</div>
|
<div class="stat-value">{{ summary.total_checked }}</div>
|
||||||
<div class="stat-label">Printers Checked</div>
|
<div class="stat-label">Printers Checked</div>
|
||||||
@@ -122,6 +132,9 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
<p v-else class="empty-state">No printers match the selected filter.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,6 +169,7 @@ const loading = ref(true)
|
|||||||
const error = ref(null)
|
const error = ref(null)
|
||||||
const printers = ref([])
|
const printers = ref([])
|
||||||
const summary = ref({ total_checked: 0, low: 0, critical: 0 })
|
const summary = ref({ total_checked: 0, low: 0, critical: 0 })
|
||||||
|
const unavailable = ref('')
|
||||||
const filter = ref('all')
|
const filter = ref('all')
|
||||||
|
|
||||||
const filterOptions = [
|
const filterOptions = [
|
||||||
@@ -239,6 +253,11 @@ onMounted(async () => {
|
|||||||
const data = response.data.data
|
const data = response.data.data
|
||||||
printers.value = data.printers || []
|
printers.value = data.printers || []
|
||||||
summary.value = data.summary || { total_checked: 0, low: 0, critical: 0 }
|
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) {
|
} catch (err) {
|
||||||
console.error('Error loading toner report:', err)
|
console.error('Error loading toner report:', err)
|
||||||
error.value = 'Failed to load supply data. Zabbix may not be configured or reachable.'
|
error.value = 'Failed to load supply data. Zabbix may not be configured or reachable.'
|
||||||
@@ -249,6 +268,13 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.blind-notice {
|
||||||
|
border-left: 3px solid var(--warning);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.blind-notice p {
|
||||||
|
margin: 0.4rem 0 0 0;
|
||||||
|
}
|
||||||
.summary-bar {
|
.summary-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
|
|||||||
@@ -47,6 +47,15 @@
|
|||||||
<img :src="withBase(slide.url)" :alt="slide.filename" class="thumb"
|
<img :src="withBase(slide.url)" :alt="slide.filename" class="thumb"
|
||||||
@mouseenter="previewSlide = slide" @mouseleave="previewSlide = null" />
|
@mouseenter="previewSlide = slide" @mouseleave="previewSlide = null" />
|
||||||
<span class="fname" :title="slide.filename">{{ slide.filename }}</span>
|
<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">
|
<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 === 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>
|
<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 { ref, computed, onMounted } from 'vue'
|
||||||
import { slidesApi } from '@/api'
|
import { slidesApi } from '@/api'
|
||||||
import { withBase } from '@/utils/basePath'
|
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.
|
// Slide currently hovered, shown enlarged. Null when the cursor is elsewhere.
|
||||||
const previewSlide = ref(null)
|
const previewSlide = ref(null)
|
||||||
@@ -133,6 +144,7 @@ const surfaces = [
|
|||||||
{ key: 'shopfloor', label: 'Shopfloor Screensaver' }
|
{ key: 'shopfloor', label: 'Shopfloor Screensaver' }
|
||||||
]
|
]
|
||||||
const surface = ref('lobby')
|
const surface = ref('lobby')
|
||||||
|
const toast = useToast()
|
||||||
const slides = ref([])
|
const slides = ref([])
|
||||||
const selected = ref([])
|
const selected = ref([])
|
||||||
const loading = ref(true)
|
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) {
|
async function move(idx, delta) {
|
||||||
const target = idx + delta
|
const target = idx + delta
|
||||||
if (target < 0 || target >= slides.value.length) return
|
if (target < 0 || target >= slides.value.length) return
|
||||||
@@ -197,6 +225,17 @@ onMounted(load)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.seconds {
|
||||||
|
align-items: center;
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.seconds input {
|
||||||
|
width: 4.5rem;
|
||||||
|
}
|
||||||
|
.seconds-unit {
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
.tabs {
|
.tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ from flask_jwt_extended import jwt_required, get_jwt_identity, current_user
|
|||||||
from shopdb.api import (
|
from shopdb.api import (
|
||||||
db, cache, Setting, client_ip,
|
db, cache, Setting, client_ip,
|
||||||
success_response, error_response, ErrorCodes,
|
success_response, error_response, ErrorCodes,
|
||||||
|
caller_holds,
|
||||||
)
|
)
|
||||||
|
|
||||||
from ..models import SecureSecret
|
from ..models import SecureSecret
|
||||||
@@ -155,7 +156,9 @@ def create_secret():
|
|||||||
ErrorCodes.VALIDATION_ERROR,
|
ErrorCodes.VALIDATION_ERROR,
|
||||||
'Too many links from this address. Try again shortly.',
|
'Too many links from this address. Try again shortly.',
|
||||||
http_code=429)
|
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(
|
return error_response(
|
||||||
ErrorCodes.FORBIDDEN,
|
ErrorCodes.FORBIDDEN,
|
||||||
'You do not have permission to create secret links.',
|
'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
|
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.
|
only write in the product offered to guests at all.
|
||||||
|
|
||||||
tools.view rides along because tools.secrets without it is a page a
|
tools.secrets is NOT here, and that is the fix for a grant that did
|
||||||
guest holds permission to use and no way to reach: it gates the nav
|
nothing. Whether a guest may create a secret link is decided by the
|
||||||
entry, and the browser tools behind it (codes, conversions) run client
|
securesend_allow_guests setting - which fails closed, carries the rate
|
||||||
side and touch no data at all. Declared explicitly rather than inferred
|
limit, and is read straight from the row rather than the cache. The
|
||||||
from the .view suffix, which is what the grantable rule now asks for.
|
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]:
|
def get_settings_defaults(self) -> List[dict]:
|
||||||
"""The one switch this plugin owns.
|
"""The one switch this plugin owns.
|
||||||
|
|||||||
@@ -993,16 +993,30 @@ def kiosk_checkout():
|
|||||||
'Badge not recognized - see IT to check this device out',
|
'Badge not recognized - see IT to check this device out',
|
||||||
http_code=422)
|
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(
|
cur.execute(
|
||||||
'INSERT INTO checkinoutlog '
|
'INSERT INTO checkinoutlog '
|
||||||
'(badge_number, device_id, action, timestamp, locker_location) '
|
'(badge_number, device_id, action, timestamp, locker_location) '
|
||||||
"VALUES (%s, %s, 'check-out', %s, %s)",
|
"VALUES (%s, %s, 'check-out', %s, %s)",
|
||||||
(badge, device_id, _now(), device.get('locker_location')),
|
(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()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -33,11 +33,24 @@ _FILTER = {'available': STATUS_CHECKED_IN, 'checkedout': STATUS_CHECKED_OUT, 're
|
|||||||
|
|
||||||
|
|
||||||
def _resolve_name(sso):
|
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:
|
try:
|
||||||
from plugins.employees.models import DirectoryEmployee
|
from plugins.employees.models import DirectoryEmployee
|
||||||
if sso and str(sso).isdigit():
|
emp = db.session.get(DirectoryEmployee, int(digits))
|
||||||
emp = db.session.get(DirectoryEmployee, int(sso))
|
|
||||||
if emp:
|
if emp:
|
||||||
return f'{emp.firstname} {emp.lastname}'.strip()
|
return f'{emp.firstname} {emp.lastname}'.strip()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -45,6 +58,31 @@ def _resolve_name(sso):
|
|||||||
return None
|
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):
|
def _status(device):
|
||||||
if not device.isactive:
|
if not device.isactive:
|
||||||
return STATUS_RETIRED
|
return STATUS_RETIRED
|
||||||
@@ -307,9 +345,9 @@ def kiosk_device(device_id):
|
|||||||
|
|
||||||
def kiosk_session(data):
|
def kiosk_session(data):
|
||||||
"""See routes.kiosk_session for why the list costs a valid badge."""
|
"""See routes.kiosk_session for why the list costs a valid badge."""
|
||||||
badge = (data.get('badge') or '').strip()
|
badge, badgeerror = _kiosk_badge_digits(data.get('badge'))
|
||||||
if not badge:
|
if badgeerror:
|
||||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'Scan your badge')
|
return badgeerror
|
||||||
name = _resolve_name(badge)
|
name = _resolve_name(badge)
|
||||||
if not name:
|
if not name:
|
||||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
@@ -335,9 +373,9 @@ def kiosk_session(data):
|
|||||||
|
|
||||||
|
|
||||||
def kiosk_others(data):
|
def kiosk_others(data):
|
||||||
badge = (data.get('badge') or '').strip()
|
badge, badgeerror = _kiosk_badge_digits(data.get('badge'))
|
||||||
if not badge:
|
if badgeerror:
|
||||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'Scan your badge')
|
return badgeerror
|
||||||
if not _resolve_name(badge):
|
if not _resolve_name(badge):
|
||||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
'Badge not recognized - see IT', http_code=422)
|
'Badge not recognized - see IT', http_code=422)
|
||||||
@@ -358,9 +396,9 @@ def kiosk_others(data):
|
|||||||
|
|
||||||
def kiosk_checkout(data):
|
def kiosk_checkout(data):
|
||||||
device_id = (data.get('device_id') or '').strip()
|
device_id = (data.get('device_id') or '').strip()
|
||||||
badge = (data.get('badge') or '').strip()
|
badge, badgeerror = _kiosk_badge_digits(data.get('badge'))
|
||||||
if not badge:
|
if badgeerror:
|
||||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'Scan your badge')
|
return badgeerror
|
||||||
|
|
||||||
device = _find(device_id)
|
device = _find(device_id)
|
||||||
if not device:
|
if not device:
|
||||||
@@ -387,12 +425,34 @@ def kiosk_checkout(data):
|
|||||||
http_code=422)
|
http_code=422)
|
||||||
|
|
||||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
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,
|
db.session.add(USBCheckout(usbdeviceid=device.usbdeviceid, sso=badge,
|
||||||
checkoutname=name, checkouttime=now))
|
checkoutname=name, checkouttime=now))
|
||||||
device.ischeckedout = True
|
|
||||||
device.currentuserid = badge
|
|
||||||
device.currentusername = name
|
|
||||||
device.currentcheckoutdate = now
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return success_response({'device_id': device.serialnumber,
|
return success_response({'device_id': device.serialnumber,
|
||||||
'holdername': name},
|
'holdername': name},
|
||||||
@@ -402,9 +462,9 @@ def kiosk_checkout(data):
|
|||||||
|
|
||||||
def kiosk_checkin(data):
|
def kiosk_checkin(data):
|
||||||
device_id = (data.get('device_id') or '').strip()
|
device_id = (data.get('device_id') or '').strip()
|
||||||
badge = (data.get('badge') or '').strip()
|
badge, badgeerror = _kiosk_badge_digits(data.get('badge'))
|
||||||
if not badge:
|
if badgeerror:
|
||||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'Scan your badge')
|
return badgeerror
|
||||||
forsomeoneelse = bool(data.get('forsomeoneelse'))
|
forsomeoneelse = bool(data.get('forsomeoneelse'))
|
||||||
|
|
||||||
device = _find(device_id)
|
device = _find(device_id)
|
||||||
@@ -439,15 +499,38 @@ def kiosk_checkin(data):
|
|||||||
'holdername': device.currentusername or ''},
|
'holdername': device.currentusername or ''},
|
||||||
http_code=409)
|
http_code=409)
|
||||||
|
|
||||||
open_checkout = (USBCheckout.query
|
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)
|
.filter_by(usbdeviceid=device.usbdeviceid, checkintime=None)
|
||||||
.order_by(USBCheckout.checkouttime.desc()).first())
|
.update({USBCheckout.checkintime: now,
|
||||||
if open_checkout:
|
USBCheckout.checkinsso: badge,
|
||||||
open_checkout.checkintime = datetime.now(timezone.utc).replace(tzinfo=None)
|
USBCheckout.checkinname: name},
|
||||||
device.ischeckedout = False
|
synchronize_session=False))
|
||||||
device.currentuserid = None
|
|
||||||
device.currentusername = None
|
|
||||||
device.currentcheckoutdate = None
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return success_response({'device_id': device.serialnumber},
|
return success_response({'device_id': device.serialnumber},
|
||||||
message='{} checked in'.format(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
|
# Checkout details
|
||||||
checkouttime = db.Column(db.DateTime, nullable=False, default=_utcnow)
|
checkouttime = db.Column(db.DateTime, nullable=False, default=_utcnow)
|
||||||
checkintime = db.Column(db.DateTime, nullable=True)
|
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
|
# Metadata
|
||||||
checkoutreason = db.Column(db.Text, nullable=True, comment='Reason for checkout')
|
checkoutreason = db.Column(db.Text, nullable=True, comment='Reason for checkout')
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from shopdb.api import (
|
|||||||
db, Asset,
|
db, Asset,
|
||||||
success_response, error_response, ErrorCodes,
|
success_response, error_response, ErrorCodes,
|
||||||
require_permission,
|
require_permission,
|
||||||
|
DateValueError, parse_date, parse_date_fields,
|
||||||
)
|
)
|
||||||
|
|
||||||
from ..models import Warranty, WarrantyAsset
|
from ..models import Warranty, WarrantyAsset
|
||||||
@@ -42,13 +43,16 @@ def _proof_dir():
|
|||||||
return os.path.join(current_app.instance_path, 'warrantyproofs')
|
return os.path.join(current_app.instance_path, 'warrantyproofs')
|
||||||
|
|
||||||
|
|
||||||
def _parse_date(value):
|
def _vendor_date(value):
|
||||||
"""Accept 'YYYY-MM-DD' (or None/empty) -> date or None."""
|
"""A date off a VENDOR response, which this code does not control.
|
||||||
if not value:
|
|
||||||
return None
|
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:
|
try:
|
||||||
return datetime.strptime(value[:10], '%Y-%m-%d').date()
|
return parse_date(value)
|
||||||
except (ValueError, TypeError):
|
except DateValueError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -203,13 +207,20 @@ def create_warranty():
|
|||||||
vendor = (data.get('vendor') or '').strip()
|
vendor = (data.get('vendor') or '').strip()
|
||||||
if not vendor:
|
if not vendor:
|
||||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'vendor is required')
|
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(
|
warranty = Warranty(
|
||||||
vendor=vendor,
|
vendor=vendor,
|
||||||
servicetag=(data.get('servicetag') or '').strip() or None,
|
servicetag=(data.get('servicetag') or '').strip() or None,
|
||||||
provider=(data.get('provider') or 'manual').strip().lower(),
|
provider=(data.get('provider') or 'manual').strip().lower(),
|
||||||
servicelevel=(data.get('servicelevel') or '').strip() or None,
|
servicelevel=(data.get('servicelevel') or '').strip() or None,
|
||||||
startdate=_parse_date(data.get('startdate')),
|
startdate=dates.get('startdate'),
|
||||||
enddate=_parse_date(data.get('enddate')),
|
enddate=dates.get('enddate'),
|
||||||
notes=(data.get('notes') or '').strip() or None,
|
notes=(data.get('notes') or '').strip() or None,
|
||||||
)
|
)
|
||||||
_apply_links(warranty, data.get('assetids'))
|
_apply_links(warranty, data.get('assetids'))
|
||||||
@@ -225,6 +236,10 @@ def update_warranty(warrantyid):
|
|||||||
if not warranty:
|
if not warranty:
|
||||||
return error_response(ErrorCodes.NOT_FOUND, 'Warranty not found', http_code=404)
|
return error_response(ErrorCodes.NOT_FOUND, 'Warranty not found', http_code=404)
|
||||||
data = request.get_json() or {}
|
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:
|
if 'vendor' in data:
|
||||||
warranty.vendor = (data['vendor'] or '').strip() or warranty.vendor
|
warranty.vendor = (data['vendor'] or '').strip() or warranty.vendor
|
||||||
if 'servicetag' in data:
|
if 'servicetag' in data:
|
||||||
@@ -233,10 +248,8 @@ def update_warranty(warrantyid):
|
|||||||
warranty.provider = (data['provider'] or 'manual').strip().lower()
|
warranty.provider = (data['provider'] or 'manual').strip().lower()
|
||||||
if 'servicelevel' in data:
|
if 'servicelevel' in data:
|
||||||
warranty.servicelevel = (data['servicelevel'] or '').strip() or None
|
warranty.servicelevel = (data['servicelevel'] or '').strip() or None
|
||||||
if 'startdate' in data:
|
for field, parsed in dates.items():
|
||||||
warranty.startdate = _parse_date(data['startdate'])
|
setattr(warranty, field, parsed)
|
||||||
if 'enddate' in data:
|
|
||||||
warranty.enddate = _parse_date(data['enddate'])
|
|
||||||
if 'notes' in data:
|
if 'notes' in data:
|
||||||
warranty.notes = (data['notes'] or '').strip() or None
|
warranty.notes = (data['notes'] or '').strip() or None
|
||||||
if 'isactive' in data:
|
if 'isactive' in data:
|
||||||
@@ -279,9 +292,9 @@ def refresh_warranty(warrantyid):
|
|||||||
if result.get('servicelevel'):
|
if result.get('servicelevel'):
|
||||||
warranty.servicelevel = result['servicelevel']
|
warranty.servicelevel = result['servicelevel']
|
||||||
if result.get('startdate'):
|
if result.get('startdate'):
|
||||||
warranty.startdate = _parse_date(result['startdate'])
|
warranty.startdate = _vendor_date(result['startdate'])
|
||||||
if result.get('enddate'):
|
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)
|
warranty.lastcheckeddate = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return success_response(_warranty_payload(warranty), message='Warranty refreshed')
|
return success_response(_warranty_payload(warranty), message='Warranty refreshed')
|
||||||
@@ -363,8 +376,8 @@ def sync_dell():
|
|||||||
existing.provider = 'dell'
|
existing.provider = 'dell'
|
||||||
existing.vendor = existing.vendor or 'Dell'
|
existing.vendor = existing.vendor or 'Dell'
|
||||||
existing.servicelevel = found.get('servicelevel')
|
existing.servicelevel = found.get('servicelevel')
|
||||||
existing.startdate = _parse_date(found.get('startdate'))
|
existing.startdate = _vendor_date(found.get('startdate'))
|
||||||
existing.enddate = _parse_date(found.get('enddate'))
|
existing.enddate = _vendor_date(found.get('enddate'))
|
||||||
existing.servicetag = tag
|
existing.servicetag = tag
|
||||||
existing.lastcheckeddate = now
|
existing.lastcheckeddate = now
|
||||||
updated += 1
|
updated += 1
|
||||||
@@ -372,8 +385,8 @@ def sync_dell():
|
|||||||
warranty = Warranty(
|
warranty = Warranty(
|
||||||
vendor='Dell', provider='dell', servicetag=tag,
|
vendor='Dell', provider='dell', servicetag=tag,
|
||||||
servicelevel=found.get('servicelevel'),
|
servicelevel=found.get('servicelevel'),
|
||||||
startdate=_parse_date(found.get('startdate')),
|
startdate=_vendor_date(found.get('startdate')),
|
||||||
enddate=_parse_date(found.get('enddate')),
|
enddate=_vendor_date(found.get('enddate')),
|
||||||
lastcheckeddate=now,
|
lastcheckeddate=now,
|
||||||
)
|
)
|
||||||
warranty.links.append(WarrantyAsset(assetid=assetid))
|
warranty.links.append(WarrantyAsset(assetid=assetid))
|
||||||
|
|||||||
@@ -195,11 +195,19 @@ while IFS= read -r hit; do
|
|||||||
[ -z "$hit" ] && continue
|
[ -z "$hit" ] && continue
|
||||||
file=${hit%%:*}
|
file=${hit%%:*}
|
||||||
line=${hit#*:}; line=${line%%:*}
|
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'
|
MISSING_LEVEL="$MISSING_LEVEL$file:$line"$'\n'
|
||||||
fi
|
fi
|
||||||
done <<EOF
|
done <<EOF
|
||||||
$(grep -rn "'mapx':" --include='*.py' shopdb/ plugins/ scripts/ 2>/dev/null | grep -v '/tests\?/' || true)
|
$(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
|
EOF
|
||||||
if [ -n "$MISSING_LEVEL" ]; then
|
if [ -n "$MISSING_LEVEL" ]; then
|
||||||
echo "FAIL: these emit 'mapx' with no 'levelid' beside it - a position with no"
|
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,
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
'The database rejected that change.', http_code=400)
|
'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)
|
@app.errorhandler(ShopDBException)
|
||||||
def handle_shopdb_exception(error):
|
def handle_shopdb_exception(error):
|
||||||
http_codes = {
|
http_codes = {
|
||||||
@@ -393,6 +435,48 @@ def register_error_handlers(app: Flask):
|
|||||||
http_code=404
|
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)
|
@app.errorhandler(500)
|
||||||
def internal_error(error):
|
def internal_error(error):
|
||||||
return error_response(
|
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):
|
def register_frontend_routes(app: Flask):
|
||||||
"""Serve Vue frontend static files."""
|
"""Serve Vue frontend static files."""
|
||||||
frontend_dist = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'frontend', 'dist')
|
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.
|
# nothing. See shopdb/utils/clientip.py.
|
||||||
from shopdb.utils.clientip import client_ip, strip_port
|
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
|
# Import-mode helpers: preserve legacy timestamps during a bulk data import
|
||||||
from shopdb.utils.import_mode import (
|
from shopdb.utils.import_mode import (
|
||||||
apply_import_timestamps,
|
apply_import_timestamps,
|
||||||
@@ -350,6 +363,15 @@ __all__ = [
|
|||||||
# Response + pagination helpers
|
# Response + pagination helpers
|
||||||
'success_response',
|
'success_response',
|
||||||
'error_response',
|
'error_response',
|
||||||
|
'DateValueError',
|
||||||
|
'QueryArgError',
|
||||||
|
'BadgeShapeError',
|
||||||
|
'badge_kind',
|
||||||
|
'normalise_badge',
|
||||||
|
'intarg',
|
||||||
|
'intargs',
|
||||||
|
'parse_date',
|
||||||
|
'parse_date_fields',
|
||||||
'paginated_response',
|
'paginated_response',
|
||||||
'ErrorCodes',
|
'ErrorCodes',
|
||||||
'get_pagination_params',
|
'get_pagination_params',
|
||||||
|
|||||||
@@ -38,7 +38,26 @@ class Config:
|
|||||||
# Pin the client connection to utf8mb4 so reads/writes match the
|
# Pin the client connection to utf8mb4 so reads/writes match the
|
||||||
# utf8mb4 schema regardless of the server default charset. TestingConfig
|
# utf8mb4 schema regardless of the server default charset. TestingConfig
|
||||||
# (SQLite) replaces this whole dict, so the charset arg never reaches it.
|
# (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')
|
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')
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'name cannot be empty')
|
||||||
token.name = newname
|
token.name = newname
|
||||||
if 'isactive' in data:
|
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:
|
if 'scopes' in data:
|
||||||
scopelist, scope_error = _validate_scopes(data.get('scopes'), token.user)
|
scopelist, scope_error = _validate_scopes(data.get('scopes'), token.user)
|
||||||
if scope_error is not None:
|
if scope_error is not None:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import glob
|
import glob
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import re
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from flask import Blueprint, request, current_app
|
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.pagination import get_pagination_params, paginate_query
|
||||||
from shopdb.utils.import_mode import apply_import_timestamps
|
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
|
# 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'):
|
if not data or not data.get('version'):
|
||||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'version is required')
|
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():
|
if AppVersion.query.filter_by(appid=app_id, version=data['version']).first():
|
||||||
return error_response(
|
return error_response(
|
||||||
ErrorCodes.CONFLICT,
|
ErrorCodes.CONFLICT,
|
||||||
@@ -336,9 +356,9 @@ def create_version(app_id: int):
|
|||||||
version = AppVersion(
|
version = AppVersion(
|
||||||
appid=app_id,
|
appid=app_id,
|
||||||
version=data['version'],
|
version=data['version'],
|
||||||
releasedate=data.get('releasedate'),
|
releasedate=releasedate,
|
||||||
notes=data.get('notes'),
|
notes=data.get('notes'),
|
||||||
sha256=data.get('sha256'),
|
sha256=digest or None,
|
||||||
silentargs=data.get('silentargs')
|
silentargs=data.get('silentargs')
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -558,9 +578,20 @@ def upload_application_image(app_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@applications_bp.route('/image/<path:filename>', methods=['GET'])
|
@applications_bp.route('/image/<path:filename>', methods=['GET'])
|
||||||
@require_permission('applications.view')
|
|
||||||
def serve_application_image(filename):
|
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)
|
return send_upload(_appimage_dir(), filename)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from shopdb.utils.responses import (
|
|||||||
ErrorCodes
|
ErrorCodes
|
||||||
)
|
)
|
||||||
from shopdb.utils.pagination import get_pagination_params, paginate_query
|
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.authz import require_permission
|
||||||
from shopdb.utils.import_mode import apply_import_timestamps
|
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)
|
query = query.join(AssetType).filter(AssetType.assettype == type_name)
|
||||||
|
|
||||||
# Type filter by ID
|
# Type filter by ID
|
||||||
if type_id := request.args.get('typeid', request.args.get('type_id')):
|
if (type_id := intarg('typeid', intarg('type_id'))) is not None:
|
||||||
query = query.filter(Asset.assettypeid == int(type_id))
|
query = query.filter(Asset.assettypeid == type_id)
|
||||||
|
|
||||||
# Status filter
|
# Status filter
|
||||||
if status_id := request.args.get('statusid', request.args.get('status_id')):
|
if (status_id := intarg('statusid', intarg('status_id'))) is not None:
|
||||||
query = query.filter(Asset.statusid == int(status_id))
|
query = query.filter(Asset.statusid == status_id)
|
||||||
|
|
||||||
# Location filter
|
# Location filter
|
||||||
if location_id := request.args.get('locationid', request.args.get('location_id')):
|
if (location_id := intarg('locationid', intarg('location_id'))) is not None:
|
||||||
query = query.filter(Asset.locationid == int(location_id))
|
query = query.filter(Asset.locationid == location_id)
|
||||||
|
|
||||||
# Business unit filter
|
# Business unit filter
|
||||||
if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')):
|
if (bu_id := intarg('businessunitid', intarg('businessunit_id'))) is not None:
|
||||||
query = query.filter(Asset.businessunitid == int(bu_id))
|
query = query.filter(Asset.businessunitid == bu_id)
|
||||||
|
|
||||||
# Sorting
|
# Sorting
|
||||||
sort_by = request.args.get('sort', 'assetnumber')
|
sort_by = request.args.get('sort', 'assetnumber')
|
||||||
@@ -1011,17 +1012,26 @@ def get_assets_map():
|
|||||||
Asset.mapy.isnot(None)
|
Asset.mapy.isnot(None)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Dualpath single-machine collapse: hide the secondary bay marker so a
|
# Dualpath single-machine collapse on the map: a secondary bay draws its
|
||||||
# dual-bay pair shows one marker; the primary carries the partner label.
|
# OWN marker when somebody has positioned it, and is represented by the
|
||||||
# Gated on the site setting (default on).
|
# 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 (
|
from shopdb.core.services.dualpath import (
|
||||||
resolve_dualpath_pairs, dualpath_single_machine_enabled)
|
resolve_dualpath_pairs, dualpath_single_machine_enabled)
|
||||||
dualpath_collapse = None
|
dualpath_collapse = None
|
||||||
if dualpath_single_machine_enabled():
|
if dualpath_single_machine_enabled():
|
||||||
dualpath_collapse = resolve_dualpath_pairs()
|
dualpath_collapse = resolve_dualpath_pairs()
|
||||||
if dualpath_collapse.secondaryassetids:
|
|
||||||
query = query.filter(
|
|
||||||
Asset.assetid.notin_(dualpath_collapse.secondaryassetids))
|
|
||||||
|
|
||||||
selected_assettype = request.args.get('assettype')
|
selected_assettype = request.args.get('assettype')
|
||||||
|
|
||||||
@@ -1030,8 +1040,7 @@ def get_assets_map():
|
|||||||
query = query.join(AssetType).filter(AssetType.assettype == selected_assettype)
|
query = query.join(AssetType).filter(AssetType.assettype == selected_assettype)
|
||||||
|
|
||||||
# Filter by subtype (depends on asset type) - case-insensitive matching
|
# Filter by subtype (depends on asset type) - case-insensitive matching
|
||||||
if subtype_id := request.args.get('subtype'):
|
if (subtype_id := intarg('subtype')) is not None:
|
||||||
subtype_id = int(subtype_id)
|
|
||||||
# Normalize the underscore DB form (measuring_tool, network_device) to
|
# Normalize the underscore DB form (measuring_tool, network_device) to
|
||||||
# the space form the branches below compare against.
|
# the space form the branches below compare against.
|
||||||
asset_type_lower = (
|
asset_type_lower = (
|
||||||
@@ -1079,16 +1088,16 @@ def get_assets_map():
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# Filter by business unit
|
# Filter by business unit
|
||||||
if bu_id := request.args.get('businessunitid'):
|
if (bu_id := intarg('businessunitid')) is not None:
|
||||||
query = query.filter(Asset.businessunitid == int(bu_id))
|
query = query.filter(Asset.businessunitid == bu_id)
|
||||||
|
|
||||||
# Filter by status
|
# Filter by status
|
||||||
if status_id := request.args.get('statusid'):
|
if (status_id := intarg('statusid')) is not None:
|
||||||
query = query.filter(Asset.statusid == int(status_id))
|
query = query.filter(Asset.statusid == status_id)
|
||||||
|
|
||||||
# Filter by location
|
# Filter by location
|
||||||
if location_id := request.args.get('locationid'):
|
if (location_id := intarg('locationid')) is not None:
|
||||||
query = query.filter(Asset.locationid == int(location_id))
|
query = query.filter(Asset.locationid == location_id)
|
||||||
|
|
||||||
# Search filter
|
# Search filter
|
||||||
if search := request.args.get('search'):
|
if search := request.args.get('search'):
|
||||||
@@ -1102,6 +1111,10 @@ def get_assets_map():
|
|||||||
|
|
||||||
assets = query.all()
|
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.
|
# 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).
|
# Prefer isprimary=True IP, fall back to any IP (comtypeid=1).
|
||||||
asset_ids = [a.assetid for a in assets]
|
asset_ids = [a.assetid for a in assets]
|
||||||
@@ -1153,10 +1166,17 @@ def get_assets_map():
|
|||||||
if type_data:
|
if type_data:
|
||||||
item['typedata'] = 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:
|
if dualpath_collapse:
|
||||||
partner = dualpath_collapse.partnerbyasset.get(asset.assetid)
|
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)
|
data.append(item)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ from flask_jwt_extended import jwt_required
|
|||||||
|
|
||||||
from shopdb.core.models import AuditLog
|
from shopdb.core.models import AuditLog
|
||||||
from shopdb.extensions import db
|
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
|
from shopdb.utils.authz import require_permission
|
||||||
|
|
||||||
auditlogs_bp = Blueprint('auditlogs', __name__)
|
auditlogs_bp = Blueprint('auditlogs', __name__)
|
||||||
@@ -123,15 +124,12 @@ def list_auditlogs():
|
|||||||
for r in rows:
|
for r in rows:
|
||||||
r['userfullname'] = fullnames.get(str(r.get('username')))
|
r['userfullname'] = fullnames.get(str(r.get('username')))
|
||||||
|
|
||||||
return success_response(
|
# paginated_response, like every other list in the product. This one built
|
||||||
rows,
|
# its own meta with the fields FLAT and named 'pages', so a client written
|
||||||
meta={
|
# against the documented meta.pagination.{page,perpage,total,totalpages}
|
||||||
'page': page,
|
# found nothing where it looked - on the one list whose whole purpose is to
|
||||||
'perpage': perpage,
|
# be read by somebody checking what happened.
|
||||||
'total': pagination.total,
|
return paginated_response(rows, page, perpage, pagination.total)
|
||||||
'pages': pagination.pages
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@auditlogs_bp.route('/entity/<entitytype>/<int:entityid>', methods=['GET'])
|
@auditlogs_bp.route('/entity/<entitytype>/<int:entityid>', methods=['GET'])
|
||||||
|
|||||||
@@ -36,10 +36,27 @@ def _blueprint_dir():
|
|||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Read - public
|
# 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'])
|
@maplevels_bp.route('', methods=['GET'])
|
||||||
@require_permission('assets.view')
|
@jwt_required(optional=True)
|
||||||
def list_levels():
|
def list_levels():
|
||||||
"""Every active level, grouped by building, in display order.
|
"""Every active level, grouped by building, in display order.
|
||||||
|
|
||||||
@@ -74,7 +91,7 @@ def list_levels():
|
|||||||
|
|
||||||
|
|
||||||
@maplevels_bp.route('/<int:levelid>', methods=['GET'])
|
@maplevels_bp.route('/<int:levelid>', methods=['GET'])
|
||||||
@require_permission('assets.view')
|
@jwt_required(optional=True)
|
||||||
def get_level(levelid):
|
def get_level(levelid):
|
||||||
level = db.session.get(MapLevel, levelid)
|
level = db.session.get(MapLevel, levelid)
|
||||||
if not level or not level.isactive:
|
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'])
|
@maplevels_bp.route('/<int:levelid>/blueprint/<path:filename>', methods=['GET'])
|
||||||
@require_permission('assets.view')
|
@jwt_required(optional=True)
|
||||||
def serve_blueprint(filename, levelid=None):
|
def serve_blueprint(filename, levelid=None):
|
||||||
"""Serve a level's blueprint image. Public, for the same reason as above."""
|
"""Serve a level's blueprint image. Public, for the same reason as above."""
|
||||||
return send_upload(_blueprint_dir(), filename)
|
return send_upload(_blueprint_dir(), filename)
|
||||||
@@ -160,9 +177,17 @@ def create_level():
|
|||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
name = (data.get('levelname') or '').strip()
|
name = (data.get('levelname') or '').strip()
|
||||||
buildingid = data.get('buildingid')
|
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:
|
if not name or not buildingid:
|
||||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
missing = [field for field, value in
|
||||||
'buildingname and buildingid are required')
|
(('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):
|
if not db.session.get(Building, buildingid):
|
||||||
return error_response(ErrorCodes.NOT_FOUND, 'No such building',
|
return error_response(ErrorCodes.NOT_FOUND, 'No such building',
|
||||||
http_code=404)
|
http_code=404)
|
||||||
|
|||||||
@@ -235,7 +235,6 @@ def upload_model_image(model_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@models_bp.route('/image/<path:filename>', methods=['GET'])
|
@models_bp.route('/image/<path:filename>', methods=['GET'])
|
||||||
@require_permission('assets.view')
|
|
||||||
def serve_model_image(filename):
|
def serve_model_image(filename):
|
||||||
"""Serve an uploaded model image (public - asset detail pages read it)."""
|
"""Serve an uploaded model image (public - asset detail pages read it)."""
|
||||||
return send_upload(_modelimage_dir(), filename)
|
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.responses import success_response, error_response, ErrorCodes
|
||||||
from shopdb.utils.authz import require_permission
|
from shopdb.utils.authz import require_permission
|
||||||
|
from shopdb.utils.queryargs import intarg
|
||||||
|
|
||||||
reports_bp = Blueprint('reports', __name__)
|
reports_bp = Blueprint('reports', __name__)
|
||||||
|
|
||||||
@@ -62,8 +63,8 @@ def machines_by_type():
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Business unit filter
|
# Business unit filter
|
||||||
if bu_id := request.args.get('businessunitid'):
|
if (bu_id := intarg('businessunitid')) is not None:
|
||||||
query = query.filter(Asset.businessunitid == int(bu_id))
|
query = query.filter(Asset.businessunitid == bu_id)
|
||||||
|
|
||||||
# Dualpath single-machine collapse: exclude the secondary bay so a dual-bay
|
# 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).
|
# pair counts once per type bucket. Gated on the site setting (default on).
|
||||||
@@ -128,12 +129,12 @@ def assets_by_status():
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Asset type filter
|
# Asset type filter
|
||||||
if type_id := request.args.get('assettypeid'):
|
if (type_id := intarg('assettypeid')) is not None:
|
||||||
query = query.filter(Asset.assettypeid == int(type_id))
|
query = query.filter(Asset.assettypeid == type_id)
|
||||||
|
|
||||||
# Business unit filter
|
# Business unit filter
|
||||||
if bu_id := request.args.get('businessunitid'):
|
if (bu_id := intarg('businessunitid')) is not None:
|
||||||
query = query.filter(Asset.businessunitid == int(bu_id))
|
query = query.filter(Asset.businessunitid == bu_id)
|
||||||
|
|
||||||
query = query.group_by(
|
query = query.group_by(
|
||||||
AssetStatus.statusid,
|
AssetStatus.statusid,
|
||||||
@@ -175,7 +176,7 @@ def kb_popularity():
|
|||||||
- limit: Number of results (default 20)
|
- limit: Number of results (default 20)
|
||||||
- format: 'json' (default) or 'csv'
|
- 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.
|
# Knowledge Base is a plugin; degrade gracefully if it is not installed.
|
||||||
try:
|
try:
|
||||||
@@ -362,10 +363,10 @@ def asset_inventory():
|
|||||||
AssetType.isactive == True
|
AssetType.isactive == True
|
||||||
)
|
)
|
||||||
|
|
||||||
if bu_id := request.args.get('businessunitid'):
|
if (bu_id := intarg('businessunitid')) is not None:
|
||||||
by_type = by_type.filter(Asset.businessunitid == int(bu_id))
|
by_type = by_type.filter(Asset.businessunitid == bu_id)
|
||||||
if loc_id := request.args.get('locationid'):
|
if (loc_id := intarg('locationid')) is not None:
|
||||||
by_type = by_type.filter(Asset.locationid == int(loc_id))
|
by_type = by_type.filter(Asset.locationid == loc_id)
|
||||||
|
|
||||||
by_type = by_type.group_by(AssetType.assettype).all()
|
by_type = by_type.group_by(AssetType.assettype).all()
|
||||||
|
|
||||||
@@ -380,10 +381,10 @@ def asset_inventory():
|
|||||||
AssetStatus.isactive == True
|
AssetStatus.isactive == True
|
||||||
)
|
)
|
||||||
|
|
||||||
if bu_id := request.args.get('businessunitid'):
|
if (bu_id := intarg('businessunitid')) is not None:
|
||||||
by_status = by_status.filter(Asset.businessunitid == int(bu_id))
|
by_status = by_status.filter(Asset.businessunitid == bu_id)
|
||||||
if loc_id := request.args.get('locationid'):
|
if (loc_id := intarg('locationid')) is not None:
|
||||||
by_status = by_status.filter(Asset.locationid == int(loc_id))
|
by_status = by_status.filter(Asset.locationid == loc_id)
|
||||||
|
|
||||||
by_status = by_status.group_by(AssetStatus.status, AssetStatus.color).all()
|
by_status = by_status.group_by(AssetStatus.status, AssetStatus.color).all()
|
||||||
|
|
||||||
@@ -398,8 +399,8 @@ def asset_inventory():
|
|||||||
Location.isactive == True
|
Location.isactive == True
|
||||||
)
|
)
|
||||||
|
|
||||||
if bu_id := request.args.get('businessunitid'):
|
if (bu_id := intarg('businessunitid')) is not None:
|
||||||
by_location = by_location.filter(Asset.businessunitid == int(bu_id))
|
by_location = by_location.filter(Asset.businessunitid == bu_id)
|
||||||
|
|
||||||
by_location = by_location.group_by(Location.locationname).all()
|
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:
|
if relevance is None:
|
||||||
relevance = 15
|
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 {
|
return {
|
||||||
'type': asset_type_name,
|
'type': asset_type_name,
|
||||||
'id': result_id,
|
'id': result_id,
|
||||||
@@ -251,7 +263,8 @@ def _get_asset_result(asset, query, relevance=None):
|
|||||||
'subtitle': subtitle,
|
'subtitle': subtitle,
|
||||||
'location': location_name,
|
'location': location_name,
|
||||||
'url': url,
|
'url': url,
|
||||||
'relevance': relevance
|
'relevance': relevance,
|
||||||
|
'retired': retired
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -499,11 +512,17 @@ def _search_assets(query, search_term):
|
|||||||
joinedload(Asset.assettype),
|
joinedload(Asset.assettype),
|
||||||
joinedload(Asset.location),
|
joinedload(Asset.location),
|
||||||
).filter(
|
).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,
|
_word_match(query, Asset.assetnumber, Asset.name,
|
||||||
Asset.serialnumber, Asset.notes,
|
Asset.serialnumber, Asset.notes,
|
||||||
Asset.gaugelabreference, Asset.maintenancereference)
|
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:
|
for asset in assets:
|
||||||
relevance = 15
|
relevance = 15
|
||||||
|
|||||||
@@ -811,6 +811,34 @@ def update_setting(key: str):
|
|||||||
if _is_secret(key) and value == SECRET_MASK:
|
if _is_secret(key) and value == SECRET_MASK:
|
||||||
return success_response(_serialize_setting(setting), message='Setting unchanged')
|
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
|
# Convert value to string for storage
|
||||||
if isinstance(value, bool):
|
if isinstance(value, bool):
|
||||||
setting.value = 'true' if value else 'false'
|
setting.value = 'true' if value else 'false'
|
||||||
@@ -1460,14 +1488,13 @@ def build_default_settings():
|
|||||||
'Incoming Webhook), adaptivecard (Teams Workflow), '
|
'Incoming Webhook), adaptivecard (Teams Workflow), '
|
||||||
'or json (generic {title,text})'
|
'or json (generic {title,text})'
|
||||||
},
|
},
|
||||||
# Audit log settings
|
# Audit log retention lives at 'audit_retentiondays' (category site),
|
||||||
{
|
# defined above and read by shopdb/core/services/auditretention.py. A
|
||||||
'key': 'audit_retention_days',
|
# second key 'audit_retention_days' was seeded here with a different
|
||||||
'value': '90',
|
# default and a different category, and the Audit & Logging page wrote
|
||||||
'valuetype': 'integer',
|
# to THAT one - so the page saved a value, read it back, and nothing
|
||||||
'category': 'audit',
|
# ever consulted it. Removed rather than aliased: two keys for one
|
||||||
'description': 'Number of days to retain audit logs (0 = keep forever)'
|
# setting is how this happened.
|
||||||
},
|
|
||||||
# Authentication settings
|
# Authentication settings
|
||||||
{
|
{
|
||||||
'key': 'saml_enabled',
|
'key': 'saml_enabled',
|
||||||
|
|||||||
@@ -187,9 +187,28 @@ def update_user(userid: int):
|
|||||||
user.failedlogins = 0
|
user.failedlogins = 0
|
||||||
changes['unlocked'] = {'old': True, 'new': False}
|
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 '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'])
|
user.passwordhash = generate_password_hash(data['password'])
|
||||||
|
# Issued by somebody else, so it is temporary by definition.
|
||||||
|
user.mustchangepassword = True
|
||||||
changes['password'] = {'old': '***', 'new': '***'}
|
changes['password'] = {'old': '***', 'new': '***'}
|
||||||
|
|
||||||
if changes:
|
if changes:
|
||||||
|
|||||||
@@ -256,6 +256,24 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
|
|||||||
elif hasattr(self, 'measuringtool') and self.measuringtool:
|
elif hasattr(self, 'measuringtool') and self.measuringtool:
|
||||||
result['pluginid'] = self.measuringtool.measuringtoolid
|
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
|
# Include inherited location if this asset has no location data
|
||||||
if include_inherited_location:
|
if include_inherited_location:
|
||||||
inherited = self.get_inherited_location()
|
inherited = self.get_inherited_location()
|
||||||
|
|||||||
@@ -8,6 +8,28 @@ def _utcnow():
|
|||||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
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):
|
class AuditLog(db.Model):
|
||||||
"""
|
"""
|
||||||
Audit log for tracking user actions.
|
Audit log for tracking user actions.
|
||||||
@@ -51,10 +73,21 @@ class AuditLog(db.Model):
|
|||||||
|
|
||||||
@changes.setter
|
@changes.setter
|
||||||
def changes(self, value):
|
def changes(self, value):
|
||||||
"""Set changes from dict."""
|
"""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:
|
if value is not None:
|
||||||
import json
|
import json
|
||||||
self._changes = json.dumps(value)
|
self._changes = json.dumps(value, default=_jsonsafe)
|
||||||
else:
|
else:
|
||||||
self._changes = None
|
self._changes = None
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Base model class with common fields."""
|
"""Base model class with common fields."""
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
from shopdb.extensions import db
|
from shopdb.extensions import db
|
||||||
|
|
||||||
|
|
||||||
@@ -63,6 +63,13 @@ class BaseModel(db.Model):
|
|||||||
value = getattr(self, c.name)
|
value = getattr(self, c.name)
|
||||||
if isinstance(value, datetime):
|
if isinstance(value, datetime):
|
||||||
value = value.isoformat() + 'Z'
|
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
|
result[c.name] = value
|
||||||
return result
|
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
|
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
|
guest role was granted, so the answer must come from the same place
|
||||||
require_permission gets it.
|
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()
|
user = currentuser()
|
||||||
if user is None:
|
if user is None:
|
||||||
return guestholds(permission_name)
|
return guestholds(permission_name)
|
||||||
try:
|
try:
|
||||||
return bool(user.haspermission(permission_name))
|
if not user.haspermission(permission_name):
|
||||||
|
return False
|
||||||
|
patscopes = get_jwt().get('patscopes')
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
if patscopes is not None:
|
||||||
|
return permission_name in patscopes
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def require_permission(permission_name: str):
|
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)
|
headers=headers)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert 'applications.delete' in response.get_json()['data']['error']['message']
|
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']
|
data = resp.get_json()['data']
|
||||||
assert data['sha256'] is None
|
assert data['sha256'] is None
|
||||||
assert data['silentargs'] 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)
|
resp = client.get('/api/employees/search?q=smith', headers=auth_headers)
|
||||||
assert resp.status_code == 500
|
assert resp.status_code == 500
|
||||||
assert resp.get_json()['status'] == 'error'
|
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')
|
grant(db, guestrole, 'assets.view')
|
||||||
body = client.get('/api/dashboard/capabilities').get_json()['data']
|
body = client.get('/api/dashboard/capabilities').get_json()['data']
|
||||||
assert set(body) == {'cansearch'}
|
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 expected <= keys
|
||||||
assert len(expected) == len(IDENTIFIER_LABELS) * len(IDENTIFIER_ASSETTYPES)
|
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_branding_logo',
|
||||||
'settings.serve_map_blueprint',
|
'settings.serve_map_blueprint',
|
||||||
'employees.serve_employee_photo',
|
'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.
|
# API documentation: shapes, not data. The docs page loads its own script.
|
||||||
'docs.docs_index', 'docs.openapi_spec', 'docs.redoc_js', 'docs.llms_txt',
|
'docs.docs_index', 'docs.openapi_spec', 'docs.redoc_js', 'docs.llms_txt',
|
||||||
@@ -59,6 +65,13 @@ PUBLIC_ENDPOINTS = {
|
|||||||
'notifications.get_employee_recognitions',
|
'notifications.get_employee_recognitions',
|
||||||
'slides.feed', 'slides.serve_image',
|
'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.
|
# 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
|
# 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
|
# 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'
|
EXPECTED_HEAD_REVISION['employees'] = 'employees0002photo'
|
||||||
# usb drops the dead usbcheckouts.machineid column on top of its anchor, then
|
# usb drops the dead usbcheckouts.machineid column on top of its anchor, then
|
||||||
# adds scannedviruses so an imported external history keeps the scan result -
|
# adds scannedviruses so an imported external history keeps the scan result -
|
||||||
# the field a removable-media audit actually asks about.
|
# the field a removable-media audit actually asks about - and finally records
|
||||||
EXPECTED_HEAD_REVISION['usb'] = 'usb0003scanfields'
|
# 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
|
# network links a device to a catalog model, which is where its photo comes
|
||||||
# from - machines, PCs and printers already had that link.
|
# from - machines, PCs and printers already had that link.
|
||||||
EXPECTED_HEAD_REVISION['network'] = 'network0003prefix'
|
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