diff --git a/docs/DEVELOPMENT-SETUP.md b/docs/DEVELOPMENT-SETUP.md index 870e137..5131363 100644 --- a/docs/DEVELOPMENT-SETUP.md +++ b/docs/DEVELOPMENT-SETUP.md @@ -45,6 +45,41 @@ identically across 20-24, so it does not matter. To pin exactly: --- +## 0b. Corp network (SSL cert) - if you are behind a GE/Zscaler proxy + +A proxy that inspects HTTPS (Zscaler on GE PCs) re-signs every connection +with a corporate root CA. `git`, `npm`, `pip`, and Node each keep their own +trust store and do not trust that CA by default, so downloads fail: + +| Tool | Symptom | +| --- | --- | +| npm | `UNABLE_TO_GET_ISSUER_CERT_LOCALLY` | +| git | `SSL certificate problem: unable to get local issuer certificate` | +| pip | `SSLError` / `CERTIFICATE_VERIFY_FAILED` | + +Fix once - export the corp root CA, point every tool at it. PowerShell +mangles multi-line pastes, so each step below is **one physical line**: paste +it, press Enter, then the next. Do not paste both at once. + +```powershell +# 1) Bundle EVERY trusted root into one PEM (one line). Guessing which single cert is the proxy's is fragile; bundling all always includes it. +$sb = New-Object System.Text.StringBuilder; Get-ChildItem Cert:\LocalMachine\Root | ForEach-Object { [void]$sb.AppendLine("-----BEGIN CERTIFICATE-----"); [void]$sb.AppendLine([Convert]::ToBase64String($_.RawData,'InsertLineBreaks')); [void]$sb.AppendLine("-----END CERTIFICATE-----") }; [IO.File]::WriteAllText("$HOME\corp-root-ca.pem", $sb.ToString()) +``` + +Confirm it has many certs (dozens, not 1): +`(Select-String "BEGIN CERTIFICATE" $HOME\corp-root-ca.pem).Count` + +```powershell +# 2) Point every tool at it (one line, persistent). NODE_EXTRA_CA_CERTS also fixes Vite / npm run dev. +git config --global http.sslCAInfo "$HOME\corp-root-ca.pem"; npm config set cafile "$HOME\corp-root-ca.pem"; setx NODE_EXTRA_CA_CERTS "$HOME\corp-root-ca.pem"; setx PIP_CERT "$HOME\corp-root-ca.pem" +``` + +Reopen the terminal so `setx` takes effect. Quick unblock if you cannot +export right now (skips verification - use briefly, then set back): +`npm config set strict-ssl false`, `git config --global http.sslVerify false`. + +--- + ## 1. Get the code ```powershell @@ -76,6 +111,7 @@ docker compose exec api flask seed permissions docker compose exec api flask seed settings docker compose exec api flask seed reference-data docker compose exec api flask seed admin --username admin --email you@example.com +docker compose exec api flask seed demo # OPTIONAL: sample data (undo: flask seed demo-clear) ``` The app is on the port the compose file maps (see `docker-compose.yml`). Good @@ -125,6 +161,7 @@ flask seed permissions flask seed settings flask seed reference-data flask seed admin --username admin --email you@example.com # password printed once +flask seed demo # OPTIONAL: ~25 sample assets across plugins + printed parts (undo: flask seed demo-clear) ``` Enable the plugins you want visible (they install on a fresh box; some ship @@ -149,7 +186,13 @@ Run the backend ON PORT 5001 - the frontend dev server proxies `/api` and flask run --port 5001 ``` -### Frontend (a second terminal) +**Leave this running.** `flask run` does not return to a prompt - that is +correct, not a hang. The server holds this terminal until you stop it. Do +NOT press Ctrl+C to move on; that kills the backend. Open the frontend in a +separate terminal (next section) and leave this one alone. Ctrl+C only when +you are done for the day. + +### Frontend (a second terminal - leave the backend running) ```powershell cd frontend @@ -253,5 +296,6 @@ section of the plugin lab for the full review checklist. | `flask db upgrade` error 1071 (key too long) | MySQL 5.6 without the `innodb_large_prefix`/Barracuda flags; use MySQL 8 for dev. | | Nav missing Machines/PCs/... | plugins not installed/enabled (step 2b), or the backend not restarted after enabling. | | "No time zone found with key America/New_York" | `tzdata` not installed - `pip install -r requirements.txt` includes it. | +| npm/git/pip SSL error (`UNABLE_TO_GET_ISSUER_CERT_LOCALLY`, `unable to get local issuer certificate`) | corp proxy (Zscaler) intercepts HTTPS - point each tool at the corp root CA. See section 0b. | | Naming hook rejects a commit | you used snake_case on a DB-mirrored field or a banned acronym - see `CONTRIBUTING.md`. | | Plugin toggle throws an internal error | app cannot write `instance/` (the plugin registry lives there) - fix directory permissions. | diff --git a/shopdb/cli/__init__.py b/shopdb/cli/__init__.py index 3d99133..9da7249 100644 --- a/shopdb/cli/__init__.py +++ b/shopdb/cli/__init__.py @@ -511,3 +511,280 @@ def seed_settings(): db.session.commit() click.echo(click.style(f"{created} default settings created.", fg='green')) + + +# Demo assets carry this assetnumber prefix so a re-run skips what it made and +# an operator can bulk-delete them without touching imported/real rows. +DEMO_PREFIX = 'DEMO-' + + +@seed_cli.command('demo') +@click.option('--force', is_flag=True, + help='Add demo rows even if DEMO- assets already exist.') +@with_appcontext +def seed_demo(force): + """Seed a small, broad sample dataset for a dev/eval site. + + Populates a handful of rows across every asset-based plugin (machines, + computers, printers, network devices, measuring tools) plus 3D-printed + parts, with supporting vendors/business-units/locations and a few + relationships, so every screen has something to show. Run AFTER + `flask seed reference-data` and after the plugins are installed. Idempotent: + all rows are keyed on the DEMO- prefix and skipped if already present. + + Not for production. Remove later with: + flask seed demo-clear + """ + from shopdb.extensions import db + from shopdb.core.models import (Asset, AssetType, AssetStatus, Location, + BusinessUnit, Vendor) + + existing = Asset.query.filter( + Asset.assetnumber.like(f'{DEMO_PREFIX}%')).count() + if existing and not force: + click.echo(click.style( + f"{existing} demo assets already present - nothing to do " + f"(use --force to add more, or `flask seed demo-clear` to reset).", + fg='yellow')) + return + + def status_id(name, fallback=1): + # resolve status by name, fall back to whatever id 1 is + s = AssetStatus.query.filter_by(status=name).first() + return s.statusid if s else fallback + + def get_or_make(model, defaults=None, **lookup): + # tiny idempotent upsert keyed on lookup fields + row = model.query.filter_by(**lookup).first() + if row: + return row + row = model(**lookup, **(defaults or {})) + db.session.add(row) + db.session.flush() + return row + + # Supporting reference rows (shared across the asset types below). + vendors = {v: get_or_make(Vendor, vendor=v) for v in + ('Haas Automation', 'DMG Mori', 'Dell', 'Zeiss', 'Cisco', + 'Brother')} + units = {u: get_or_make(BusinessUnit, businessunit=u) for u in + ('Machining', 'Inspection', 'IT')} + locations = {loc: get_or_make(Location, locationname=loc) for loc in + ('Cell A', 'Cell B', 'QA Lab', 'Server Room', 'Front Office')} + + made = {'assets': 0, 'skipped': 0} + + def make_asset(assettype_name, number, name, subtype_model, + status='In Use', location=None, unit=None, vendor=None, + serialnumber=None, subtype_kwargs=None): + # create one Asset + its plugin subtype row, idempotent on assetnumber. + # returns the Asset, or None when the plugin type is not installed. + atype = AssetType.query.filter_by(assettype=assettype_name).first() + if not atype: + return None + assetnumber = f'{DEMO_PREFIX}{number}' + if Asset.query.filter_by(assetnumber=assetnumber).first(): + made['skipped'] += 1 + return None + asset = Asset( + assetnumber=assetnumber, + name=name, + assettypeid=atype.assettypeid, + statusid=status_id(status), + serialnumber=serialnumber, + locationid=locations[location].locationid if location else None, + businessunitid=units[unit].businessunitid if unit else None, + ) + db.session.add(asset) + db.session.flush() + sub = subtype_model(assetid=asset.assetid, **(subtype_kwargs or {})) + db.session.add(sub) + made['assets'] += 1 + return asset + + from plugins.machines.models import Machine + from plugins.computers.models import Computer + from plugins.printers.models import Printer + from plugins.network.models import NetworkDevice + from plugins.measuringtools.models import MeasuringTool + + machines = [ + ('MILL-01', 'Haas VF-2 Mill', 'In Use', 'Cell A', 'Machining'), + ('MILL-02', 'Haas VF-4 Mill', 'In Use', 'Cell A', 'Machining'), + ('LATHE-01', 'DMG Mori NLX Lathe', 'In Use', 'Cell B', 'Machining'), + ('LATHE-02', 'DMG Mori CLX Lathe', 'In Repair', 'Cell B', 'Machining'), + ('EDM-01', 'Wire EDM', 'Inventory', 'Cell B', 'Machining'), + ('GRIND-01', 'Surface Grinder', 'In Use', 'Cell A', 'Machining'), + ] + for num, name, st, loc, unit in machines: + make_asset('machine', num, name, Machine, status=st, + location=loc, unit=unit, serialnumber=f'SN-{num}') + + computers = [ + ('PC-01', 'Shopfloor PC - Cell A', 'In Use', 'Cell A'), + ('PC-02', 'Shopfloor PC - Cell B', 'In Use', 'Cell B'), + ('PC-03', 'QA Workstation', 'In Use', 'QA Lab'), + ('PC-04', 'Engineering Laptop', 'In Use', 'Front Office'), + ('PC-05', 'Spare Desktop', 'Inventory', 'Front Office'), + ('PC-06', 'Retired Tower', 'Retired', 'Front Office'), + ] + for num, name, st, loc in computers: + make_asset('computer', num, name, Computer, status=st, + location=loc, unit='IT', serialnumber=f'SN-{num}') + + printers = [ + ('PRN-01', 'Cell A Label Printer', 'In Use', 'Cell A'), + ('PRN-02', 'QA Report Printer', 'In Use', 'QA Lab'), + ('PRN-03', 'Office MFP', 'In Use', 'Front Office'), + ('PRN-04', 'Spare Printer', 'Inventory', 'Front Office'), + ] + for num, name, st, loc in printers: + make_asset('printer', num, name, Printer, status=st, + location=loc, unit='IT') + + network = [ + ('NET-01', 'Cell A Switch', 'In Use', 'Cell A'), + ('NET-02', 'Cell B Switch', 'In Use', 'Cell B'), + ('NET-03', 'Core Switch', 'In Use', 'Server Room'), + ('NET-04', 'Shop Access Point', 'In Use', 'Cell A'), + ] + for num, name, st, loc in network: + make_asset('network_device', num, name, NetworkDevice, status=st, + location=loc, unit='IT') + + tools = [ + ('CMM-01', 'Zeiss CMM', 'In Use', 'QA Lab'), + ('GAGE-01', 'Height Gage', 'In Use', 'QA Lab'), + ('GAGE-02', 'Bore Gage', 'In Use', 'QA Lab'), + ('MIC-01', 'Digital Micrometer', 'In Use', 'Cell A'), + ('CAL-01', 'Digital Caliper', 'Inventory', 'QA Lab'), + ] + for num, name, st, loc in tools: + make_asset('measuring_tool', num, name, MeasuringTool, status=st, + location=loc, unit='Inspection') + + # 3D-printed parts are not assets - own table. A couple sit below their + # low-stock threshold on purpose so the low-stock alert has something to fire. + printedparts_made = 0 + try: + from plugins.printedparts.models import PrintedItem + parts = [ + # itemname, itemcode, gagelabtag, qty, threshold, bin + ('Fixture Bracket', 'PP0001', 'WJRP10021', 12, 4, 'A1'), + ('Gage Holder', 'PP0002', 'WJRP10022', 3, 5, 'A2'), + ('Cable Clip', 'PP0003', None, 40, 10, 'B1'), + ('Sensor Mount', 'PP0004', 'WJRP10023', 2, 6, 'B2'), + ('Label Guide', 'PP0005', None, 25, 8, 'C1'), + ('Knob Cover', 'PP0006', None, 0, 3, 'C2'), + ] + for name, code, tag, qty, thr, binloc in parts: + if PrintedItem.query.filter_by(itemcode=code).first(): + continue + db.session.add(PrintedItem( + itemname=name, itemcode=code, gagelabtag=tag, + quantityonhand=qty, lowstockthreshold=thr, binlocation=binloc, + itemdescription=f'Sample 3D-printed part: {name}.')) + printedparts_made += 1 + except ImportError: + pass # printedparts plugin not installed - skip + + db.session.flush() + + # A few relationships so the map + relationship cards are not empty. + rels_made = 0 + try: + from shopdb.core.models.relationship import (RelationshipType, + AssetRelationship) + + def asset_by(number): + return Asset.query.filter_by( + assetnumber=f'{DEMO_PREFIX}{number}').first() + + def link(source_num, target_num, typename): + nonlocal rels_made + rt = RelationshipType.query.filter_by( + relationshiptype=typename).first() + s, t = asset_by(source_num), asset_by(target_num) + if not (rt and s and t): + return + exists = AssetRelationship.query.filter_by( + sourceassetid=s.assetid, targetassetid=t.assetid, + relationshiptypeid=rt.relationshiptypeid).first() + if exists: + return + db.session.add(AssetRelationship( + sourceassetid=s.assetid, targetassetid=t.assetid, + relationshiptypeid=rt.relationshiptypeid)) + rels_made += 1 + + link('PC-01', 'MILL-01', 'controls') # cell PC drives the mill + link('PC-02', 'LATHE-01', 'controls') + link('PC-01', 'PRN-01', 'defaultprinter') # PC to its default printer + link('MILL-01', 'NET-01', 'connectedto') # machine on the cell switch + link('NET-01', 'NET-03', 'connectedto') # cell switch to core + except Exception: + pass # relationship model surface changed - skip, assets still seeded + + db.session.commit() + click.echo(click.style( + f"Demo data seeded: {made['assets']} assets, " + f"{printedparts_made} printed parts, {rels_made} relationships " + f"({made['skipped']} already existed).", fg='green')) + click.echo("Remove later with: flask seed demo-clear") + + +@seed_cli.command('demo-clear') +@click.option('--yes', is_flag=True, help='Skip the confirmation prompt.') +@with_appcontext +def seed_demo_clear(yes): + """Delete everything `flask seed demo` created (DEMO- assets + sample parts). + + Only touches rows the demo seeder made: assets with the DEMO- prefix (their + plugin subtype rows cascade) and the PP000x sample printed parts. Leaves + reference data, settings, users, and any real/imported rows alone. + """ + from shopdb.extensions import db + from shopdb.core.models import Asset + + demo_ids = [a.assetid for a in Asset.query.filter( + Asset.assetnumber.like(f'{DEMO_PREFIX}%')).all()] + try: + from plugins.printedparts.models import PrintedItem + parts_count = PrintedItem.query.filter( + PrintedItem.itemcode.like('PP000%')).count() + except ImportError: + parts_count = 0 + + if not demo_ids and not parts_count: + click.echo(click.style("No demo data found.", fg='yellow')) + return + if not yes: + click.confirm( + f"Delete {len(demo_ids)} demo assets and " + f"{parts_count} sample parts?", abort=True) + + if demo_ids: + # Drop the demo relationships first - assetrelationships has no cascade + # to assets, so a leftover edge would block the asset delete. + from shopdb.core.models.relationship import AssetRelationship + AssetRelationship.query.filter( + db.or_(AssetRelationship.sourceassetid.in_(demo_ids), + AssetRelationship.targetassetid.in_(demo_ids)) + ).delete(synchronize_session=False) + # Bulk hard-delete via a single DELETE statement so the DB-level + # ON DELETE CASCADE removes each plugin subtype row. Per-object + # ORM delete would instead try to NULL the child assetid (NOT NULL) + # and fail. + Asset.query.filter(Asset.assetid.in_(demo_ids)).delete( + synchronize_session=False) + + parts_deleted = 0 + if parts_count: + parts_deleted = PrintedItem.query.filter( + PrintedItem.itemcode.like('PP000%')).delete( + synchronize_session=False) + + db.session.commit() + click.echo(click.style( + f"Removed {len(demo_ids)} demo assets and " + f"{parts_deleted} sample parts.", fg='green'))