Remediate the 2026-09-03 feature-test findings

This commit is contained in:
2026-09-03 17:14:53 -04:00
parent ddf41c3e65
commit ff9ae693a6
112 changed files with 5182 additions and 857 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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