relationships: refuse links that cannot both be true, and report the ones already stored
Prod grew rows saying both "PC controls 2005" and "2005 controls PC", and a CMM PC showing "<- controls from CMM4" beside its own outgoing link. Only one direction can be true: a PC drives a machine, never the reverse. Nothing stopped it. The duplicate check was keyed on (source, target, type), so the inverse inserted cleanly, and the Add Relationship dialog offers an incoming direction that writes exactly that. The legacy import stores controls the wrong way round as well. Directional creates now refuse the reverse with a 409 naming the row that already holds it, and refuse self-links, which render as a duplicate on the asset's own page and mean nothing. Symmetric types are exempt: Dualpath stores both directions on purpose and the card collapses them. The propagation fan-out got the same guard so a rail meant to spread one direction across sibling bays cannot manufacture a pair. fix-controls-direction only matched source assettype 'machine', so every measuring_tool, printer and network_device row it was written to clean survived it - which is why running it would never have fixed the CMM. It now matches any non-computer controlled BY a computer. New `flask relationships audit` reports what is already stored: reciprocal pairs, self-links, and PCs controlling several assets of one type. Read-only, and it prints each row's label because that usually names the writer outright - collector:* means this code made it, anything else means a person or the import did. That distinction decides the fix for duplicate device assets, which is not in this commit: the collector keys idempotency on its own label, so a device somebody created by hand is invisible to it and it mints another, and the adoption rule needs the audit run against prod before it can be written. Two false positives were found writing it, against the dev database, and both would have made the report useless. A self-link is its own inverse, so it was counted as a reciprocal pair AND printed twice. And Dualpath siblings looked like duplicate devices - a dual-bay machine is one physical machine with one controller and controls is propagated to both bays deliberately. That was 30 of 32 findings, consecutive bay numbers pair by pair.
This commit is contained in:
@@ -680,14 +680,18 @@ def propagate_relationships():
|
||||
@relationships_cli.command('fix-controls-direction')
|
||||
@with_appcontext
|
||||
def fix_controls_direction():
|
||||
"""Flip reversed legacy controls rows to PC -> machine.
|
||||
"""Flip reversed legacy controls rows so the PC is the source.
|
||||
|
||||
Legacy import stores controls as machine -> PC, which reads as the machine
|
||||
Legacy import stores controls as device -> PC, which reads as the device
|
||||
having authority over the PC. In reality the PC is the controller (it
|
||||
sends programs to the machine and receives logs), so per ADR-001 the PC
|
||||
must be the source. Flips every active controls row whose source is a
|
||||
machine asset and target is a computer asset. If the flipped row already
|
||||
exists, the reversed duplicate is deactivated instead. Idempotent.
|
||||
sends programs to the device and receives logs), so per ADR-001 the PC
|
||||
must be the source. Flips every active controls row whose target is a
|
||||
computer and whose source is not. If the flipped row already exists, the
|
||||
reversed duplicate is deactivated instead. Idempotent.
|
||||
|
||||
Covers machines, measuring tools, printers and network devices. It used to
|
||||
match machines ONLY, so a CMM PC kept showing "<- controls from CMM4"
|
||||
alongside its own outgoing link and no amount of running this fixed it.
|
||||
"""
|
||||
from sqlalchemy.orm import aliased
|
||||
from shopdb.extensions import db
|
||||
@@ -713,7 +717,13 @@ def fix_controls_direction():
|
||||
.filter(
|
||||
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
|
||||
AssetRelationship.isactive == True,
|
||||
sourcetype.assettype == 'machine',
|
||||
# ANY non-computer controlled BY a computer, not just machines. The
|
||||
# original filter said assettype == 'machine', which left every
|
||||
# measuring_tool -> computer row untouched - a CMM PC showing
|
||||
# "<- controls from CMM4" beside its own outgoing link, which is
|
||||
# exactly the shape this command exists to clean. Printers and
|
||||
# network devices reach shopdb the same way and had the same gap.
|
||||
sourcetype.assettype != 'computer',
|
||||
targettype.assettype == 'computer',
|
||||
)
|
||||
.all()
|
||||
@@ -1302,3 +1312,195 @@ def check_shared_machines():
|
||||
'device becomes its own asset.')
|
||||
else:
|
||||
click.echo(click.style('Every shared number has child assets.', fg='green'))
|
||||
|
||||
|
||||
@relationships_cli.command('audit')
|
||||
@with_appcontext
|
||||
def audit_relationships():
|
||||
"""Report relationship rows that cannot all be true. Read-only.
|
||||
|
||||
Three faults share one symptom - an asset page listing the same peer more
|
||||
than once - and they need different fixes, so this names which is which.
|
||||
|
||||
1. RECIPROCAL PAIRS. Both `A controls B` and `B controls A` exist. Only one
|
||||
can be true: a PC drives a machine, never the reverse. The create path
|
||||
only ever checked (source, target, type), so the inverse inserted
|
||||
cleanly, and the Add Relationship dialog offers an `incoming` direction
|
||||
that writes exactly that. The legacy import stores controls the wrong way
|
||||
round as well. `flask relationships fix-controls-direction` cleans the
|
||||
machine->computer ones; anything else is listed here for a decision.
|
||||
|
||||
2. SELF-LINKS. An asset pointing at ITSELF, which renders as a duplicate on
|
||||
its own page and is never meaningful.
|
||||
|
||||
3. DUPLICATE DEVICES. One PC controlling several assets of the same type -
|
||||
three measuring tools for one physical CMM, say. The collector keys its
|
||||
idempotency on its own label, so a device somebody created by hand, or
|
||||
the legacy import created, is invisible to it and it mints another one on
|
||||
every fresh PC.
|
||||
|
||||
The `label` on each row usually names the writer outright, so it is printed:
|
||||
`collector:*` means this code made it, anything else means a person or the
|
||||
import did. Nothing is modified - decide from the output which side of a
|
||||
pair is authoritative before deleting anything.
|
||||
"""
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import Asset, AssetType
|
||||
from shopdb.core.models.relationship import AssetRelationship, RelationshipType
|
||||
from sqlalchemy.orm import aliased
|
||||
from collections import defaultdict
|
||||
|
||||
faults = 0
|
||||
|
||||
def assetlabel(asset):
|
||||
if not asset:
|
||||
return '?'
|
||||
return f'{asset.assetnumber or asset.name or asset.assetid}'
|
||||
|
||||
# ---- 1. reciprocal pairs (directional types only) ----------------------
|
||||
# Symmetric types store both directions ON PURPOSE, so they are excluded -
|
||||
# flagging them would bury the real faults in noise.
|
||||
directional = {t.relationshiptypeid: t.relationshiptype
|
||||
for t in RelationshipType.query.all() if t.isdirectional}
|
||||
rows = (AssetRelationship.query
|
||||
.filter(AssetRelationship.isactive.is_(True),
|
||||
AssetRelationship.relationshiptypeid.in_(directional or [0]))
|
||||
.all()) if directional else []
|
||||
|
||||
bykey = {}
|
||||
for rel in rows:
|
||||
bykey[(rel.sourceassetid, rel.targetassetid, rel.relationshiptypeid)] = rel
|
||||
|
||||
seen = set()
|
||||
reciprocal = []
|
||||
for (source, target, typeid), rel in bykey.items():
|
||||
# A self-link is its own inverse. It is a fault, but a DIFFERENT one,
|
||||
# reported below - counting it here would double-report it and print
|
||||
# the same row twice as though it were a pair.
|
||||
if source == target:
|
||||
continue
|
||||
inverse = bykey.get((target, source, typeid))
|
||||
if inverse and (target, source, typeid) not in seen:
|
||||
seen.add((source, target, typeid))
|
||||
reciprocal.append((rel, inverse))
|
||||
|
||||
click.echo(click.style('\n== Reciprocal pairs (both directions stored) ==',
|
||||
bold=True))
|
||||
if reciprocal:
|
||||
faults += len(reciprocal)
|
||||
for rel, inverse in reciprocal:
|
||||
typename = directional.get(rel.relationshiptypeid, '?')
|
||||
a = db.session.get(Asset, rel.sourceassetid)
|
||||
b = db.session.get(Asset, rel.targetassetid)
|
||||
click.echo(f' {assetlabel(a)} -{typename}-> {assetlabel(b)} '
|
||||
f'[id {rel.relationshipid}, label={rel.label or "-"}]')
|
||||
click.echo(f' {assetlabel(b)} -{typename}-> {assetlabel(a)} '
|
||||
f'[id {inverse.relationshipid}, label={inverse.label or "-"}]')
|
||||
click.echo('')
|
||||
click.echo(click.style(
|
||||
f' {len(reciprocal)} pair(s). Only one direction can be true.',
|
||||
fg='yellow'))
|
||||
else:
|
||||
click.echo(click.style(' none', fg='green'))
|
||||
|
||||
# ---- 2. self-links ------------------------------------------------------
|
||||
selflinks = (AssetRelationship.query
|
||||
.filter(AssetRelationship.isactive.is_(True),
|
||||
AssetRelationship.sourceassetid
|
||||
== AssetRelationship.targetassetid)
|
||||
.all())
|
||||
click.echo(click.style('\n== Self-links (asset pointing at itself) ==',
|
||||
bold=True))
|
||||
if selflinks:
|
||||
faults += len(selflinks)
|
||||
for rel in selflinks:
|
||||
asset = db.session.get(Asset, rel.sourceassetid)
|
||||
typename = (rel.relationshiptype.relationshiptype
|
||||
if rel.relationshiptype else '?')
|
||||
click.echo(f' {assetlabel(asset)} -{typename}-> itself '
|
||||
f'[id {rel.relationshipid}, label={rel.label or "-"}]')
|
||||
click.echo(click.style(f' {len(selflinks)} row(s). Never meaningful.',
|
||||
fg='yellow'))
|
||||
else:
|
||||
click.echo(click.style(' none', fg='green'))
|
||||
|
||||
# ---- 3. duplicate devices per PC ---------------------------------------
|
||||
controls = RelationshipType.query.filter_by(relationshiptype='controls').first()
|
||||
click.echo(click.style(
|
||||
'\n== PCs controlling several assets of the SAME type ==', bold=True))
|
||||
if not controls:
|
||||
click.echo(click.style(' no controls type; run flask seed reference-data',
|
||||
fg='yellow'))
|
||||
else:
|
||||
sourceasset = aliased(Asset)
|
||||
targetasset = aliased(Asset)
|
||||
targettype = aliased(AssetType)
|
||||
pairs = (db.session.query(AssetRelationship, sourceasset,
|
||||
targetasset, targettype.assettype)
|
||||
.select_from(AssetRelationship)
|
||||
.join(sourceasset,
|
||||
AssetRelationship.sourceassetid == sourceasset.assetid)
|
||||
.join(targetasset,
|
||||
AssetRelationship.targetassetid == targetasset.assetid)
|
||||
.join(targettype,
|
||||
targetasset.assettypeid == targettype.assettypeid)
|
||||
.filter(AssetRelationship.relationshiptypeid
|
||||
== controls.relationshiptypeid,
|
||||
AssetRelationship.isactive.is_(True))
|
||||
.all())
|
||||
|
||||
# Devices that are SYMMETRIC partners of each other (Dualpath) are one
|
||||
# physical machine with one controller, and `controls` is propagated to
|
||||
# both bays on purpose - see propagate_relationship. Reporting those as
|
||||
# duplicates buries the real faults: on this dev database they are the
|
||||
# overwhelming majority, consecutive bay numbers pair by pair.
|
||||
siblings = set()
|
||||
symmetric = [t.relationshiptypeid for t in RelationshipType.query.all()
|
||||
if not t.isdirectional]
|
||||
if symmetric:
|
||||
for rel in (AssetRelationship.query
|
||||
.filter(AssetRelationship.isactive.is_(True),
|
||||
AssetRelationship.relationshiptypeid.in_(symmetric))
|
||||
.all()):
|
||||
siblings.add((rel.sourceassetid, rel.targetassetid))
|
||||
siblings.add((rel.targetassetid, rel.sourceassetid))
|
||||
|
||||
def all_siblings(deviceids):
|
||||
return all((a, b) in siblings
|
||||
for index, a in enumerate(deviceids)
|
||||
for b in deviceids[index + 1:])
|
||||
|
||||
grouped = defaultdict(list)
|
||||
for rel, pcasset, device, devicetype in pairs:
|
||||
grouped[(pcasset.assetid, devicetype)].append((rel, pcasset, device))
|
||||
|
||||
dupes = {key: value for key, value in grouped.items()
|
||||
if len(value) > 1
|
||||
and not all_siblings([item[2].assetid for item in value])}
|
||||
if dupes:
|
||||
faults += len(dupes)
|
||||
for (pcassetid, devicetype), items in sorted(
|
||||
dupes.items(), key=lambda kv: -len(kv[1])):
|
||||
pcasset = items[0][1]
|
||||
click.echo(f' {assetlabel(pcasset)} controls '
|
||||
f'{len(items)} x {devicetype}:')
|
||||
for rel, _, device in items:
|
||||
click.echo(f' {assetlabel(device)}'
|
||||
f' (name={device.name or "-"})'
|
||||
f' [id {rel.relationshipid}, '
|
||||
f'label={rel.label or "-"}]')
|
||||
click.echo(click.style(
|
||||
f' {len(dupes)} PC/type group(s). A label of "-" or a '
|
||||
'non-collector value means the collector cannot see that row '
|
||||
'and will keep minting its own.', fg='yellow'))
|
||||
else:
|
||||
click.echo(click.style(' none', fg='green'))
|
||||
|
||||
click.echo('')
|
||||
if faults:
|
||||
click.echo(click.style(f'{faults} fault group(s) found. Nothing was '
|
||||
'changed.', fg='yellow'))
|
||||
click.echo('Do NOT bulk-delete one side of a reciprocal pair before '
|
||||
'knowing which side is authoritative.')
|
||||
else:
|
||||
click.echo(click.style('No relationship faults found.', fg='green'))
|
||||
|
||||
@@ -610,6 +610,12 @@ def _add_propagated(sourceid, targetid, rel, created):
|
||||
return
|
||||
if _relationship_exists(sourceid, targetid, rel.relationshiptypeid):
|
||||
return
|
||||
# Never fan out INTO an existing inverse. The same one-way rule the create
|
||||
# path enforces: if target-T->source is already stored, adding source-T->
|
||||
# target would manufacture a reciprocal pair that no user asked for, on a
|
||||
# rail that is meant to spread one direction across sibling bays.
|
||||
if _relationship_exists(targetid, sourceid, rel.relationshiptypeid):
|
||||
return
|
||||
newrel = AssetRelationship(
|
||||
sourceassetid=sourceid,
|
||||
targetassetid=targetid,
|
||||
@@ -754,6 +760,14 @@ def create_asset_relationship():
|
||||
if not db.session.get(RelationshipType, type_id):
|
||||
return error_response(ErrorCodes.NOT_FOUND, f'Relationship type {type_id} not found', http_code=404)
|
||||
|
||||
# An asset cannot relate to itself. It renders as a duplicate row on that
|
||||
# asset's own page (the card lists both ends) and means nothing.
|
||||
if source_id == target_id:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'An asset cannot be related to itself'
|
||||
)
|
||||
|
||||
# Check for duplicate relationship
|
||||
existing = AssetRelationship.query.filter_by(
|
||||
sourceassetid=source_id,
|
||||
@@ -768,6 +782,32 @@ def create_asset_relationship():
|
||||
http_code=409
|
||||
)
|
||||
|
||||
# And it cannot relate BOTH WAYS on a directional type. Only one direction
|
||||
# can be true - a PC drives a machine, never the reverse - but the check
|
||||
# above is keyed on (source, target, type), so the inverse used to insert
|
||||
# cleanly and both rows then rendered on both asset pages. The dialog offers
|
||||
# an `incoming` direction, so this is reachable by hand, and the legacy
|
||||
# import stores controls the wrong way round.
|
||||
#
|
||||
# Symmetric types are exempt: they store both directions ON PURPOSE and the
|
||||
# card collapses them to one entry per peer.
|
||||
reltype = db.session.get(RelationshipType, type_id)
|
||||
if reltype is not None and reltype.isdirectional:
|
||||
inverse = AssetRelationship.query.filter_by(
|
||||
sourceassetid=target_id,
|
||||
targetassetid=source_id,
|
||||
relationshiptypeid=type_id
|
||||
).first()
|
||||
if inverse is not None:
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
f"The reverse of this relationship already exists "
|
||||
f"(relationship {inverse.relationshipid}). A "
|
||||
f"'{reltype.relationshiptype}' link points one way only - "
|
||||
f"remove the existing one first if the direction is wrong.",
|
||||
http_code=409
|
||||
)
|
||||
|
||||
rel = AssetRelationship(
|
||||
sourceassetid=source_id,
|
||||
targetassetid=target_id,
|
||||
|
||||
Reference in New Issue
Block a user