Add two commands for cleaning up computer types
All checks were successful
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 41s
CI / backend (push) Successful in 4m10s

Twenty computer types existed and six had a machine on them. Three of the
empty ones were near-duplicates of live types - Shopfloor beside Shopfloor PC,
CMM beside CMM PC, Engineer Workstation beside Engineer - which is the kind of
list that grows because deleting from it by hand is nerve-wracking.

retype-by-role types a PC by WHAT IT CONTROLS. A metrology box has an active
relationship to a measuring_tool asset, so the fact is already recorded and
does not need inferring from a hostname or a tool's name. The imaging scope is
the authoritative source and stays so, but only 17 of roughly 292 PCs have
ever reported one, so it cannot answer for the fleet yet. Relationships can:
49 PCs drive a measuring tool, and 36 of them are currently typed as ordinary
shopfloor machines.

prune-types deletes a type only when nothing is on it AND the live pctype map
does not name it. The second guard is the one worth having: the map is what
the collector assigns from at check-in, so deleting a mapped type would leave
every future check-in logging 'mapped ComputerType not found' instead of
typing the machine.

Both report by default and write only with --apply, and prune prints why it is
keeping each type it keeps rather than only what it removes.

Also fixes `computers stats`, which counted the computer type named
'Shopfloor' - the EMPTY duplicate - rather than 'Shopfloor PC'. It has been
reporting 0 shopfloor PCs for as long as both rows existed, which is the same
duplication these commands clear up.

Scope: product
This commit is contained in:
cproudlock
2026-08-31 16:51:54 -04:00
parent 1bf4efa9e1
commit 241d8f40fc

View File

@@ -1875,6 +1875,123 @@ class ComputersPlugin(BasePlugin):
for t in types: for t in types:
click.echo(f" [{t.computertypeid}] {t.computertype}") click.echo(f" [{t.computertypeid}] {t.computertype}")
@computerscli.command('retype-by-role')
@click.option('--apply', 'apply_changes', is_flag=True,
help='Actually write. Without this the command only reports.')
@click.option('--typename', default='Metrology PC', show_default=True,
help='ComputerType to assign to PCs that drive a measuring tool.')
def retype_by_role(apply_changes, typename):
"""Type PCs that drive a measuring tool as metrology machines.
WHY THE RELATIONSHIP AND NOT THE NAME. A PC's role is already
recorded in what it controls: a metrology box has an active
relationship to a measuring_tool asset. Matching on hostnames or on
the tool's name would guess; this reads the fact.
The imaging scope (gea-shopfloor-keyence and friends) is the OTHER
source, and it is the authoritative one - but only 17 of ~292 PCs
have ever reported an enforcement scope, so it cannot answer for the
fleet today. Relationships can.
"""
from shopdb.api import Asset
target = ComputerType.query.filter_by(computertype=typename).first()
if not target:
if not apply_changes:
click.echo(click.style(
"Type '%s' does not exist yet; it would be created."
% typename, fg='yellow'))
else:
target = ComputerType(computertype=typename, isactive=True)
db.session.add(target)
db.session.flush()
click.echo("Created ComputerType '%s'." % typename)
rows = db.session.execute(db.text("""
SELECT DISTINCT c.computerid, a.assetnumber, c.hostname,
ct.computertype
FROM computers c
JOIN assets a ON a.assetid = c.assetid
LEFT JOIN computertypes ct ON ct.computertypeid = c.computertypeid
JOIN assetrelationships r ON r.sourceassetid = c.assetid
JOIN assets ta ON ta.assetid = r.targetassetid
JOIN assettypes at ON at.assettypeid = ta.assettypeid
WHERE at.assettype = 'measuring_tool' AND r.isactive = 1
ORDER BY ct.computertype, a.assetnumber
""")).fetchall()
moving = [r for r in rows if (r[3] or '') != typename]
click.echo('%d PC(s) drive a measuring tool; %d would change type.'
% (len(rows), len(moving)))
if not moving:
return
for r in moving:
click.echo(' %-14s %-14s %-16s -> %s'
% (r[1], r[2] or '(no hostname)', r[3] or '(none)', typename))
if not apply_changes:
click.echo(click.style(
'Dry run. Nothing was written. Re-run with --apply.', fg='yellow'))
return
for r in moving:
db.session.get(Computer, r[0]).computertypeid = target.computertypeid
db.session.commit()
click.echo(click.style('Retyped %d PC(s).' % len(moving), fg='green'))
@computerscli.command('prune-types')
@click.option('--apply', 'apply_changes', is_flag=True,
help='Actually delete. Without this the command only reports.')
def prune_types(apply_changes):
"""Delete computer types nothing uses and nothing maps to.
Two guards, because a type is cheap to keep and expensive to delete
by mistake. A type is KEPT when any computer is on it, or when the
live pctype map names it - the map is what the collector assigns
from at check-in, so deleting a mapped type would make every future
check-in log 'mapped ComputerType not found' instead of typing the
machine.
"""
from .pctypemap import pctype_mapping
mapped = {str(v).strip().lower() for v in pctype_mapping().values()}
counts = dict(db.session.execute(db.text("""
SELECT ct.computertypeid, COUNT(c.computerid)
FROM computertypes ct
LEFT JOIN computers c ON c.computertypeid = ct.computertypeid
GROUP BY ct.computertypeid
""")).fetchall())
removable, kept = [], []
for ctype in ComputerType.query.order_by(ComputerType.computertype).all():
inuse = counts.get(ctype.computertypeid, 0)
ismapped = ctype.computertype.strip().lower() in mapped
if inuse:
kept.append((ctype.computertype, '%d computer(s)' % inuse))
elif ismapped:
kept.append((ctype.computertype, 'named in the pctype map'))
else:
removable.append(ctype)
click.echo('KEEPING %d:' % len(kept))
for name, why in kept:
click.echo(' %-24s %s' % (name, why))
click.echo('')
click.echo('DELETING %d:' % len(removable))
for ctype in removable:
click.echo(' %s' % ctype.computertype)
if not removable:
return
if not apply_changes:
click.echo(click.style(
'Dry run. Nothing was deleted. Re-run with --apply.', fg='yellow'))
return
for ctype in removable:
db.session.delete(ctype)
db.session.commit()
click.echo(click.style('Deleted %d type(s).' % len(removable), fg='green'))
@computerscli.command('stats') @computerscli.command('stats')
def stats(): def stats():
"""Show computer statistics.""" """Show computer statistics."""
@@ -1888,8 +2005,10 @@ class ComputersPlugin(BasePlugin):
click.echo(f"Total active computers: {total}") click.echo(f"Total active computers: {total}")
# Shopfloor count (by the Shopfloor computer type) # 'Shopfloor PC' - NOT 'Shopfloor', which is an empty
sf = ComputerType.query.filter_by(computertype='Shopfloor').first() # duplicate this counted for as long as both existed, so the
# number reported was always 0.
sf = ComputerType.query.filter_by(computertype='Shopfloor PC').first()
shopfloor = db.session.query(Computer).join(Asset).filter( shopfloor = db.session.query(Computer).join(Asset).filter(
Asset.isactive == True, Asset.isactive == True,
Computer.computertypeid == (sf.computertypeid if sf else -1) Computer.computertypeid == (sf.computertypeid if sf else -1)