Remediate the 2026-09-03 feature-test findings
This commit is contained in:
@@ -116,13 +116,25 @@ def asset_revisions(assetid):
|
||||
|
||||
revisions = query.order_by(BackupRevision.backuprevisionid.desc()).all()
|
||||
|
||||
# "Latest" is per kind, not per asset, so an unfiltered listing still marks
|
||||
# the current revision of each kind correctly.
|
||||
# "Latest" is per kind AND per source chain. Per kind alone was wrong on
|
||||
# the case this exists for: NTLARS settings describe a machine but are read
|
||||
# off the controlling PC, so a machine with two reporting PCs has two
|
||||
# independent chains of the same kind. Marking only the highest revision id
|
||||
# meant the second PC's current revision was badged as history and lost its
|
||||
# "last verified" stamp - the machine looked like it had one source that
|
||||
# kept re-reporting, rather than two.
|
||||
#
|
||||
# sourcehostname is the chain: it records which PC produced the revision.
|
||||
# A revision with none (an older row, or a share-drop with no host) forms
|
||||
# its own chain under the empty key, which keeps the per-kind behaviour for
|
||||
# everything that has only ever had one source.
|
||||
seen = set()
|
||||
out = []
|
||||
for revision in revisions:
|
||||
islatest = revision.backupkind not in seen
|
||||
seen.add(revision.backupkind)
|
||||
chain = (revision.backupkind,
|
||||
(revision.sourcehostname or '').strip().lower())
|
||||
islatest = chain not in seen
|
||||
seen.add(chain)
|
||||
out.append(_summary(revision, islatest=islatest))
|
||||
return success_response(out)
|
||||
|
||||
@@ -297,6 +309,23 @@ def diff_revision(backuprevisionid):
|
||||
againstid = request.args.get('against', type=int)
|
||||
if againstid:
|
||||
other = db.session.get(BackupRevision, againstid)
|
||||
# It has to belong to the SAME asset. Any revision id was accepted, so
|
||||
# ?against=<another asset's revision> rendered that asset's
|
||||
# configuration values as the "before" side of this asset's history -
|
||||
# a diff that reads as a change on this machine and describes a
|
||||
# different one. The default branch below has always been asset-scoped;
|
||||
# only the explicit one was not.
|
||||
if other is not None and other.assetid != revision.assetid:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Revision {} belongs to a different asset and cannot be '
|
||||
'compared against this one'.format(againstid))
|
||||
if other is not None and other.backupkind != revision.backupkind:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Revision {} is a {} backup and this one is {} - there is '
|
||||
'nothing to compare'.format(againstid, other.backupkind,
|
||||
revision.backupkind))
|
||||
else:
|
||||
# The previous revision FROM THE SAME PC. Several PCs can share a
|
||||
# machine number, and diffing across them reported one device's COM port
|
||||
|
||||
@@ -7,7 +7,7 @@ from shopdb.api import db, Asset, AssetType, OperatingSystem, Application, Audit
|
||||
|
||||
from ..models import Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess
|
||||
|
||||
from shopdb.api import require_permission, require_role, apply_import_timestamps
|
||||
from shopdb.api import require_permission, require_role, apply_import_timestamps, intarg
|
||||
|
||||
computers_bp = Blueprint('computers', __name__)
|
||||
|
||||
@@ -258,13 +258,42 @@ def _computer_access_links(comp, domain=None):
|
||||
return links
|
||||
|
||||
|
||||
def _ip_comtype():
|
||||
"""The 'IP' communication type, created if this site has none.
|
||||
|
||||
Both the create and the update path did `if ip_comtype:` and silently did
|
||||
nothing when the row was missing - so a person typed an address, saved, got
|
||||
a 200, and the address was gone. A missing reference row is a seeding gap,
|
||||
not a reason to discard what somebody typed.
|
||||
"""
|
||||
comtype = CommunicationType.query.filter_by(comtype='IP').first()
|
||||
if comtype is None:
|
||||
comtype = CommunicationType(comtype='IP', description='IP address')
|
||||
db.session.add(comtype)
|
||||
db.session.flush()
|
||||
return comtype
|
||||
|
||||
|
||||
def _sync_access_methods(comp, data):
|
||||
"""Replace a computer's enabled protocols from data['accessmethods'] (a list
|
||||
of {protocolid, portoverride?}). No-op if the key is absent, so callers that
|
||||
don't touch access aren't affected."""
|
||||
don't touch access aren't affected.
|
||||
|
||||
Returns a {'old': [...], 'new': [...]} change entry when the set actually
|
||||
moved, or None. The caller records it: which remote-access protocols reach
|
||||
a shop-floor PC, and on which ports, is exactly the kind of change an audit
|
||||
log exists to remember, and this wrote nothing at all.
|
||||
"""
|
||||
if 'accessmethods' not in data:
|
||||
return
|
||||
return None
|
||||
desired = data.get('accessmethods') or []
|
||||
|
||||
def describe(rows):
|
||||
return sorted('%s:%s' % (row.protocolid, row.portoverride or 'default')
|
||||
for row in rows)
|
||||
|
||||
before = describe(ComputerAccess.query.filter_by(
|
||||
computerid=comp.computerid).all())
|
||||
ComputerAccess.query.filter_by(computerid=comp.computerid).delete()
|
||||
seen = set()
|
||||
for m in desired:
|
||||
@@ -287,6 +316,13 @@ def _sync_access_methods(comp, data):
|
||||
isactive=True,
|
||||
))
|
||||
|
||||
db.session.flush()
|
||||
after = describe(ComputerAccess.query.filter_by(
|
||||
computerid=comp.computerid).all())
|
||||
if before != after:
|
||||
return {'old': before, 'new': after}
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Computers CRUD
|
||||
@@ -380,20 +416,20 @@ def list_computers():
|
||||
)
|
||||
|
||||
# Computer type filter
|
||||
if type_id := request.args.get('typeid', request.args.get('type_id')):
|
||||
query = query.filter(Computer.computertypeid == int(type_id))
|
||||
if (type_id := intarg('typeid', intarg('type_id'))) is not None:
|
||||
query = query.filter(Computer.computertypeid == type_id)
|
||||
|
||||
# OS filter
|
||||
if os_id := request.args.get('osid', request.args.get('os_id')):
|
||||
query = query.filter(Computer.osid == int(os_id))
|
||||
if (os_id := intarg('osid', intarg('os_id'))) is not None:
|
||||
query = query.filter(Computer.osid == os_id)
|
||||
|
||||
# Location filter
|
||||
if location_id := request.args.get('locationid', request.args.get('location_id')):
|
||||
query = query.filter(Asset.locationid == int(location_id))
|
||||
if (location_id := intarg('locationid', intarg('location_id'))) is not None:
|
||||
query = query.filter(Asset.locationid == location_id)
|
||||
|
||||
# Business unit filter
|
||||
if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')):
|
||||
query = query.filter(Asset.businessunitid == int(bu_id))
|
||||
if (bu_id := intarg('businessunitid', intarg('businessunit_id'))) is not None:
|
||||
query = query.filter(Asset.businessunitid == bu_id)
|
||||
|
||||
# Shopfloor filter (by the Shopfloor computer type)
|
||||
if shopfloor := request.args.get('shopfloor'):
|
||||
@@ -587,7 +623,7 @@ def create_computer():
|
||||
|
||||
# Optional primary IP communication
|
||||
if data.get('ipaddress'):
|
||||
ip_comtype = CommunicationType.query.filter_by(comtype='IP').first()
|
||||
ip_comtype = _ip_comtype()
|
||||
if ip_comtype:
|
||||
db.session.add(Communication(
|
||||
assetid=asset.assetid,
|
||||
@@ -596,7 +632,9 @@ def create_computer():
|
||||
isprimary=True,
|
||||
))
|
||||
|
||||
# Remote-access protocols
|
||||
# Remote-access protocols. The return value is not recorded here: a create
|
||||
# is already described in full by its 'created' entry, and there is no
|
||||
# previous state to compare against.
|
||||
_sync_access_methods(comp, data)
|
||||
|
||||
# Preserve legacy timestamps in import mode (no-op otherwise)
|
||||
@@ -697,20 +735,27 @@ def update_computer(computer_id: int):
|
||||
ip = (data.get('ipaddress') or '').strip()
|
||||
primary = Communication.query.filter_by(
|
||||
assetid=asset.assetid, isprimary=True).first()
|
||||
# Recorded, like every other field on this form. The address a PC
|
||||
# answers on is how somebody finds it later; changing it wrote no audit
|
||||
# entry at all, so a machine that moved had no history of moving.
|
||||
oldip = primary.ipaddress if primary else None
|
||||
if (oldip or None) != (ip or None):
|
||||
changes['ipaddress'] = {'old': oldip, 'new': ip or None}
|
||||
if ip:
|
||||
if primary:
|
||||
primary.ipaddress = ip
|
||||
else:
|
||||
ip_comtype = CommunicationType.query.filter_by(comtype='IP').first()
|
||||
if ip_comtype:
|
||||
db.session.add(Communication(
|
||||
assetid=asset.assetid, comtypeid=ip_comtype.comtypeid,
|
||||
ipaddress=ip, isprimary=True))
|
||||
db.session.add(Communication(
|
||||
assetid=asset.assetid,
|
||||
comtypeid=_ip_comtype().comtypeid,
|
||||
ipaddress=ip, isprimary=True))
|
||||
elif primary:
|
||||
primary.ipaddress = None
|
||||
|
||||
# Remote-access protocols
|
||||
_sync_access_methods(comp, data)
|
||||
accesschange = _sync_access_methods(comp, data)
|
||||
if accesschange:
|
||||
changes['accessmethods'] = accesschange
|
||||
|
||||
# Audit log if there were changes
|
||||
if changes:
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Site Policies</h2>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add Site Policy</button>
|
||||
<button v-if="!featureOff" class="btn btn-primary" @click="openModal()">+ Add Site Policy</button>
|
||||
</div>
|
||||
|
||||
<div v-if="featureOff" class="card feature-off-notice">
|
||||
<strong>Site policies are not enabled on this site.</strong>
|
||||
<p>Nothing can be created here until the feature is turned on.
|
||||
The list below is empty because the feature is off, not
|
||||
because nothing has been configured.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
@@ -110,6 +117,7 @@ const toast = useToast()
|
||||
const policies = ref([])
|
||||
const configPolicies = ref([])
|
||||
const loading = ref(true)
|
||||
const featureOff = ref(false)
|
||||
|
||||
const showModal = ref(false)
|
||||
const editing = ref(null)
|
||||
@@ -135,6 +143,10 @@ async function loadData() {
|
||||
configPolicies.value = config.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading business policies:', err)
|
||||
// The API answers 404 'Policy feature is not enabled' when the
|
||||
// flag is off. Without this the page rendered its empty table
|
||||
// and an Add button for a feature that cannot accept one.
|
||||
featureOff.value = err?.response?.status === 404
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -214,6 +226,13 @@ async function remove(p) {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.feature-off-notice {
|
||||
border-left: 3px solid var(--warning);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.feature-off-notice p {
|
||||
margin: 0.4rem 0 0 0;
|
||||
}
|
||||
.item-row { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
|
||||
.order-badge {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Configuration Policies</h2>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add Policy</button>
|
||||
<button v-if="!featureOff" class="btn btn-primary" @click="openModal()">+ Add Policy</button>
|
||||
</div>
|
||||
|
||||
<div v-if="featureOff" class="card feature-off-notice">
|
||||
<strong>Configuration policies are not enabled on this site.</strong>
|
||||
<p>Nothing can be created here until the feature is turned on. The
|
||||
list is empty because the feature is off, not because nothing
|
||||
has been configured.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
@@ -123,6 +130,7 @@ const apps = ref([])
|
||||
const actionCatalog = ref([])
|
||||
const monitors = ref([])
|
||||
const loading = ref(true)
|
||||
const featureOff = ref(false)
|
||||
|
||||
const showModal = ref(false)
|
||||
const editing = ref(null)
|
||||
@@ -145,8 +153,8 @@ async function loadData() {
|
||||
// 500'd, which looked like data loss.
|
||||
const [pol, types, appList, actions, monitorList] = await Promise.allSettled([
|
||||
computersApi.policies.configuration.list(),
|
||||
computersApi.types.list({ perpage: 200 }),
|
||||
applicationsApi.listAll({ perpage: 200 }),
|
||||
computersApi.types.listAll(),
|
||||
applicationsApi.listAll(),
|
||||
computersApi.policies.actions(),
|
||||
endpointsApi.monitors.list()
|
||||
])
|
||||
@@ -155,8 +163,13 @@ async function loadData() {
|
||||
|
||||
if (pol.status === 'fulfilled') {
|
||||
policies.value = pol.value.data.data || []
|
||||
featureOff.value = false
|
||||
} else {
|
||||
failures.push('policies')
|
||||
// The API answers 404 'Policy feature is not enabled' when the flag is
|
||||
// off. Without telling them apart the page rendered an empty table and
|
||||
// an Add button for a feature that cannot accept one.
|
||||
featureOff.value = pol.reason?.response?.status === 404
|
||||
if (!featureOff.value) failures.push('policies')
|
||||
console.error('Error loading policies:', pol.reason)
|
||||
}
|
||||
|
||||
@@ -284,6 +297,13 @@ async function remove(p) {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.feature-off-notice {
|
||||
border-left: 3px solid var(--warning);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.feature-off-notice p {
|
||||
margin: 0.4rem 0 0 0;
|
||||
}
|
||||
.modal-lg {
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
@@ -10,11 +10,21 @@
|
||||
<div class="header-actions">
|
||||
<router-link :to="`/print/asset-label/computer/${$route.params.id}`" class="btn btn-secondary" target="_blank">Print Label</router-link>
|
||||
<router-link :to="`/pcs/${$route.params.id}/edit${fromQuery}`" class="btn btn-primary">Edit</router-link>
|
||||
<button @click="confirmRetire" class="btn btn-danger">Retire</button>
|
||||
<button v-if="!isRetired" @click="confirmRetire" class="btn btn-danger">Retire</button>
|
||||
<!-- A retired record offered Edit and Retire exactly like a live
|
||||
one, and nothing said it was retired. Restoring it was possible
|
||||
through the API and nowhere in the product. -->
|
||||
<button v-if="isRetired" @click="restore" class="btn btn-primary">Restore</button>
|
||||
<router-link :to="returnTo" class="btn btn-secondary">{{ returnLabel }}</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="computer && computer.isactive === false" class="retired-banner card">
|
||||
<strong>This PC is retired.</strong>
|
||||
It stays out of every list and report, and its history and relationships
|
||||
are intact. Restore puts it back.
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else-if="computer">
|
||||
@@ -644,6 +654,21 @@ async function confirmRestart() {
|
||||
// Retire, not delete: the server deactivates the asset, so the PC
|
||||
// leaves every list and report while its history and relationships stay put.
|
||||
// Reversible by an admin setting isactive back to true.
|
||||
const isRetired = computed(() => computer.value?.isactive === false)
|
||||
|
||||
async function restore() {
|
||||
const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
|
||||
try {
|
||||
await computersApi.update(route.params.id, { isactive: true })
|
||||
toast.success(`Restored ${label}`)
|
||||
const response = await computersApi.get(route.params.id)
|
||||
computer.value = response.data.data
|
||||
} catch (error) {
|
||||
console.error('Error restoring:', error)
|
||||
toast.error(apiError(error, 'Failed to restore'))
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRetire() {
|
||||
const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
|
||||
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
|
||||
@@ -660,6 +685,10 @@ async function confirmRetire() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.retired-banner {
|
||||
border-left: 3px solid var(--warning);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.portal-link {
|
||||
font-size: 0.78rem;
|
||||
margin-left: 0.5rem;
|
||||
|
||||
@@ -143,6 +143,10 @@
|
||||
v-model="form.locationid"
|
||||
class="form-control"
|
||||
>
|
||||
<!-- Blank means "no location of its own", which is a real state:
|
||||
the PC then follows whatever it is attached to. The hint
|
||||
below says where that currently resolves to, so leaving the
|
||||
box empty is an informed choice rather than a missing one. -->
|
||||
<option value="">Select location...</option>
|
||||
<option
|
||||
v-for="l in locations"
|
||||
@@ -152,6 +156,33 @@
|
||||
{{ l.locationname }}
|
||||
</option>
|
||||
</select>
|
||||
<small v-if="!form.locationid && inheritedLocation" class="input-hint">
|
||||
No location of its own. It currently shows
|
||||
<strong>{{ inheritedLocation.locationname }}</strong>, inherited
|
||||
from {{ inheritedLocation.inheritedfrom }}. Pick one here only to
|
||||
override that.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<!-- The PC detail page has always rendered a Business Unit row,
|
||||
and the API has always accepted the field - only the form had
|
||||
no control, so it could be seen and never set from the UI. -->
|
||||
<label for="businessunitid">Business Unit</label>
|
||||
<select
|
||||
id="businessunitid"
|
||||
v-model="form.businessunitid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">Select business unit...</option>
|
||||
<option
|
||||
v-for="bu in businessunits"
|
||||
:key="bu.businessunitid"
|
||||
:value="bu.businessunitid"
|
||||
>
|
||||
{{ bu.businessunit }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -364,7 +395,7 @@
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computersApi, assetsApi, vendorsApi, locationsApi, modelsApi, operatingsystemsApi,
|
||||
printersApi, relationshipTypesApi } from '@/api'
|
||||
printersApi, relationshipTypesApi, businessunitsApi } from '@/api'
|
||||
import ShopFloorMap from '@/components/ShopFloorMap.vue'
|
||||
import { loadMapConfig, levelOptions, levelName, state as mapConfig }
|
||||
from '@/composables/mapConfig'
|
||||
@@ -373,6 +404,7 @@ import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
|
||||
import PrinterAssignmentPicker from '@/components/PrinterAssignmentPicker.vue'
|
||||
import { currentTheme } from '@/stores/theme'
|
||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||
import { loadAssetTypeIds, assetTypeId } from '@/composables/assetTypeIds'
|
||||
import { isPluginEnabled, loadEnabledPlugins } from '@/composables/enabledPlugins'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
@@ -390,13 +422,20 @@ const isEdit = computed(() => !!route.params.id)
|
||||
// falls back to the PCs list.
|
||||
const returnTo = computed(() => (route.query.from === 'fleet' ? '/endpoints' : '/pcs'))
|
||||
|
||||
// Seeded asset-type id for computers (see /api/assets/types). Custom-field
|
||||
// values are keyed by the underlying asset id, captured on load / create.
|
||||
const COMPUTER_ASSETTYPEID = 2
|
||||
// Resolved by NAME at load, never hardcoded: the id comes from seed
|
||||
// order, so the constant that used to live here pointed at a different
|
||||
// asset type on a differently-seeded site - the form then rendered THAT
|
||||
// type's custom fields and dropped whatever was typed into them.
|
||||
const COMPUTER_ASSETTYPEID = computed(() => assetTypeId('computer'))
|
||||
const customFieldsRef = ref(null)
|
||||
const printerPickerRef = ref(null)
|
||||
const currentAssetId = ref(null)
|
||||
|
||||
// What this PC resolves to today when it owns no location, captured at load.
|
||||
// Read-only: the form must never post it back as the record's own, which is
|
||||
// exactly what binding the location select to the merged `locationid` did.
|
||||
const inheritedLocation = ref(null)
|
||||
|
||||
// PC Number (assetnumber) defaults to the serial number while the user hasn't
|
||||
// typed their own. Editable; only auto-fills on a new PC.
|
||||
const manualPcNumber = ref(false)
|
||||
@@ -470,6 +509,7 @@ function setProtocolPort(protocolid, value) {
|
||||
const vendors = ref([])
|
||||
const models = ref([])
|
||||
const locations = ref([])
|
||||
const businessunits = ref([])
|
||||
const operatingsystems = ref([])
|
||||
|
||||
|
||||
@@ -519,17 +559,20 @@ const filteredVendors = computed(() => {
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadAssetTypeIds()
|
||||
try {
|
||||
// Load reference data
|
||||
// perpage 100 so dropdowns aren't truncated to the default 20-row page
|
||||
const [ptRes, statusRes, vendorRes, allModels, locRes, osRes, protoRes] = await Promise.all([
|
||||
const [ptRes, statusRes, vendorRes, allModels, locRes, osRes, protoRes,
|
||||
allBusinessUnits] = await Promise.all([
|
||||
computersApi.types.list({ perpage: 100 }),
|
||||
assetsApi.statuses.list(),
|
||||
vendorsApi.list({ perpage: 100 }),
|
||||
vendorsApi.listAll(),
|
||||
modelsApi.listAll({ category: 'PC' }), // backend caps perpage at 100; page through all
|
||||
locationsApi.listAll(), // backend caps perpage at 100; page through all
|
||||
operatingsystemsApi.list({ perpage: 100 }),
|
||||
computersApi.protocols.list(),
|
||||
businessunitsApi.listAll(),
|
||||
// Handles its own failure: a site without printers still gets a form.
|
||||
])
|
||||
|
||||
@@ -540,6 +583,7 @@ onMounted(async () => {
|
||||
locations.value = locRes || []
|
||||
operatingsystems.value = osRes.data.data || []
|
||||
protocols.value = protoRes.data.data || []
|
||||
businessunits.value = allBusinessUnits || []
|
||||
|
||||
// Load PC if editing (asset-based shape: extension under pc.computer)
|
||||
if (isEdit.value) {
|
||||
@@ -550,6 +594,7 @@ onMounted(async () => {
|
||||
|
||||
const primaryComm = pc.communications?.find(c => c.isprimary) || pc.communications?.[0]
|
||||
|
||||
inheritedLocation.value = pc.inheritedlocation || null
|
||||
form.value = {
|
||||
machinenumber: pc.assetnumber || '',
|
||||
alias: pc.name && pc.name.toUpperCase() !== 'NONE' ? pc.name : '',
|
||||
@@ -561,7 +606,12 @@ onMounted(async () => {
|
||||
statusid: pc.statusid || '',
|
||||
vendorid: ext.vendorid || '',
|
||||
modelnumberid: ext.modelnumberid || '',
|
||||
locationid: pc.locationid || '',
|
||||
// The OWN location, not the resolved one. Binding to `locationid`
|
||||
// meant the form pre-selected a location this PC had inherited from a
|
||||
// machine, and saving wrote it back as the PC's own - so an edit that
|
||||
// never touched location silently stopped the PC following its parent.
|
||||
locationid: pc.ownlocationid || '',
|
||||
businessunitid: pc.businessunitid || '',
|
||||
osid: ext.osid || '',
|
||||
loggedinuser: ext.loggedinuser || '',
|
||||
azuredeviceid: ext.azuredeviceid || '',
|
||||
@@ -572,9 +622,9 @@ onMounted(async () => {
|
||||
portoverride: a.portoverride ?? null
|
||||
})),
|
||||
notes: pc.notes || '',
|
||||
mapx: pc.mapx ?? null,
|
||||
mapy: pc.mapy ?? null,
|
||||
levelid: pc.levelid ?? null,
|
||||
mapx: pc.ownmapx ?? null,
|
||||
mapy: pc.ownmapy ?? null,
|
||||
levelid: pc.ownlevelid ?? null,
|
||||
ipaddress: primaryComm?.ipaddress || ''
|
||||
}
|
||||
|
||||
@@ -640,6 +690,7 @@ async function savePC() {
|
||||
vendorid: form.value.vendorid || null,
|
||||
modelnumberid: form.value.modelnumberid || null,
|
||||
locationid: form.value.locationid || null,
|
||||
businessunitid: form.value.businessunitid || null,
|
||||
osid: form.value.osid || null,
|
||||
loggedinuser: form.value.loggedinuser || null,
|
||||
azuredeviceid: form.value.azuredeviceid || null,
|
||||
@@ -649,10 +700,22 @@ async function savePC() {
|
||||
notes: form.value.notes || null,
|
||||
ipaddress: form.value.ipaddress || null,
|
||||
mapx: form.value.mapx,
|
||||
mapy: form.value.mapy
|
||||
mapy: form.value.mapy,
|
||||
// ADR-017: a position without its level cannot be drawn on the right
|
||||
// floor plan. The picker sets all three and this payload sent two, so a
|
||||
// picked pin saved a levelid of null and then rendered nowhere - the
|
||||
// form even showed "Position: x, y" back, because it kept the value it
|
||||
// had never sent.
|
||||
levelid: form.value.levelid
|
||||
}
|
||||
// only set display name when an alias is given, so we don't clobber it
|
||||
if (form.value.alias) {
|
||||
// An emptied alias has to be SENT, or it cannot be cleared: the payload
|
||||
// simply omitted `name`, the old value survived, and the form came back
|
||||
// showing the alias the operator had just deleted, with no error and no
|
||||
// toast. On CREATE the field is still omitted when blank, because there
|
||||
// the server may fill it in and sending null would overwrite that.
|
||||
if (isEdit.value) {
|
||||
payload.name = form.value.alias || null
|
||||
} else if (form.value.alias) {
|
||||
payload.name = form.value.alias
|
||||
}
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ onMounted(() => loadData())
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await computersApi.types.list({ perpage: 200, active: false })
|
||||
const response = await computersApi.types.listAll({ active: false })
|
||||
pcTypes.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading PC types:', err)
|
||||
|
||||
@@ -15,7 +15,14 @@
|
||||
placeholder="Search computers..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</div>
|
||||
<!-- Retire is a soft delete and the confirm dialog promises the record
|
||||
"can be restored". The API kept that promise (?active=false lists
|
||||
them, PUT isactive restores); nothing in the UI could reach it. -->
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="showRetired" @change="loadComputers" />
|
||||
Show retired
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
@@ -65,7 +72,15 @@
|
||||
</tr>
|
||||
<tr v-if="computers.length === 0">
|
||||
<td colspan="8" style="text-align: center; color: var(--text-light);">
|
||||
No computers found
|
||||
<!-- A 403 is not an empty result. Rendering "No computers
|
||||
found" to somebody who simply may not see them tells
|
||||
them the fleet is empty, and they go looking for the
|
||||
data rather than for the permission. -->
|
||||
<template v-if="denied">
|
||||
You do not have permission to view computers. Ask an
|
||||
administrator for the computers.view permission.
|
||||
</template>
|
||||
<template v-else>No computers found</template>
|
||||
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
|
||||
</td>
|
||||
</tr>
|
||||
@@ -106,6 +121,9 @@ function applySuggestion(word) {
|
||||
setSearch(word)
|
||||
loadComputers()
|
||||
}
|
||||
const showRetired = ref(false)
|
||||
// Told apart from an empty list: see the table's empty row.
|
||||
const denied = ref(false)
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
@@ -123,6 +141,8 @@ async function loadComputers() {
|
||||
perpage: perPage.value
|
||||
}
|
||||
if (search.value) params.search = search.value
|
||||
// active=false means "include retired", matching the API.
|
||||
if (showRetired.value) params.active = 'false'
|
||||
|
||||
const response = await computersApi.list(params)
|
||||
computers.value = response.data.data || []
|
||||
@@ -130,6 +150,7 @@ async function loadComputers() {
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||
} catch (error) {
|
||||
console.error('Error loading computers:', error)
|
||||
denied.value = error?.response?.status === 403
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ from shopdb.api import (
|
||||
employee_connection,
|
||||
require_role,
|
||||
)
|
||||
from shopdb.api import Setting
|
||||
from shopdb.api import Setting, intarg
|
||||
from shopdb.api import send_upload
|
||||
|
||||
from ..models import DirectoryEmployee
|
||||
@@ -287,7 +287,7 @@ def search_employees():
|
||||
- limit: Max results (default 10)
|
||||
"""
|
||||
query = request.args.get('q', '').strip()
|
||||
limit = min(int(request.args.get('limit', 10)), 50)
|
||||
limit = min(intarg('limit', 10), 50)
|
||||
|
||||
if len(query) < 2:
|
||||
return error_response(
|
||||
@@ -295,12 +295,31 @@ def search_employees():
|
||||
'Search query must be at least 2 characters'
|
||||
)
|
||||
|
||||
# A multi-word query is a FULL NAME. Each word was matched against first
|
||||
# name or last name on its own, so "Dana Reyes" found nobody: no single
|
||||
# column contains that string. Somebody typing the whole name - which is
|
||||
# what a person does before pressing Enter, rather than picking from the
|
||||
# list - got an empty result and their entry became free text linked to no
|
||||
# employee record.
|
||||
words = query.split()
|
||||
|
||||
if _selfhosted():
|
||||
term = f'%{query}%'
|
||||
rows = (DirectoryEmployee.query
|
||||
.filter(db.or_(DirectoryEmployee.firstname.ilike(term),
|
||||
DirectoryEmployee.lastname.ilike(term),
|
||||
db.cast(DirectoryEmployee.sso, db.String).ilike(term)))
|
||||
rows = DirectoryEmployee.query
|
||||
if len(words) > 1:
|
||||
# Every word has to appear in the name, in either column, so
|
||||
# "Reyes Dana" works as well as "Dana Reyes".
|
||||
for word in words:
|
||||
term = f'%{word}%'
|
||||
rows = rows.filter(db.or_(
|
||||
DirectoryEmployee.firstname.ilike(term),
|
||||
DirectoryEmployee.lastname.ilike(term)))
|
||||
else:
|
||||
term = f'%{query}%'
|
||||
rows = rows.filter(db.or_(
|
||||
DirectoryEmployee.firstname.ilike(term),
|
||||
DirectoryEmployee.lastname.ilike(term),
|
||||
db.cast(DirectoryEmployee.sso, db.String).ilike(term)))
|
||||
rows = (rows
|
||||
.order_by(DirectoryEmployee.lastname, DirectoryEmployee.firstname)
|
||||
.limit(limit).all())
|
||||
return success_response([_with_photo_url(e.to_dict()) for e in rows])
|
||||
@@ -308,15 +327,32 @@ def search_employees():
|
||||
try:
|
||||
conn = employee_connection()
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f'''
|
||||
SELECT {_FIELDS}
|
||||
FROM employees
|
||||
WHERE First_Name LIKE %s
|
||||
OR Last_Name LIKE %s
|
||||
OR CAST(SSO AS CHAR) LIKE %s
|
||||
ORDER BY Last_Name, First_Name
|
||||
LIMIT %s
|
||||
''', (f'%{query}%', f'%{query}%', f'%{query}%', limit))
|
||||
if len(words) > 1:
|
||||
# Same rule as the self-hosted branch: every word must appear
|
||||
# somewhere in the name, so a full name finds the person.
|
||||
clauses = ' AND '.join(
|
||||
['(First_Name LIKE %s OR Last_Name LIKE %s)'] * len(words))
|
||||
params = []
|
||||
for word in words:
|
||||
params.extend([f'%{word}%', f'%{word}%'])
|
||||
params.append(limit)
|
||||
cur.execute(f'''
|
||||
SELECT {_FIELDS}
|
||||
FROM employees
|
||||
WHERE {clauses}
|
||||
ORDER BY Last_Name, First_Name
|
||||
LIMIT %s
|
||||
''', tuple(params))
|
||||
else:
|
||||
cur.execute(f'''
|
||||
SELECT {_FIELDS}
|
||||
FROM employees
|
||||
WHERE First_Name LIKE %s
|
||||
OR Last_Name LIKE %s
|
||||
OR CAST(SSO AS CHAR) LIKE %s
|
||||
ORDER BY Last_Name, First_Name
|
||||
LIMIT %s
|
||||
''', (f'%{query}%', f'%{query}%', f'%{query}%', limit))
|
||||
employees = cur.fetchall()
|
||||
conn.close()
|
||||
return success_response([_with_photo_url(e) for e in employees])
|
||||
|
||||
@@ -1376,8 +1376,13 @@ def enqueue_endpoint_command(asset_id: int):
|
||||
# Destructive actions (for example system_restart) demand a stricter grant
|
||||
# ON TOP OF endpoints.command, so a remote reboot cannot ride the routine
|
||||
# toggle permission.
|
||||
# caller_holds, not current_user.haspermission: the question is what THIS
|
||||
# caller may do, and a scoped personal token is a narrower caller than its
|
||||
# owner. Asking the owner let a token scoped to endpoints.command queue a
|
||||
# remote restart, a script run and an app deploy - the exact bypass
|
||||
# require_permission exists to prevent, reintroduced by checking by hand.
|
||||
elevated = elevated_permission_for(action)
|
||||
if elevated and not current_user.haspermission(elevated):
|
||||
if elevated and not caller_holds(elevated):
|
||||
return error_response(
|
||||
ErrorCodes.FORBIDDEN,
|
||||
f'Action {action!r} requires the {elevated} permission',
|
||||
|
||||
@@ -15,7 +15,7 @@ and ShopDB, the same discipline the backups collector uses for machine numbers.
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Type
|
||||
|
||||
@@ -56,18 +56,35 @@ KNOWN_PAYLOAD_KEYS = frozenset({
|
||||
|
||||
|
||||
def _parse_dt(value):
|
||||
"""Best-effort parse of an ISO-8601 string to naive UTC, or None."""
|
||||
"""Best-effort parse of an ISO-8601 string to naive UTC, or None.
|
||||
|
||||
astimezone(tz=None) converts to the SERVER's local zone, not to UTC, so an
|
||||
agent reporting 17:26:16Z was stored as 13:26:16 on an EDT server and then
|
||||
re-served labelled 'Z'. The same row's server-written lastreportedat stayed
|
||||
true UTC, so one row carried two time bases four hours apart, and the
|
||||
dashboard's 48-hour stability window - which compares a real UTC cutoff
|
||||
against these values - dropped events four hours early.
|
||||
|
||||
Invisible on a UTC server, which is why it survived: nothing in the
|
||||
Dockerfile, the compose files or the Windows deploy pins TZ, so whether the
|
||||
data is right depends on the host's clock settings.
|
||||
|
||||
AGENT-CONTRACT.md mandates UTC on the wire. A NAIVE value is therefore
|
||||
taken as UTC already, rather than guessed at.
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value.replace(tzinfo=None)
|
||||
if value.tzinfo is not None:
|
||||
return value.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
return value
|
||||
text = str(value).strip().replace('Z', '+00:00')
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is not None:
|
||||
parsed = parsed.astimezone(tz=None).replace(tzinfo=None)
|
||||
parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
return parsed
|
||||
|
||||
|
||||
|
||||
@@ -454,6 +454,15 @@ def create_scope():
|
||||
if not scopename:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'scopename is required', http_code=400)
|
||||
# The name becomes a directory on the imaging share. Refused here as well
|
||||
# as at the write, so a bad name never reaches the database in the first
|
||||
# place - see valid_scopename for what a traversing name did.
|
||||
if not service.valid_scopename(scopename):
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'scopename may contain only letters, digits, dot, dash and '
|
||||
'underscore, and must start with a letter or digit',
|
||||
http_code=400)
|
||||
if phase not in PHASES:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
f'phase must be one of {PHASES}', http_code=400)
|
||||
|
||||
@@ -10,6 +10,7 @@ Kept out of the CLI and routes so both share one implementation:
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -532,6 +533,30 @@ def store_inline_payload(entry, filename, contenttype, rawbytes):
|
||||
return payload
|
||||
|
||||
|
||||
# A scope name becomes a DIRECTORY on the imaging share, so it has to be a
|
||||
# single safe path segment. It was validated only as "not empty", which meant a
|
||||
# name like "../escape" or an absolute path wrote the manifest outside the
|
||||
# share root - proven by writing /tmp/manifest.json and files one and two
|
||||
# levels above it. The real exposure is the permission split: geenforce.manage
|
||||
# creates the scope, geenforce.publish writes the file, so a manage-only author
|
||||
# could aim a publisher's write anywhere that process can reach.
|
||||
#
|
||||
# Allowed: letters, digits, dot, dash, underscore. Not '.' or '..', and no
|
||||
# separator of either platform - the share is written from Linux and read by
|
||||
# Windows, so a backslash is a separator too.
|
||||
SCOPENAME_PATTERN = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]*$')
|
||||
|
||||
|
||||
def valid_scopename(scopename):
|
||||
"""True when this name is safe to use as a directory on the share."""
|
||||
name = (scopename or '').strip()
|
||||
if not name or name in ('.', '..') or len(name) > 100:
|
||||
return False
|
||||
if '/' in name or '\\' in name or '\x00' in name:
|
||||
return False
|
||||
return bool(SCOPENAME_PATTERN.match(name))
|
||||
|
||||
|
||||
def export_scope_to_share(scopename, phase, shareroot):
|
||||
"""Write a scope's current published JSON to the share, backing up the old
|
||||
file to _meta/history first. Returns the written path."""
|
||||
@@ -546,7 +571,21 @@ def export_scope_to_share(scopename, phase, shareroot):
|
||||
if phase == 'preinstall':
|
||||
target = os.path.join(shareroot, 'preinstall.json')
|
||||
else:
|
||||
# Checked again here, not only at creation: rows predating the
|
||||
# validation exist, and this is the line that actually writes.
|
||||
if not valid_scopename(scopename):
|
||||
raise ValueError(
|
||||
f'Refusing to export scope {scopename!r}: the name is not a '
|
||||
f'safe directory name')
|
||||
target = os.path.join(shareroot, scopename, 'manifest.json')
|
||||
|
||||
# Belt and braces: whatever the name, the resolved target must sit inside
|
||||
# the share root.
|
||||
root = os.path.realpath(shareroot)
|
||||
if os.path.commonpath([root, os.path.realpath(os.path.dirname(target))]) != root:
|
||||
raise ValueError(
|
||||
f'Refusing to export scope {scopename!r}: it resolves outside the '
|
||||
f'share root')
|
||||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||||
|
||||
if os.path.isfile(target):
|
||||
|
||||
@@ -503,8 +503,19 @@ def restore_revision(guideid, revisionid):
|
||||
'You do not have permission to edit this guide',
|
||||
http_code=403)
|
||||
|
||||
before = guide.revision
|
||||
authoring.restore(guide, revision, userid=_userid())
|
||||
db.session.commit()
|
||||
|
||||
# save() dedups on the content hash, so restoring the version that is
|
||||
# already current writes nothing. Saying "Restored version N as a new draft
|
||||
# version" in that case describes a revision that does not exist - and the
|
||||
# revision list, unchanged, quietly disagrees with the message.
|
||||
if guide.revision == before:
|
||||
return success_response(
|
||||
guide.to_dict(includeblocks=True),
|
||||
message='Version {} is already the current content, so nothing '
|
||||
'changed'.format(revision.revision))
|
||||
return success_response(guide.to_dict(includeblocks=True),
|
||||
message='Restored version {} as a new draft version'
|
||||
.format(revision.revision))
|
||||
|
||||
@@ -4,6 +4,7 @@ from flask import Blueprint, request
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.api import (
|
||||
intarg,
|
||||
db,
|
||||
Application,
|
||||
success_response,
|
||||
@@ -16,7 +17,7 @@ from shopdb.api import (
|
||||
|
||||
from ..models import KnowledgeBase
|
||||
|
||||
from shopdb.api import require_permission, apply_import_timestamps
|
||||
from shopdb.api import require_permission, apply_import_timestamps, intarg
|
||||
|
||||
knowledgebase_bp = Blueprint('knowledgebase', __name__)
|
||||
|
||||
@@ -91,8 +92,8 @@ def list_articles():
|
||||
query = query.filter(_search_clause(search))
|
||||
|
||||
# Filter by topic/application
|
||||
if appid := request.args.get('appid'):
|
||||
query = query.filter(KnowledgeBase.appid == int(appid))
|
||||
if (appid := intarg('appid')) is not None:
|
||||
query = query.filter(KnowledgeBase.appid == appid)
|
||||
|
||||
# Exact-match natural-key lookups for idempotent import. linkurl is the
|
||||
# stable natural key; shortdescription (the title) is offered as a fallback.
|
||||
|
||||
@@ -7,7 +7,8 @@ from shopdb.api import db, Asset, AssetType, AuditLog, Vendor, success_response,
|
||||
|
||||
from ..models import Machine, MachineType
|
||||
|
||||
from shopdb.api import require_permission, apply_import_timestamps
|
||||
from shopdb.api import (require_permission, apply_import_timestamps, intarg,
|
||||
DateValueError, parse_date_fields)
|
||||
|
||||
machines_bp = Blueprint('machines', __name__)
|
||||
|
||||
@@ -197,20 +198,20 @@ def list_machines():
|
||||
)
|
||||
|
||||
# Machine type filter
|
||||
if type_id := request.args.get('typeid', request.args.get('type_id')):
|
||||
query = query.filter(Machine.machinetypeid == int(type_id))
|
||||
if (type_id := intarg('typeid', intarg('type_id'))) is not None:
|
||||
query = query.filter(Machine.machinetypeid == type_id)
|
||||
|
||||
# Vendor filter
|
||||
if vendor_id := request.args.get('vendorid', request.args.get('vendor_id')):
|
||||
query = query.filter(Machine.vendorid == int(vendor_id))
|
||||
if (vendor_id := intarg('vendorid', intarg('vendor_id'))) is not None:
|
||||
query = query.filter(Machine.vendorid == vendor_id)
|
||||
|
||||
# Location filter
|
||||
if location_id := request.args.get('locationid', request.args.get('location_id')):
|
||||
query = query.filter(Asset.locationid == int(location_id))
|
||||
if (location_id := intarg('locationid', intarg('location_id'))) is not None:
|
||||
query = query.filter(Asset.locationid == location_id)
|
||||
|
||||
# Business unit filter
|
||||
if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')):
|
||||
query = query.filter(Asset.businessunitid == int(bu_id))
|
||||
if (bu_id := intarg('businessunitid', intarg('businessunit_id'))) is not None:
|
||||
query = query.filter(Asset.businessunitid == bu_id)
|
||||
|
||||
# Sorting
|
||||
sort_by = request.args.get('sort', 'assetnumber')
|
||||
@@ -336,6 +337,14 @@ def create_machine():
|
||||
- mapx, mapy, notes
|
||||
"""
|
||||
data = request.get_json()
|
||||
# Refused rather than stored raw or nulled: these are db.Date columns
|
||||
# (migration 7d40), so a string would be rejected by the engine and a
|
||||
# mistyped one used to become NULL further down the stack.
|
||||
try:
|
||||
maintenancedates = parse_date_fields(
|
||||
data, ('lastmaintenancedate', 'nextmaintenancedate'))
|
||||
except DateValueError as bad:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(bad))
|
||||
|
||||
if not data:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
||||
@@ -388,8 +397,8 @@ def create_machine():
|
||||
modelnumberid=data.get('modelnumberid'),
|
||||
requiresmanualconfig=data.get('requiresmanualconfig', False),
|
||||
islocationonly=data.get('islocationonly', False),
|
||||
lastmaintenancedate=data.get('lastmaintenancedate'),
|
||||
nextmaintenancedate=data.get('nextmaintenancedate'),
|
||||
lastmaintenancedate=maintenancedates.get('lastmaintenancedate'),
|
||||
nextmaintenancedate=maintenancedates.get('nextmaintenancedate'),
|
||||
maintenanceintervaldays=data.get('maintenanceintervaldays'),
|
||||
controllervendorid=data.get('controllervendorid'),
|
||||
controllermodelid=data.get('controllermodelid')
|
||||
@@ -427,6 +436,14 @@ def update_machine(machine_id: int):
|
||||
)
|
||||
|
||||
data = request.get_json()
|
||||
# Refused rather than stored raw or nulled: these are db.Date columns
|
||||
# (migration 7d40), so a string would be rejected by the engine and a
|
||||
# mistyped one used to become NULL further down the stack.
|
||||
try:
|
||||
maintenancedates = parse_date_fields(
|
||||
data, ('lastmaintenancedate', 'nextmaintenancedate'))
|
||||
except DateValueError as bad:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(bad))
|
||||
if not data:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
|
||||
|
||||
@@ -471,7 +488,7 @@ def update_machine(machine_id: int):
|
||||
# Update machine fields
|
||||
machine_fields = ['machinetypeid', 'vendorid', 'modelnumberid',
|
||||
'requiresmanualconfig', 'islocationonly',
|
||||
'lastmaintenancedate', 'nextmaintenancedate', 'maintenanceintervaldays',
|
||||
'maintenanceintervaldays',
|
||||
'controllervendorid', 'controllermodelid']
|
||||
for key in machine_fields:
|
||||
if key in data:
|
||||
@@ -481,6 +498,14 @@ def update_machine(machine_id: int):
|
||||
changes[key] = {'old': old_val, 'new': new_val}
|
||||
setattr(mach, key, data[key])
|
||||
|
||||
# The maintenance dates are real dates now (migration 7d40), so they are
|
||||
# parsed rather than assigned as whatever string arrived - and a mistyped
|
||||
# one is refused instead of stored or nulled, like every other date.
|
||||
for key, parsed in maintenancedates.items():
|
||||
if getattr(mach, key) != parsed:
|
||||
changes[key] = {'old': getattr(mach, key), 'new': parsed}
|
||||
setattr(mach, key, parsed)
|
||||
|
||||
# Audit log if there were changes
|
||||
if changes:
|
||||
AuditLog.log('updated', 'Machine', entityid=mach.machineid,
|
||||
@@ -553,16 +578,22 @@ def dashboard_summary():
|
||||
by_type_query = by_type_query.filter(Asset.assetid.notin_(secondaryassetids))
|
||||
by_type = by_type_query.group_by(MachineType.machinetype).all()
|
||||
|
||||
# Count by status
|
||||
# Count by status. The dualpath exclusion applies here too: it was applied
|
||||
# to total and bytype and NOT to this one, so the status bars summed to
|
||||
# more than the total they sit under - by exactly the number of dualpath
|
||||
# secondaries. A breakdown that does not add up to its own headline is the
|
||||
# kind of thing somebody spends an afternoon reconciling.
|
||||
from shopdb.api import AssetStatus
|
||||
by_status = db.session.query(
|
||||
by_status_query = db.session.query(
|
||||
AssetStatus.status,
|
||||
db.func.count(Machine.machineid)
|
||||
).join(Asset, Asset.assetid == Machine.assetid
|
||||
).join(AssetStatus, AssetStatus.statusid == Asset.statusid
|
||||
).filter(Asset.isactive == True
|
||||
).group_by(AssetStatus.status
|
||||
).all()
|
||||
).filter(Asset.isactive == True)
|
||||
if secondaryassetids:
|
||||
by_status_query = by_status_query.filter(
|
||||
Asset.assetid.notin_(secondaryassetids))
|
||||
by_status = by_status_query.group_by(AssetStatus.status).all()
|
||||
|
||||
return success_response({
|
||||
'total': total,
|
||||
|
||||
@@ -9,14 +9,24 @@
|
||||
<router-link :to="`/print/asset-label/machine/${machine?.machine?.machineid}`" class="btn btn-secondary" v-if="machine" target="_blank">
|
||||
Print Label
|
||||
</router-link>
|
||||
<router-link :to="`/machines/${machine?.machine?.machineid}/edit`" class="btn btn-primary" v-if="machine">
|
||||
<router-link :to="`/machines/${machine?.machine?.machineid}/edit`" class="btn btn-primary" v-if="machine && !isRetired">
|
||||
Edit
|
||||
</router-link>
|
||||
<button @click="confirmRetire" class="btn btn-danger" v-if="machine">Retire</button>
|
||||
<button @click="confirmRetire" class="btn btn-danger" v-if="machine && !isRetired">Retire</button>
|
||||
<!-- A retired record offered Edit and Retire exactly like a live one,
|
||||
with nothing on the page saying it was retired. Restoring it was
|
||||
possible through the API and nowhere in the product. -->
|
||||
<button @click="restore" class="btn btn-primary" v-if="machine && isRetired">Restore</button>
|
||||
<router-link to="/machines" class="btn btn-secondary">Back to List</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="machine && isRetired" class="retired-banner card">
|
||||
<strong>This machine is retired.</strong>
|
||||
It stays out of every list and report, and its history and relationships
|
||||
are intact. Restore puts it back.
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else-if="machine">
|
||||
@@ -295,7 +305,7 @@ const { heroWarranty, warrantyDate } = useWarrantyBadge(() => machine.value?.ass
|
||||
|
||||
// relationships render via the shared AssetRelationships card
|
||||
|
||||
onMounted(async () => {
|
||||
async function loadMachine() {
|
||||
try {
|
||||
const response = await machinesApi.get(route.params.id)
|
||||
machine.value = response.data.data
|
||||
@@ -305,7 +315,9 @@ onMounted(async () => {
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(loadMachine)
|
||||
|
||||
function getStatusClass(status) {
|
||||
if (!status) return 'badge-info'
|
||||
@@ -324,6 +336,20 @@ function formatDate(dateStr) {
|
||||
// Retire, not delete: the server deactivates the asset, so the machine
|
||||
// leaves every list and report while its history and relationships stay put.
|
||||
// Reversible by an admin setting isactive back to true.
|
||||
const isRetired = computed(() => machine.value?.isactive === false)
|
||||
|
||||
async function restore() {
|
||||
const label = machine.value?.assetnumber || machine.value?.name || 'this machine'
|
||||
try {
|
||||
await machinesApi.update(machine.value?.machine?.machineid, { isactive: true })
|
||||
toast.success(`Restored ${label}`)
|
||||
await loadMachine()
|
||||
} catch (error) {
|
||||
console.error('Error restoring machine:', error)
|
||||
toast.error(apiError(error, 'Failed to restore machine'))
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRetire() {
|
||||
const label = machine.value?.assetnumber || machine.value?.name || 'this machine'
|
||||
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
|
||||
@@ -340,6 +366,10 @@ async function confirmRetire() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.retired-banner {
|
||||
border-left: 3px solid var(--warning);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.mono {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
@@ -416,6 +416,7 @@ import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
|
||||
import PrinterAssignmentPicker from '@/components/PrinterAssignmentPicker.vue'
|
||||
import { currentTheme } from '@/stores/theme'
|
||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||
import { loadAssetTypeIds, assetTypeId } from '@/composables/assetTypeIds'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
import { planControllingPcChange } from './controllingPc'
|
||||
|
||||
@@ -426,8 +427,11 @@ const router = useRouter()
|
||||
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
|
||||
// Seeded asset-type id for machines (see /api/assets/types).
|
||||
const MACHINE_ASSETTYPEID = 1
|
||||
// Resolved by NAME at load, never hardcoded: the id comes from seed
|
||||
// order, so the constant that used to live here pointed at a different
|
||||
// asset type on a differently-seeded site - the form then rendered THAT
|
||||
// type's custom fields and dropped whatever was typed into them.
|
||||
const MACHINE_ASSETTYPEID = computed(() => assetTypeId('machine'))
|
||||
const customFieldsRef = ref(null)
|
||||
const printerPickerRef = ref(null)
|
||||
const currentAssetId = ref(null)
|
||||
@@ -571,15 +575,16 @@ watch(() => form.value.controllervendorid, (newVal, oldVal) => {
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadAssetTypeIds()
|
||||
try {
|
||||
// Load reference data in parallel
|
||||
const [typesRes, statusRes, vendorRes, locRes, allModels, buRes, pcsRes, relTypesRes] = await Promise.all([
|
||||
machinesApi.types.list(),
|
||||
assetsApi.statuses.list(),
|
||||
vendorsApi.list({ perpage: 500 }),
|
||||
vendorsApi.listAll(),
|
||||
locationsApi.listAll(), // backend caps perpage at 100; page through all
|
||||
modelsApi.listAll({ category: 'Equipment' }), // backend caps perpage at 100; page through all
|
||||
businessunitsApi.list({ perpage: 500 }),
|
||||
businessunitsApi.listAll(),
|
||||
computersApi.listAll(), // backend caps perpage at 100; page through all
|
||||
assetsApi.types.list() // Used for relationship types, will fix below
|
||||
])
|
||||
@@ -763,7 +768,13 @@ async function saveMachine() {
|
||||
maintenanceintervaldays: form.value.maintenanceintervaldays || null,
|
||||
notes: form.value.notes || null,
|
||||
mapx: form.value.mapx,
|
||||
mapy: form.value.mapy
|
||||
mapy: form.value.mapy,
|
||||
// ADR-017: a position without its level cannot be drawn on the right
|
||||
// floor plan. The picker sets all three and this payload sent two, so a
|
||||
// picked pin saved a levelid of null and then rendered nowhere - the
|
||||
// form even showed "Position: x, y" back, because it kept the value it
|
||||
// had never sent.
|
||||
levelid: form.value.levelid
|
||||
}
|
||||
|
||||
let savedMachine
|
||||
|
||||
@@ -95,7 +95,7 @@ onMounted(loadData)
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await machinesApi.types.list({ perpage: 200, active: false })
|
||||
const response = await machinesApi.types.list({ perpage: 100, active: false })
|
||||
items.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading machine types:', err)
|
||||
|
||||
@@ -15,6 +15,14 @@
|
||||
placeholder="Search machines..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
<!-- Retire is a soft delete and the confirm dialog promises the record
|
||||
"can be restored". The API kept that promise all along (?active=false
|
||||
lists them, PUT isactive restores) but nothing in the UI could reach
|
||||
it, so a retired machine was simply gone. -->
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="showRetired" @change="loadMachines" />
|
||||
Show retired
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
@@ -41,6 +49,7 @@
|
||||
@click="item.machine?.machineid && $router.push(`/machines/${item.machine.machineid}`)">
|
||||
<td>
|
||||
{{ item.assetnumber }}<template v-if="item.dualpathpartner"> / {{ item.dualpathpartner.assetnumber }}</template>
|
||||
<span v-if="item.isactive === false" class="badge badge-secondary">retired</span>
|
||||
</td>
|
||||
<td>{{ item.name || '-' }}</td>
|
||||
<td class="mono">{{ item.serialnumber || '-' }}</td>
|
||||
@@ -122,6 +131,7 @@ function applySuggestion(word) {
|
||||
// search page documents. Load directly.
|
||||
loadMachines()
|
||||
}
|
||||
const showRetired = ref(false)
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
@@ -139,6 +149,8 @@ async function loadMachines() {
|
||||
perpage: perPage.value
|
||||
}
|
||||
if (search.value) params.search = search.value
|
||||
// active=false means "include retired", matching the API.
|
||||
if (showRetired.value) params.active = 'false'
|
||||
|
||||
const response = await machinesApi.list(params)
|
||||
machines.value = response.data.data || []
|
||||
|
||||
@@ -73,8 +73,12 @@ class Machine(BaseModel):
|
||||
)
|
||||
|
||||
# Maintenance tracking
|
||||
lastmaintenancedate = db.Column(db.DateTime, nullable=True)
|
||||
nextmaintenancedate = db.Column(db.DateTime, nullable=True)
|
||||
# Dates, not datetimes: there is no time in a maintenance date, and a
|
||||
# DATETIME went out of to_dict as "...T00:00:00Z", which a browser west of
|
||||
# UTC rendered as the previous day. measuringtools and warranties already
|
||||
# model theirs as db.Date. See migration 7d40.
|
||||
lastmaintenancedate = db.Column(db.Date, nullable=True)
|
||||
nextmaintenancedate = db.Column(db.Date, nullable=True)
|
||||
maintenanceintervaldays = db.Column(db.Integer, nullable=True)
|
||||
|
||||
# Controller info (for CNC machines)
|
||||
|
||||
@@ -20,6 +20,7 @@ from shopdb.api import (
|
||||
success_response, error_response, paginated_response, ErrorCodes,
|
||||
get_pagination_params, paginate_query,
|
||||
require_permission, apply_import_timestamps,
|
||||
DateValueError, parse_date_fields,
|
||||
)
|
||||
|
||||
from ..models import MeasuringTool, MeasuringToolType, derive_status, STATUS_COLORS
|
||||
@@ -27,16 +28,6 @@ from ..models import MeasuringTool, MeasuringToolType, derive_status, STATUS_COL
|
||||
measuringtools_bp = Blueprint('measuringtools', __name__)
|
||||
|
||||
|
||||
def _parse_date(value):
|
||||
"""Accept 'YYYY-MM-DD' (or None/empty) -> date or None."""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(str(value)[:10], '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _merged(tool, today=None):
|
||||
"""Asset core dict with the measuringtools extension nested under 'measuringtool'."""
|
||||
result = tool.asset.to_dict() if tool.asset else {}
|
||||
@@ -191,19 +182,32 @@ def list_tools():
|
||||
query = query.filter(Asset.statusid == status_id)
|
||||
|
||||
query = query.order_by(Asset.assetnumber)
|
||||
items, total = paginate_query(query, page, per_page)
|
||||
|
||||
today = date.today()
|
||||
data = [_merged(tool, today) for tool in items]
|
||||
|
||||
# Calibration status is derived, so it cannot be a SQL filter - apply it
|
||||
# to the built rows. Filtering post-pagination is acceptable here because
|
||||
# the dataset is a gage lab's worth of tools, not a fleet.
|
||||
# Calibration status is DERIVED (see derive_status), so it cannot be a SQL
|
||||
# filter. It used to be applied to the rows of the current page, which
|
||||
# meant the page showed the matches that happened to fall on it while
|
||||
# meta.pagination.total counted the unfiltered set: asking for 'overdue'
|
||||
# with one overdue tool in sixteen returned an empty page 1, a total of 16
|
||||
# and eight pages, with the actual row on page 3. A filter that hides
|
||||
# matching rows and miscounts the rest is worse than no filter.
|
||||
#
|
||||
# So when this filter is in play the whole matching set is built and
|
||||
# filtered before paging. That is the same tradeoff the previous comment
|
||||
# named - a gage lab's worth of tools, not a fleet - spent where it buys
|
||||
# correct answers rather than a cheaper query.
|
||||
calibrationstatus = request.args.get('calibrationstatus')
|
||||
if calibrationstatus:
|
||||
data = [d for d in data
|
||||
if d['measuringtool']['calibrationstatus'] == calibrationstatus]
|
||||
matched = [_merged(tool, today) for tool in query.all()]
|
||||
matched = [row for row in matched
|
||||
if row['measuringtool']['calibrationstatus'] == calibrationstatus]
|
||||
total = len(matched)
|
||||
start = (page - 1) * per_page
|
||||
data = matched[start:start + per_page]
|
||||
return paginated_response(data, page, per_page, total)
|
||||
|
||||
items, total = paginate_query(query, page, per_page)
|
||||
data = [_merged(tool, today) for tool in items]
|
||||
return paginated_response(data, page, per_page, total)
|
||||
|
||||
|
||||
@@ -240,6 +244,12 @@ def _asset_type_id():
|
||||
def create_tool():
|
||||
"""Create a measuring tool: one Asset core row + one extension row."""
|
||||
data = request.get_json() or {}
|
||||
# Refused, not nulled - see the note at the update site.
|
||||
try:
|
||||
dates = parse_date_fields(
|
||||
data, ('lastcalibrationdate', 'nextcalibrationdate'))
|
||||
except DateValueError as bad:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(bad))
|
||||
if not data.get('assetnumber'):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'assetnumber is required')
|
||||
if Asset.query.filter_by(assetnumber=data['assetnumber']).first():
|
||||
@@ -275,8 +285,8 @@ def create_tool():
|
||||
assetid=asset.assetid,
|
||||
measuringtooltypeid=data.get('measuringtooltypeid'),
|
||||
calibrationintervaldays=data.get('calibrationintervaldays'),
|
||||
lastcalibrationdate=_parse_date(data.get('lastcalibrationdate')),
|
||||
nextcalibrationdate=_parse_date(data.get('nextcalibrationdate')),
|
||||
lastcalibrationdate=dates.get('lastcalibrationdate'),
|
||||
nextcalibrationdate=dates.get('nextcalibrationdate'),
|
||||
calibrationprovider=(data.get('calibrationprovider') or '').strip() or None,
|
||||
notes=(data.get('notes') or '').strip() or None,
|
||||
)
|
||||
@@ -309,6 +319,12 @@ def update_tool(tool_id: int):
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'Measuring tool {tool_id} not found', http_code=404)
|
||||
data = request.get_json() or {}
|
||||
# Refused, not nulled - see the note at the update site.
|
||||
try:
|
||||
dates = parse_date_fields(
|
||||
data, ('lastcalibrationdate', 'nextcalibrationdate'))
|
||||
except DateValueError as bad:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(bad))
|
||||
asset = tool.asset
|
||||
|
||||
# An UPDATE may not blank the asset number. Create validates it and the
|
||||
@@ -339,9 +355,11 @@ def update_tool(tool_id: int):
|
||||
if getattr(tool, key) != data[key]:
|
||||
changes[key] = {'old': getattr(tool, key), 'new': data[key]}
|
||||
setattr(tool, key, data[key])
|
||||
for key in ('lastcalibrationdate', 'nextcalibrationdate'):
|
||||
if key in data:
|
||||
setattr(tool, key, _parse_date(data[key]))
|
||||
# Refused, not nulled: a mistyped calibration date used to store NULL and
|
||||
# answer 200, which flipped the tool to 'unknown' and dropped it out of the
|
||||
# calibration report and the map overlay.
|
||||
for key, parsed in dates.items():
|
||||
setattr(tool, key, parsed)
|
||||
|
||||
if changes:
|
||||
AuditLog.log('updated', 'MeasuringTool', entityid=tool.measuringtoolid,
|
||||
|
||||
@@ -9,11 +9,21 @@
|
||||
<router-link :to="`/measuringtools/${tool?.measuringtool?.measuringtoolid}/edit`" class="btn btn-primary" v-if="tool">
|
||||
Edit
|
||||
</router-link>
|
||||
<button @click="confirmRetire" class="btn btn-danger" v-if="tool">Retire</button>
|
||||
<button v-if="tool && !isRetired" @click="confirmRetire" class="btn btn-danger">Retire</button>
|
||||
<!-- A retired record offered Edit and Retire exactly like a live
|
||||
one, and nothing said it was retired. Restoring it was possible
|
||||
through the API and nowhere in the product. -->
|
||||
<button v-if="tool && isRetired" @click="restore" class="btn btn-primary">Restore</button>
|
||||
<router-link to="/measuringtools" class="btn btn-secondary">Back to List</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="tool && tool.isactive === false" class="retired-banner card">
|
||||
<strong>This measuring tool is retired.</strong>
|
||||
It stays out of every list and report, and its history and relationships
|
||||
are intact. Restore puts it back.
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else-if="tool">
|
||||
@@ -181,7 +191,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import { measuringtoolsApi } from '@/api'
|
||||
@@ -227,6 +237,21 @@ onMounted(async () => {
|
||||
// Retire, not delete: the server deactivates the asset, so the measuring tool
|
||||
// leaves every list and report while its history and relationships stay put.
|
||||
// Reversible by an admin setting isactive back to true.
|
||||
const isRetired = computed(() => tool.value?.isactive === false)
|
||||
|
||||
async function restore() {
|
||||
const label = tool.value?.assetnumber || tool.value?.name || 'this tool'
|
||||
try {
|
||||
await measuringtoolsApi.update(route.params.id, { isactive: true })
|
||||
toast.success(`Restored ${label}`)
|
||||
const response = await measuringtoolsApi.get(route.params.id)
|
||||
tool.value = response.data.data
|
||||
} catch (error) {
|
||||
console.error('Error restoring:', error)
|
||||
toast.error(apiError(error, 'Failed to restore'))
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRetire() {
|
||||
const label = tool.value?.assetnumber || tool.value?.name || 'this measuring tool'
|
||||
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
|
||||
@@ -243,6 +268,10 @@ async function confirmRetire() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.retired-banner {
|
||||
border-left: 3px solid var(--warning);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.mono {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
@@ -179,10 +179,10 @@ const businessunits = ref([])
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [typesResponse, statusResponse, locResponse, buResponse, assetTypesResponse] = await Promise.all([
|
||||
measuringtoolsApi.types.list({ perpage: 200 }),
|
||||
measuringtoolsApi.types.list({ perpage: 100 }),
|
||||
assetsApi.statuses.list(),
|
||||
locationsApi.listAll(), // backend caps perpage at 100; page through all
|
||||
businessunitsApi.list({ perpage: 500 }),
|
||||
businessunitsApi.listAll(),
|
||||
assetsApi.types.list(),
|
||||
])
|
||||
types.value = typesResponse.data.data || []
|
||||
|
||||
@@ -107,7 +107,7 @@ onMounted(() => loadData())
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await measuringtoolsApi.types.list({ perpage: 200, active: false })
|
||||
const response = await measuringtoolsApi.types.list({ perpage: 100, active: false })
|
||||
types.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading measuring tool types:', err)
|
||||
|
||||
@@ -28,7 +28,14 @@
|
||||
<option value="current">Current</option>
|
||||
<option value="unknown">Unknown</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Retire is a soft delete and the confirm dialog promises the record
|
||||
"can be restored". The API kept that promise (?active=false lists
|
||||
them, PUT isactive restores); nothing in the UI could reach it. -->
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="showRetired" @change="loadTools" />
|
||||
Show retired
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
@@ -115,6 +122,7 @@ function applySuggestion(word) {
|
||||
}
|
||||
const typeid = ref('')
|
||||
const calibrationstatus = ref('')
|
||||
const showRetired = ref(false)
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
@@ -126,7 +134,7 @@ function formatDate(d) { return new Date(d + 'T00:00:00').toLocaleDateString() }
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const typesResponse = await measuringtoolsApi.types.list({ perpage: 200 })
|
||||
const typesResponse = await measuringtoolsApi.types.list({ perpage: 100 })
|
||||
types.value = typesResponse.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading types:', err)
|
||||
@@ -139,6 +147,8 @@ async function loadTools() {
|
||||
try {
|
||||
const params = { page: page.value, perpage: perPage.value }
|
||||
if (search.value) params.search = search.value
|
||||
// active=false means "include retired", matching the API.
|
||||
if (showRetired.value) params.active = 'false'
|
||||
if (typeid.value) params.typeid = typeid.value
|
||||
if (calibrationstatus.value) params.calibrationstatus = calibrationstatus.value
|
||||
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
from flask import Blueprint, request
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
import ipaddress
|
||||
|
||||
from shopdb.api import db, Asset, AssetType, Vendor, Communication, CommunicationType, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
|
||||
|
||||
from ..services.assetnumbers import generate_for_type
|
||||
from ..models import NetworkDevice, NetworkDeviceType, Subnet, VLAN
|
||||
|
||||
from shopdb.api import require_permission, apply_import_timestamps
|
||||
from shopdb.api import require_permission, apply_import_timestamps, intarg
|
||||
|
||||
network_bp = Blueprint('network', __name__)
|
||||
|
||||
@@ -232,20 +234,20 @@ def list_network_devices():
|
||||
)
|
||||
|
||||
# Type filter
|
||||
if type_id := request.args.get('typeid', request.args.get('type_id')):
|
||||
query = query.filter(NetworkDevice.networkdevicetypeid == int(type_id))
|
||||
if (type_id := intarg('typeid', intarg('type_id'))) is not None:
|
||||
query = query.filter(NetworkDevice.networkdevicetypeid == type_id)
|
||||
|
||||
# Vendor filter
|
||||
if vendor_id := request.args.get('vendorid', request.args.get('vendor_id')):
|
||||
query = query.filter(NetworkDevice.vendorid == int(vendor_id))
|
||||
if (vendor_id := intarg('vendorid', intarg('vendor_id'))) is not None:
|
||||
query = query.filter(NetworkDevice.vendorid == vendor_id)
|
||||
|
||||
# Location filter
|
||||
if location_id := request.args.get('locationid', request.args.get('location_id')):
|
||||
query = query.filter(Asset.locationid == int(location_id))
|
||||
if (location_id := intarg('locationid', intarg('location_id'))) is not None:
|
||||
query = query.filter(Asset.locationid == location_id)
|
||||
|
||||
# Business unit filter
|
||||
if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')):
|
||||
query = query.filter(Asset.businessunitid == int(bu_id))
|
||||
if (bu_id := intarg('businessunitid', intarg('businessunit_id'))) is not None:
|
||||
query = query.filter(Asset.businessunitid == bu_id)
|
||||
|
||||
# PoE filter
|
||||
if poe := request.args.get('poe'):
|
||||
@@ -697,11 +699,27 @@ def create_vlan():
|
||||
if not data.get('name'):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required')
|
||||
|
||||
# 802.1Q says 1-4094, and nothing checked. 99999 and -5 were stored, and a
|
||||
# non-numeric value was worse: the duplicate probe below compared a string
|
||||
# against an integer column, which MySQL coerces to 0, so 'xyz' matched an
|
||||
# existing VLAN 0 and the error said "VLAN xyz already exists" about a row
|
||||
# that had nothing to do with it.
|
||||
try:
|
||||
vlannumber = int(data['vlannumber'])
|
||||
except (TypeError, ValueError):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'vlannumber must be a whole number')
|
||||
if not 1 <= vlannumber <= 4094:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'vlannumber must be between 1 and 4094 (802.1Q)')
|
||||
data['vlannumber'] = vlannumber
|
||||
|
||||
# Check for duplicate VLAN number
|
||||
if VLAN.query.filter_by(vlannumber=data['vlannumber']).first():
|
||||
if VLAN.query.filter_by(vlannumber=vlannumber).first():
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
f"VLAN {data['vlannumber']} already exists",
|
||||
f"VLAN {vlannumber} already exists",
|
||||
http_code=409
|
||||
)
|
||||
|
||||
@@ -784,10 +802,14 @@ def delete_vlan(vlan_id: int):
|
||||
|
||||
# Check if VLAN has associated subnets
|
||||
if vlan.subnets.filter_by(isactive=True).count() > 0:
|
||||
# 409 like every other in-use guard in the product (network types,
|
||||
# machine types, vendors). It answered 400, which tells the caller
|
||||
# their request was malformed when the request was fine and the state
|
||||
# was the problem.
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
ErrorCodes.CONFLICT,
|
||||
'Cannot delete VLAN with associated subnets',
|
||||
http_code=400
|
||||
http_code=409
|
||||
)
|
||||
|
||||
vlan.isactive = False
|
||||
@@ -827,12 +849,12 @@ def list_subnets():
|
||||
)
|
||||
|
||||
# VLAN filter
|
||||
if vlan_id := request.args.get('vlanid'):
|
||||
query = query.filter(Subnet.vlanid == int(vlan_id))
|
||||
if (vlan_id := intarg('vlanid')) is not None:
|
||||
query = query.filter(Subnet.vlanid == vlan_id)
|
||||
|
||||
# Location filter
|
||||
if location_id := request.args.get('locationid'):
|
||||
query = query.filter(Subnet.locationid == int(location_id))
|
||||
if (location_id := intarg('locationid')) is not None:
|
||||
query = query.filter(Subnet.locationid == location_id)
|
||||
|
||||
# Type filter
|
||||
if subnet_type := request.args.get('type'):
|
||||
@@ -936,10 +958,30 @@ def create_subnet():
|
||||
if not data.get('name'):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required')
|
||||
|
||||
# Validate CIDR format (basic check)
|
||||
cidr = data['cidr']
|
||||
# Parsed, not eyeballed for a slash. 'notanip/xx' passed the old check and
|
||||
# was stored; _devices_in_subnet then caught the ValueError from
|
||||
# ip_network and returned an empty device list, so the subnet looked real
|
||||
# and permanently empty. ipaddress is the same library that has to read it
|
||||
# later, so agreeing with it is the whole test.
|
||||
cidr = (data['cidr'] or '').strip()
|
||||
# An explicit prefix is required. ipaddress reads a bare '10.1.1.0' as a
|
||||
# /32, which is legal and almost never what somebody typing a SUBNET meant
|
||||
# - it would silently create a subnet holding one address.
|
||||
if '/' not in cidr:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'cidr must be in CIDR notation (e.g., 10.1.1.0/24)')
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'cidr needs a prefix length, e.g. 10.1.1.0/24')
|
||||
try:
|
||||
network = ipaddress.ip_network(cidr, strict=False)
|
||||
except ValueError as bad:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'cidr must be a network in CIDR notation, e.g. 10.1.1.0/24 (%s)'
|
||||
% bad)
|
||||
# Store the normalised form so 10.1.1.5/24 and 10.1.1.0/24 are one subnet
|
||||
# rather than two rows nobody can tell apart.
|
||||
cidr = str(network)
|
||||
data['cidr'] = cidr
|
||||
|
||||
# Check for duplicate CIDR
|
||||
if Subnet.query.filter_by(cidr=cidr).first():
|
||||
|
||||
@@ -334,6 +334,7 @@ import { loadMapConfig, levelOptions, levelName, state as mapConfig }
|
||||
import Modal from '@/components/Modal.vue'
|
||||
import { currentTheme } from '@/stores/theme'
|
||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||
import { loadAssetTypeIds, assetTypeId } from '@/composables/assetTypeIds'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
@@ -350,8 +351,11 @@ const router = useRouter()
|
||||
const deviceId = route.params.id
|
||||
const isEdit = computed(() => !!deviceId)
|
||||
|
||||
// Seeded asset-type id for network devices (see /api/assets/types).
|
||||
const NETWORK_ASSETTYPEID = 3
|
||||
// Resolved by NAME at load, never hardcoded: the id comes from seed
|
||||
// order, so the constant that used to live here pointed at a different
|
||||
// asset type on a differently-seeded site - the form then rendered THAT
|
||||
// type's custom fields and dropped whatever was typed into them.
|
||||
const NETWORK_ASSETTYPEID = computed(() => assetTypeId('network_device'))
|
||||
const customFieldsRef = ref(null)
|
||||
const currentAssetId = ref(null)
|
||||
|
||||
@@ -424,6 +428,7 @@ const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
await loadAssetTypeIds()
|
||||
await Promise.all([
|
||||
loadDeviceTypes(),
|
||||
loadVendors(),
|
||||
|
||||
@@ -45,7 +45,14 @@
|
||||
{{ loc.locationname }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Retire is a soft delete and the confirm dialog promises the record
|
||||
"can be restored". The API kept that promise (?active=false lists
|
||||
them, PUT isactive restores); nothing in the UI could reach it. -->
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="showRetired" @change="loadDevices" />
|
||||
Show retired
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
@@ -143,6 +150,7 @@ function applySuggestion(word) {
|
||||
const selectedType = ref(null)
|
||||
const vendorFilter = ref('')
|
||||
const locationFilter = ref('')
|
||||
const showRetired = ref(false)
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(25)
|
||||
const totalCount = ref(0)
|
||||
@@ -225,6 +233,8 @@ async function loadDevices() {
|
||||
perpage: perPage.value
|
||||
}
|
||||
if (search.value) params.search = search.value
|
||||
// active=false means "include retired", matching the API.
|
||||
if (showRetired.value) params.active = 'false'
|
||||
if (selectedType.value) params.typeid = selectedType.value
|
||||
if (vendorFilter.value) params.vendorid = vendorFilter.value
|
||||
if (locationFilter.value) params.locationid = locationFilter.value
|
||||
|
||||
@@ -112,7 +112,7 @@ onMounted(loadData)
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await networkApi.types.list({ perpage: 200, active: false })
|
||||
const response = await networkApi.types.list({ perpage: 100, active: false })
|
||||
items.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading network device types:', err)
|
||||
|
||||
@@ -13,7 +13,7 @@ from shopdb.api import db, success_response, error_response, paginated_response,
|
||||
from ..models import Notification, NotificationType
|
||||
from ..models.notification import _utc_iso
|
||||
|
||||
from shopdb.api import require_permission, Setting
|
||||
from shopdb.api import require_permission, Setting, intarg
|
||||
|
||||
notifications_bp = Blueprint('notifications', __name__)
|
||||
|
||||
@@ -355,8 +355,8 @@ def list_notifications():
|
||||
query = query.filter(Notification.isactive == True)
|
||||
|
||||
# Type filter
|
||||
if type_id := request.args.get('typeid', request.args.get('type_id')):
|
||||
query = query.filter(Notification.notificationtypeid == int(type_id))
|
||||
if (type_id := intarg('typeid', intarg('type_id'))) is not None:
|
||||
query = query.filter(Notification.notificationtypeid == type_id)
|
||||
|
||||
# Exact-match lookup for idempotent import. Notifications have no strong
|
||||
# natural key; ticketnumber is the best available when a ticket is set.
|
||||
|
||||
@@ -244,6 +244,7 @@
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { notificationsApi, applicationsApi, businessUnitsApi, employeesApi, settingsApi } from '@/api'
|
||||
import { resolveEmployeeName, fullname } from '@/composables/employeeResolve'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
import { zonedInputFromUtc, utcFromZonedInput, DEFAULT_TZ } from '@/utils/datetime'
|
||||
|
||||
@@ -459,6 +460,30 @@ async function addCustomEmployee() {
|
||||
}
|
||||
selectedEmployees.value.push({ sso: token, name })
|
||||
} else {
|
||||
// Look the name up before treating it as free text. Typing a name in
|
||||
// full and pressing Enter used to produce `NAME:<text>`, linked to
|
||||
// nothing - no photo, no directory record - while picking the same
|
||||
// person off the suggestion list produced a real SSO. The chip looked
|
||||
// identical either way, which is why it went unnoticed.
|
||||
const { match, matches, failed } = await resolveEmployeeName(token)
|
||||
if (match) {
|
||||
const sso = String(match.SSO)
|
||||
if (selectedEmployees.value.some(e => e.sso === sso)) continue
|
||||
selectedEmployees.value.push({ sso, name: fullname(match) || token })
|
||||
continue
|
||||
}
|
||||
if (matches && matches.length) {
|
||||
// Several people match. Guessing is exactly wrong here, so show the
|
||||
// list and let the operator pick.
|
||||
employeeResults.value = matches
|
||||
error.value = `More than one employee matches "${token}" - pick one.`
|
||||
return
|
||||
}
|
||||
if (failed) {
|
||||
error.value = 'Could not reach the employee directory. Try again, or '
|
||||
+ 'pick from the list.'
|
||||
return
|
||||
}
|
||||
const key = `NAME:${token}`
|
||||
if (selectedEmployees.value.some(e => e.sso === key)) continue
|
||||
selectedEmployees.value.push({ sso: key, name: token })
|
||||
|
||||
@@ -75,6 +75,7 @@ def get_item(item_id: int):
|
||||
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
|
||||
from flask import current_app
|
||||
from werkzeug.utils import secure_filename
|
||||
@@ -257,6 +258,52 @@ from ..models import PrintedItemTransaction
|
||||
from ..services.badges import BadgeError, resolve_badge
|
||||
|
||||
|
||||
class InsufficientStock(Exception):
|
||||
"""A decrement was refused because the row no longer holds that many.
|
||||
|
||||
Carries the quantity the DATABASE holds, not the stale one the caller read,
|
||||
so the message the operator sees is the truth at the moment of the write.
|
||||
"""
|
||||
|
||||
def __init__(self, available):
|
||||
super().__init__('insufficient stock')
|
||||
self.available = available
|
||||
|
||||
|
||||
def _move_quantity(item, quantitychange):
|
||||
"""Move quantityonhand by quantitychange in ONE statement, and let the row
|
||||
itself refuse a decrement it cannot cover.
|
||||
|
||||
This is the guard, not the caller's earlier `quantity > item.quantityonhand`
|
||||
check. That check reads, decides in Python, and writes - so two kiosk takes
|
||||
that interleave both read the same on-hand figure and both pass it. Five
|
||||
concurrent takes of 5 from a bin holding 5 all succeeded, each wrote its own
|
||||
5 - 5 = 0, and quantityonhand ended up permanently out of step with the
|
||||
ledger that recorded all five.
|
||||
|
||||
Expressing it as `UPDATE ... WHERE quantityonhand >= :n` makes the database
|
||||
do the comparing at write time, where it is atomic. A row that no longer
|
||||
holds enough matches nothing, rowcount is 0, and the take is refused.
|
||||
Returns the resulting quantity.
|
||||
"""
|
||||
query = PrintedItem.query.filter(
|
||||
PrintedItem.printeditemid == item.printeditemid)
|
||||
if quantitychange < 0:
|
||||
query = query.filter(PrintedItem.quantityonhand >= -quantitychange)
|
||||
moved = query.update(
|
||||
{PrintedItem.quantityonhand:
|
||||
PrintedItem.quantityonhand + quantitychange},
|
||||
synchronize_session=False)
|
||||
if not moved:
|
||||
# Read back what is actually there so the refusal can say so.
|
||||
db.session.rollback()
|
||||
available = db.session.query(PrintedItem.quantityonhand).filter(
|
||||
PrintedItem.printeditemid == item.printeditemid).scalar() or 0
|
||||
raise InsufficientStock(available)
|
||||
db.session.refresh(item)
|
||||
return item.quantityonhand
|
||||
|
||||
|
||||
def _ledger_write(item, transactiontype, quantitychange, sso, name, reason=None,
|
||||
revision=None):
|
||||
"""Append a ledger row and move the cached quantity in ONE commit.
|
||||
@@ -266,9 +313,17 @@ def _ledger_write(item, transactiontype, quantitychange, sso, name, reason=None,
|
||||
alert when this write CROSSES the item's threshold downward - crossing
|
||||
(not being below) is the natural debounce: one alert per depletion, and
|
||||
restocking above the threshold rearms it.
|
||||
|
||||
Raises InsufficientStock when the row cannot cover the decrement. The
|
||||
caller has already refused the obvious case; this is the one that only
|
||||
shows up under concurrency.
|
||||
"""
|
||||
quantitybefore = item.quantityonhand
|
||||
item.quantityonhand += quantitychange
|
||||
# Derived from what the write ACTUALLY produced, not from what this request
|
||||
# read earlier. Under concurrency the two differ, and the threshold
|
||||
# crossing has to be judged on the real numbers or the alert fires for the
|
||||
# wrong take (or not at all).
|
||||
quantityafter = _move_quantity(item, quantitychange)
|
||||
quantitybefore = quantityafter - quantitychange
|
||||
db.session.add(PrintedItemTransaction(
|
||||
printeditemid=item.printeditemid,
|
||||
transactiontype=transactiontype,
|
||||
@@ -427,7 +482,13 @@ def adjust_item(item_id: int):
|
||||
except BadgeError as badge_error:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(badge_error),
|
||||
http_code=422)
|
||||
_ledger_write(item, 'adjust', quantitychange, sso, name, reason=reason)
|
||||
try:
|
||||
_ledger_write(item, 'adjust', quantitychange, sso, name, reason=reason)
|
||||
except InsufficientStock as shortfall:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
f'Adjustment would drive stock below zero '
|
||||
f'(on hand: {shortfall.available})')
|
||||
return success_response(item.to_dict(), message='Stock adjusted')
|
||||
|
||||
|
||||
@@ -500,8 +561,14 @@ def _kiosk_find_item(itemcode):
|
||||
matches = []
|
||||
for candidate in PrintedItem.query.filter_by(isactive=True).all():
|
||||
for value in (candidate.itemcode, candidate.gagelabtag):
|
||||
tail = ''.join(ch for ch in (value or '') if ch.isdigit())
|
||||
if tail and int(tail) == wanted:
|
||||
# The TRAILING digit run, not every digit in the string. A
|
||||
# prefix can contain digits - the shipped default is '3DP' -
|
||||
# and collecting all of them turned 3DP0004 into 30004, so the
|
||||
# operator typing the 4 printed on the bin got "no part matches
|
||||
# that barcode" and had to work out that the 3 belonged to the
|
||||
# prefix. The number on the label is the number at the end.
|
||||
digits = re.search(r'(\d+)$', (value or '').strip())
|
||||
if digits and int(digits.group(1)) == wanted:
|
||||
matches.append(candidate)
|
||||
break
|
||||
if len(matches) == 1:
|
||||
@@ -579,7 +646,16 @@ def kiosk_take():
|
||||
except (ValueError, TypeError):
|
||||
revision = None
|
||||
|
||||
_ledger_write(item, 'take', -quantity, sso, name, revision=revision)
|
||||
try:
|
||||
_ledger_write(item, 'take', -quantity, sso, name, revision=revision)
|
||||
except InsufficientStock as shortfall:
|
||||
# Another kiosk took the last of it between this request's read and its
|
||||
# write. Same message as the pre-check, with the number the database
|
||||
# actually holds now.
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
f'Only {shortfall.available} on hand - take fewer or see the '
|
||||
f'parts team')
|
||||
return success_response(item.to_dict(),
|
||||
message=f'Took {quantity}, {item.quantityonhand} left')
|
||||
|
||||
|
||||
@@ -7,10 +7,31 @@
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="error" class="kiosk-error">{{ error }}</div>
|
||||
<div v-if="error" class="kiosk-error">
|
||||
{{ error }}
|
||||
<p v-if="misses >= 3" class="kiosk-hint">
|
||||
Several scans in a row have found nothing. The kiosk may have been
|
||||
switched off, or this screen's setup link may have been replaced - ask
|
||||
an administrator to check Settings > 3D Printed Parts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- This screen was never provisioned. The SERVER cannot say so - the
|
||||
routes answer a bare 404 on purpose, so a caller without the token
|
||||
does not learn the door exists - but the browser knows whether it
|
||||
holds one, and that is not a disclosure. Without this the kiosk drew
|
||||
its normal scan prompt and answered "Not found" to every scan, which
|
||||
reads as "we do not stock that part". -->
|
||||
<section v-if="!provisioned" class="kiosk-step kiosk-unprovisioned">
|
||||
<p class="kiosk-prompt">This screen is not set up yet</p>
|
||||
<p class="kiosk-hint">
|
||||
An administrator opens it once using the kiosk link from
|
||||
Settings > 3D Printed Parts. The screen remembers it after that.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- step 1: the bin label -->
|
||||
<section v-if="step === 'item'" class="kiosk-step">
|
||||
<section v-if="provisioned && step === 'item'" class="kiosk-step">
|
||||
<p class="kiosk-prompt">Scan the barcode on the bin</p>
|
||||
<!-- one visible field: keyboard-wedge scanners, a plugged-in keyboard or
|
||||
numpad, and the on-screen keypad all type into it. -->
|
||||
@@ -108,6 +129,13 @@ const item = ref(null)
|
||||
const badge = ref('')
|
||||
const quantity = ref('')
|
||||
const error = ref('')
|
||||
// Whether THIS browser holds the kiosk's device token. The server will not say
|
||||
// (see the template), and it does not need to: an unprovisioned screen should
|
||||
// say so rather than pretending to be a working kiosk with an empty catalog.
|
||||
const provisioned = ref(true)
|
||||
// Consecutive lookups that found nothing. Every scan missing usually means the
|
||||
// kiosk was switched off, not that no bin is known.
|
||||
const misses = ref(0)
|
||||
const doneMessage = ref('')
|
||||
const submitting = ref(false)
|
||||
const manualCode = ref('')
|
||||
@@ -118,6 +146,14 @@ let resetTimer = null
|
||||
|
||||
onMounted(async () => {
|
||||
claimKioskToken(KIOSK_TOKEN_KEYS['/printedparts/kiosk/'])
|
||||
try {
|
||||
provisioned.value = Boolean(
|
||||
localStorage.getItem(KIOSK_TOKEN_KEYS['/printedparts/kiosk/']))
|
||||
} catch {
|
||||
// A browser refusing localStorage cannot be provisioned at all, and
|
||||
// saying so is more use than a scan box that answers nothing.
|
||||
provisioned.value = false
|
||||
}
|
||||
// A kiosk left running for weeks reads this once at load, which is fine: the
|
||||
// label format is a property of the site's physical tags, not something that
|
||||
// changes during a shift.
|
||||
@@ -153,11 +189,17 @@ async function lookupItem(itemcode) {
|
||||
try {
|
||||
const response = await printedpartsApi.kioskItem(code)
|
||||
item.value = response.data.data
|
||||
misses.value = 0
|
||||
step.value = 'badge'
|
||||
manualCode.value = ''
|
||||
} catch (lookupError) {
|
||||
error.value = lookupError.response?.data?.data?.error?.message ||
|
||||
'No part matches that barcode'
|
||||
// The gate and a genuine miss both answer 404, deliberately, so the client
|
||||
// cannot tell them apart from one response. What it CAN say is that
|
||||
// repeated misses may mean the kiosk was switched off rather than that
|
||||
// every bin is unknown.
|
||||
misses.value += 1
|
||||
}
|
||||
focusEntry()
|
||||
}
|
||||
@@ -215,6 +257,14 @@ function reset() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.kiosk-unprovisioned {
|
||||
text-align: center;
|
||||
}
|
||||
.kiosk-hint {
|
||||
color: var(--text-light);
|
||||
font-size: 1rem;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
.parts-kiosk {
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
|
||||
@@ -36,16 +36,24 @@ class BadgeError(ValueError):
|
||||
|
||||
|
||||
def _parse_badge(badge):
|
||||
"""Return ('sso'|'payno', digits) or raise BadgeError on unknown shape."""
|
||||
badge = (badge or '').strip()
|
||||
if not badge:
|
||||
raise BadgeError('Scan or enter a badge')
|
||||
if badge.isdigit():
|
||||
return 'sso', badge
|
||||
match = _PAYNO_BADGE.match(badge)
|
||||
if match:
|
||||
return 'payno', match.group(1)
|
||||
raise BadgeError('Unrecognized badge format')
|
||||
"""Return ('sso'|'payno', digits) or raise BadgeError on unknown shape.
|
||||
|
||||
The shapes themselves live in shopdb.api (normalise_badge), because the USB
|
||||
kiosk reads the same physical badges and used to disagree: it took the
|
||||
string raw, so the PayNo wrapper resolved to nobody and an operator whose
|
||||
scanner emits it could take a part and not check out a drive.
|
||||
|
||||
That also closed a subtler hole. This used str.isdigit(), which is true for
|
||||
Arabic-Indic and other decimal forms, so a badge written in them was
|
||||
accepted, resolved against int() to a REAL employee, and then stored as an
|
||||
SSO string that joins to nothing.
|
||||
"""
|
||||
from shopdb.api import BadgeShapeError, badge_kind, normalise_badge
|
||||
|
||||
try:
|
||||
return badge_kind(badge), normalise_badge(badge)
|
||||
except BadgeShapeError as bad:
|
||||
raise BadgeError(str(bad))
|
||||
|
||||
|
||||
def _selfhosted_lookup(digits):
|
||||
|
||||
@@ -6,7 +6,7 @@ import re
|
||||
from flask import Blueprint, request, Response, current_app
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.api import db, cache, Setting, Asset, AssetType, Vendor, Model, Communication, CommunicationType, AssetRelationship, RelationshipType, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
|
||||
from shopdb.api import db, cache, Setting, Asset, AssetType, Vendor, Model, Communication, CommunicationType, AssetRelationship, RelationshipType, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query, intarg
|
||||
|
||||
from ..models import Printer, PrinterType, ModelSupply, PrinterDriver, PrinterObservedQueue
|
||||
from ..models.model_supply import SUPPLY_TYPES, SUPPLY_COLORS, CAPACITY_TIERS
|
||||
@@ -163,10 +163,20 @@ def create_driver():
|
||||
data = request.get_json() or {}
|
||||
if not (data.get('name') and data.get('location')):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'name and location are required')
|
||||
# drivername and vendorid are accepted here because they are the two
|
||||
# fields the converger actually uses. drivername is the exact string from
|
||||
# the INF that Add-PrinterDriver matches on - `name` is ours to choose and
|
||||
# means nothing to Windows - and vendorid attaches a universal driver to a
|
||||
# whole make. Neither was read from the payload, so both came back null on
|
||||
# a 201 and there was no way to set them through any interface: the
|
||||
# converger had nothing to match and installed nothing, and driver-name
|
||||
# drift could never be detected because the expected value was never there.
|
||||
d = PrinterDriver(
|
||||
name=data['name'],
|
||||
location=data['location'],
|
||||
description=data.get('description'),
|
||||
drivername=(data.get('drivername') or '').strip() or None,
|
||||
vendorid=data.get('vendorid') or None,
|
||||
modelnumberid=data.get('modelnumberid') or None,
|
||||
isactive=data.get('isactive', True),
|
||||
)
|
||||
@@ -185,8 +195,11 @@ def update_driver(driver_id):
|
||||
for key in ('name', 'location', 'description', 'isactive'):
|
||||
if key in data:
|
||||
setattr(d, key, data[key])
|
||||
if 'modelnumberid' in data:
|
||||
d.modelnumberid = data['modelnumberid'] or None
|
||||
if 'drivername' in data:
|
||||
d.drivername = (data['drivername'] or '').strip() or None
|
||||
for key in ('vendorid', 'modelnumberid'):
|
||||
if key in data:
|
||||
setattr(d, key, data[key] or None)
|
||||
db.session.commit()
|
||||
return success_response(d.to_dict(), message='Driver updated')
|
||||
|
||||
@@ -261,20 +274,20 @@ def list_printers():
|
||||
)
|
||||
|
||||
# Type filter
|
||||
if typeid := request.args.get('typeid', request.args.get('type_id')):
|
||||
query = query.filter(Printer.printertypeid == int(typeid))
|
||||
if (typeid := intarg('typeid', intarg('type_id'))) is not None:
|
||||
query = query.filter(Printer.printertypeid == typeid)
|
||||
|
||||
# Vendor filter
|
||||
if vendor_id := request.args.get('vendorid', request.args.get('vendor_id')):
|
||||
query = query.filter(Printer.vendorid == int(vendor_id))
|
||||
if (vendor_id := intarg('vendorid', intarg('vendor_id'))) is not None:
|
||||
query = query.filter(Printer.vendorid == vendor_id)
|
||||
|
||||
# Location filter
|
||||
if location_id := request.args.get('locationid', request.args.get('location_id')):
|
||||
query = query.filter(Asset.locationid == int(location_id))
|
||||
if (location_id := intarg('locationid', intarg('location_id'))) is not None:
|
||||
query = query.filter(Asset.locationid == location_id)
|
||||
|
||||
# Business unit filter
|
||||
if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')):
|
||||
query = query.filter(Asset.businessunitid == int(bu_id))
|
||||
if (bu_id := intarg('businessunitid', intarg('businessunit_id'))) is not None:
|
||||
query = query.filter(Asset.businessunitid == bu_id)
|
||||
|
||||
# Sorting
|
||||
sort_by = request.args.get('sort', 'hostname')
|
||||
@@ -2316,15 +2329,28 @@ def printer_lookup():
|
||||
'Provide ip or fqdn'
|
||||
)
|
||||
|
||||
# match the IP against any active printer communication
|
||||
row = (
|
||||
# An IP matches a communication row; an FQDN matches the printer's
|
||||
# hostname. Both were compared against Communication.ipaddress, so
|
||||
# ?fqdn=<hostname> never found anything - it 404d on printers whose
|
||||
# hostname was right there on the record. Zabbix uses this to jump to a
|
||||
# printer, and it knows names as often as addresses.
|
||||
query = (
|
||||
db.session.query(Printer, Asset)
|
||||
.join(Asset, Asset.assetid == Printer.assetid)
|
||||
.join(Communication, Communication.assetid == Asset.assetid)
|
||||
.filter(Asset.isactive == True)
|
||||
.filter(Communication.ipaddress == lookup_value)
|
||||
.first()
|
||||
)
|
||||
if ip:
|
||||
row = (query
|
||||
.join(Communication, Communication.assetid == Asset.assetid)
|
||||
.filter(Communication.ipaddress == ip)
|
||||
.first())
|
||||
else:
|
||||
row = query.filter(Printer.hostname == fqdn).first()
|
||||
if row is None:
|
||||
# A caller holding a fully-qualified name against a site that
|
||||
# stores the short one, or the reverse.
|
||||
shortname = fqdn.split('.', 1)[0]
|
||||
row = query.filter(Printer.hostname == shortname).first()
|
||||
|
||||
if not row:
|
||||
return error_response(
|
||||
@@ -2481,8 +2507,8 @@ def list_supply_models():
|
||||
|
||||
if search := request.args.get('search'):
|
||||
query = query.filter(Model.modelnumber.ilike(f'%{search}%'))
|
||||
if vendor_id := request.args.get('vendorid', request.args.get('vendor_id')):
|
||||
query = query.filter(Model.vendorid == int(vendor_id))
|
||||
if (vendor_id := intarg('vendorid', intarg('vendor_id'))) is not None:
|
||||
query = query.filter(Model.vendorid == vendor_id)
|
||||
if request.args.get('withsupplies', '').lower() == 'true':
|
||||
query = query.having(supplycount > 0)
|
||||
|
||||
@@ -2755,7 +2781,7 @@ def supplies_forecast():
|
||||
)
|
||||
|
||||
try:
|
||||
days = max(1, min(365, int(request.args.get('days', 90))))
|
||||
days = max(1, min(365, intarg('days', 90)))
|
||||
except (TypeError, ValueError):
|
||||
days = 90
|
||||
|
||||
|
||||
@@ -10,11 +10,21 @@
|
||||
Print Label
|
||||
</router-link>
|
||||
<router-link :to="`/printers/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
|
||||
<button @click="confirmRetire" class="btn btn-danger">Retire</button>
|
||||
<button v-if="!isRetired" @click="confirmRetire" class="btn btn-danger">Retire</button>
|
||||
<!-- A retired record offered Edit and Retire exactly like a live
|
||||
one, and nothing said it was retired. Restoring it was possible
|
||||
through the API and nowhere in the product. -->
|
||||
<button v-if="isRetired" @click="restore" class="btn btn-primary">Restore</button>
|
||||
<router-link to="/printers" class="btn btn-secondary">Back to List</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="printer && printer.isactive === false" class="retired-banner card">
|
||||
<strong>This printer is retired.</strong>
|
||||
It stays out of every list and report, and its history and relationships
|
||||
are intact. Restore puts it back.
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else-if="printer">
|
||||
@@ -306,7 +316,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { printersApi } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
@@ -406,6 +416,21 @@ function formatDate(dateStr) {
|
||||
// Retire, not delete: the server deactivates the asset, so the printer
|
||||
// leaves every list and report while its history and relationships stay put.
|
||||
// Reversible by an admin setting isactive back to true.
|
||||
const isRetired = computed(() => printer.value?.isactive === false)
|
||||
|
||||
async function restore() {
|
||||
const label = printer.value?.assetnumber || printer.value?.name || 'this printer'
|
||||
try {
|
||||
await printersApi.update(route.params.id, { isactive: true })
|
||||
toast.success(`Restored ${label}`)
|
||||
const response = await printersApi.get(route.params.id)
|
||||
printer.value = response.data.data
|
||||
} catch (error) {
|
||||
console.error('Error restoring:', error)
|
||||
toast.error(apiError(error, 'Failed to restore'))
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRetire() {
|
||||
const label = displayTitle.value || 'this printer'
|
||||
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
|
||||
@@ -422,6 +447,10 @@ async function confirmRetire() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.retired-banner {
|
||||
border-left: 3px solid var(--warning);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
/* Printer-specific styles - shared styles are in global style.css */
|
||||
|
||||
.empty-state {
|
||||
|
||||
@@ -61,6 +61,26 @@
|
||||
<label>Name *</label>
|
||||
<input v-model="form.name" type="text" class="form-control" maxlength="150" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
INF Driver Name
|
||||
<span class="hint">(the exact string the INF declares - what
|
||||
Add-PrinterDriver matches on. Blank means the converger
|
||||
cannot install this driver.)</span>
|
||||
</label>
|
||||
<input v-model="form.drivername" type="text" class="form-control"
|
||||
maxlength="255"
|
||||
placeholder="HP Universal Printing PCL 6" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Vendor <span class="hint">(a universal driver serves every printer of that make; a model below wins over it)</span></label>
|
||||
<select v-model="form.vendorid" class="form-control">
|
||||
<option :value="null">-- none --</option>
|
||||
<option v-for="v in vendors" :key="v.vendorid" :value="v.vendorid">
|
||||
{{ v.vendor }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Printer Model <span class="hint">(links driver to a model so it shows on matching printers)</span></label>
|
||||
<select v-model="form.modelnumberid" class="form-control">
|
||||
@@ -96,13 +116,14 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { printersApi } from '@/api'
|
||||
import { printersApi, vendorsApi } from '@/api'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
const toast = useToast()
|
||||
|
||||
const items = ref([])
|
||||
const models = ref([])
|
||||
const vendors = ref([])
|
||||
const showInactive = ref(false)
|
||||
const visibleItems = computed(() => showInactive.value ? items.value : items.value.filter(x => x.isactive !== false))
|
||||
const loading = ref(true)
|
||||
@@ -110,13 +131,13 @@ const showModal = ref(false)
|
||||
const editing = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const form = ref({ name: '', location: '', description: '', modelnumberid: null, isactive: true })
|
||||
const form = ref({ name: '', location: '', description: '', drivername: '', vendorid: null, modelnumberid: null, isactive: true })
|
||||
|
||||
function isHttp(loc) {
|
||||
return typeof loc === 'string' && /^https?:\/\//i.test(loc)
|
||||
}
|
||||
|
||||
onMounted(() => { loadData(); loadModels() })
|
||||
onMounted(() => { loadData(); loadModels(); loadVendors() })
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
@@ -139,11 +160,23 @@ async function loadModels() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVendors() {
|
||||
try {
|
||||
// A universal driver (HP UPD, Xerox GPD) attaches to the whole make rather
|
||||
// than one model, which is what vendorid is for. listAll, because the
|
||||
// picker must show every vendor - perpage is clamped at 100.
|
||||
const response = await vendorsApi.list({ perpage: 100 })
|
||||
vendors.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading vendors:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(item = null) {
|
||||
editing.value = item
|
||||
form.value = item
|
||||
? { name: item.name || '', location: item.location || '', description: item.description || '', modelnumberid: item.modelnumberid || null, isactive: item.isactive !== false }
|
||||
: { name: '', location: '', description: '', modelnumberid: null, isactive: true }
|
||||
? { name: item.name || '', location: item.location || '', description: item.description || '', drivername: item.drivername || '', vendorid: item.vendorid || null, modelnumberid: item.modelnumberid || null, isactive: item.isactive !== false }
|
||||
: { name: '', location: '', description: '', drivername: '', vendorid: null, modelnumberid: null, isactive: true }
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
@@ -336,6 +336,7 @@ import Modal from '@/components/Modal.vue'
|
||||
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
|
||||
import { currentTheme } from '@/stores/theme'
|
||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||
import { loadAssetTypeIds, assetTypeId } from '@/composables/assetTypeIds'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
import { getPrinterHostnameTemplate } from '@/utils/siteSettings'
|
||||
|
||||
@@ -346,8 +347,11 @@ const router = useRouter()
|
||||
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
|
||||
// Seeded asset-type id for printers (see /api/assets/types).
|
||||
const PRINTER_ASSETTYPEID = 4
|
||||
// Resolved by NAME at load, never hardcoded: the id comes from seed
|
||||
// order, so the constant that used to live here pointed at a different
|
||||
// asset type on a differently-seeded site - the form then rendered THAT
|
||||
// type's custom fields and dropped whatever was typed into them.
|
||||
const PRINTER_ASSETTYPEID = computed(() => assetTypeId('printer'))
|
||||
const customFieldsRef = ref(null)
|
||||
const currentAssetId = ref(null)
|
||||
const manualHostname = ref(false)
|
||||
@@ -542,6 +546,7 @@ function onWindowsNameInput() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadAssetTypeIds()
|
||||
try {
|
||||
// Load reference data (models paged in full via listAll, see api/index.js)
|
||||
// perpage 100 so dropdowns aren't truncated to the default 20-row page
|
||||
@@ -686,7 +691,13 @@ async function savePrinter() {
|
||||
isduplex: !!form.value.isduplex,
|
||||
isnetwork: !!form.value.isnetwork,
|
||||
mapx: form.value.mapx,
|
||||
mapy: form.value.mapy
|
||||
mapy: form.value.mapy,
|
||||
// ADR-017: a position without its level cannot be drawn on the right
|
||||
// floor plan. The picker sets all three and this payload sent two, so a
|
||||
// picked pin saved a levelid of null and then rendered nowhere - the
|
||||
// form even showed "Position: x, y" back, because it kept the value it
|
||||
// had never sent.
|
||||
levelid: form.value.levelid
|
||||
}
|
||||
// only set the display name when an alias is given, so we don't clobber it
|
||||
if (form.value.alias) {
|
||||
|
||||
@@ -95,7 +95,7 @@ onMounted(loadData)
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await printersApi.types.list({ perpage: 200, active: false })
|
||||
const response = await printersApi.types.list({ perpage: 100, active: false })
|
||||
items.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading printer types:', err)
|
||||
|
||||
@@ -25,7 +25,14 @@
|
||||
{{ pt.printertype }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Retire is a soft delete and the confirm dialog promises the record
|
||||
"can be restored". The API kept that promise (?active=false lists
|
||||
them, PUT isactive restores); nothing in the UI could reach it. -->
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="showRetired" @change="loadPrinters" />
|
||||
Show retired
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
@@ -111,6 +118,7 @@ function applySuggestion(word) {
|
||||
// search page documents. Load directly.
|
||||
loadPrinters()
|
||||
}
|
||||
const showRetired = ref(false)
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
@@ -134,6 +142,8 @@ async function loadPrinters() {
|
||||
perpage: perPage.value
|
||||
}
|
||||
if (search.value) params.search = search.value
|
||||
// active=false means "include retired", matching the API.
|
||||
if (showRetired.value) params.active = 'false'
|
||||
if (typeFilter.value) params.typeid = typeFilter.value
|
||||
|
||||
const response = await printersApi.list(params)
|
||||
|
||||
@@ -17,8 +17,18 @@
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Cannot see the supplies: say so instead of reporting zeros. -->
|
||||
<div v-if="unavailable" class="card blind-notice">
|
||||
<strong>Supply levels are not being read.</strong>
|
||||
<p>{{ unavailable }}</p>
|
||||
<p class="muted">
|
||||
The counts below are not a result - nothing was measured. Check the
|
||||
supply monitoring configuration in Settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Summary Bar -->
|
||||
<div class="summary-bar">
|
||||
<div v-if="!unavailable" class="summary-bar">
|
||||
<div class="summary-stat card">
|
||||
<div class="stat-value">{{ summary.total_checked }}</div>
|
||||
<div class="stat-label">Printers Checked</div>
|
||||
@@ -122,6 +132,9 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else-if="unavailable" class="empty-state">
|
||||
No supply data was read, so there is nothing to list.
|
||||
</p>
|
||||
<p v-else class="empty-state">No printers match the selected filter.</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,6 +169,7 @@ const loading = ref(true)
|
||||
const error = ref(null)
|
||||
const printers = ref([])
|
||||
const summary = ref({ total_checked: 0, low: 0, critical: 0 })
|
||||
const unavailable = ref('')
|
||||
const filter = ref('all')
|
||||
|
||||
const filterOptions = [
|
||||
@@ -239,6 +253,11 @@ onMounted(async () => {
|
||||
const data = response.data.data
|
||||
printers.value = data.printers || []
|
||||
summary.value = data.summary || { total_checked: 0, low: 0, critical: 0 }
|
||||
// The API says so explicitly when it cannot see supplies at all. Without
|
||||
// this the page rendered the zeros it was given - "0 Printers Checked,
|
||||
// 0 Low, 0 Critical" and "No printers match the selected filter" - which
|
||||
// reads as a clean bill of health for a fleet nobody has looked at.
|
||||
unavailable.value = data.unavailable || ''
|
||||
} catch (err) {
|
||||
console.error('Error loading toner report:', err)
|
||||
error.value = 'Failed to load supply data. Zabbix may not be configured or reachable.'
|
||||
@@ -249,6 +268,13 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.blind-notice {
|
||||
border-left: 3px solid var(--warning);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.blind-notice p {
|
||||
margin: 0.4rem 0 0 0;
|
||||
}
|
||||
.summary-bar {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
|
||||
@@ -47,6 +47,15 @@
|
||||
<img :src="withBase(slide.url)" :alt="slide.filename" class="thumb"
|
||||
@mouseenter="previewSlide = slide" @mouseleave="previewSlide = null" />
|
||||
<span class="fname" :title="slide.filename">{{ slide.filename }}</span>
|
||||
<!-- Per-slide hold time. The API and the feed have always carried
|
||||
`seconds`, and the manager offered no way to set it, so the one
|
||||
documented per-slide control was API-only. 0 means "use the
|
||||
default interval", which is what the feed already does. -->
|
||||
<label class="seconds" title="Seconds to hold this slide (0 = default)">
|
||||
<input type="number" min="0" max="600" :value="slide.seconds"
|
||||
@change="setSeconds(slide, $event.target.value)" />
|
||||
<span class="seconds-unit">s</span>
|
||||
</label>
|
||||
<div class="move">
|
||||
<button class="btn btn-sm btn-secondary" :disabled="idx === 0" @click="move(idx, -1)" title="Move up">↑</button>
|
||||
<button class="btn btn-sm btn-secondary" :disabled="idx === slides.length - 1" @click="move(idx, 1)" title="Move down">↓</button>
|
||||
@@ -73,6 +82,8 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { slidesApi } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
|
||||
// Slide currently hovered, shown enlarged. Null when the cursor is elsewhere.
|
||||
const previewSlide = ref(null)
|
||||
@@ -133,6 +144,7 @@ const surfaces = [
|
||||
{ key: 'shopfloor', label: 'Shopfloor Screensaver' }
|
||||
]
|
||||
const surface = ref('lobby')
|
||||
const toast = useToast()
|
||||
const slides = ref([])
|
||||
const selected = ref([])
|
||||
const loading = ref(true)
|
||||
@@ -176,6 +188,22 @@ async function onUpload(event) {
|
||||
}
|
||||
}
|
||||
|
||||
async function setSeconds(slide, raw) {
|
||||
const seconds = Math.max(0, Math.min(600, Number(raw) || 0))
|
||||
if (seconds === slide.seconds) return
|
||||
try {
|
||||
await slidesApi.update(surface.value, slide.slideid, { seconds })
|
||||
slide.seconds = seconds
|
||||
toast.success(seconds
|
||||
? `${slide.filename} holds for ${seconds}s`
|
||||
: `${slide.filename} uses the default interval`)
|
||||
} catch (err) {
|
||||
console.error('Error setting slide seconds:', err)
|
||||
toast.error(apiError(err, 'Could not set the hold time'))
|
||||
await load()
|
||||
}
|
||||
}
|
||||
|
||||
async function move(idx, delta) {
|
||||
const target = idx + delta
|
||||
if (target < 0 || target >= slides.value.length) return
|
||||
@@ -197,6 +225,17 @@ onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.seconds {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.seconds input {
|
||||
width: 4.5rem;
|
||||
}
|
||||
.seconds-unit {
|
||||
color: var(--text-light);
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
||||
@@ -39,6 +39,7 @@ from flask_jwt_extended import jwt_required, get_jwt_identity, current_user
|
||||
from shopdb.api import (
|
||||
db, cache, Setting, client_ip,
|
||||
success_response, error_response, ErrorCodes,
|
||||
caller_holds,
|
||||
)
|
||||
|
||||
from ..models import SecureSecret
|
||||
@@ -155,7 +156,9 @@ def create_secret():
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Too many links from this address. Try again shortly.',
|
||||
http_code=429)
|
||||
elif not current_user.haspermission('tools.secrets'):
|
||||
# caller_holds, for the same reason as the endpoints elevated gate: a
|
||||
# scoped token is a narrower caller than the person who minted it.
|
||||
elif not caller_holds('tools.secrets'):
|
||||
return error_response(
|
||||
ErrorCodes.FORBIDDEN,
|
||||
'You do not have permission to create secret links.',
|
||||
|
||||
@@ -86,13 +86,21 @@ class ToolsPlugin(BasePlugin):
|
||||
from where, and never who. That is the cost, and it is why this is the
|
||||
only write in the product offered to guests at all.
|
||||
|
||||
tools.view rides along because tools.secrets without it is a page a
|
||||
guest holds permission to use and no way to reach: it gates the nav
|
||||
entry, and the browser tools behind it (codes, conversions) run client
|
||||
side and touch no data at all. Declared explicitly rather than inferred
|
||||
from the .view suffix, which is what the grantable rule now asks for.
|
||||
tools.secrets is NOT here, and that is the fix for a grant that did
|
||||
nothing. Whether a guest may create a secret link is decided by the
|
||||
securesend_allow_guests setting - which fails closed, carries the rate
|
||||
limit, and is read straight from the row rather than the cache. The
|
||||
route never consulted the guest ROLE, so ticking tools.secrets for
|
||||
guests in the role grid was accepted, stored, and ignored: the site
|
||||
looked open and was not, or looked closed and was not. Two mechanisms
|
||||
for one decision, and the setting is the one with the safeguards.
|
||||
|
||||
tools.view stays grantable: it gates the nav entry, and the browser
|
||||
tools behind it (codes, conversions) run client side and touch no data
|
||||
at all. Declared explicitly rather than inferred from the .view suffix,
|
||||
which is what the grantable rule now asks for.
|
||||
"""
|
||||
return ['tools.secrets', 'tools.view']
|
||||
return ['tools.view']
|
||||
|
||||
def get_settings_defaults(self) -> List[dict]:
|
||||
"""The one switch this plugin owns.
|
||||
|
||||
@@ -993,16 +993,30 @@ def kiosk_checkout():
|
||||
'Badge not recognized - see IT to check this device out',
|
||||
http_code=422)
|
||||
|
||||
# Claim the device FIRST, and let the status transition be the
|
||||
# guard: the read-then-write above cannot stop two scans of the
|
||||
# same drive that interleave, because both read 'available'. With
|
||||
# the status in the WHERE clause the second UPDATE matches no row
|
||||
# and there is no log entry to undo.
|
||||
cur.execute(
|
||||
'UPDATE devices SET status = %s '
|
||||
'WHERE device_id = %s AND status <> %s',
|
||||
(_STATUS_CHECKED_OUT, device_id, _STATUS_CHECKED_OUT),
|
||||
)
|
||||
if not cur.rowcount:
|
||||
holder = _kiosk_holder_name(cur, device_id)
|
||||
conn.rollback()
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
'That device is already checked out{}'.format(
|
||||
' to {}'.format(holder) if holder else ''),
|
||||
http_code=409)
|
||||
cur.execute(
|
||||
'INSERT INTO checkinoutlog '
|
||||
'(badge_number, device_id, action, timestamp, locker_location) '
|
||||
"VALUES (%s, %s, 'check-out', %s, %s)",
|
||||
(badge, device_id, _now(), device.get('locker_location')),
|
||||
)
|
||||
cur.execute(
|
||||
'UPDATE devices SET status = %s WHERE device_id = %s',
|
||||
(_STATUS_CHECKED_OUT, device_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -33,18 +33,56 @@ _FILTER = {'available': STATUS_CHECKED_IN, 'checkedout': STATUS_CHECKED_OUT, 're
|
||||
|
||||
|
||||
def _resolve_name(sso):
|
||||
"""Best-effort name from the self-hosted employee directory."""
|
||||
"""Best-effort name from the self-hosted employee directory.
|
||||
|
||||
The badge goes through the shared shape rule first. This compared the raw
|
||||
string, so the PayNo wrapper some scanners emit (0<digits>BZ) resolved to
|
||||
nobody and the kiosk answered "Badge not recognized - see IT" - for a badge
|
||||
the parts kiosk a few metres away accepted. It also used str.isdigit(),
|
||||
true for Arabic-Indic digits, so a badge in those characters reached int()
|
||||
and matched a real employee.
|
||||
"""
|
||||
from shopdb.api import BadgeShapeError, normalise_badge
|
||||
|
||||
try:
|
||||
digits = normalise_badge(sso)
|
||||
except BadgeShapeError:
|
||||
return None
|
||||
try:
|
||||
from plugins.employees.models import DirectoryEmployee
|
||||
if sso and str(sso).isdigit():
|
||||
emp = db.session.get(DirectoryEmployee, int(sso))
|
||||
if emp:
|
||||
return f'{emp.firstname} {emp.lastname}'.strip()
|
||||
emp = db.session.get(DirectoryEmployee, int(digits))
|
||||
if emp:
|
||||
return f'{emp.firstname} {emp.lastname}'.strip()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _kiosk_badge_digits(raw):
|
||||
"""Return (digits, errorresponse) for a scanned badge.
|
||||
|
||||
Every kiosk write stores the DIGITS rather than the string that arrived:
|
||||
the PayNo wrapper (0<digits>BZ) and the bare number are the same person,
|
||||
and recording one row under each spelling makes a custody history that
|
||||
cannot be joined to itself.
|
||||
|
||||
An empty scan and an unreadable one get different answers, because they
|
||||
are different problems for the person standing at the cabinet: one has not
|
||||
scanned yet, the other has scanned something this site does not know.
|
||||
"""
|
||||
from shopdb.api import BadgeShapeError, normalise_badge
|
||||
|
||||
if not (raw or '').strip():
|
||||
return None, error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'Scan your badge')
|
||||
try:
|
||||
return normalise_badge(raw), None
|
||||
except BadgeShapeError:
|
||||
return None, error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Badge not recognized - see IT', http_code=422)
|
||||
|
||||
|
||||
def _status(device):
|
||||
if not device.isactive:
|
||||
return STATUS_RETIRED
|
||||
@@ -307,9 +345,9 @@ def kiosk_device(device_id):
|
||||
|
||||
def kiosk_session(data):
|
||||
"""See routes.kiosk_session for why the list costs a valid badge."""
|
||||
badge = (data.get('badge') or '').strip()
|
||||
if not badge:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'Scan your badge')
|
||||
badge, badgeerror = _kiosk_badge_digits(data.get('badge'))
|
||||
if badgeerror:
|
||||
return badgeerror
|
||||
name = _resolve_name(badge)
|
||||
if not name:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
@@ -335,9 +373,9 @@ def kiosk_session(data):
|
||||
|
||||
|
||||
def kiosk_others(data):
|
||||
badge = (data.get('badge') or '').strip()
|
||||
if not badge:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'Scan your badge')
|
||||
badge, badgeerror = _kiosk_badge_digits(data.get('badge'))
|
||||
if badgeerror:
|
||||
return badgeerror
|
||||
if not _resolve_name(badge):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'Badge not recognized - see IT', http_code=422)
|
||||
@@ -358,9 +396,9 @@ def kiosk_others(data):
|
||||
|
||||
def kiosk_checkout(data):
|
||||
device_id = (data.get('device_id') or '').strip()
|
||||
badge = (data.get('badge') or '').strip()
|
||||
if not badge:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'Scan your badge')
|
||||
badge, badgeerror = _kiosk_badge_digits(data.get('badge'))
|
||||
if badgeerror:
|
||||
return badgeerror
|
||||
|
||||
device = _find(device_id)
|
||||
if not device:
|
||||
@@ -387,12 +425,34 @@ def kiosk_checkout(data):
|
||||
http_code=422)
|
||||
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
# The transition IS the guard. The ischeckedout test above reads, decides
|
||||
# in Python and then writes, so two kiosk scans of the same drive that
|
||||
# interleave both read "available" and both insert a custody row - one
|
||||
# drive recorded out to two people, and the documented 409 never fires.
|
||||
# Making the flag part of the WHERE clause moves the decision into the
|
||||
# write, where the database settles it: the second UPDATE matches no row.
|
||||
claimed = USBDevice.query.filter(
|
||||
USBDevice.usbdeviceid == device.usbdeviceid,
|
||||
USBDevice.ischeckedout == False).update(
|
||||
{USBDevice.ischeckedout: True,
|
||||
USBDevice.currentuserid: badge,
|
||||
USBDevice.currentusername: name,
|
||||
USBDevice.currentcheckoutdate: now},
|
||||
synchronize_session=False)
|
||||
if not claimed:
|
||||
db.session.rollback()
|
||||
# Re-read the row rather than refreshing the object this request is
|
||||
# holding: the whole point of arriving here is that the object is
|
||||
# stale, and the caller deserves the name of whoever actually has it.
|
||||
holder = db.session.query(USBDevice.currentusername).filter(
|
||||
USBDevice.usbdeviceid == device.usbdeviceid).scalar()
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
'That device is already checked out{}'.format(
|
||||
' to {}'.format(holder) if holder else ''),
|
||||
http_code=409)
|
||||
db.session.add(USBCheckout(usbdeviceid=device.usbdeviceid, sso=badge,
|
||||
checkoutname=name, checkouttime=now))
|
||||
device.ischeckedout = True
|
||||
device.currentuserid = badge
|
||||
device.currentusername = name
|
||||
device.currentcheckoutdate = now
|
||||
db.session.commit()
|
||||
return success_response({'device_id': device.serialnumber,
|
||||
'holdername': name},
|
||||
@@ -402,9 +462,9 @@ def kiosk_checkout(data):
|
||||
|
||||
def kiosk_checkin(data):
|
||||
device_id = (data.get('device_id') or '').strip()
|
||||
badge = (data.get('badge') or '').strip()
|
||||
if not badge:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'Scan your badge')
|
||||
badge, badgeerror = _kiosk_badge_digits(data.get('badge'))
|
||||
if badgeerror:
|
||||
return badgeerror
|
||||
forsomeoneelse = bool(data.get('forsomeoneelse'))
|
||||
|
||||
device = _find(device_id)
|
||||
@@ -439,15 +499,38 @@ def kiosk_checkin(data):
|
||||
'holdername': device.currentusername or ''},
|
||||
http_code=409)
|
||||
|
||||
open_checkout = (USBCheckout.query
|
||||
.filter_by(usbdeviceid=device.usbdeviceid, checkintime=None)
|
||||
.order_by(USBCheckout.checkouttime.desc()).first())
|
||||
if open_checkout:
|
||||
open_checkout.checkintime = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
device.ischeckedout = False
|
||||
device.currentuserid = None
|
||||
device.currentusername = None
|
||||
device.currentcheckoutdate = None
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
# Guarded the same way as the checkout: only a device that is still out can
|
||||
# be brought back, and the database decides that, not a Python read from
|
||||
# earlier in this request.
|
||||
released = USBDevice.query.filter(
|
||||
USBDevice.usbdeviceid == device.usbdeviceid,
|
||||
USBDevice.ischeckedout == True).update(
|
||||
{USBDevice.ischeckedout: False,
|
||||
USBDevice.currentuserid: None,
|
||||
USBDevice.currentusername: None,
|
||||
USBDevice.currentcheckoutdate: None},
|
||||
synchronize_session=False)
|
||||
if not released:
|
||||
db.session.rollback()
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'That device is not checked out')
|
||||
|
||||
# EVERY open row, not just the newest. One device can only be out once, so
|
||||
# a second open row is corruption - closing only the newest one left the
|
||||
# older ones open forever, and the device then reads as returned while its
|
||||
# custody history says otherwise.
|
||||
#
|
||||
# checkinsso/checkinname record WHO HANDED IT BACK, which is not always the
|
||||
# holder: returning for a colleague is a supported flow, and attributing
|
||||
# the check-in to the holder made the audit trail name somebody who was not
|
||||
# there.
|
||||
(USBCheckout.query
|
||||
.filter_by(usbdeviceid=device.usbdeviceid, checkintime=None)
|
||||
.update({USBCheckout.checkintime: now,
|
||||
USBCheckout.checkinsso: badge,
|
||||
USBCheckout.checkinname: name},
|
||||
synchronize_session=False))
|
||||
db.session.commit()
|
||||
return success_response({'device_id': device.serialnumber},
|
||||
message='{} checked in'.format(device.serialnumber))
|
||||
|
||||
59
plugins/usb/migrations/versions/0004_checkin_identity.py
Normal file
59
plugins/usb/migrations/versions/0004_checkin_identity.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Record who handed a drive back, not just when.
|
||||
|
||||
usbcheckouts held the checkout identity (sso, checkoutname) and, for the
|
||||
return, only checkintime. Returning a drive for a colleague is a supported
|
||||
kiosk flow - usb_kiosk_allow_return_for_others gates it and the kiosk asks for
|
||||
confirmation - but the row it wrote attributed the check-in to the HOLDER. The
|
||||
person who actually walked the drive back was recorded nowhere, so the custody
|
||||
history and the audit report both named somebody who was not there.
|
||||
|
||||
Two columns rather than one: the badge is what the scanner emits and what joins
|
||||
to a directory, and the name is what a report has to print when the directory
|
||||
no longer resolves that badge.
|
||||
|
||||
Nullable, and not backfilled. A row closed before this column existed is one
|
||||
where the returner was not recorded; copying the holder's badge into it would
|
||||
manufacture evidence that the holder returned it, which is the exact claim this
|
||||
migration exists to stop making.
|
||||
|
||||
Idempotent, so a re-run on an already-migrated database is a no-op.
|
||||
|
||||
Revision ID: usb0004checkinidentity
|
||||
Revises: usb0003scanfields
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = 'usb0004checkinidentity'
|
||||
down_revision = 'usb0003scanfields'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
NEW_COLUMNS = (
|
||||
('checkinsso', sa.String(length=20)),
|
||||
('checkinname', sa.String(length=100)),
|
||||
)
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
insp = sa.inspect(bind)
|
||||
if 'usbcheckouts' not in insp.get_table_names():
|
||||
return
|
||||
cols = {c['name'] for c in insp.get_columns('usbcheckouts')}
|
||||
for name, coltype in NEW_COLUMNS:
|
||||
if name not in cols:
|
||||
op.add_column('usbcheckouts',
|
||||
sa.Column(name, coltype, nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
insp = sa.inspect(bind)
|
||||
if 'usbcheckouts' not in insp.get_table_names():
|
||||
return
|
||||
cols = {c['name'] for c in insp.get_columns('usbcheckouts')}
|
||||
for name, _ in NEW_COLUMNS:
|
||||
if name in cols:
|
||||
op.drop_column('usbcheckouts', name)
|
||||
@@ -128,6 +128,13 @@ class USBCheckout(BaseModel):
|
||||
# Checkout details
|
||||
checkouttime = db.Column(db.DateTime, nullable=False, default=_utcnow)
|
||||
checkintime = db.Column(db.DateTime, nullable=True)
|
||||
# WHO handed it back. Not always the holder: returning for a colleague is a
|
||||
# supported flow, and recording the check-in against the holder named
|
||||
# somebody who was not there. See migration usb0004checkinidentity.
|
||||
checkinsso = db.Column(db.String(20), nullable=True,
|
||||
comment='SSO of whoever returned it')
|
||||
checkinname = db.Column(db.String(100), nullable=True,
|
||||
comment='Name of whoever returned it')
|
||||
|
||||
# Metadata
|
||||
checkoutreason = db.Column(db.Text, nullable=True, comment='Reason for checkout')
|
||||
|
||||
@@ -19,6 +19,7 @@ from shopdb.api import (
|
||||
db, Asset,
|
||||
success_response, error_response, ErrorCodes,
|
||||
require_permission,
|
||||
DateValueError, parse_date, parse_date_fields,
|
||||
)
|
||||
|
||||
from ..models import Warranty, WarrantyAsset
|
||||
@@ -42,13 +43,16 @@ def _proof_dir():
|
||||
return os.path.join(current_app.instance_path, 'warrantyproofs')
|
||||
|
||||
|
||||
def _parse_date(value):
|
||||
"""Accept 'YYYY-MM-DD' (or None/empty) -> date or None."""
|
||||
if not value:
|
||||
return None
|
||||
def _vendor_date(value):
|
||||
"""A date off a VENDOR response, which this code does not control.
|
||||
|
||||
Kept lenient on purpose, unlike a date a person typed: a provider that
|
||||
starts returning a shape we do not parse must not fail the refresh of an
|
||||
otherwise good record. The field is simply left as it was.
|
||||
"""
|
||||
try:
|
||||
return datetime.strptime(value[:10], '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
return parse_date(value)
|
||||
except DateValueError:
|
||||
return None
|
||||
|
||||
|
||||
@@ -203,13 +207,20 @@ def create_warranty():
|
||||
vendor = (data.get('vendor') or '').strip()
|
||||
if not vendor:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'vendor is required')
|
||||
# Refused, not nulled. A mistyped end date used to store NULL and answer
|
||||
# 200, which dropped the warranty out of every expiry surface on the
|
||||
# strength of a typo.
|
||||
try:
|
||||
dates = parse_date_fields(data, ('startdate', 'enddate'))
|
||||
except DateValueError as bad:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(bad))
|
||||
warranty = Warranty(
|
||||
vendor=vendor,
|
||||
servicetag=(data.get('servicetag') or '').strip() or None,
|
||||
provider=(data.get('provider') or 'manual').strip().lower(),
|
||||
servicelevel=(data.get('servicelevel') or '').strip() or None,
|
||||
startdate=_parse_date(data.get('startdate')),
|
||||
enddate=_parse_date(data.get('enddate')),
|
||||
startdate=dates.get('startdate'),
|
||||
enddate=dates.get('enddate'),
|
||||
notes=(data.get('notes') or '').strip() or None,
|
||||
)
|
||||
_apply_links(warranty, data.get('assetids'))
|
||||
@@ -225,6 +236,10 @@ def update_warranty(warrantyid):
|
||||
if not warranty:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Warranty not found', http_code=404)
|
||||
data = request.get_json() or {}
|
||||
try:
|
||||
dates = parse_date_fields(data, ('startdate', 'enddate'))
|
||||
except DateValueError as bad:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(bad))
|
||||
if 'vendor' in data:
|
||||
warranty.vendor = (data['vendor'] or '').strip() or warranty.vendor
|
||||
if 'servicetag' in data:
|
||||
@@ -233,10 +248,8 @@ def update_warranty(warrantyid):
|
||||
warranty.provider = (data['provider'] or 'manual').strip().lower()
|
||||
if 'servicelevel' in data:
|
||||
warranty.servicelevel = (data['servicelevel'] or '').strip() or None
|
||||
if 'startdate' in data:
|
||||
warranty.startdate = _parse_date(data['startdate'])
|
||||
if 'enddate' in data:
|
||||
warranty.enddate = _parse_date(data['enddate'])
|
||||
for field, parsed in dates.items():
|
||||
setattr(warranty, field, parsed)
|
||||
if 'notes' in data:
|
||||
warranty.notes = (data['notes'] or '').strip() or None
|
||||
if 'isactive' in data:
|
||||
@@ -279,9 +292,9 @@ def refresh_warranty(warrantyid):
|
||||
if result.get('servicelevel'):
|
||||
warranty.servicelevel = result['servicelevel']
|
||||
if result.get('startdate'):
|
||||
warranty.startdate = _parse_date(result['startdate'])
|
||||
warranty.startdate = _vendor_date(result['startdate'])
|
||||
if result.get('enddate'):
|
||||
warranty.enddate = _parse_date(result['enddate'])
|
||||
warranty.enddate = _vendor_date(result['enddate'])
|
||||
warranty.lastcheckeddate = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
db.session.commit()
|
||||
return success_response(_warranty_payload(warranty), message='Warranty refreshed')
|
||||
@@ -363,8 +376,8 @@ def sync_dell():
|
||||
existing.provider = 'dell'
|
||||
existing.vendor = existing.vendor or 'Dell'
|
||||
existing.servicelevel = found.get('servicelevel')
|
||||
existing.startdate = _parse_date(found.get('startdate'))
|
||||
existing.enddate = _parse_date(found.get('enddate'))
|
||||
existing.startdate = _vendor_date(found.get('startdate'))
|
||||
existing.enddate = _vendor_date(found.get('enddate'))
|
||||
existing.servicetag = tag
|
||||
existing.lastcheckeddate = now
|
||||
updated += 1
|
||||
@@ -372,8 +385,8 @@ def sync_dell():
|
||||
warranty = Warranty(
|
||||
vendor='Dell', provider='dell', servicetag=tag,
|
||||
servicelevel=found.get('servicelevel'),
|
||||
startdate=_parse_date(found.get('startdate')),
|
||||
enddate=_parse_date(found.get('enddate')),
|
||||
startdate=_vendor_date(found.get('startdate')),
|
||||
enddate=_vendor_date(found.get('enddate')),
|
||||
lastcheckeddate=now,
|
||||
)
|
||||
warranty.links.append(WarrantyAsset(assetid=assetid))
|
||||
|
||||
Reference in New Issue
Block a user