Files
shopdb-flask/plugins/backups/services/registry.py
cproudlock efe2f7e6ca
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s
backups: show a kind's panels only when that asset has data
Every machine was getting a Part Marker Configuration panel, and only two of
the 147 known machines are part markers. A kind applies to an asset TYPE, but
whether a given asset ever carries that kind of backup is a property of the
individual machine, so type alone cannot decide what to show.

The generic renderer already handled this: a list panel is visible when it has
rows OR declares empty text. Declaring emptytext on both kinds defeated it and
forced them to render everywhere. emptytext now defaults to None on the base
class, neither bundled kind sets one, and the panel builder OMITS the key
rather than emitting null - a present-but-null 'empty' would still have kept
the panel on screen.

The DNC Info card carried empty text that could never be displayed, since
keyvalue visibility is decided purely on field count. Removed rather than left
to mislead.

A lathe now shows DNC Info and NTLARS history; 0600 and 0614 additionally show
Part Marker once something collects for them; a machine with no NTLARS data
shows no backup panels at all. The emptytext mechanism stays available for a
kind that genuinely wants to say "expected here, nothing yet".
2026-08-07 14:53:37 -04:00

243 lines
9.6 KiB
Python

"""Backup kind registry.
A "kind" is one category of configuration backup (NTLARS/DNC registry, part
marker, UDC, CMM ...). Each kind declares three things the rest of the plugin
needs and nothing else:
where its bytes live 'shopdb' (parsed into payloadjson) or 'share'
(opaque vendor file left on the SFLD share)
how to resolve an asset the collector reports an identifier; the kind turns
it into the assetid the revision belongs to
how to render downloads one or more output formats
Adding a kind is a class plus one REGISTRY entry. Nothing else in the plugin
knows kind names.
"""
import hashlib
import json
from . import ntlars as ntlarscodec
# Default root of the opaque-backup tree on the SFLD share. Connected PCs write
# here directly (they need SFLD creds - a SYSTEM process hitting a UNC path
# without them gets an access-denied that Test-Path reports as "not found").
#
# This is the WEST JEFFERSON path and is only a DEFAULT: the live value is the
# backups_shareroot setting, because a bundled plugin in a multi-site product
# must not hardcode one site's file server.
DEFAULTSHAREROOT = r'\\tsgwp00525.wjs.geaerospace.net\shared\dt\shopfloor\backups'
def canonicalhash(projection):
"""sha256 over the SEMANTIC content only. Dedup key for 'shopdb' kinds.
Deliberately hashes just schema + keys, excluding sourcedialect. The same
settings exported through NTLARS's Save... button and through a scripted
reg export parse to identical keys but differing sourcedialect; hashing the
whole projection would record that as a change and produce a spurious
revision every time the collection route changed. Storage is
dialect-neutral, so the dedup key has to be too.
"""
subset = {
'schema': projection.get('schema'),
'keys': projection.get('keys'),
}
blob = json.dumps(subset, sort_keys=True, separators=(',', ':'))
return hashlib.sha256(blob.encode('utf-8')).hexdigest()
def byteshash(raw):
"""sha256 over raw bytes. Dedup key for 'share' kinds."""
return hashlib.sha256(raw).hexdigest()
class BackupKind:
"""Base class. Subclasses override what applies to them."""
key = None
displayname = None
storagebackend = 'shopdb'
assettypes = ['*']
# Text shown when the kind has no revisions for an asset. None means the
# panel HIDES entirely instead (the generic list renderer shows a panel
# only when it has rows or declares empty text). Default to hiding: a kind
# applies to an asset TYPE, but whether a given asset ever has this kind of
# backup is a property of the individual machine. A part marker panel on
# every one of 144 machines is noise, not information.
emptytext = None
def parse(self, raw):
"""Opaque kinds return None; parseable kinds return the projection."""
return None
def formats(self):
"""Downloadable formats: [{'id','label','ext','mimetype'}]."""
return []
def render(self, projection, formatid, comments=None):
"""Return (bytes, extension, mimetype) for a 'shopdb' kind."""
raise NotImplementedError
def resolveassetid(self, payload):
"""Map a collector payload to the assetid the backup belongs to."""
raise NotImplementedError
def infopanel(self):
"""Optional 'at a glance' card built from this kind's LATEST revision.
Returns an ADR-010 panel dict, or None when the kind has no summary
worth surfacing. Declared by the kind rather than hardcoded in the
plugin so a successor technology (NTLARS/DNC is expected to give way to
Shopfloor Connect) ships its own card by adding a class, without
touching the plugin or the panel wiring.
"""
return None
def buildinfo(self, projection, assetid, **options):
"""Build this kind's info-card payload. Only called when infopanel()."""
return {'fields': [], 'sectioncount': 0}
def sharedir(self, machinetype, identifier, shareroot=None):
"""UNC directory a 'share' kind's files are expected under.
Advisory only - the authoritative path is the sharepath the collector
reports, since the PC is what actually wrote the file. This builds the
conventional location for display and for validating a reported path.
"""
return '{}\\{}\\{}\\{}'.format(
shareroot or DEFAULTSHAREROOT,
machinetype or 'unknown', identifier or 'unknown', self.key)
class NtlarsKind(BackupKind):
"""NTLARS / DNC registry settings.
Collected from the CONTROLLING PC's registry but belongs to the MACHINE:
the settings describe how to talk to that machine's controller, so they
follow the machine when a PC is swapped. resolveassetid therefore keys on
the reported machine number, not the hostname.
"""
key = 'ntlars'
displayname = 'NTLARS / DNC Settings'
storagebackend = 'shopdb'
assettypes = ['machine']
@staticmethod
def embeddedmachineno(projection):
"""The MachineNo NTLARS itself is configured with (General tab)."""
for key in (projection or {}).get('keys', []):
if (key.get('path') or '').lower() == 'general':
entry = (key.get('values') or {}).get('MachineNo') or {}
return str(entry.get('data') or '').strip()
return ''
def parse(self, raw):
"""Parse, then refuse to record an unconfigured NTLARS install.
A freshly imaged PC opens NTLARS with a blank General tab (no MachineNo,
CNC, Host or Interface type) until a tech restores the config. Storing
that would make an empty config the newest revision at exactly the
moment someone needs the last good one - so a blank MachineNo is
rejected as "nothing worth backing up" rather than accepted as a change.
Two of the 320 known-good backups on the share already have this shape.
"""
projection = ntlarscodec.parse(raw)
if not self.embeddedmachineno(projection):
raise ValueError(
'NTLARS General\\MachineNo is empty - unconfigured install, '
'refusing to record it as a revision')
return projection
def formats(self):
return [
{
'id': 'ntlars',
'label': 'NTLARS Load... (.reg)',
'ext': '.reg',
'mimetype': 'application/octet-stream',
'hint': 'Restore with the Load... button in the NTLARS settings dialog.',
},
{
'id': 'wow6432node',
'label': 'Direct reg import (.reg, WOW6432Node)',
'ext': '.reg',
'mimetype': 'application/octet-stream',
'hint': 'Use when importing outside NTLARS on a 64-bit machine.',
},
]
def render(self, projection, formatid, comments=None):
if formatid not in ('ntlars', 'wow6432node'):
raise ValueError('unknown format for ntlars kind: {}'.format(formatid))
raw = ntlarscodec.render(projection, dialect=formatid, comments=comments)
return raw, '.reg', 'application/octet-stream'
def infopanel(self):
return {
'id': 'backups-dncinfo',
'title': 'DNC Info',
'assettypes': ['machine'],
'endpoint': '/api/backups/asset/{assetid}/info?kind=ntlars',
# No 'empty' key: the keyvalue renderer decides visibility purely on
# field count and never displays empty text, so a machine with no
# NTLARS revision simply has no DNC Info card at all.
'render': 'keyvalue',
# Above the history panels: this answers the question a tech
# arrives with, while history is for the rarer restore case.
'position': 38,
}
def buildinfo(self, projection, assetid, **options):
from . import dncinfo
return dncinfo.build(projection, assetid,
partmarkertypes=options.get('partmarkertypes'))
def resolveassetid(self, payload):
from shopdb.api import db, Asset
machinenumber = (payload.get('machinenumber') or '').strip()
if not machinenumber:
return None, 'no machinenumber in payload'
asset = db.session.query(Asset).filter(
Asset.assetnumber == machinenumber).first()
if asset is None:
return None, 'no asset with assetnumber {!r}'.format(machinenumber)
return asset.assetid, None
class PartMarkerKind(BackupKind):
"""Telesis part marker configuration.
Opaque vendor filetype with no useful JSON representation, so ShopDB stores
metadata and a UNC pointer while the file itself stays on the share. The
original filename and extension are preserved because the vendor tool
rejects a renamed file.
"""
key = 'partmarker'
displayname = 'Part Marker Configuration'
storagebackend = 'share'
assettypes = ['machine', 'measuring_tool']
def resolveassetid(self, payload):
from shopdb.api import db, Asset
identifier = (payload.get('machinenumber')
or payload.get('assetnumber') or '').strip()
if not identifier:
return None, 'no machinenumber/assetnumber in payload'
asset = db.session.query(Asset).filter(
Asset.assetnumber == identifier).first()
if asset is None:
return None, 'no asset with assetnumber {!r}'.format(identifier)
return asset.assetid, None
REGISTRY = {k.key: k for k in (NtlarsKind(), PartMarkerKind())}
def getkind(key):
return REGISTRY.get((key or '').strip().lower())