From 331d27e07e6c3ec1ec8bfbb477e45ee096410508 Mon Sep 17 00:00:00 2001 From: Cam Proudlock Date: Mon, 31 Aug 2026 16:52:04 -0400 Subject: [PATCH] Add computer-type cleanup commands --- plugins/computers/plugin.py | 123 +++++++++++++++++++++++++++++++++++- 1 file changed, 121 insertions(+), 2 deletions(-) diff --git a/plugins/computers/plugin.py b/plugins/computers/plugin.py index 30863bff..da4eb5aa 100644 --- a/plugins/computers/plugin.py +++ b/plugins/computers/plugin.py @@ -1875,6 +1875,123 @@ class ComputersPlugin(BasePlugin): for t in types: 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') def stats(): """Show computer statistics.""" @@ -1888,8 +2005,10 @@ class ComputersPlugin(BasePlugin): click.echo(f"Total active computers: {total}") - # Shopfloor count (by the Shopfloor computer type) - sf = ComputerType.query.filter_by(computertype='Shopfloor').first() + # 'Shopfloor PC' - NOT 'Shopfloor', which is an empty + # 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( Asset.isactive == True, Computer.computertypeid == (sf.computertypeid if sf else -1)