Blank asset numbers, contested device claims, and honest toner forecasting
This commit is contained in:
@@ -639,6 +639,17 @@ def update_computer(computer_id: int):
|
||||
|
||||
asset = comp.asset
|
||||
|
||||
# An UPDATE may not blank the asset number. Create validates it and the
|
||||
# column is NOT NULL, but the conflict check below only runs when the value
|
||||
# DIFFERS, and '' never collides with anything - so a payload carrying an
|
||||
# empty assetnumber wrote it straight through. A form that loaded blank
|
||||
# (a failed reference load, a partial fetch) then saved the blank over a
|
||||
# real record.
|
||||
if 'assetnumber' in data and not (data['assetnumber'] or '').strip():
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'assetnumber cannot be empty')
|
||||
|
||||
# Check for conflicting assetnumber
|
||||
if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber:
|
||||
if Asset.query.filter_by(assetnumber=data['assetnumber']).first():
|
||||
|
||||
@@ -790,7 +790,7 @@ class ComputersPlugin(BasePlugin):
|
||||
return bool(row) and row[0] == spec['typename']
|
||||
|
||||
def _ensure_device_rows(self, deviceasset, spec, pcasset, controls, label,
|
||||
warnings):
|
||||
warnings, active=True):
|
||||
"""Get-or-create the extension row and the PC -> device control link.
|
||||
|
||||
Shared by the named-device and minted paths: an adopted asset may have
|
||||
@@ -819,7 +819,7 @@ class ComputersPlugin(BasePlugin):
|
||||
targetassetid=deviceasset.assetid,
|
||||
relationshiptypeid=controls.relationshiptypeid).first()
|
||||
if controlrow is not None:
|
||||
controlrow.isactive = True
|
||||
controlrow.isactive = active
|
||||
if not controlrow.label:
|
||||
controlrow.label = label
|
||||
else:
|
||||
@@ -828,7 +828,58 @@ class ComputersPlugin(BasePlugin):
|
||||
targetassetid=deviceasset.assetid,
|
||||
relationshiptypeid=controls.relationshiptypeid,
|
||||
label=label,
|
||||
isactive=True))
|
||||
isactive=active))
|
||||
|
||||
def _device_incumbents_yield(self, deviceasset, pcasset, label, warnings):
|
||||
"""Settle a device already controlled by a DIFFERENT PC.
|
||||
|
||||
A physical instrument or marker hangs off one PC. Before this, both
|
||||
paths only ever looked at links whose source was THIS PC, so a bay
|
||||
naming a device another PC still holds produced two active `controls`
|
||||
rows with no warning - the replaced PC kept its link forever, and a
|
||||
file copied to a second bay claimed the device silently.
|
||||
|
||||
The machine link settled this years ago and this reuses its rule rather
|
||||
than inventing a second one: an incumbent that has gone quiet past the
|
||||
claim window, or been moved off In Use, has yielded, and its link is
|
||||
ARCHIVED (never deleted, so "which PC drove this in June" stays
|
||||
answerable). An incumbent still alive keeps the device, and the
|
||||
challenger is recorded dormant instead of contesting a link that can
|
||||
only have one holder.
|
||||
|
||||
Returns True when this PC may hold the device actively.
|
||||
"""
|
||||
from shopdb.api import AssetRelationship, RelationshipType, Asset
|
||||
|
||||
controls = RelationshipType.query.filter_by(
|
||||
relationshiptype='controls').first()
|
||||
if not controls or not deviceasset or not pcasset:
|
||||
return True
|
||||
|
||||
others = AssetRelationship.query.filter(
|
||||
AssetRelationship.targetassetid == deviceasset.assetid,
|
||||
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
|
||||
AssetRelationship.label == label,
|
||||
AssetRelationship.isactive.is_(True),
|
||||
AssetRelationship.sourceassetid != pcasset.assetid,
|
||||
).all()
|
||||
if not others:
|
||||
return True
|
||||
|
||||
holding = []
|
||||
for rel in others:
|
||||
if self._incumbent_has_yielded(rel.sourceassetid):
|
||||
rel.isactive = False
|
||||
else:
|
||||
holder = db.session.get(Asset, rel.sourceassetid)
|
||||
holding.append(holder.assetnumber if holder else rel.sourceassetid)
|
||||
|
||||
if holding:
|
||||
warnings.append(
|
||||
'{} is still controlled by {}; recorded but not activated'
|
||||
.format(deviceasset.assetnumber, ', '.join(str(h) for h in holding)))
|
||||
return False
|
||||
return True
|
||||
|
||||
def _sync_partmarker(self, comp, pctype, machinenumber, warnings,
|
||||
deviceid=None):
|
||||
@@ -892,6 +943,16 @@ class ComputersPlugin(BasePlugin):
|
||||
label = spec['label']
|
||||
|
||||
# --- 1. an explicitly named device wins over everything -------------
|
||||
#
|
||||
# NAMING A DEVICE IS A COMMITMENT. If the bay says which device it
|
||||
# drives and that name does not resolve, the answer is to link nothing
|
||||
# and say so - NOT to fall through and mint <HOST>-PARTMARKER, which is
|
||||
# the hostname-derived twin this whole path exists to prevent. The
|
||||
# warning used to be true about the typo'd number and false about the
|
||||
# twin: it warned, then minted anyway.
|
||||
#
|
||||
# Silence still means "work it out", so a bay with no file keeps the
|
||||
# reuse-then-mint behaviour it has always had.
|
||||
named = (deviceid or '').strip()
|
||||
namedasset = None
|
||||
if named:
|
||||
@@ -901,15 +962,16 @@ class ComputersPlugin(BasePlugin):
|
||||
# device that nobody can account for.
|
||||
warnings.append(
|
||||
'no asset for device {!r}; not linked'.format(named))
|
||||
elif not self._is_device_of_type(candidate, spec):
|
||||
return []
|
||||
if not self._is_device_of_type(candidate, spec):
|
||||
# The name resolved, but not to this pc-type's device. Refuse
|
||||
# and say which asset it hit, rather than filing an unrelated
|
||||
# asset under a device label the collector also owns.
|
||||
warnings.append(
|
||||
'asset {!r} is not a {}; not linked'.format(
|
||||
candidate.assetnumber, spec['typename']))
|
||||
else:
|
||||
namedasset = candidate
|
||||
return []
|
||||
namedasset = candidate
|
||||
|
||||
existing = AssetRelationship.query.filter(
|
||||
AssetRelationship.sourceassetid == pcasset.assetid,
|
||||
@@ -920,6 +982,11 @@ class ComputersPlugin(BasePlugin):
|
||||
or (existing[0] if existing else None)
|
||||
|
||||
if namedasset is not None:
|
||||
# The file is authoritative FOR THIS PC, but the device may still be
|
||||
# held by another one. Settle that first: a yielded incumbent is
|
||||
# archived, a live one keeps it and this link is recorded dormant.
|
||||
mayhold = self._device_incumbents_yield(
|
||||
namedasset, pcasset, label, warnings)
|
||||
# The file is authoritative. A PC that previously minted its own
|
||||
# marker and is now told the real one keeps only the named link;
|
||||
# the stale link is archived by the one-marker-per-PC sweep below.
|
||||
@@ -929,7 +996,7 @@ class ComputersPlugin(BasePlugin):
|
||||
if reuse is not None:
|
||||
reuse.isactive = True
|
||||
self._ensure_device_rows(markerasset, spec, pcasset, controls,
|
||||
label, warnings)
|
||||
label, warnings, active=mayhold)
|
||||
elif reuse:
|
||||
reuse.isactive = True
|
||||
markerasset = db.session.get(Asset, reuse.targetassetid)
|
||||
@@ -1280,10 +1347,14 @@ class ComputersPlugin(BasePlugin):
|
||||
candidate = self._asset_by_number(named)
|
||||
if candidate is None:
|
||||
# Warn rather than invent: a typo in the file must not mint a
|
||||
# phantom instrument that nobody can account for.
|
||||
# phantom instrument that nobody can account for. RETURN, do
|
||||
# not fall through - minting <HOST>-CMM here would create the
|
||||
# hostname-derived twin this resolution order exists to avoid,
|
||||
# while the warning claimed nothing was linked.
|
||||
warnings.append(
|
||||
'no asset for measuring tool {!r}; not linked'.format(named))
|
||||
elif candidate.assetid not in self._measuringtool_assetids():
|
||||
return []
|
||||
if candidate.assetid not in self._measuringtool_assetids():
|
||||
# The name resolved, but not to an instrument. measuringtool-id
|
||||
# .txt holding a machine number would otherwise link the PC to
|
||||
# that MACHINE under a measuring-tool label - a link that reads
|
||||
@@ -1292,8 +1363,8 @@ class ComputersPlugin(BasePlugin):
|
||||
warnings.append(
|
||||
'asset {!r} is not a measuring tool; not linked'.format(
|
||||
candidate.assetnumber))
|
||||
else:
|
||||
adopted = candidate
|
||||
return []
|
||||
adopted = candidate
|
||||
|
||||
# --- 2. a prior collector link (reactivate + retype) -----------------
|
||||
reuse = next((rel for rel in existing if rel.isactive), None) \
|
||||
@@ -1307,6 +1378,11 @@ class ComputersPlugin(BasePlugin):
|
||||
pcasset, controls, machinenumber)
|
||||
|
||||
if adopted is not None:
|
||||
# Settle any OTHER PC still holding this instrument first: a yielded
|
||||
# incumbent is archived, a live one keeps it and this link is
|
||||
# recorded dormant rather than becoming a second active holder.
|
||||
mayhold = self._device_incumbents_yield(
|
||||
adopted, pcasset, MEASURINGTOOL_LINK_ORIGIN, warnings)
|
||||
# CLAIM the existing link rather than adding a second one. A row for
|
||||
# (pc, tool, controls) usually already exists - that is how the tool
|
||||
# was found - and assetrelationships is unique on exactly that
|
||||
@@ -1330,13 +1406,14 @@ class ComputersPlugin(BasePlugin):
|
||||
# is ours from here, so a later cycle reuses it instead of
|
||||
# minting.
|
||||
claimed.label = MEASURINGTOOL_LINK_ORIGIN
|
||||
claimed.isactive = True
|
||||
claimed.isactive = mayhold
|
||||
else:
|
||||
db.session.add(AssetRelationship(
|
||||
sourceassetid=pcasset.assetid,
|
||||
targetassetid=adopted.assetid,
|
||||
relationshiptypeid=controls.relationshiptypeid,
|
||||
label=MEASURINGTOOL_LINK_ORIGIN))
|
||||
label=MEASURINGTOOL_LINK_ORIGIN,
|
||||
isactive=mayhold))
|
||||
if adopted.measuringtool and tooltype:
|
||||
adopted.measuringtool.measuringtooltypeid = \
|
||||
tooltype.measuringtooltypeid
|
||||
|
||||
Reference in New Issue
Block a user