Add flask seed demo sample data; widen settings.description to TEXT
This commit is contained in:
9
.github/workflows/ci.yml
vendored
9
.github/workflows/ci.yml
vendored
@@ -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'
|
||||
|
||||
61
migrations/versions/7d26_settings_description_text.py
Normal file
61
migrations/versions/7d26_settings_description_text.py
Normal file
@@ -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)
|
||||
@@ -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'))
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user