Bring the wilm branch into main: endpoints plugin, agent, policies, CSV import
Ports the corporate-GitHub wilm branch onto main, keeping the two branches
separate. Thirteen upstream commits arrive with their authorship intact; the
review fixes and the portability work below are on top.
What arrives:
endpoints plugin - a ShopDB agent reporting endpoint state, telemetry,
services and interfaces, with a command channel the agent
long-polls. Ingest stays on the generic collector route
(ADR-006); the agent key is a UI-managed single-scope
token whose revocation is an instant kill switch.
computers policies - Configuration and Business Policies (RMM-style), scope
trees validated at write time, actions checked against
the endpoints command allowlist. Feature-gated: every
route 404s while the setting is off, so enabling it can
never half-expose the surface.
asset CSV import - templates, a collector script carrying the site path, and
a validate-then-commit import. A plugin can own a virtual
column plus a post-write row hook, which is how a PC's
machine number becomes a controls link without a
hand-built relationships sheet.
agent/ - the Windows agent and its installer.
Contract 0.21.0 -> 0.24.0, additive throughout: register_import_hook (0.22),
mint/list/revoke_service_token (0.23), collector_authorized (0.24). The stability
table had no rows for any of them and now does.
MySQL 5.6 portability. The branch stored three policy columns as sa.JSON, the
only JSON columns in the product, and 5.6 has no JSON type: the migration failed
with a bare syntax error near the word JSON. They are TEXT now, through a
JSONText type decorator, so all seventeen call sites still read and write dicts.
The convention elsewhere is already TEXT (guides.blocksjson,
geenforce.manifestjson).
Review fixes carried on top:
- six policy GETs were jwt_required(optional=True), which authenticates a
caller without checking anything; they require computers.view now, and six
redundant jwt_required decorators are gone
- the agent installer took a default BaseUrl and CollectorKey, so a mistyped
run silently pointed an endpoint at the wrong instance; both are mandatory
and a non-https BaseUrl throws unless -AllowInsecureHttp is passed
- a committing CSV import wrote no audit row
Documentation: the 34 new routes are described in docs/api-inventory.json, which
is hand-maintained on purpose, and the generated spec now covers 331 paths and
477 operations across 22 surfaces. DEVELOPMENT-SETUP's 1071 troubleshooting row
said "use MySQL 8 for dev" and now gives the actual fix, including the trap that
5.6 downgrades ROW_FORMAT=DYNAMIC to Compact with a warning instead of an error.
Full suite 2123 passed, vitest 260 passed, all seventeen plugin chains migrate
clean on MySQL 5.6 and the naming, docs, parity and read-surface gates are green.
This commit is contained in:
239
scripts/dev/seed_endpoints_sample.py
Normal file
239
scripts/dev/seed_endpoints_sample.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""Seed sample endpoint telemetry for the Fleet module (DEV ONLY).
|
||||
|
||||
Attaches EndpointState snapshots plus a handful of curated EndpointEvents to
|
||||
existing Computer assets so the fleet list, the PC-detail asset panels
|
||||
(status / services / recent events), and the dashboard quiet + stability cards
|
||||
have something to render before a real agent exists.
|
||||
|
||||
Sample rows are marked with a sentinel agentversion ("0.0.0-sample") so they can
|
||||
be listed and cleared without touching real agent data.
|
||||
|
||||
DEV ONLY: this file lives under scripts/dev/ and must never ship in a
|
||||
production image (see devnotes/live-container-and-testing-workflow.md). Do not
|
||||
import it from application code.
|
||||
|
||||
Usage (in the API container):
|
||||
docker exec shopdb-flask-wilm-api-1 python scripts/dev/seed_endpoints_sample.py
|
||||
docker exec shopdb-flask-wilm-api-1 python scripts/dev/seed_endpoints_sample.py --clear
|
||||
docker exec shopdb-flask-wilm-api-1 python scripts/dev/seed_endpoints_sample.py --limit 8
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
# Repo root on sys.path so `shopdb` and `plugins` import when run as a script.
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')))
|
||||
|
||||
from shopdb import create_app # noqa: E402
|
||||
from shopdb.api import db # noqa: E402
|
||||
|
||||
SAMPLE_MARKER = '0.0.0-sample'
|
||||
|
||||
|
||||
def _naive_utcnow():
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _services(*running_then_stopped):
|
||||
"""Build a services snapshot. Positional pairs are (name, state)."""
|
||||
return [{'name': name, 'state': state} for name, state in running_then_stopped]
|
||||
|
||||
|
||||
def _ports(*pairs):
|
||||
return [{'port': port, 'process': process} for port, process in pairs]
|
||||
|
||||
|
||||
def _interfaces(ip, mac):
|
||||
return [{'name': 'Ethernet', 'ip': ip, 'mac': mac}]
|
||||
|
||||
|
||||
# Per-machine telemetry shapes. minutesago drives whether a machine reads as
|
||||
# fresh, stale, or quiet; a couple are pushed past a plausible quiet window so
|
||||
# the dashboard quiet card has rows. Everything is offline-plausible because
|
||||
# there is no live agent.
|
||||
SAMPLE_PROFILES = [
|
||||
{
|
||||
'loggedinuser': 'CORP\\jmartin',
|
||||
'agentversion': SAMPLE_MARKER,
|
||||
'minutesago': 3,
|
||||
'uptimeseconds': 4 * 86400 + 6 * 3600,
|
||||
'idleseconds': 12 * 60,
|
||||
'cpupercent': 14.0,
|
||||
'memorypercent': 46.0,
|
||||
'services': _services(('Spooler', 'Running'), ('W32Time', 'Running'),
|
||||
('WinRM', 'Running'), ('MSSQLSERVER', 'Running')),
|
||||
'ports': _ports((3389, 'svchost'), (5985, 'winrm'), (445, 'System')),
|
||||
'interfaces': _interfaces('10.20.4.11', '00:1A:2B:3C:4D:01'),
|
||||
'events': [(4624, -8), (4672, -8), (4698, -120)],
|
||||
},
|
||||
{
|
||||
'loggedinuser': 'CORP\\operator2',
|
||||
'agentversion': SAMPLE_MARKER,
|
||||
'minutesago': 9,
|
||||
'uptimeseconds': 2 * 3600 + 40 * 60,
|
||||
'idleseconds': 3 * 60,
|
||||
'cpupercent': 61.0,
|
||||
'memorypercent': 72.0,
|
||||
'services': _services(('Spooler', 'Running'), ('W32Time', 'Running'),
|
||||
('WinRM', 'Stopped')),
|
||||
'ports': _ports((3389, 'svchost'), (445, 'System')),
|
||||
'interfaces': _interfaces('10.20.4.27', '00:1A:2B:3C:4D:02'),
|
||||
# Recent unexpected shutdown -> shows on the stability card.
|
||||
'events': [(41, -50), (1001, -30), (4625, -12)],
|
||||
},
|
||||
{
|
||||
'loggedinuser': None,
|
||||
'agentversion': SAMPLE_MARKER,
|
||||
'minutesago': 22,
|
||||
'uptimeseconds': 11 * 86400,
|
||||
'idleseconds': 9 * 3600,
|
||||
'cpupercent': 3.0,
|
||||
'memorypercent': 38.0,
|
||||
'services': _services(('Spooler', 'Running'), ('W32Time', 'Running'),
|
||||
('WinRM', 'Running')),
|
||||
'ports': _ports((3389, 'svchost'), (5985, 'winrm')),
|
||||
'interfaces': _interfaces('10.20.4.44', '00:1A:2B:3C:4D:03'),
|
||||
'events': [(4624, -600), (7011, -180)],
|
||||
},
|
||||
{
|
||||
'loggedinuser': 'CORP\\nightshift',
|
||||
'agentversion': SAMPLE_MARKER,
|
||||
# Quiet: no report in over a day.
|
||||
'minutesago': 1500,
|
||||
'uptimeseconds': 30 * 60,
|
||||
'idleseconds': 30 * 60,
|
||||
'cpupercent': 1.0,
|
||||
'memorypercent': 29.0,
|
||||
'services': _services(('Spooler', 'Stopped'), ('W32Time', 'Running')),
|
||||
'ports': _ports((445, 'System'),),
|
||||
'interfaces': _interfaces('10.20.4.58', '00:1A:2B:3C:4D:04'),
|
||||
'events': [(55, -1600), (41, -1580)],
|
||||
},
|
||||
{
|
||||
'loggedinuser': 'CORP\\qa_kiosk',
|
||||
'agentversion': SAMPLE_MARKER,
|
||||
'minutesago': 6,
|
||||
'uptimeseconds': 6 * 3600,
|
||||
'idleseconds': 45 * 60,
|
||||
'cpupercent': 22.0,
|
||||
'memorypercent': 55.0,
|
||||
'services': _services(('Spooler', 'Running'), ('W32Time', 'Running'),
|
||||
('WinRM', 'Running'), ('BITS', 'Running')),
|
||||
'ports': _ports((3389, 'svchost'), (5985, 'winrm'), (135, 'rpc')),
|
||||
'interfaces': _interfaces('10.20.4.63', '00:1A:2B:3C:4D:05'),
|
||||
'events': [(4624, -20), (10016, -90), (4697, -240)],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _clear(EndpointState, EndpointEvent):
|
||||
sample_states = EndpointState.query.filter_by(agentversion=SAMPLE_MARKER).all()
|
||||
asset_ids = [s.assetid for s in sample_states]
|
||||
events_removed = 0
|
||||
if asset_ids:
|
||||
events_removed = (EndpointEvent.query
|
||||
.filter(EndpointEvent.assetid.in_(asset_ids))
|
||||
.delete(synchronize_session=False))
|
||||
states_removed = (EndpointState.query
|
||||
.filter_by(agentversion=SAMPLE_MARKER)
|
||||
.delete(synchronize_session=False))
|
||||
db.session.commit()
|
||||
print('Cleared {} sample state(s) and {} sample event(s).'
|
||||
.format(states_removed, events_removed))
|
||||
|
||||
|
||||
def _seed(limit):
|
||||
from plugins.computers.models import Computer
|
||||
from plugins.endpoints.models import EndpointState, EndpointEvent
|
||||
from plugins.endpoints.eventcatalog import EVENT_CATALOG
|
||||
|
||||
computers = (Computer.query
|
||||
.filter(Computer.assetid.isnot(None))
|
||||
.order_by(Computer.computerid.asc())
|
||||
.limit(limit)
|
||||
.all())
|
||||
if not computers:
|
||||
print('No Computer assets found to attach sample endpoints to. '
|
||||
'Seed the computers plugin first.')
|
||||
return
|
||||
|
||||
now = _naive_utcnow()
|
||||
created, updated, events_added = 0, 0, 0
|
||||
|
||||
for index, computer in enumerate(computers):
|
||||
profile = SAMPLE_PROFILES[index % len(SAMPLE_PROFILES)]
|
||||
reported = now - timedelta(minutes=profile['minutesago'])
|
||||
boot = reported - timedelta(seconds=profile['uptimeseconds'])
|
||||
|
||||
state = EndpointState.query.filter_by(assetid=computer.assetid).first()
|
||||
if state and state.agentversion not in (None, SAMPLE_MARKER):
|
||||
# Never stomp a real agent's snapshot.
|
||||
continue
|
||||
if not state:
|
||||
state = EndpointState(assetid=computer.assetid, createdat=now)
|
||||
db.session.add(state)
|
||||
created += 1
|
||||
else:
|
||||
updated += 1
|
||||
|
||||
state.hostname = computer.hostname or 'HOST-{}'.format(computer.computerid)
|
||||
state.loggedinuser = profile['loggedinuser']
|
||||
state.agentversion = SAMPLE_MARKER
|
||||
state.lastboottime = boot
|
||||
state.uptimeseconds = profile['uptimeseconds']
|
||||
state.idleseconds = profile['idleseconds']
|
||||
state.cpupercent = profile['cpupercent']
|
||||
state.memorypercent = profile['memorypercent']
|
||||
state.servicesjson = json.dumps(profile['services'])
|
||||
state.portsjson = json.dumps(profile['ports'])
|
||||
state.interfacesjson = json.dumps(profile['interfaces'])
|
||||
state.scheduledtasksjson = json.dumps([])
|
||||
state.lastreportedat = reported
|
||||
|
||||
# Refresh this asset's sample events so re-running stays idempotent.
|
||||
EndpointEvent.query.filter_by(assetid=computer.assetid).delete(
|
||||
synchronize_session=False)
|
||||
for eventid, offset_minutes in profile['events']:
|
||||
title, severity, channel = EVENT_CATALOG.get(
|
||||
eventid, ('Event {}'.format(eventid), 'info', 'System'))
|
||||
event = EndpointEvent(
|
||||
assetid=computer.assetid,
|
||||
eventid=eventid,
|
||||
eventchannel=channel,
|
||||
eventsource='sample-seed',
|
||||
eventtime=reported + timedelta(minutes=offset_minutes),
|
||||
eventmessage='{} (sample)'.format(title),
|
||||
payloadjson=json.dumps({'sample': True, 'severity': severity}),
|
||||
recordedat=now,
|
||||
)
|
||||
db.session.add(event)
|
||||
events_added += 1
|
||||
|
||||
db.session.commit()
|
||||
print('Seeded sample endpoints: {} created, {} updated, {} event(s) added '
|
||||
'across {} computer(s).'
|
||||
.format(created, updated, events_added, len(computers)))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--clear', action='store_true',
|
||||
help='Remove sample-marked endpoint states and their events.')
|
||||
parser.add_argument('--limit', type=int, default=5,
|
||||
help='Max number of computers to attach sample endpoints to.')
|
||||
args = parser.parse_args()
|
||||
|
||||
app = create_app(os.environ.get('FLASK_ENV', 'production'))
|
||||
with app.app_context():
|
||||
from plugins.endpoints.models import EndpointState, EndpointEvent
|
||||
if args.clear:
|
||||
_clear(EndpointState, EndpointEvent)
|
||||
else:
|
||||
_seed(args.limit)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
102
scripts/dev/seed_shared_applications.py
Normal file
102
scripts/dev/seed_shared_applications.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Seed the org's shared shopfloor applications (DEV ONLY).
|
||||
|
||||
Inserts the applications that ship as common across the organization so the
|
||||
Configuration Policy builder has real apps to scope against and the software
|
||||
inventory has canonical rows to match collected installs to. Each app records
|
||||
its executable and (where relevant) its Windows service name in
|
||||
applicationnotes, since the Application model has no dedicated process/service
|
||||
column - these identifiers are what a generic service_*/process_* policy action
|
||||
targets.
|
||||
|
||||
Shipped set:
|
||||
* UDC - UDC.exe
|
||||
* MTConnect - agent.exe process, "MTConnect UDC Agent" service
|
||||
* eDNC - DncMain.exe process
|
||||
|
||||
Idempotent: matches on appname and updates in place, so re-running does not
|
||||
duplicate. Marked installable so they appear in installable-app pickers.
|
||||
|
||||
DEV ONLY: lives under scripts/dev/ and must never be imported by application
|
||||
code. Run inside the API container.
|
||||
|
||||
Usage (in the API container):
|
||||
docker exec shopdb-flask-wilm-api-1 python scripts/dev/seed_shared_applications.py
|
||||
docker exec shopdb-flask-wilm-api-1 python scripts/dev/seed_shared_applications.py --clear
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Repo root on sys.path so `shopdb` imports when run as a script.
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')))
|
||||
|
||||
from shopdb import create_app # noqa: E402
|
||||
from shopdb.api import db # noqa: E402
|
||||
|
||||
# appname -> seed fields. 'notes' captures the process image and service name so
|
||||
# a generic policy action (params.target) has a canonical identifier to bind to.
|
||||
SHARED_APPS = [
|
||||
{
|
||||
'appname': 'UDC',
|
||||
'appdescription': 'Universal Data Collector',
|
||||
'notes': 'process=UDC.exe',
|
||||
},
|
||||
{
|
||||
'appname': 'MTConnect',
|
||||
'appdescription': 'MTConnect UDC agent',
|
||||
'notes': 'process=agent.exe; service=MTConnect UDC Agent',
|
||||
},
|
||||
{
|
||||
'appname': 'eDNC',
|
||||
'appdescription': 'eDNC distributed NC file transfer',
|
||||
'notes': 'process=DncMain.exe',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _seed(Application):
|
||||
created = 0
|
||||
updated = 0
|
||||
for spec in SHARED_APPS:
|
||||
app_row = Application.query.filter_by(appname=spec['appname']).first()
|
||||
if app_row is None:
|
||||
app_row = Application(appname=spec['appname'])
|
||||
db.session.add(app_row)
|
||||
created += 1
|
||||
else:
|
||||
updated += 1
|
||||
app_row.appdescription = spec['appdescription']
|
||||
app_row.applicationnotes = spec['notes']
|
||||
app_row.isinstallable = True
|
||||
db.session.commit()
|
||||
print('Seeded shared applications: {} created, {} updated.'
|
||||
.format(created, updated))
|
||||
|
||||
|
||||
def _clear(Application):
|
||||
names = [spec['appname'] for spec in SHARED_APPS]
|
||||
removed = (Application.query
|
||||
.filter(Application.appname.in_(names))
|
||||
.delete(synchronize_session=False))
|
||||
db.session.commit()
|
||||
print('Removed {} shared application row(s).'.format(removed))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--clear', action='store_true',
|
||||
help='Remove the seeded shared applications.')
|
||||
args = parser.parse_args()
|
||||
|
||||
app = create_app(os.environ.get('FLASK_ENV', 'production'))
|
||||
with app.app_context():
|
||||
from shopdb.api import Application
|
||||
if args.clear:
|
||||
_clear(Application)
|
||||
else:
|
||||
_seed(Application)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate docs/PROJECT-MAP.md: the facts about this repo that go stale.
|
||||
"""Generate the PROJECT-MAP wiki page: the facts about this repo that go stale.
|
||||
|
||||
Versions, the plugin inventory, every Alembic chain head, the ADR index and the
|
||||
endpoint count were all written by hand, in several places. Written facts about
|
||||
@@ -12,7 +12,7 @@ restate them point at the result. Anything in this file that cannot be read from
|
||||
the repository does not belong in it.
|
||||
|
||||
Usage:
|
||||
venv/bin/python scripts/gen_project_map.py # write docs/PROJECT-MAP.md
|
||||
venv/bin/python scripts/gen_project_map.py # write the PROJECT-MAP wiki page
|
||||
venv/bin/python scripts/gen_project_map.py --check # fail if it is out of date
|
||||
venv/bin/python scripts/gen_project_map.py --stdout # print, write nothing
|
||||
|
||||
@@ -230,10 +230,10 @@ def main():
|
||||
current = _read(OUTPUT) if OUTPUT.is_file() else ''
|
||||
if current != content:
|
||||
sys.stderr.write(
|
||||
'docs/PROJECT-MAP.md is out of date.\n'
|
||||
'the PROJECT-MAP wiki page is out of date.\n'
|
||||
'Regenerate: venv/bin/python scripts/gen_project_map.py\n')
|
||||
return 1
|
||||
print('docs/PROJECT-MAP.md is current')
|
||||
print('the PROJECT-MAP wiki page is current')
|
||||
return 0
|
||||
OUTPUT.write_text(content, encoding='utf-8')
|
||||
print('wrote %s' % OUTPUT.relative_to(REPO))
|
||||
|
||||
@@ -10,13 +10,13 @@ columns that no longer exist) and were actively misleading.
|
||||
## Use the import API + a site loader instead
|
||||
|
||||
Legacy data is imported through the maintained, schema-agnostic contract in
|
||||
[`docs/IMPORT-API.md`](../../docs/IMPORT-API.md) (create through real endpoints
|
||||
the IMPORT-API page in the project wiki (create through real endpoints
|
||||
with an admin PAT + `X-Import-Mode`). Each adopting site writes a thin loader
|
||||
that reads its own source database and drives that API.
|
||||
|
||||
- Reference implementation: [`scripts/site_imports/wjf/`](../site_imports/wjf/)
|
||||
(West Jefferson classic-ASP -> flask).
|
||||
- Adoption playbook: [`docs/IMPORT-ADOPTION.md`](../../docs/IMPORT-ADOPTION.md).
|
||||
- Adoption playbook: the IMPORT-ADOPTION page in the project wiki.
|
||||
|
||||
## What's kept here
|
||||
|
||||
|
||||
156
scripts/reconcile_locations.py
Normal file
156
scripts/reconcile_locations.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Generate reconciliation SQL for the locations table.
|
||||
|
||||
Reads a TSV export (locationid, locationname, description, locationtypeid,
|
||||
parentlocationid) and emits a single transaction-wrapped SQL script that:
|
||||
- Treats bare grid coordinates ([A-V][0-9]{1,2}W? and LD[0-9]+W?) as the
|
||||
canonical identity for a cell.
|
||||
- Folds coordinate-plus-suffix rows into the canonical coordinate, moving the
|
||||
suffix text into the canonical row's description (never the name).
|
||||
- Folds named-area suffix variants ("NDE - 6409") into the parent named area
|
||||
("NDE"); named areas are kept as-is and never forced into coordinates.
|
||||
- Repoints assets.locationid, subnets.locationid and child
|
||||
locations.parentlocationid onto the canonical row, then deletes duplicates.
|
||||
|
||||
Bare coordinates with no variants are left untouched. The trailing W (west
|
||||
column, e.g. G2W, K3W) is part of the coordinate and is never stripped, so
|
||||
G2W is kept distinct from a hypothetical G2.
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
|
||||
COORD = re.compile(r'^(LD|[ABCDEFGHJKLMNPQRSTUV])(\d{1,2})(W)?(?=$|[ \-])')
|
||||
|
||||
|
||||
def esc(s):
|
||||
return s.replace("'", "''")
|
||||
|
||||
|
||||
def load(path):
|
||||
rows = []
|
||||
with open(path, encoding='utf-8-sig') as fh:
|
||||
for line in fh:
|
||||
line = line.rstrip('\n').rstrip('\r')
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split('\t')
|
||||
if len(parts) < 5:
|
||||
parts += [''] * (5 - len(parts))
|
||||
lid, name, desc, typeid, parentid = parts[:5]
|
||||
rows.append({
|
||||
'id': int(lid),
|
||||
'name': name,
|
||||
'desc': desc,
|
||||
'typeid': typeid,
|
||||
'parentid': parentid,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def classify(name):
|
||||
"""Return (canonical, suffix). canonical is the bare coordinate or named
|
||||
parent; suffix is the trailing detail (may be '')."""
|
||||
m = COORD.match(name)
|
||||
if m:
|
||||
prefix, digits, west = m.group(1), m.group(2), m.group(3) or ''
|
||||
canonical = f"{prefix}{digits}{west}"
|
||||
suffix = name[m.end():].strip()
|
||||
suffix = re.sub(r'^[\-\s]+', '', suffix).strip()
|
||||
return canonical, suffix, 'coord'
|
||||
# named area: fold on ' - ' separator only
|
||||
if ' - ' in name:
|
||||
base, _, rest = name.partition(' - ')
|
||||
return base.strip(), rest.strip(), 'named'
|
||||
return name.strip(), '', 'named'
|
||||
|
||||
|
||||
def main(tsv, out):
|
||||
rows = load(tsv)
|
||||
|
||||
# Build canonical groups. Key is casefolded canonical for grouping, but we
|
||||
# preserve a display canonical (prefer an existing exact bare/standalone).
|
||||
groups = {}
|
||||
for r in rows:
|
||||
canonical, suffix, kind = classify(r['name'])
|
||||
key = (kind, canonical.casefold())
|
||||
g = groups.setdefault(key, {
|
||||
'canonical': canonical,
|
||||
'kind': kind,
|
||||
'members': [],
|
||||
'exact_id': None,
|
||||
})
|
||||
r['_suffix'] = suffix
|
||||
r['_canonical'] = canonical
|
||||
g['members'].append(r)
|
||||
# exact row: name equals canonical (no suffix)
|
||||
if suffix == '' and r['name'].strip() == canonical:
|
||||
g['exact_id'] = r['id']
|
||||
g['canonical'] = r['name'].strip()
|
||||
|
||||
lines = []
|
||||
lines.append('-- Auto-generated locations reconciliation. Review before running.')
|
||||
lines.append('START TRANSACTION;')
|
||||
|
||||
touched = 0
|
||||
for key, g in sorted(groups.items(), key=lambda kv: kv[0][1]):
|
||||
members = sorted(g['members'], key=lambda r: r['id'])
|
||||
# nothing to do: single member that is already the exact canonical
|
||||
if len(members) == 1 and g['exact_id'] == members[0]['id']:
|
||||
continue
|
||||
touched += 1
|
||||
canonical = g['canonical']
|
||||
|
||||
# choose target id: prefer exact bare row, else promote smallest id
|
||||
if g['exact_id'] is not None:
|
||||
target = g['exact_id']
|
||||
promote = None
|
||||
else:
|
||||
target = members[0]['id']
|
||||
promote = members[0]
|
||||
|
||||
# accumulate suffix/description text from all members (dedup, ordered)
|
||||
details = []
|
||||
for m in members:
|
||||
for chunk in (m['_suffix'], m['desc']):
|
||||
chunk = (chunk or '').strip()
|
||||
if chunk and chunk not in details:
|
||||
details.append(chunk)
|
||||
desc_val = '; '.join(details)
|
||||
|
||||
lines.append('')
|
||||
lines.append(f"-- {canonical} (target locationid {target}) <= "
|
||||
+ ', '.join(str(m['id']) for m in members))
|
||||
if promote is not None:
|
||||
lines.append(
|
||||
f"UPDATE locations SET locationname='{esc(canonical)}' "
|
||||
f"WHERE locationid={target};")
|
||||
if desc_val:
|
||||
lines.append(
|
||||
f"UPDATE locations SET description='{esc(desc_val)}' "
|
||||
f"WHERE locationid={target};")
|
||||
|
||||
others = [m['id'] for m in members if m['id'] != target]
|
||||
if others:
|
||||
ids = ','.join(str(i) for i in others)
|
||||
lines.append(
|
||||
f"UPDATE assets SET locationid={target} "
|
||||
f"WHERE locationid IN ({ids});")
|
||||
lines.append(
|
||||
f"UPDATE subnets SET locationid={target} "
|
||||
f"WHERE locationid IN ({ids});")
|
||||
lines.append(
|
||||
f"UPDATE locations SET parentlocationid={target} "
|
||||
f"WHERE parentlocationid IN ({ids});")
|
||||
lines.append(
|
||||
f"DELETE FROM locations WHERE locationid IN ({ids});")
|
||||
|
||||
lines.append('')
|
||||
lines.append('COMMIT;')
|
||||
lines.append(f'-- groups touched: {touched}')
|
||||
|
||||
with open(out, 'w', encoding='utf-8') as fh:
|
||||
fh.write('\n'.join(lines) + '\n')
|
||||
print(f'wrote {out}: {touched} groups touched, {len(rows)} source rows')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main(sys.argv[1], sys.argv[2])
|
||||
594
scripts/site_imports/assetcollector/Collect-ShopDB.ps1
Normal file
594
scripts/site_imports/assetcollector/Collect-ShopDB.ps1
Normal file
@@ -0,0 +1,594 @@
|
||||
#Requires -Version 5.1
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
# Where CSVs are collected/accumulated. Overrides the in-script CONFIG block
|
||||
# below. See the CONFIG block for the easy place to set this permanently.
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$OutputRoot,
|
||||
|
||||
# Optional network share to mirror the store into after a run. Overrides the
|
||||
# in-script CONFIG block below.
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$PublishRoot,
|
||||
|
||||
# Business identifier for this PC (workstation number). Defaults to hostname.
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$AssetNumber,
|
||||
|
||||
# Value written into assets.assettypeid for the PC (resolved by name).
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$AssetType = 'Computer',
|
||||
|
||||
# Value written into computers.computertypeid (resolved by name).
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$ComputerType,
|
||||
|
||||
# Value written into machines.machinetypeid (resolved by name).
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$MachineType,
|
||||
|
||||
# Explicit eDNC/UDC source overrides. When omitted, standard on-asset
|
||||
# locations are searched.
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$RegPath,
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$JsonPath,
|
||||
|
||||
# Skip switches for partial runs.
|
||||
[switch]$SkipMachine,
|
||||
[switch]$SkipApps
|
||||
)
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Unified ShopDB site-adoption collector. Runs on any PC, collects the PC and any
|
||||
attached eDNC/DNC machine, and UPSERTS the results into a shared set of
|
||||
importable CSVs so a site can build its inventory PC by PC.
|
||||
|
||||
.DESCRIPTION
|
||||
Accumulating store under OutputRoot:
|
||||
vendors.csv, models.csv, operatingsystems.csv, assets.csv,
|
||||
computers.csv, machines.csv
|
||||
Rows are merged field-by-field on each table's natural key (a later run fills
|
||||
gaps and refreshes values without wiping data an earlier run captured).
|
||||
|
||||
Phase-2 data that CSV cannot carry is written per entity under OutputRoot\sidecars:
|
||||
computer_<assetnumber>.json (interfaces, installed apps)
|
||||
machine_<machinenumber>.json (PC<->Machine link, connection detail)
|
||||
|
||||
Read-only against the machine. Local reads may run elevated; publishing to a
|
||||
share runs in the current user context only.
|
||||
|
||||
.NOTES
|
||||
PS 5.1 compatible. Headers match the ShopDB importer (see CSV-SCHEMA.md).
|
||||
#>
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# =============================================================================
|
||||
# CONFIG - SET YOUR CSV DESTINATIONS HERE
|
||||
# -----------------------------------------------------------------------------
|
||||
# Two-stage flow so you can collect locally (possibly elevated) and then append
|
||||
# the captured rows to a CSV store that lives on a network share:
|
||||
#
|
||||
# 1) LocalStageRoot Fast, always-writable local folder where CSVs are built
|
||||
# and merged. Collection can run elevated here.
|
||||
# 2) NetworkStoreRoot The shared CSV store the whole site accumulates into and
|
||||
# that you later point 'flask csv import --dir' at. Leave
|
||||
# blank ('') to keep everything local. When set, the merged
|
||||
# store is copied here at the end of the run. Network
|
||||
# writes must happen as a STANDARD user - do NOT run the
|
||||
# publish step elevated.
|
||||
#
|
||||
# Example network path: '\\fileserver\shopdb$\intake'
|
||||
#
|
||||
# Command-line -OutputRoot / -PublishRoot still override these values.
|
||||
# =============================================================================
|
||||
|
||||
$LocalStageRoot = Join-Path $env:ProgramData 'ShopDB\intake'
|
||||
# Ship blank so a site that has not configured its share keeps everything local
|
||||
# instead of publishing to another site's path. Set this to your own share (or
|
||||
# pass -PublishRoot), e.g. '\\fileserver\shopdb$\intake'. The download endpoint
|
||||
# fills this in from asset_import_csv_location when the site has set it.
|
||||
$NetworkStoreRoot = ''
|
||||
|
||||
# Resolve effective destinations (command-line params win over CONFIG above).
|
||||
if (-not $OutputRoot) { $OutputRoot = $LocalStageRoot }
|
||||
if (-not $PublishRoot) { $PublishRoot = $NetworkStoreRoot }
|
||||
|
||||
# --- Store definitions (header order + natural key per importable table) -------
|
||||
|
||||
$script:Stores = [ordered]@{
|
||||
'vendors.csv' = @{ key = 'vendor'; header = @('vendor') }
|
||||
'models.csv' = @{ key = 'modelnumber'; header = @('modelnumber','vendorid','modeltypeid') }
|
||||
'operatingsystems.csv' = @{ key = 'osname'; header = @('osname') }
|
||||
'assets.csv' = @{ key = 'assetnumber'; header = @('assetnumber','assettypeid','name','statusid','serialnumber','gaugelabreference','maintenancereference','locationid','businessunitid','notes') }
|
||||
'computers.csv' = @{ key = 'assetid'; header = @('assetid','hostname','computertypeid','osid','vendorid','modelnumberid','loggedinuser','lastreporteddate','lastboottime') }
|
||||
'machines.csv' = @{ key = 'assetid'; header = @('assetid','machinetypeid','vendorid','modelnumberid','controllervendorid','controllermodelid','requiresmanualconfig','islocationonly','notes') }
|
||||
}
|
||||
|
||||
# --- Shared helpers -----------------------------------------------------------
|
||||
|
||||
function ConvertTo-CsvField {
|
||||
param([AllowNull()]$Value)
|
||||
if ($null -eq $Value) { return '' }
|
||||
$text = [string]$Value
|
||||
if ($text -match '[",\r\n]') { return '"' + ($text -replace '"', '""') + '"' }
|
||||
return $text
|
||||
}
|
||||
|
||||
function Get-Prop {
|
||||
param([AllowNull()]$Object, [Parameter(Mandatory = $true)][string]$Name)
|
||||
if ($null -eq $Object) { return $null }
|
||||
$prop = $Object.PSObject.Properties[$Name]
|
||||
if ($null -eq $prop) { return $null }
|
||||
return $prop.Value
|
||||
}
|
||||
|
||||
function Get-NonEmpty {
|
||||
param([AllowNull()]$Value)
|
||||
if ($null -ne $Value -and -not [string]::IsNullOrWhiteSpace([string]$Value)) {
|
||||
return ([string]$Value).Trim()
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Get-FirstNonEmpty {
|
||||
param([object[]]$Values)
|
||||
foreach ($v in $Values) { $n = Get-NonEmpty $v; if ($n) { return $n } }
|
||||
return $null
|
||||
}
|
||||
|
||||
function Format-ShopDbDate {
|
||||
param([AllowNull()]$Value)
|
||||
if ($null -eq $Value) { return $null }
|
||||
try { return ([datetime]$Value).ToString('yyyy-MM-dd HH:mm:ss') } catch { return $null }
|
||||
}
|
||||
|
||||
function Write-Utf8NoBom {
|
||||
param([string]$Path, [string]$Content)
|
||||
$encoding = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($Path, $Content, $encoding)
|
||||
}
|
||||
|
||||
# Field-level upsert into a persistent CSV store, keyed by natural key. Existing
|
||||
# values are kept when the incoming cell is empty, so a partial run never blanks
|
||||
# out data an earlier run captured.
|
||||
function Merge-CsvStore {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Path,
|
||||
[Parameter(Mandatory = $true)][string[]]$Header,
|
||||
[Parameter(Mandatory = $true)][string]$Key,
|
||||
[Parameter(Mandatory = $true)][AllowNull()][object[]]$IncomingRows
|
||||
)
|
||||
|
||||
$index = [ordered]@{}
|
||||
if (Test-Path -LiteralPath $Path) {
|
||||
foreach ($existing in @(Import-Csv -LiteralPath $Path)) {
|
||||
$keyVal = Get-NonEmpty (Get-Prop $existing $Key)
|
||||
if (-not $keyVal) { continue }
|
||||
$index[$keyVal.ToLowerInvariant()] = $existing
|
||||
}
|
||||
}
|
||||
|
||||
$changed = 0
|
||||
foreach ($row in $IncomingRows) {
|
||||
if ($null -eq $row) { continue }
|
||||
$keyVal = Get-NonEmpty (Get-Prop $row $Key)
|
||||
if (-not $keyVal) { continue }
|
||||
$lc = $keyVal.ToLowerInvariant()
|
||||
if ($index.Contains($lc)) {
|
||||
$target = $index[$lc]
|
||||
foreach ($col in $Header) {
|
||||
$incoming = Get-NonEmpty (Get-Prop $row $col)
|
||||
if ($incoming) {
|
||||
if ($target.PSObject.Properties[$col]) { $target.$col = $incoming }
|
||||
else { $target | Add-Member -NotePropertyName $col -NotePropertyValue $incoming -Force }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$fresh = [ordered]@{}
|
||||
foreach ($col in $Header) { $fresh[$col] = Get-NonEmpty (Get-Prop $row $col) }
|
||||
$index[$lc] = [pscustomobject]$fresh
|
||||
}
|
||||
$changed++
|
||||
}
|
||||
|
||||
$lines = New-Object System.Collections.Generic.List[string]
|
||||
$lines.Add(($Header -join ','))
|
||||
foreach ($stored in $index.Values) {
|
||||
$cells = foreach ($col in $Header) { ConvertTo-CsvField (Get-Prop $stored $col) }
|
||||
$lines.Add(($cells -join ','))
|
||||
}
|
||||
Write-Utf8NoBom -Path $Path -Content (($lines -join "`r`n") + "`r`n")
|
||||
return $changed
|
||||
}
|
||||
|
||||
# --- eDNC/UDC parsing (registry, .reg export, UDC settings json) --------------
|
||||
|
||||
function Resolve-ControllerVendor {
|
||||
param([AllowNull()][string]$ControlType, [AllowNull()][string]$NcIF)
|
||||
$ct = ('' + $ControlType).Trim().ToUpperInvariant()
|
||||
$nc = ('' + $NcIF).Trim().ToUpperInvariant()
|
||||
if ($nc -eq 'EFOCAS' -or $ct -eq 'EFOCAS') { return 'FANUC' }
|
||||
if ($ct -eq 'SIEMENS') { return 'Siemens' }
|
||||
if ($ct -eq 'OKUMA') { return 'Okuma' }
|
||||
return $null
|
||||
}
|
||||
|
||||
function Read-RegExport {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
$lines = @(Get-Content -LiteralPath $Path -Encoding Unicode -ErrorAction Stop)
|
||||
$firstMeaningful = ($lines | Where-Object { $_ -match '\S' } | Select-Object -First 1)
|
||||
if (-not ($firstMeaningful -match '^(REGEDIT4|Windows Registry Editor Version)')) {
|
||||
$lines = @(Get-Content -LiteralPath $Path -Encoding Default -ErrorAction Stop)
|
||||
}
|
||||
$data = @{}
|
||||
$currentKey = $null
|
||||
foreach ($raw in $lines) {
|
||||
$line = $raw.Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($line)) { continue }
|
||||
if ($line -match '^(REGEDIT4|Windows Registry Editor Version)') { continue }
|
||||
if ($line -match '^\s*;') { continue }
|
||||
if ($line -match '^\[(?<key>[^\]]+)\]$') {
|
||||
$currentKey = $Matches['key']
|
||||
if (-not $data.ContainsKey($currentKey)) { $data[$currentKey] = @{} }
|
||||
continue
|
||||
}
|
||||
if (-not $currentKey) { continue }
|
||||
if ($line -match '^"(?<name>[^"]+)"=(?<rhs>.+)$') {
|
||||
$name = $Matches['name']
|
||||
$rhs = $Matches['rhs'].Trim()
|
||||
if ($rhs -match '^"(?<s>.*)"$') { $val = $Matches['s'] }
|
||||
elseif ($rhs -match '^dword:(?<hex>[0-9a-fA-F]{8})$') { $val = [Convert]::ToInt32($Matches['hex'], 16) }
|
||||
else { $val = $rhs }
|
||||
$data[$currentKey][$name] = $val
|
||||
}
|
||||
}
|
||||
return $data
|
||||
}
|
||||
|
||||
function Get-RegExportValue {
|
||||
param([hashtable]$Reg, [string]$KeySuffix, [string]$Name)
|
||||
foreach ($key in $Reg.Keys) {
|
||||
if ($key -match ('\\' + [regex]::Escape($KeySuffix) + '$')) {
|
||||
if ($Reg[$key].ContainsKey($Name)) { return $Reg[$key][$Name] }
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function ConvertFrom-RegExport {
|
||||
param([hashtable]$Reg)
|
||||
[ordered]@{
|
||||
source = 'reg'
|
||||
machinenumber = Get-NonEmpty (Get-RegExportValue $Reg 'General' 'MachineNo')
|
||||
site = Get-NonEmpty (Get-RegExportValue $Reg 'General' 'Site')
|
||||
controltype = Get-NonEmpty (Get-RegExportValue $Reg 'General' 'Cnc')
|
||||
ncif = Get-NonEmpty (Get-RegExportValue $Reg 'General' 'NcIF')
|
||||
dnctype = 'eDNC'
|
||||
tcpaddress = Get-NonEmpty (Get-RegExportValue $Reg 'eFocas' 'IpAddr')
|
||||
tcpport = Get-NonEmpty (Get-RegExportValue $Reg 'eFocas' 'SocketNo')
|
||||
comport = Get-NonEmpty (Get-RegExportValue $Reg 'PPDCS' 'Port Id')
|
||||
baudrate = Get-NonEmpty (Get-RegExportValue $Reg 'PPDCS' 'Baud')
|
||||
}
|
||||
}
|
||||
|
||||
function Get-RegistryValue {
|
||||
param([string]$Path, [string]$Name)
|
||||
if (-not (Test-Path -LiteralPath $Path)) { return $null }
|
||||
return Get-Prop (Get-ItemProperty -LiteralPath $Path -ErrorAction SilentlyContinue) $Name
|
||||
}
|
||||
|
||||
function ConvertFrom-LiveRegistry {
|
||||
param([string]$Base)
|
||||
if (-not (Test-Path -LiteralPath $Base)) { return $null }
|
||||
$general = Join-Path $Base 'General'
|
||||
$efocas = Join-Path $Base 'eFocas'
|
||||
$ppdcs = Join-Path $Base 'PPDCS'
|
||||
[ordered]@{
|
||||
source = 'registry'
|
||||
machinenumber = Get-NonEmpty (Get-RegistryValue $general 'MachineNo')
|
||||
site = Get-NonEmpty (Get-RegistryValue $general 'Site')
|
||||
controltype = Get-NonEmpty (Get-RegistryValue $general 'Cnc')
|
||||
ncif = Get-NonEmpty (Get-RegistryValue $general 'NcIF')
|
||||
dnctype = 'eDNC'
|
||||
tcpaddress = Get-NonEmpty (Get-RegistryValue $efocas 'IpAddr')
|
||||
tcpport = Get-NonEmpty (Get-RegistryValue $efocas 'SocketNo')
|
||||
comport = Get-NonEmpty (Get-RegistryValue $ppdcs 'Port Id')
|
||||
baudrate = Get-NonEmpty (Get-RegistryValue $ppdcs 'Baud')
|
||||
}
|
||||
}
|
||||
|
||||
function ConvertFrom-UdcJson {
|
||||
param([string]$Path)
|
||||
$obj = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop | ConvertFrom-Json
|
||||
$general = Get-Prop $obj 'GeneralSettings'
|
||||
$ethernet = Get-Prop $obj 'EthernetSettings'
|
||||
$serial = Get-Prop $obj 'SerialSettings'
|
||||
[ordered]@{
|
||||
source = 'json'
|
||||
machinenumber = Get-NonEmpty (Get-Prop $general 'MachineNumber')
|
||||
site = Get-NonEmpty (Get-Prop $general 'Site')
|
||||
controltype = Get-NonEmpty (Get-Prop $general 'ControlType')
|
||||
ncif = $null
|
||||
dnctype = Get-NonEmpty (Get-Prop $general 'DncType')
|
||||
tcpaddress = Get-NonEmpty (Get-Prop $ethernet 'TCPAddress')
|
||||
tcpport = Get-NonEmpty (Get-Prop $ethernet 'TCPPort')
|
||||
comport = Get-NonEmpty (Get-Prop $serial 'ComPort')
|
||||
baudrate = Get-NonEmpty (Get-Prop $serial 'BaudRate')
|
||||
}
|
||||
}
|
||||
|
||||
function Find-FirstFile {
|
||||
param([string[]]$Roots, [string]$Filter)
|
||||
foreach ($root in $Roots) {
|
||||
if (-not $root -or -not (Test-Path -LiteralPath $root)) { continue }
|
||||
$hit = Get-ChildItem -LiteralPath $root -Filter $Filter -File -ErrorAction SilentlyContinue |
|
||||
Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||||
if ($hit) { return $hit.FullName }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Resolve-DncSource {
|
||||
param([string]$RegPath, [string]$JsonPath)
|
||||
if ($JsonPath -and (Test-Path -LiteralPath $JsonPath)) {
|
||||
Write-Host (' machine source: JSON (explicit) ' + $JsonPath) -ForegroundColor DarkGray
|
||||
return ConvertFrom-UdcJson -Path $JsonPath
|
||||
}
|
||||
if ($RegPath -and (Test-Path -LiteralPath $RegPath)) {
|
||||
Write-Host (' machine source: .reg (explicit) ' + $RegPath) -ForegroundColor DarkGray
|
||||
return ConvertFrom-RegExport -Reg (Read-RegExport -Path $RegPath)
|
||||
}
|
||||
foreach ($base in @(
|
||||
'HKLM:\SOFTWARE\GE Aircraft Engines\DNC',
|
||||
'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC')) {
|
||||
$fromReg = ConvertFrom-LiveRegistry -Base $base
|
||||
if ($fromReg -and $fromReg.machinenumber) {
|
||||
Write-Host (' machine source: live registry ' + $base) -ForegroundColor DarkGray
|
||||
return $fromReg
|
||||
}
|
||||
}
|
||||
$localReg = Find-FirstFile -Roots @('C:\Program Files (x86)\Dnc', (Join-Path $env:SystemDrive 'GE Aerospace')) -Filter '*.reg'
|
||||
if ($localReg) {
|
||||
Write-Host (' machine source: local .reg ' + $localReg) -ForegroundColor DarkGray
|
||||
return ConvertFrom-RegExport -Reg (Read-RegExport -Path $localReg)
|
||||
}
|
||||
$localJson = Find-FirstFile -Roots @((Join-Path $env:SystemDrive 'GE Aerospace'), 'C:\Program Files (x86)\UDC', 'C:\Program Files (x86)\Dnc') -Filter 'udc_settings_*.json'
|
||||
if ($localJson) {
|
||||
Write-Host (' machine source: local UDC json ' + $localJson) -ForegroundColor DarkGray
|
||||
return ConvertFrom-UdcJson -Path $localJson
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# --- PC collection (read-only) ------------------------------------------------
|
||||
|
||||
function Get-PcFacts {
|
||||
$computerSystem = Get-CimInstance Win32_ComputerSystem -ErrorAction SilentlyContinue
|
||||
$operatingSys = Get-CimInstance Win32_OperatingSystem -ErrorAction SilentlyContinue
|
||||
$bios = Get-CimInstance Win32_BIOS -ErrorAction SilentlyContinue
|
||||
|
||||
$interfaces = @()
|
||||
try {
|
||||
foreach ($cfg in @(Get-NetIPConfiguration -ErrorAction SilentlyContinue)) {
|
||||
$adapter = $null
|
||||
try { $adapter = Get-NetAdapter -InterfaceAlias $cfg.InterfaceAlias -ErrorAction SilentlyContinue } catch {}
|
||||
if (-not $adapter -or (Get-Prop $adapter 'HardwareInterface') -ne $true) { continue }
|
||||
$ipv4 = @(Get-Prop $cfg 'IPv4Address') | Select-Object -First 1
|
||||
$interfaces += [ordered]@{
|
||||
interfacealias = [string]$cfg.InterfaceAlias
|
||||
ipaddress = if ($ipv4) { [string](Get-Prop $ipv4 'IPAddress') } else { $null }
|
||||
macaddress = [string](Get-Prop $adapter 'MacAddress')
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
$installedApps = @()
|
||||
if (-not $SkipApps) {
|
||||
foreach ($keyPath in @(
|
||||
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
|
||||
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*')) {
|
||||
foreach ($entry in @(Get-ItemProperty -Path $keyPath -ErrorAction SilentlyContinue)) {
|
||||
$displayName = Get-NonEmpty (Get-Prop $entry 'DisplayName')
|
||||
if (-not $displayName) { continue }
|
||||
if ((Get-Prop $entry 'SystemComponent') -eq 1) { continue }
|
||||
$installedApps += [ordered]@{
|
||||
appname = $displayName
|
||||
installedversion = Get-NonEmpty (Get-Prop $entry 'DisplayVersion')
|
||||
publisher = Get-NonEmpty (Get-Prop $entry 'Publisher')
|
||||
}
|
||||
}
|
||||
}
|
||||
$installedApps = @($installedApps | Sort-Object { $_.appname } -Unique)
|
||||
}
|
||||
|
||||
[ordered]@{
|
||||
hostname = Get-FirstNonEmpty @($env:COMPUTERNAME, (Get-Prop $computerSystem 'Name'))
|
||||
vendor = Get-NonEmpty (Get-Prop $computerSystem 'Manufacturer')
|
||||
model = Get-NonEmpty (Get-Prop $computerSystem 'Model')
|
||||
osname = Get-NonEmpty (Get-Prop $operatingSys 'Caption')
|
||||
serial = Get-FirstNonEmpty @((Get-Prop $bios 'SerialNumber'), (Get-Prop $computerSystem 'SerialNumber'))
|
||||
lastboot = Format-ShopDbDate (Get-Prop $operatingSys 'LastBootUpTime')
|
||||
loggeduser = Get-NonEmpty (Get-Prop $computerSystem 'UserName')
|
||||
interfaces = $interfaces
|
||||
installedapps = $installedApps
|
||||
}
|
||||
}
|
||||
|
||||
# --- Main ---------------------------------------------------------------------
|
||||
|
||||
Write-Host ''
|
||||
Write-Host ' ShopDB unified collector' -ForegroundColor Cyan
|
||||
Write-Host (' ' + ('-' * 56)) -ForegroundColor DarkGray
|
||||
|
||||
if (-not (Test-Path -LiteralPath $OutputRoot)) { New-Item -ItemType Directory -Path $OutputRoot -Force | Out-Null }
|
||||
$sidecarDir = Join-Path $OutputRoot 'sidecars'
|
||||
if (-not (Test-Path -LiteralPath $sidecarDir)) { New-Item -ItemType Directory -Path $sidecarDir -Force | Out-Null }
|
||||
|
||||
$collectedAt = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss')
|
||||
|
||||
$pc = Get-PcFacts
|
||||
if (-not $AssetNumber) { $AssetNumber = $pc.hostname }
|
||||
|
||||
# Row sets keyed to the shared stores.
|
||||
$vendorRows = @()
|
||||
$modelRows = @()
|
||||
$osRows = @()
|
||||
$assetRows = @()
|
||||
$computerRows = @()
|
||||
$machineRows = @()
|
||||
|
||||
if ($pc.vendor) { $vendorRows += [pscustomobject]@{ vendor = $pc.vendor } }
|
||||
if ($pc.model) { $modelRows += [pscustomobject]@{ modelnumber = $pc.model; vendorid = $pc.vendor; modeltypeid = $null } }
|
||||
if ($pc.osname) { $osRows += [pscustomobject]@{ osname = $pc.osname } }
|
||||
|
||||
$assetRows += [pscustomobject]@{
|
||||
assetnumber = $AssetNumber; assettypeid = $AssetType; name = $pc.hostname; statusid = $null
|
||||
serialnumber = $pc.serial; gaugelabreference = $null; maintenancereference = $null
|
||||
locationid = $null; businessunitid = $null
|
||||
notes = 'PC collected ' + $collectedAt
|
||||
}
|
||||
$computerRows += [pscustomobject]@{
|
||||
assetid = $AssetNumber; hostname = $pc.hostname; computertypeid = $ComputerType; osid = $pc.osname
|
||||
vendorid = $pc.vendor; modelnumberid = $pc.model; loggedinuser = $pc.loggeduser
|
||||
lastreporteddate = $collectedAt; lastboottime = $pc.lastboot
|
||||
}
|
||||
|
||||
# Machine (best-effort).
|
||||
$machine = $null
|
||||
if (-not $SkipMachine) {
|
||||
try { $machine = Resolve-DncSource -RegPath $RegPath -JsonPath $JsonPath } catch { $machine = $null }
|
||||
}
|
||||
if ($machine -and $machine.machinenumber) {
|
||||
$controllerVendor = Resolve-ControllerVendor -ControlType $machine.controltype -NcIF $machine.ncif
|
||||
$transport = if ($machine.tcpaddress) { 'ethernet' } elseif ($machine.comport) { 'serial' } else { 'unknown' }
|
||||
$noteBits = @("DNC=$($machine.dnctype)")
|
||||
if ($machine.controltype) { $noteBits += "control=$($machine.controltype)" }
|
||||
$noteBits += "transport=$transport"
|
||||
$machineNote = 'eDNC collected ' + $collectedAt + '; ' + ($noteBits -join '; ')
|
||||
|
||||
if ($controllerVendor) { $vendorRows += [pscustomobject]@{ vendor = $controllerVendor } }
|
||||
$assetRows += [pscustomobject]@{
|
||||
assetnumber = $machine.machinenumber; assettypeid = 'Machine'; name = $machine.machinenumber; statusid = $null
|
||||
serialnumber = $null; gaugelabreference = $null; maintenancereference = $null
|
||||
locationid = $null; businessunitid = $null; notes = $machineNote
|
||||
}
|
||||
$machineRows += [pscustomobject]@{
|
||||
assetid = $machine.machinenumber; machinetypeid = $MachineType; vendorid = $null; modelnumberid = $null
|
||||
controllervendorid = $controllerVendor; controllermodelid = $null
|
||||
requiresmanualconfig = $null; islocationonly = $null; notes = $machineNote
|
||||
}
|
||||
|
||||
$link = [ordered]@{
|
||||
pcassetnumber = $AssetNumber; machineassetnumber = $machine.machinenumber; relationship = 'controls'
|
||||
dnctype = $machine.dnctype; controltype = $machine.controltype; controllervendor = $controllerVendor
|
||||
site = $machine.site; source = $machine.source; collectedat = $collectedAt
|
||||
connection = [ordered]@{
|
||||
transport = $transport; tcpaddress = $machine.tcpaddress; tcpport = $machine.tcpport
|
||||
comport = $machine.comport; baudrate = $machine.baudrate
|
||||
}
|
||||
}
|
||||
Write-Utf8NoBom -Path (Join-Path $sidecarDir ('machine_' + $machine.machinenumber + '.json')) -Content ($link | ConvertTo-Json -Depth 6)
|
||||
Write-Host (' linked machine ' + $machine.machinenumber + ' (' + $controllerVendor + ', ' + $transport + ')') -ForegroundColor DarkGray
|
||||
} elseif (-not $SkipMachine) {
|
||||
Write-Host ' no eDNC/DNC/UDC machine config found on this PC' -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
# --- Upsert into the shared store --------------------------------------------
|
||||
|
||||
$rowsByStore = @{
|
||||
'vendors.csv' = $vendorRows
|
||||
'models.csv' = $modelRows
|
||||
'operatingsystems.csv' = $osRows
|
||||
'assets.csv' = $assetRows
|
||||
'computers.csv' = $computerRows
|
||||
'machines.csv' = $machineRows
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host (' Store: ' + $OutputRoot) -ForegroundColor Green
|
||||
foreach ($fileName in $script:Stores.Keys) {
|
||||
$spec = $script:Stores[$fileName]
|
||||
$rows = @($rowsByStore[$fileName])
|
||||
if ($rows.Count -eq 0) { continue }
|
||||
$path = Join-Path $OutputRoot $fileName
|
||||
$n = Merge-CsvStore -Path $path -Header $spec.header -Key $spec.key -IncomingRows $rows
|
||||
Write-Host (' merged ' + $n + ' row(s) into ' + $fileName) -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
# PC phase-2 sidecar.
|
||||
$pcSidecar = [ordered]@{
|
||||
assetnumber = $AssetNumber; hostname = $pc.hostname; collectedat = $collectedAt
|
||||
interfaces = $pc.interfaces; installedapps = $pc.installedapps
|
||||
}
|
||||
Write-Utf8NoBom -Path (Join-Path $sidecarDir ('computer_' + $AssetNumber + '.json')) -Content ($pcSidecar | ConvertTo-Json -Depth 6)
|
||||
|
||||
# --- Publish (standard-user network mirror, optional) -------------------------
|
||||
|
||||
if ($PublishRoot) {
|
||||
if (([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
|
||||
[Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
Write-Host ''
|
||||
Write-Host ' WARNING: publishing while elevated. Network writes should run as the standard user.' -ForegroundColor Yellow
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $PublishRoot)) { New-Item -ItemType Directory -Path $PublishRoot -Force | Out-Null }
|
||||
|
||||
# Merge THIS PC's rows into the shared store rather than overwriting it. A
|
||||
# blind copy would erase every other PC's rows (issue: the store is meant to
|
||||
# accumulate site-wide). Merge-CsvStore upserts field-by-field, so a second
|
||||
# PC's run adds its rows and refreshes shared ones without dropping the rest.
|
||||
# A lock file serializes concurrent publishers so two PCs cannot read-modify-
|
||||
# write the same store CSV at once and lose each other's rows.
|
||||
$lockPath = Join-Path $PublishRoot '.publish.lock'
|
||||
$lockStream = $null
|
||||
for ($attempt = 0; $attempt -lt 60; $attempt++) {
|
||||
try {
|
||||
$lockStream = [System.IO.File]::Open(
|
||||
$lockPath, [System.IO.FileMode]::OpenOrCreate,
|
||||
[System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None)
|
||||
break
|
||||
} catch {
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
}
|
||||
if ($null -eq $lockStream) {
|
||||
Write-Host ''
|
||||
Write-Host (' WARNING: could not acquire publish lock at ' + $lockPath + '; skipping network merge.') -ForegroundColor Yellow
|
||||
} else {
|
||||
try {
|
||||
foreach ($fileName in $script:Stores.Keys) {
|
||||
$spec = $script:Stores[$fileName]
|
||||
$localPath = Join-Path $OutputRoot $fileName
|
||||
if (-not (Test-Path -LiteralPath $localPath)) { continue }
|
||||
$localRows = @(Import-Csv -LiteralPath $localPath)
|
||||
if ($localRows.Count -eq 0) { continue }
|
||||
$sharePath = Join-Path $PublishRoot $fileName
|
||||
$n = Merge-CsvStore -Path $sharePath -Header $spec.header -Key $spec.key -IncomingRows $localRows
|
||||
Write-Host (' merged ' + $n + ' row(s) into ' + $fileName + ' on share') -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
# Sidecars are uniquely named per asset, so a straight copy is safe and
|
||||
# nothing is overwritten across PCs.
|
||||
$localSidecars = Join-Path $OutputRoot 'sidecars'
|
||||
if (Test-Path -LiteralPath $localSidecars) {
|
||||
$shareSidecars = Join-Path $PublishRoot 'sidecars'
|
||||
if (-not (Test-Path -LiteralPath $shareSidecars)) { New-Item -ItemType Directory -Path $shareSidecars -Force | Out-Null }
|
||||
Copy-Item -Path (Join-Path $localSidecars '*') -Destination $shareSidecars -Recurse -Force
|
||||
}
|
||||
} finally {
|
||||
$lockStream.Close()
|
||||
$lockStream.Dispose()
|
||||
Remove-Item -LiteralPath $lockPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
Write-Host ''
|
||||
Write-Host (' Published store to: ' + $PublishRoot) -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host ' Done. Dry-run validate the accumulated store with:' -ForegroundColor Cyan
|
||||
Write-Host (' flask csv import --dir "' + $OutputRoot + '"') -ForegroundColor White
|
||||
Write-Host ''
|
||||
91
scripts/site_imports/assetcollector/overview.md
Normal file
91
scripts/site_imports/assetcollector/overview.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Collect-ShopDB.ps1 Overview
|
||||
|
||||
## Overview
|
||||
|
||||
- **Purpose**: A unified, read-only "site adoption" collector that runs on any
|
||||
Windows PC, gathers facts about that PC plus any attached eDNC/DNC/UDC machine,
|
||||
and merges them into a shared set of importable CSVs so a site can build its
|
||||
ShopDB inventory one PC at a time.
|
||||
- **Compatibility**: PowerShell 5.1+; CSV headers are aligned to the ShopDB
|
||||
importer (`flask csv import --dir`).
|
||||
|
||||
## Configuration and destinations
|
||||
|
||||
- **Two-stage flow**: a fast local stage folder (`%ProgramData%\ShopDB\intake`)
|
||||
where CSVs are built/merged, and an optional network store (a file share) that
|
||||
the whole site accumulates into.
|
||||
- Command-line params `-OutputRoot` and `-PublishRoot` override the in-script
|
||||
CONFIG block.
|
||||
- Other params: `-AssetNumber` (defaults to hostname),
|
||||
`-AssetType`/`-ComputerType`/`-MachineType` (resolved by name), explicit
|
||||
`-RegPath`/`-JsonPath` machine source overrides, and `-SkipMachine`/`-SkipApps`
|
||||
switches for partial runs.
|
||||
|
||||
## Data model (the stores)
|
||||
|
||||
- Six importable CSVs, each with a defined header order and natural key:
|
||||
`vendors.csv`, `models.csv`, `operatingsystems.csv`, `assets.csv`,
|
||||
`computers.csv`, `machines.csv`.
|
||||
- Column names follow ShopDB conventions (e.g., `assetid`, `modelnumberid`,
|
||||
`controllervendorid`, `lastreporteddate`).
|
||||
|
||||
## PC collection (`Get-PcFacts`, read-only)
|
||||
|
||||
- Uses CIM/WMI (`Win32_ComputerSystem`, `Win32_OperatingSystem`, `Win32_BIOS`)
|
||||
to gather hostname, vendor/manufacturer, model, OS caption, serial number,
|
||||
last boot time, and logged-in user.
|
||||
- Enumerates **network interfaces** (hardware adapters only: alias, IPv4, MAC).
|
||||
- Enumerates **installed applications** from the registry Uninstall keys (name,
|
||||
version, publisher), skipping system components - unless `-SkipApps` is set.
|
||||
|
||||
## Machine (eDNC/DNC/UDC) collection
|
||||
|
||||
- `Resolve-DncSource` searches in priority order: explicit JSON path -> explicit
|
||||
.reg path -> live registry (`HKLM\...\GE Aircraft Engines\DNC`) -> local `.reg`
|
||||
export files -> local `udc_settings_*.json` files.
|
||||
- Three parsers normalize the different source formats into one shape:
|
||||
`ConvertFrom-RegExport` (parses exported `.reg` files including dword decoding),
|
||||
`ConvertFrom-LiveRegistry`, and `ConvertFrom-UdcJson`.
|
||||
- Extracts machine number, site, control type, DNC type, and connection detail
|
||||
(TCP address/port for ethernet, COM port/baud for serial).
|
||||
- `Resolve-ControllerVendor` maps control type / NcIF to a controller vendor
|
||||
(e.g., eFOCAS -> FANUC, Siemens, Okuma).
|
||||
|
||||
## Merge / upsert logic (`Merge-CsvStore`)
|
||||
|
||||
- Field-level UPSERT keyed on each table's natural key: existing rows are matched
|
||||
case-insensitively, and empty incoming cells never overwrite previously captured
|
||||
values - so partial or repeated runs fill gaps without wiping earlier data.
|
||||
- CSV writing is UTF-8 without BOM, with proper quoting/escaping.
|
||||
|
||||
## Sidecar files (phase-2 data CSV can't carry)
|
||||
|
||||
- Per-PC `sidecars\computer_<assetnumber>.json`: interfaces + installed apps.
|
||||
- Per-machine `sidecars\machine_<machinenumber>.json`: PC-to-machine link
|
||||
(relationship "controls"), DNC/control details, and connection transport info.
|
||||
|
||||
## Publishing and output
|
||||
|
||||
- Optionally mirrors the whole local store to the network share
|
||||
(`Copy-Item -Recurse -Force`), with a warning if run elevated (network writes
|
||||
should run as standard user).
|
||||
- Prints a summary and the follow-up dry-run command:
|
||||
`flask csv import --dir "<OutputRoot>"`.
|
||||
|
||||
---
|
||||
|
||||
## Relevance to an "Asset Collector" package
|
||||
|
||||
This script is a solid template for one collector in a broader package. Notable
|
||||
patterns worth generalizing across other collectors (existing databases,
|
||||
Intune/Azure exports, etc.):
|
||||
|
||||
- **Normalized intermediate schema** (the six CSV stores + JSON sidecars) that
|
||||
all sources converge on, decoupling collection from import.
|
||||
- **Idempotent field-level merge** so multiple heterogeneous sources can
|
||||
contribute to the same asset without clobbering each other.
|
||||
- **Source-priority resolution** and per-format parsers - the same shape could
|
||||
wrap an Intune/Azure `Get-MgDevice` export or a SQL query.
|
||||
- **Machines, PCs, Applications** are already modeled here; an Intune/Azure
|
||||
source would map naturally onto `computers.csv` + the installed-apps sidecar,
|
||||
while a MES/CNC database could feed `machines.csv`.
|
||||
5
scripts/site_imports/assetcollector/sample/assets.csv
Normal file
5
scripts/site_imports/assetcollector/sample/assets.csv
Normal file
@@ -0,0 +1,5 @@
|
||||
assetnumber,name,serialnumber,assettypeid,statusid,locationid,businessunitid,notes
|
||||
SAMPLE-PC-01,Shopfloor PC 01,DELLSN0001,computer,In Use,A1,Gage Lab,Sample collected device
|
||||
SAMPLE-PC-02,CMM PC 02,DELLSN0002,computer,In Use,A1,Gage Lab,Sample collected device
|
||||
SAMPLE-MC-01,Zeiss CMM 01,ZEISSSN0001,machine,In Use,A1,Gage Lab,Sample collected device attached to SAMPLE-PC-02
|
||||
SAMPLE-MC-02,Haas VF-2SS 02,HAASSN0002,machine,In Use,A1,Gage Lab,Sample collected standalone CNC
|
||||
|
3
scripts/site_imports/assetcollector/sample/computers.csv
Normal file
3
scripts/site_imports/assetcollector/sample/computers.csv
Normal file
@@ -0,0 +1,3 @@
|
||||
assetid,computertypeid,hostname,osid,vendorid,modelnumberid,loggedinuser
|
||||
SAMPLE-PC-01,Shopfloor PC,WKS-SAMPLE01,Windows 10 Enterprise,Dell,OptiPlex 7090,EXAMPLE\operator1
|
||||
SAMPLE-PC-02,CMM PC,WKS-SAMPLE02,Windows 11 Enterprise,Dell,Precision 3660,EXAMPLE\operator2
|
||||
|
3
scripts/site_imports/assetcollector/sample/machines.csv
Normal file
3
scripts/site_imports/assetcollector/sample/machines.csv
Normal file
@@ -0,0 +1,3 @@
|
||||
assetid,machinetypeid,vendorid,modelnumberid,requiresmanualconfig,islocationonly
|
||||
SAMPLE-MC-01,CMM,Zeiss,CONTURA G2,0,0
|
||||
SAMPLE-MC-02,CNC,Haas Automation,VF-2SS,0,0
|
||||
|
5
scripts/site_imports/assetcollector/sample/models.csv
Normal file
5
scripts/site_imports/assetcollector/sample/models.csv
Normal file
@@ -0,0 +1,5 @@
|
||||
modelnumber,vendorid
|
||||
OptiPlex 7090,Dell
|
||||
Precision 3660,Dell
|
||||
VF-2SS,Haas Automation
|
||||
CONTURA G2,Zeiss
|
||||
|
@@ -0,0 +1,3 @@
|
||||
osname
|
||||
Windows 10 Enterprise
|
||||
Windows 11 Enterprise
|
||||
|
4
scripts/site_imports/assetcollector/sample/vendors.csv
Normal file
4
scripts/site_imports/assetcollector/sample/vendors.csv
Normal file
@@ -0,0 +1,4 @@
|
||||
vendor
|
||||
Dell
|
||||
Haas Automation
|
||||
Zeiss
|
||||
|
@@ -2,14 +2,14 @@
|
||||
|
||||
This is **site-specific reference glue**, not product code. It maps West
|
||||
Jefferson's classic-ASP `shopdb` / `cmmc_usb` / `wjf_employees` schema onto the
|
||||
maintained, schema-agnostic import contract in `docs/IMPORT-API.md`.
|
||||
maintained, schema-agnostic import contract in the IMPORT-API wiki page.
|
||||
|
||||
**Every adopting site has its own source database.** Nobody else runs this
|
||||
loader. Instead, copy the pattern:
|
||||
|
||||
1. Point the harness at your source DB(s) (edit `harness.Source`).
|
||||
2. Write per-entity stages that read your tables and POST to the same
|
||||
`docs/IMPORT-API.md` endpoints with `Authorization: Bearer <admin PAT>` and
|
||||
the IMPORT-API wiki page endpoints with `Authorization: Bearer <admin PAT>` and
|
||||
`X-Import-Mode: true`.
|
||||
3. Persist legacy-id -> new-id crosswalks (see `harness.IdMap`) so later stages
|
||||
resolve foreign keys and a crashed run resumes.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
This is SITE-SPECIFIC reference glue, NOT product code. It maps West Jefferson's
|
||||
classic schema onto the maintained, schema-agnostic import contract in
|
||||
docs/IMPORT-API.md. Other sites copy this pattern against their own source DB;
|
||||
the IMPORT-API wiki page. Other sites copy this pattern against their own source DB;
|
||||
they do not run this loader.
|
||||
|
||||
The harness builds the flask app against the current DATABASE_URL (point it at a
|
||||
@@ -143,7 +143,7 @@ class Harness:
|
||||
def _mint_admin_pat(self):
|
||||
"""Create (or reuse) an admin user and an unscoped admin PAT. Unscoped
|
||||
because import mode + role gates require full owner authority
|
||||
(docs/IMPORT-API.md). Returns the clear secret."""
|
||||
(the IMPORT-API wiki page). Returns the clear secret."""
|
||||
from werkzeug.security import generate_password_hash
|
||||
from shopdb.core.models import User, Role, ApiToken
|
||||
admin = User.query.join(User.roles).filter(Role.rolename == 'admin').first()
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
# PLUGIN_DIR ($1) path to the plugin directory (holds manifest.json). Required.
|
||||
# FRAMEWORK_REF ($2) git ref to test against in CI mode. Default: main.
|
||||
# FRAMEWORK_URL framework git URL for CI mode.
|
||||
# Default: https://gitea.proudtech.net/ge-aerospace/shopdb-flask.git
|
||||
# Default: https://github.com/ge-aero/shopdb-flask.git
|
||||
# LOCAL_FRAMEWORK path to an existing framework checkout. Set it to run offline.
|
||||
#
|
||||
# Examples:
|
||||
@@ -37,13 +37,13 @@
|
||||
# import-surface contract test - the scanner resolves each plugin dir before
|
||||
# walking, since bare Path.rglob does not descend symlinks. Optionally keep an
|
||||
# equivalent import-surface assertion in your own tests/ so violations fail
|
||||
# in your repo's CI too (see docs/PLUGIN-EXTERNAL-REPO.md).
|
||||
# in your repo's CI too (see the PLUGIN-EXTERNAL-REPO wiki page).
|
||||
|
||||
set -eu
|
||||
|
||||
PLUGIN_DIR="${PLUGIN_DIR:-${1:-}}"
|
||||
FRAMEWORK_REF="${FRAMEWORK_REF:-${2:-main}}"
|
||||
FRAMEWORK_URL="${FRAMEWORK_URL:-https://gitea.proudtech.net/ge-aerospace/shopdb-flask.git}"
|
||||
FRAMEWORK_URL="${FRAMEWORK_URL:-https://github.com/ge-aero/shopdb-flask.git}"
|
||||
LOCAL_FRAMEWORK="${LOCAL_FRAMEWORK:-}"
|
||||
|
||||
if [ -z "$PLUGIN_DIR" ]; then
|
||||
|
||||
Reference in New Issue
Block a user