From 199438cec941b8f480aa273e8dde44c1e3b96f51 Mon Sep 17 00:00:00 2001 From: Cam Proudlock Date: Fri, 17 Jul 2026 20:32:38 -0400 Subject: [PATCH] Add flask seed demo sample data; widen settings.description to TEXT --- .github/workflows/ci.yml | 9 + .../7d26_settings_description_text.py | 61 ++++ shopdb/cli/__init__.py | 277 ++++++++++++++++++ shopdb/core/models/setting.py | 2 +- 4 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 migrations/versions/7d26_settings_description_text.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a914e305..bf5fa5e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,6 +92,15 @@ jobs: flask plugin install "$p" done flask plugin upgrade-all + - name: Seed platform data (catches strict-mode data violations) + # Runs the real seeders on strict MySQL 8. A seeded row that exceeds a + # column width is a hard error 1406 here (not the silent truncation + # older/relaxed MySQL gives), so this is what catches over-length + # setting descriptions and similar before they reach a fresh box. + run: | + flask seed permissions + flask seed settings + flask seed reference-data - name: Assert schema built + utf8mb4 run: | python - <<'PY' diff --git a/migrations/versions/7d26_settings_description_text.py b/migrations/versions/7d26_settings_description_text.py new file mode 100644 index 00000000..b14f938d --- /dev/null +++ b/migrations/versions/7d26_settings_description_text.py @@ -0,0 +1,61 @@ +"""Widen settings.description to TEXT (was varchar(255)) + +Seeded setting descriptions are explanatory sentences; some exceed 255 chars +(e.g. dualpath_single_machine). On strict MySQL 8 an over-length insert is a +hard error 1406, not a silent truncation, so `flask seed settings` failed on a +fresh box. TEXT removes the ceiling and matches value, which is already TEXT. + +Idempotent guard on current column type; real downgrade back to varchar(255). + +Revision ID: 7d26_settings_description_text +Revises: 7d25_drop_redundant_indexes +Create Date: 2026-07-17 + +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d26_settings_description_text' +down_revision = '7d25_drop_redundant_indexes' +branch_labels = None +depends_on = None + + +def _description_type(insp): + for c in insp.get_columns('settings'): + if c['name'] == 'description': + return str(c['type']).upper() + return None + + +def upgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + + if 'settings' not in insp.get_table_names(): + return + coltype = _description_type(insp) + if coltype is None or 'TEXT' in coltype: + return + + op.alter_column('settings', 'description', + existing_type=sa.String(length=255), + type_=sa.Text(), + existing_nullable=True) + + +def downgrade(): + bind = op.get_bind() + insp = sa.inspect(bind) + + if 'settings' not in insp.get_table_names(): + return + coltype = _description_type(insp) + if coltype is None or 'TEXT' not in coltype: + return + + op.alter_column('settings', 'description', + existing_type=sa.Text(), + type_=sa.String(length=255), + existing_nullable=True) diff --git a/shopdb/cli/__init__.py b/shopdb/cli/__init__.py index 3d991339..9da72494 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')) diff --git a/shopdb/core/models/setting.py b/shopdb/core/models/setting.py index 8c82c1fa..1c209cb0 100644 --- a/shopdb/core/models/setting.py +++ b/shopdb/core/models/setting.py @@ -26,7 +26,7 @@ class Setting(db.Model): value = db.Column(db.Text, nullable=True) valuetype = db.Column(db.String(20), default='string') # string, boolean, integer, json category = db.Column(db.String(50), default='general') # For grouping in UI - description = db.Column(db.String(255), nullable=True) + description = db.Column(db.Text, nullable=True) createddate = db.Column(db.DateTime, default=_utcnow) modifieddate = db.Column(db.DateTime, default=_utcnow, onupdate=_utcnow)