Initial import: shopdb-flask multi-site asset platform

Flask + Vue rewrite of the classic-ASP shopdb, built as a plugin
framework so sister GE Aerospace sites can adopt it. Product version
0.7.0-pre-0.8.0, plugin contract 0.11.0, 966 backend tests.

Key entry points:
- docs/INSTALL-WINDOWS-IIS.md - tested Windows + IIS install runbook
  (own-site and subpath deployment methods)
- docs/PILOT-DEPLOY.md - production pilot: stand up + import + verify
- docs/IMPORT-API.md - schema-agnostic legacy data import contract
- scripts/site_imports/wjf/ - West Jefferson reference import loader
- docs/adr/ - twelve accepted architecture decision records
This commit is contained in:
2026-07-14 07:58:51 -04:00
commit a9d0dfff4a
599 changed files with 88557 additions and 0 deletions

View File

@@ -0,0 +1,956 @@
"""baseline schema
Revision ID: 68b3947ae14f
Revises:
Create Date: 2026-05-08 17:53:08.342776
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '68b3947ae14f'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('appowners',
sa.Column('appownerid', sa.Integer(), nullable=False),
sa.Column('appowner', sa.String(length=100), nullable=False),
sa.Column('sso', sa.String(length=50), nullable=True),
sa.Column('email', sa.String(length=100), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('appownerid')
)
op.create_table('assetstatuses',
sa.Column('statusid', sa.Integer(), nullable=False),
sa.Column('status', sa.String(length=50), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('color', sa.String(length=20), nullable=True, comment='CSS color for UI'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('statusid'),
sa.UniqueConstraint('status')
)
op.create_table('assettypes',
sa.Column('assettypeid', sa.Integer(), nullable=False),
sa.Column('assettype', sa.String(length=50), nullable=False, comment='Category name: equipment, computer, network_device, printer'),
sa.Column('pluginname', sa.String(length=100), nullable=True, comment='Plugin that owns this type'),
sa.Column('tablename', sa.String(length=100), nullable=True, comment='Extension table name for this type'),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('icon', sa.String(length=50), nullable=True, comment='Icon name for UI'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('assettypeid'),
sa.UniqueConstraint('assettype')
)
op.create_table('businessunits',
sa.Column('businessunitid', sa.Integer(), nullable=False),
sa.Column('businessunit', sa.String(length=100), nullable=False),
sa.Column('code', sa.String(length=20), nullable=True, comment='Short code'),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('parentid', sa.Integer(), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['parentid'], ['businessunits.businessunitid'], ),
sa.PrimaryKeyConstraint('businessunitid'),
sa.UniqueConstraint('businessunit'),
sa.UniqueConstraint('code')
)
op.create_table('communicationtypes',
sa.Column('comtypeid', sa.Integer(), nullable=False),
sa.Column('comtype', sa.String(length=50), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('comtypeid'),
sa.UniqueConstraint('comtype')
)
op.create_table('computertypes',
sa.Column('computertypeid', sa.Integer(), nullable=False),
sa.Column('computertype', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('icon', sa.String(length=50), nullable=True, comment='Icon name for UI'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('computertypeid'),
sa.UniqueConstraint('computertype')
)
op.create_table('equipmenttypes',
sa.Column('equipmenttypeid', sa.Integer(), nullable=False),
sa.Column('equipmenttype', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('icon', sa.String(length=50), nullable=True, comment='Icon name for UI'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('equipmenttypeid'),
sa.UniqueConstraint('equipmenttype')
)
op.create_table('locations',
sa.Column('locationid', sa.Integer(), nullable=False),
sa.Column('locationname', sa.String(length=100), nullable=False),
sa.Column('building', sa.String(length=100), nullable=True),
sa.Column('floor', sa.String(length=50), nullable=True),
sa.Column('room', sa.String(length=50), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('mapimage', sa.String(length=500), nullable=True, comment='Path to floor map image'),
sa.Column('mapwidth', sa.Integer(), nullable=True),
sa.Column('mapheight', sa.Integer(), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('locationid'),
sa.UniqueConstraint('locationname')
)
op.create_table('machinestatuses',
sa.Column('statusid', sa.Integer(), nullable=False),
sa.Column('status', sa.String(length=50), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('color', sa.String(length=20), nullable=True, comment='CSS color for UI'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('statusid'),
sa.UniqueConstraint('status')
)
op.create_table('machinetypes',
sa.Column('machinetypeid', sa.Integer(), nullable=False),
sa.Column('machinetype', sa.String(length=100), nullable=False),
sa.Column('category', sa.String(length=50), nullable=False, comment='Equipment, PC, Network, or Printer'),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('icon', sa.String(length=50), nullable=True, comment='Icon name for UI'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('machinetypeid'),
sa.UniqueConstraint('machinetype')
)
op.create_table('networkdevicetypes',
sa.Column('networkdevicetypeid', sa.Integer(), nullable=False),
sa.Column('networkdevicetype', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('icon', sa.String(length=50), nullable=True, comment='Icon name for UI'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('networkdevicetypeid'),
sa.UniqueConstraint('networkdevicetype')
)
op.create_table('notificationtypes',
sa.Column('notificationtypeid', sa.Integer(), nullable=False),
sa.Column('typename', sa.String(length=50), nullable=False),
sa.Column('typedescription', sa.Text(), nullable=True),
sa.Column('typecolor', sa.String(length=20), nullable=True),
sa.Column('isactive', sa.Boolean(), nullable=True),
sa.PrimaryKeyConstraint('notificationtypeid')
)
op.create_table('operatingsystems',
sa.Column('osid', sa.Integer(), nullable=False),
sa.Column('osname', sa.String(length=100), nullable=False),
sa.Column('osversion', sa.String(length=50), nullable=True),
sa.Column('architecture', sa.String(length=20), nullable=True, comment='x86, x64, ARM'),
sa.Column('endoflife', sa.Date(), nullable=True, comment='End of support date'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('osid'),
sa.UniqueConstraint('osname', 'osversion', name='uq_os_name_version')
)
op.create_table('pctypes',
sa.Column('pctypeid', sa.Integer(), nullable=False),
sa.Column('pctype', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('pctypeid'),
sa.UniqueConstraint('pctype')
)
op.create_table('permissions',
sa.Column('permissionid', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=50), nullable=False),
sa.Column('description', sa.String(length=255), nullable=True),
sa.Column('category', sa.String(length=50), nullable=True),
sa.PrimaryKeyConstraint('permissionid'),
sa.UniqueConstraint('name')
)
op.create_table('printertypes',
sa.Column('printertypeid', sa.Integer(), nullable=False),
sa.Column('printertype', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('icon', sa.String(length=50), nullable=True, comment='Icon name for UI'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('printertypeid'),
sa.UniqueConstraint('printertype')
)
op.create_table('relationshiptypes',
sa.Column('relationshiptypeid', sa.Integer(), nullable=False),
sa.Column('relationshiptype', sa.String(length=50), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('relationshiptypeid'),
sa.UniqueConstraint('relationshiptype')
)
op.create_table('roles',
sa.Column('roleid', sa.Integer(), nullable=False),
sa.Column('rolename', sa.String(length=50), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('roleid'),
sa.UniqueConstraint('rolename')
)
op.create_table('settings',
sa.Column('settingid', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('key', sa.String(length=100), nullable=False),
sa.Column('value', sa.Text(), nullable=True),
sa.Column('valuetype', sa.String(length=20), nullable=True),
sa.Column('category', sa.String(length=50), nullable=True),
sa.Column('description', sa.String(length=255), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=True),
sa.Column('modifieddate', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('settingid')
)
with op.batch_alter_table('settings', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_settings_key'), ['key'], unique=True)
op.create_table('usbdevicetypes',
sa.Column('usbdevicetypeid', sa.Integer(), nullable=False),
sa.Column('typename', sa.String(length=50), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('icon', sa.String(length=50), nullable=True, comment='Icon name for UI'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('usbdevicetypeid'),
sa.UniqueConstraint('typename')
)
op.create_table('users',
sa.Column('userid', sa.Integer(), nullable=False),
sa.Column('username', sa.String(length=100), nullable=False),
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('passwordhash', sa.String(length=255), nullable=False),
sa.Column('firstname', sa.String(length=100), nullable=True),
sa.Column('lastname', sa.String(length=100), nullable=True),
sa.Column('lastlogindate', sa.DateTime(), nullable=True),
sa.Column('failedlogins', sa.Integer(), nullable=True),
sa.Column('lockeduntil', sa.DateTime(), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('userid'),
sa.UniqueConstraint('email')
)
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_users_username'), ['username'], unique=True)
op.create_table('vendors',
sa.Column('vendorid', sa.Integer(), nullable=False),
sa.Column('vendor', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('website', sa.String(length=255), nullable=True),
sa.Column('supportphone', sa.String(length=50), nullable=True),
sa.Column('supportemail', sa.String(length=100), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('vendorid'),
sa.UniqueConstraint('vendor')
)
op.create_table('vlans',
sa.Column('vlanid', sa.Integer(), nullable=False),
sa.Column('vlannumber', sa.Integer(), nullable=False, comment='VLAN ID number'),
sa.Column('name', sa.String(length=100), nullable=False, comment='VLAN name'),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('vlantype', sa.String(length=50), nullable=True, comment='Type: data, voice, management, guest, etc.'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('vlanid'),
sa.UniqueConstraint('vlannumber')
)
with op.batch_alter_table('vlans', schema=None) as batch_op:
batch_op.create_index('idx_vlan_number', ['vlannumber'], unique=False)
op.create_table('assets',
sa.Column('assetid', sa.Integer(), nullable=False),
sa.Column('assetnumber', sa.String(length=50), nullable=False, comment='Business identifier (e.g., CMM01, G5QX1GT3ESF)'),
sa.Column('name', sa.String(length=100), nullable=True, comment='Display name/alias'),
sa.Column('serialnumber', sa.String(length=100), nullable=True, comment='Hardware serial number'),
sa.Column('assettypeid', sa.Integer(), nullable=False),
sa.Column('statusid', sa.Integer(), nullable=True, comment='In Use, Spare, Retired, etc.'),
sa.Column('locationid', sa.Integer(), nullable=True),
sa.Column('businessunitid', sa.Integer(), nullable=True),
sa.Column('mapleft', sa.Integer(), nullable=True, comment='X coordinate on floor map'),
sa.Column('maptop', sa.Integer(), nullable=True, comment='Y coordinate on floor map'),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.Column('deleteddate', sa.DateTime(), nullable=True),
sa.Column('deletedby', sa.String(length=100), nullable=True),
sa.Column('createdby', sa.String(length=100), nullable=True),
sa.Column('modifiedby', sa.String(length=100), nullable=True),
sa.ForeignKeyConstraint(['assettypeid'], ['assettypes.assettypeid'], ),
sa.ForeignKeyConstraint(['businessunitid'], ['businessunits.businessunitid'], ),
sa.ForeignKeyConstraint(['locationid'], ['locations.locationid'], ),
sa.ForeignKeyConstraint(['statusid'], ['assetstatuses.statusid'], ),
sa.PrimaryKeyConstraint('assetid')
)
with op.batch_alter_table('assets', schema=None) as batch_op:
batch_op.create_index('idx_asset_active', ['isactive'], unique=False)
batch_op.create_index('idx_asset_location', ['locationid'], unique=False)
batch_op.create_index('idx_asset_status', ['statusid'], unique=False)
batch_op.create_index('idx_asset_type_bu', ['assettypeid', 'businessunitid'], unique=False)
batch_op.create_index(batch_op.f('ix_assets_assetnumber'), ['assetnumber'], unique=True)
batch_op.create_index(batch_op.f('ix_assets_serialnumber'), ['serialnumber'], unique=False)
op.create_table('auditlogs',
sa.Column('auditlogid', sa.Integer(), nullable=False),
sa.Column('userid', sa.Integer(), nullable=True),
sa.Column('username', sa.String(length=100), nullable=True),
sa.Column('timestamp', sa.DateTime(), nullable=False),
sa.Column('ipaddress', sa.String(length=45), nullable=True),
sa.Column('useragent', sa.String(length=255), nullable=True),
sa.Column('action', sa.String(length=20), nullable=False),
sa.Column('entitytype', sa.String(length=50), nullable=False),
sa.Column('entityid', sa.Integer(), nullable=True),
sa.Column('entityname', sa.String(length=255), nullable=True),
sa.Column('changes', sa.Text(), nullable=True),
sa.Column('details', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['userid'], ['users.userid'], ),
sa.PrimaryKeyConstraint('auditlogid')
)
with op.batch_alter_table('auditlogs', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_auditlogs_action'), ['action'], unique=False)
batch_op.create_index(batch_op.f('ix_auditlogs_entitytype'), ['entitytype'], unique=False)
batch_op.create_index(batch_op.f('ix_auditlogs_timestamp'), ['timestamp'], unique=False)
op.create_table('models',
sa.Column('modelnumberid', sa.Integer(), nullable=False),
sa.Column('modelnumber', sa.String(length=100), nullable=False),
sa.Column('machinetypeid', sa.Integer(), nullable=True),
sa.Column('vendorid', sa.Integer(), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('imageurl', sa.String(length=500), nullable=True, comment='URL to product image'),
sa.Column('documentationurl', sa.String(length=500), nullable=True, comment='URL to documentation'),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['machinetypeid'], ['machinetypes.machinetypeid'], ),
sa.ForeignKeyConstraint(['vendorid'], ['vendors.vendorid'], ),
sa.PrimaryKeyConstraint('modelnumberid'),
sa.UniqueConstraint('modelnumber', 'vendorid', name='uq_model_vendor')
)
op.create_table('notifications',
sa.Column('notificationid', sa.Integer(), nullable=False),
sa.Column('notificationtypeid', sa.Integer(), nullable=True),
sa.Column('businessunitid', sa.Integer(), nullable=True),
sa.Column('appid', sa.Integer(), nullable=True),
sa.Column('notification', sa.Text(), nullable=False, comment='The message content'),
sa.Column('starttime', sa.DateTime(), nullable=True),
sa.Column('endtime', sa.DateTime(), nullable=True),
sa.Column('ticketnumber', sa.String(length=50), nullable=True),
sa.Column('link', sa.String(length=500), nullable=True),
sa.Column('isactive', sa.Boolean(), nullable=True),
sa.Column('isshopfloor', sa.Boolean(), nullable=True),
sa.Column('employeesso', sa.String(length=100), nullable=True),
sa.Column('employeename', sa.String(length=100), nullable=True),
sa.ForeignKeyConstraint(['notificationtypeid'], ['notificationtypes.notificationtypeid'], ),
sa.PrimaryKeyConstraint('notificationid')
)
op.create_table('rolepermissions',
sa.Column('roleid', sa.Integer(), nullable=False),
sa.Column('permissionid', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['permissionid'], ['permissions.permissionid'], ),
sa.ForeignKeyConstraint(['roleid'], ['roles.roleid'], ),
sa.PrimaryKeyConstraint('roleid', 'permissionid')
)
op.create_table('subnets',
sa.Column('subnetid', sa.Integer(), nullable=False),
sa.Column('cidr', sa.String(length=18), nullable=False, comment='CIDR notation (e.g., 10.1.1.0/24)'),
sa.Column('name', sa.String(length=100), nullable=False, comment='Subnet name'),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('gatewayip', sa.String(length=15), nullable=True, comment='Default gateway IP address'),
sa.Column('subnetmask', sa.String(length=15), nullable=True, comment='Subnet mask (e.g., 255.255.255.0)'),
sa.Column('networkaddress', sa.String(length=15), nullable=True, comment='Network address (e.g., 10.1.1.0)'),
sa.Column('broadcastaddress', sa.String(length=15), nullable=True, comment='Broadcast address (e.g., 10.1.1.255)'),
sa.Column('vlanid', sa.Integer(), nullable=True),
sa.Column('subnettype', sa.String(length=50), nullable=True, comment='Type: production, development, management, dmz, etc.'),
sa.Column('locationid', sa.Integer(), nullable=True),
sa.Column('dhcpenabled', sa.Boolean(), nullable=True, comment='DHCP enabled for this subnet'),
sa.Column('dhcprangestart', sa.String(length=15), nullable=True, comment='DHCP range start IP'),
sa.Column('dhcprangeend', sa.String(length=15), nullable=True, comment='DHCP range end IP'),
sa.Column('dns1', sa.String(length=15), nullable=True, comment='Primary DNS server'),
sa.Column('dns2', sa.String(length=15), nullable=True, comment='Secondary DNS server'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['locationid'], ['locations.locationid'], ),
sa.ForeignKeyConstraint(['vlanid'], ['vlans.vlanid'], ),
sa.PrimaryKeyConstraint('subnetid'),
sa.UniqueConstraint('cidr')
)
with op.batch_alter_table('subnets', schema=None) as batch_op:
batch_op.create_index('idx_subnet_cidr', ['cidr'], unique=False)
batch_op.create_index('idx_subnet_location', ['locationid'], unique=False)
batch_op.create_index('idx_subnet_vlan', ['vlanid'], unique=False)
op.create_table('supportteams',
sa.Column('supportteamid', sa.Integer(), nullable=False),
sa.Column('teamname', sa.String(length=100), nullable=False),
sa.Column('teamurl', sa.String(length=255), nullable=True),
sa.Column('appownerid', sa.Integer(), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['appownerid'], ['appowners.appownerid'], ),
sa.PrimaryKeyConstraint('supportteamid')
)
op.create_table('usbdevices',
sa.Column('usbdeviceid', sa.Integer(), nullable=False),
sa.Column('serialnumber', sa.String(length=100), nullable=False),
sa.Column('label', sa.String(length=100), nullable=True, comment='Human-readable label'),
sa.Column('assetnumber', sa.String(length=50), nullable=True, comment='Optional asset tag'),
sa.Column('usbdevicetypeid', sa.Integer(), nullable=True),
sa.Column('capacitygb', sa.Integer(), nullable=True, comment='Capacity in GB'),
sa.Column('vendorid', sa.String(length=10), nullable=True, comment='USB Vendor ID (hex)'),
sa.Column('productid', sa.String(length=10), nullable=True, comment='USB Product ID (hex)'),
sa.Column('manufacturer', sa.String(length=100), nullable=True),
sa.Column('productname', sa.String(length=100), nullable=True),
sa.Column('ischeckedout', sa.Boolean(), nullable=True),
sa.Column('currentuserid', sa.String(length=50), nullable=True, comment='SSO of current user'),
sa.Column('currentusername', sa.String(length=100), nullable=True, comment='Name of current user'),
sa.Column('currentcheckoutdate', sa.DateTime(), nullable=True),
sa.Column('storagelocation', sa.String(length=200), nullable=True, comment='Where device is stored when not checked out'),
sa.Column('pin', sa.String(length=50), nullable=True, comment='PIN for encrypted devices'),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.Column('createdby', sa.String(length=100), nullable=True),
sa.Column('modifiedby', sa.String(length=100), nullable=True),
sa.ForeignKeyConstraint(['usbdevicetypeid'], ['usbdevicetypes.usbdevicetypeid'], ),
sa.PrimaryKeyConstraint('usbdeviceid'),
sa.UniqueConstraint('serialnumber')
)
with op.batch_alter_table('usbdevices', schema=None) as batch_op:
batch_op.create_index('idx_usb_checkedout', ['ischeckedout'], unique=False)
batch_op.create_index('idx_usb_currentuser', ['currentuserid'], unique=False)
batch_op.create_index('idx_usb_serial', ['serialnumber'], unique=False)
batch_op.create_index('idx_usb_type', ['usbdevicetypeid'], unique=False)
op.create_table('userroles',
sa.Column('userid', sa.Integer(), nullable=False),
sa.Column('roleid', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['roleid'], ['roles.roleid'], ),
sa.ForeignKeyConstraint(['userid'], ['users.userid'], ),
sa.PrimaryKeyConstraint('userid', 'roleid')
)
op.create_table('applications',
sa.Column('appid', sa.Integer(), nullable=False),
sa.Column('appname', sa.String(length=100), nullable=False),
sa.Column('appdescription', sa.String(length=255), nullable=True),
sa.Column('supportteamid', sa.Integer(), nullable=True),
sa.Column('isinstallable', sa.Boolean(), nullable=True),
sa.Column('applicationnotes', sa.Text(), nullable=True),
sa.Column('installpath', sa.String(length=255), nullable=True),
sa.Column('applicationlink', sa.String(length=512), nullable=True),
sa.Column('documentationpath', sa.String(length=512), nullable=True),
sa.Column('ishidden', sa.Boolean(), nullable=True),
sa.Column('isprinter', sa.Boolean(), nullable=True),
sa.Column('islicenced', sa.Boolean(), nullable=True),
sa.Column('image', sa.String(length=255), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['supportteamid'], ['supportteams.supportteamid'], ),
sa.PrimaryKeyConstraint('appid'),
sa.UniqueConstraint('appname')
)
op.create_table('assetrelationships',
sa.Column('relationshipid', sa.Integer(), nullable=False),
sa.Column('sourceassetid', sa.Integer(), nullable=False),
sa.Column('targetassetid', sa.Integer(), nullable=False),
sa.Column('relationshiptypeid', sa.Integer(), nullable=False),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['relationshiptypeid'], ['relationshiptypes.relationshiptypeid'], ),
sa.ForeignKeyConstraint(['sourceassetid'], ['assets.assetid'], ),
sa.ForeignKeyConstraint(['targetassetid'], ['assets.assetid'], ),
sa.PrimaryKeyConstraint('relationshipid'),
sa.UniqueConstraint('sourceassetid', 'targetassetid', 'relationshiptypeid', name='uq_asset_relationship')
)
with op.batch_alter_table('assetrelationships', schema=None) as batch_op:
batch_op.create_index('idx_asset_rel_source', ['sourceassetid'], unique=False)
batch_op.create_index('idx_asset_rel_target', ['targetassetid'], unique=False)
op.create_table('computers',
sa.Column('computerid', sa.Integer(), nullable=False),
sa.Column('assetid', sa.Integer(), nullable=False),
sa.Column('computertypeid', sa.Integer(), nullable=True),
sa.Column('hostname', sa.String(length=100), nullable=True, comment='Network hostname'),
sa.Column('osid', sa.Integer(), nullable=True),
sa.Column('loggedinuser', sa.String(length=100), nullable=True),
sa.Column('lastreporteddate', sa.DateTime(), nullable=True),
sa.Column('lastboottime', sa.DateTime(), nullable=True),
sa.Column('isvnc', sa.Boolean(), nullable=True, comment='VNC remote access enabled'),
sa.Column('iswinrm', sa.Boolean(), nullable=True, comment='WinRM enabled'),
sa.Column('isshopfloor', sa.Boolean(), nullable=True, comment='Shopfloor PC (vs office PC)'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['computertypeid'], ['computertypes.computertypeid'], ),
sa.ForeignKeyConstraint(['osid'], ['operatingsystems.osid'], ),
sa.PrimaryKeyConstraint('computerid')
)
with op.batch_alter_table('computers', schema=None) as batch_op:
batch_op.create_index('idx_computer_hostname', ['hostname'], unique=False)
batch_op.create_index('idx_computer_os', ['osid'], unique=False)
batch_op.create_index('idx_computer_type', ['computertypeid'], unique=False)
batch_op.create_index(batch_op.f('ix_computers_assetid'), ['assetid'], unique=True)
batch_op.create_index(batch_op.f('ix_computers_hostname'), ['hostname'], unique=False)
op.create_table('equipment',
sa.Column('equipmentid', sa.Integer(), nullable=False),
sa.Column('assetid', sa.Integer(), nullable=False),
sa.Column('equipmenttypeid', sa.Integer(), nullable=True),
sa.Column('vendorid', sa.Integer(), nullable=True),
sa.Column('modelnumberid', sa.Integer(), nullable=True),
sa.Column('requiresmanualconfig', sa.Boolean(), nullable=True, comment='Multi-PC machine needs manual configuration'),
sa.Column('islocationonly', sa.Boolean(), nullable=True, comment='Virtual location marker (not actual equipment)'),
sa.Column('lastmaintenancedate', sa.DateTime(), nullable=True),
sa.Column('nextmaintenancedate', sa.DateTime(), nullable=True),
sa.Column('maintenanceintervaldays', sa.Integer(), nullable=True),
sa.Column('controllervendorid', sa.Integer(), nullable=True, comment='Controller vendor (e.g., FANUC)'),
sa.Column('controllermodelid', sa.Integer(), nullable=True, comment='Controller model (e.g., 31B)'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['controllermodelid'], ['models.modelnumberid'], ),
sa.ForeignKeyConstraint(['controllervendorid'], ['vendors.vendorid'], ),
sa.ForeignKeyConstraint(['equipmenttypeid'], ['equipmenttypes.equipmenttypeid'], ),
sa.ForeignKeyConstraint(['modelnumberid'], ['models.modelnumberid'], ),
sa.ForeignKeyConstraint(['vendorid'], ['vendors.vendorid'], ),
sa.PrimaryKeyConstraint('equipmentid')
)
with op.batch_alter_table('equipment', schema=None) as batch_op:
batch_op.create_index('idx_equipment_type', ['equipmenttypeid'], unique=False)
batch_op.create_index('idx_equipment_vendor', ['vendorid'], unique=False)
batch_op.create_index(batch_op.f('ix_equipment_assetid'), ['assetid'], unique=True)
op.create_table('machines',
sa.Column('machineid', sa.Integer(), nullable=False),
sa.Column('machinenumber', sa.String(length=50), nullable=False, comment='Business identifier (e.g., CMM01, G5QX1GT3ESF)'),
sa.Column('alias', sa.String(length=100), nullable=True, comment='Friendly name'),
sa.Column('hostname', sa.String(length=100), nullable=True, comment='Network hostname (for PCs)'),
sa.Column('serialnumber', sa.String(length=100), nullable=True, comment='Hardware serial number'),
sa.Column('machinetypeid', sa.Integer(), nullable=False),
sa.Column('pctypeid', sa.Integer(), nullable=True, comment='Set for PCs, NULL for equipment'),
sa.Column('businessunitid', sa.Integer(), nullable=True),
sa.Column('modelnumberid', sa.Integer(), nullable=True),
sa.Column('vendorid', sa.Integer(), nullable=True),
sa.Column('statusid', sa.Integer(), nullable=True, comment='In Use, Spare, Retired, etc.'),
sa.Column('locationid', sa.Integer(), nullable=True),
sa.Column('mapleft', sa.Integer(), nullable=True, comment='X coordinate on floor map'),
sa.Column('maptop', sa.Integer(), nullable=True, comment='Y coordinate on floor map'),
sa.Column('islocationonly', sa.Boolean(), nullable=True, comment='Virtual location marker (not actual machine)'),
sa.Column('osid', sa.Integer(), nullable=True),
sa.Column('loggedinuser', sa.String(length=100), nullable=True),
sa.Column('lastreporteddate', sa.DateTime(), nullable=True),
sa.Column('lastboottime', sa.DateTime(), nullable=True),
sa.Column('isvnc', sa.Boolean(), nullable=True, comment='VNC remote access enabled'),
sa.Column('iswinrm', sa.Boolean(), nullable=True, comment='WinRM enabled'),
sa.Column('isshopfloor', sa.Boolean(), nullable=True, comment='Shopfloor PC'),
sa.Column('requiresmanualconfig', sa.Boolean(), nullable=True, comment='Multi-PC machine needs manual configuration'),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.Column('deleteddate', sa.DateTime(), nullable=True),
sa.Column('deletedby', sa.String(length=100), nullable=True),
sa.Column('createdby', sa.String(length=100), nullable=True),
sa.Column('modifiedby', sa.String(length=100), nullable=True),
sa.ForeignKeyConstraint(['businessunitid'], ['businessunits.businessunitid'], ),
sa.ForeignKeyConstraint(['locationid'], ['locations.locationid'], ),
sa.ForeignKeyConstraint(['machinetypeid'], ['machinetypes.machinetypeid'], ),
sa.ForeignKeyConstraint(['modelnumberid'], ['models.modelnumberid'], ),
sa.ForeignKeyConstraint(['osid'], ['operatingsystems.osid'], ),
sa.ForeignKeyConstraint(['pctypeid'], ['pctypes.pctypeid'], ),
sa.ForeignKeyConstraint(['statusid'], ['machinestatuses.statusid'], ),
sa.ForeignKeyConstraint(['vendorid'], ['vendors.vendorid'], ),
sa.PrimaryKeyConstraint('machineid')
)
with op.batch_alter_table('machines', schema=None) as batch_op:
batch_op.create_index('idx_machine_active', ['isactive'], unique=False)
batch_op.create_index('idx_machine_hostname', ['hostname'], unique=False)
batch_op.create_index('idx_machine_location', ['locationid'], unique=False)
batch_op.create_index('idx_machine_type_bu', ['machinetypeid', 'businessunitid'], unique=False)
batch_op.create_index(batch_op.f('ix_machines_hostname'), ['hostname'], unique=False)
batch_op.create_index(batch_op.f('ix_machines_machinenumber'), ['machinenumber'], unique=True)
batch_op.create_index(batch_op.f('ix_machines_serialnumber'), ['serialnumber'], unique=False)
op.create_table('networkdevices',
sa.Column('networkdeviceid', sa.Integer(), nullable=False),
sa.Column('assetid', sa.Integer(), nullable=False),
sa.Column('networkdevicetypeid', sa.Integer(), nullable=True),
sa.Column('vendorid', sa.Integer(), nullable=True),
sa.Column('hostname', sa.String(length=100), nullable=True, comment='Network hostname'),
sa.Column('firmwareversion', sa.String(length=100), nullable=True),
sa.Column('portcount', sa.Integer(), nullable=True, comment='Number of ports (for switches)'),
sa.Column('ispoe', sa.Boolean(), nullable=True, comment='Power over Ethernet capable'),
sa.Column('ismanaged', sa.Boolean(), nullable=True, comment='Managed device (SNMP, web interface, etc.)'),
sa.Column('rackunit', sa.String(length=20), nullable=True, comment='Rack unit position (e.g., U1, U5)'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['networkdevicetypeid'], ['networkdevicetypes.networkdevicetypeid'], ),
sa.ForeignKeyConstraint(['vendorid'], ['vendors.vendorid'], ),
sa.PrimaryKeyConstraint('networkdeviceid')
)
with op.batch_alter_table('networkdevices', schema=None) as batch_op:
batch_op.create_index('idx_netdev_hostname', ['hostname'], unique=False)
batch_op.create_index('idx_netdev_type', ['networkdevicetypeid'], unique=False)
batch_op.create_index('idx_netdev_vendor', ['vendorid'], unique=False)
batch_op.create_index(batch_op.f('ix_networkdevices_assetid'), ['assetid'], unique=True)
batch_op.create_index(batch_op.f('ix_networkdevices_hostname'), ['hostname'], unique=False)
op.create_table('printers',
sa.Column('printerid', sa.Integer(), nullable=False),
sa.Column('assetid', sa.Integer(), nullable=False),
sa.Column('printertypeid', sa.Integer(), nullable=True),
sa.Column('vendorid', sa.Integer(), nullable=True),
sa.Column('modelnumberid', sa.Integer(), nullable=True),
sa.Column('hostname', sa.String(length=100), nullable=True, comment='Network hostname'),
sa.Column('windowsname', sa.String(length=255), nullable=True, comment='Windows printer name (e.g., \\\\server\\printer)'),
sa.Column('sharename', sa.String(length=100), nullable=True, comment='CSF/share name'),
sa.Column('iscsf', sa.Boolean(), nullable=True, comment='Is CSF printer'),
sa.Column('installpath', sa.String(length=255), nullable=True, comment='Driver install path'),
sa.Column('pin', sa.String(length=20), nullable=True),
sa.Column('iscolor', sa.Boolean(), nullable=True, comment='Color capable'),
sa.Column('isduplex', sa.Boolean(), nullable=True, comment='Duplex capable'),
sa.Column('isnetwork', sa.Boolean(), nullable=True, comment='Network connected'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['modelnumberid'], ['models.modelnumberid'], ),
sa.ForeignKeyConstraint(['printertypeid'], ['printertypes.printertypeid'], ),
sa.ForeignKeyConstraint(['vendorid'], ['vendors.vendorid'], ),
sa.PrimaryKeyConstraint('printerid')
)
with op.batch_alter_table('printers', schema=None) as batch_op:
batch_op.create_index('idx_printer_hostname', ['hostname'], unique=False)
batch_op.create_index('idx_printer_type', ['printertypeid'], unique=False)
batch_op.create_index('idx_printer_windowsname', ['windowsname'], unique=False)
batch_op.create_index(batch_op.f('ix_printers_assetid'), ['assetid'], unique=True)
batch_op.create_index(batch_op.f('ix_printers_hostname'), ['hostname'], unique=False)
op.create_table('usbcheckouts',
sa.Column('checkoutid', sa.Integer(), nullable=False),
sa.Column('usbdeviceid', sa.Integer(), nullable=True),
sa.Column('machineid', sa.Integer(), nullable=False),
sa.Column('sso', sa.String(length=20), nullable=False, comment='SSO of user'),
sa.Column('checkoutname', sa.String(length=100), nullable=True, comment='Name of user'),
sa.Column('checkouttime', sa.DateTime(), nullable=False),
sa.Column('checkintime', sa.DateTime(), nullable=True),
sa.Column('checkoutreason', sa.Text(), nullable=True, comment='Reason for checkout'),
sa.Column('checkinnotes', sa.Text(), nullable=True),
sa.Column('waswiped', sa.Boolean(), nullable=True, comment='Was device wiped after return'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['usbdeviceid'], ['usbdevices.usbdeviceid'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('checkoutid')
)
op.create_table('appversions',
sa.Column('appversionid', sa.Integer(), nullable=False),
sa.Column('appid', sa.Integer(), nullable=False),
sa.Column('version', sa.String(length=50), nullable=False),
sa.Column('releasedate', sa.Date(), nullable=True),
sa.Column('notes', sa.String(length=255), nullable=True),
sa.Column('dateadded', sa.DateTime(), nullable=True),
sa.Column('isactive', sa.Boolean(), nullable=True),
sa.ForeignKeyConstraint(['appid'], ['applications.appid'], ),
sa.PrimaryKeyConstraint('appversionid'),
sa.UniqueConstraint('appid', 'version', name='uq_app_version')
)
op.create_table('communications',
sa.Column('communicationid', sa.Integer(), nullable=False),
sa.Column('assetid', sa.Integer(), nullable=True, comment='FK to assets table (new architecture)'),
sa.Column('machineid', sa.Integer(), nullable=True, comment='DEPRECATED: FK to machines table - use assetid instead'),
sa.Column('comtypeid', sa.Integer(), nullable=False),
sa.Column('ipaddress', sa.String(length=50), nullable=True),
sa.Column('subnetmask', sa.String(length=50), nullable=True),
sa.Column('gateway', sa.String(length=50), nullable=True),
sa.Column('dns1', sa.String(length=50), nullable=True),
sa.Column('dns2', sa.String(length=50), nullable=True),
sa.Column('macaddress', sa.String(length=50), nullable=True),
sa.Column('isdhcp', sa.Boolean(), nullable=True),
sa.Column('comport', sa.String(length=20), nullable=True),
sa.Column('baudrate', sa.Integer(), nullable=True),
sa.Column('databits', sa.Integer(), nullable=True),
sa.Column('stopbits', sa.String(length=10), nullable=True),
sa.Column('parity', sa.String(length=20), nullable=True),
sa.Column('flowcontrol', sa.String(length=20), nullable=True),
sa.Column('port', sa.Integer(), nullable=True),
sa.Column('username', sa.String(length=100), nullable=True),
sa.Column('pathname', sa.String(length=255), nullable=True),
sa.Column('pathname2', sa.String(length=255), nullable=True, comment='Secondary path for dualpath'),
sa.Column('isprimary', sa.Boolean(), nullable=True, comment='Primary communication method'),
sa.Column('ismachinenetwork', sa.Boolean(), nullable=True, comment='On machine network vs office network'),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ),
sa.ForeignKeyConstraint(['comtypeid'], ['communicationtypes.comtypeid'], ),
sa.ForeignKeyConstraint(['machineid'], ['machines.machineid'], ),
sa.PrimaryKeyConstraint('communicationid')
)
with op.batch_alter_table('communications', schema=None) as batch_op:
batch_op.create_index('idx_comm_asset', ['assetid'], unique=False)
batch_op.create_index('idx_comm_ip', ['ipaddress'], unique=False)
batch_op.create_index('idx_comm_machine', ['machineid'], unique=False)
batch_op.create_index(batch_op.f('ix_communications_assetid'), ['assetid'], unique=False)
op.create_table('knowledgebase',
sa.Column('linkid', sa.Integer(), nullable=False),
sa.Column('appid', sa.Integer(), nullable=True),
sa.Column('shortdescription', sa.String(length=500), nullable=False),
sa.Column('linkurl', sa.String(length=2000), nullable=True),
sa.Column('keywords', sa.String(length=500), nullable=True),
sa.Column('clicks', sa.Integer(), nullable=True),
sa.Column('lastupdated', sa.DateTime(), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['appid'], ['applications.appid'], ),
sa.PrimaryKeyConstraint('linkid')
)
op.create_table('machinerelationships',
sa.Column('relationshipid', sa.Integer(), nullable=False),
sa.Column('parentmachineid', sa.Integer(), nullable=False),
sa.Column('childmachineid', sa.Integer(), nullable=False),
sa.Column('relationshiptypeid', sa.Integer(), nullable=False),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['childmachineid'], ['machines.machineid'], ),
sa.ForeignKeyConstraint(['parentmachineid'], ['machines.machineid'], ),
sa.ForeignKeyConstraint(['relationshiptypeid'], ['relationshiptypes.relationshiptypeid'], ),
sa.PrimaryKeyConstraint('relationshipid'),
sa.UniqueConstraint('parentmachineid', 'childmachineid', 'relationshiptypeid', name='uq_machine_relationship')
)
op.create_table('printerdata',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('machineid', sa.Integer(), nullable=False),
sa.Column('windowsname', sa.String(length=255), nullable=True, comment='Windows printer name (e.g., \\\\server\\printer)'),
sa.Column('sharename', sa.String(length=100), nullable=True, comment='CSF/share name'),
sa.Column('iscsf', sa.Boolean(), nullable=True, comment='Is CSF printer'),
sa.Column('installpath', sa.String(length=255), nullable=True, comment='Driver install path'),
sa.Column('pin', sa.String(length=20), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['machineid'], ['machines.machineid'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('printerdata', schema=None) as batch_op:
batch_op.create_index('idx_printerdata_windowsname', ['windowsname'], unique=False)
batch_op.create_index(batch_op.f('ix_printerdata_machineid'), ['machineid'], unique=True)
op.create_table('computerinstalledapps',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('computerid', sa.Integer(), nullable=False),
sa.Column('appid', sa.Integer(), nullable=False),
sa.Column('appversionid', sa.Integer(), nullable=True),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.Column('installeddate', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['appid'], ['applications.appid'], ),
sa.ForeignKeyConstraint(['appversionid'], ['appversions.appversionid'], ),
sa.ForeignKeyConstraint(['computerid'], ['computers.computerid'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('computerid', 'appid', name='uq_computer_app')
)
with op.batch_alter_table('computerinstalledapps', schema=None) as batch_op:
batch_op.create_index('idx_compapp_app', ['appid'], unique=False)
batch_op.create_index('idx_compapp_computer', ['computerid'], unique=False)
op.create_table('installedapps',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('machineid', sa.Integer(), nullable=False),
sa.Column('appid', sa.Integer(), nullable=False),
sa.Column('appversionid', sa.Integer(), nullable=True),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.Column('installeddate', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['appid'], ['applications.appid'], ),
sa.ForeignKeyConstraint(['appversionid'], ['appversions.appversionid'], ),
sa.ForeignKeyConstraint(['machineid'], ['machines.machineid'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('machineid', 'appid', name='uq_machine_app')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('installedapps')
with op.batch_alter_table('computerinstalledapps', schema=None) as batch_op:
batch_op.drop_index('idx_compapp_computer')
batch_op.drop_index('idx_compapp_app')
op.drop_table('computerinstalledapps')
with op.batch_alter_table('printerdata', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_printerdata_machineid'))
batch_op.drop_index('idx_printerdata_windowsname')
op.drop_table('printerdata')
op.drop_table('machinerelationships')
op.drop_table('knowledgebase')
with op.batch_alter_table('communications', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_communications_assetid'))
batch_op.drop_index('idx_comm_machine')
batch_op.drop_index('idx_comm_ip')
batch_op.drop_index('idx_comm_asset')
op.drop_table('communications')
op.drop_table('appversions')
op.drop_table('usbcheckouts')
with op.batch_alter_table('printers', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_printers_hostname'))
batch_op.drop_index(batch_op.f('ix_printers_assetid'))
batch_op.drop_index('idx_printer_windowsname')
batch_op.drop_index('idx_printer_type')
batch_op.drop_index('idx_printer_hostname')
op.drop_table('printers')
with op.batch_alter_table('networkdevices', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_networkdevices_hostname'))
batch_op.drop_index(batch_op.f('ix_networkdevices_assetid'))
batch_op.drop_index('idx_netdev_vendor')
batch_op.drop_index('idx_netdev_type')
batch_op.drop_index('idx_netdev_hostname')
op.drop_table('networkdevices')
with op.batch_alter_table('machines', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_machines_serialnumber'))
batch_op.drop_index(batch_op.f('ix_machines_machinenumber'))
batch_op.drop_index(batch_op.f('ix_machines_hostname'))
batch_op.drop_index('idx_machine_type_bu')
batch_op.drop_index('idx_machine_location')
batch_op.drop_index('idx_machine_hostname')
batch_op.drop_index('idx_machine_active')
op.drop_table('machines')
with op.batch_alter_table('equipment', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_equipment_assetid'))
batch_op.drop_index('idx_equipment_vendor')
batch_op.drop_index('idx_equipment_type')
op.drop_table('equipment')
with op.batch_alter_table('computers', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_computers_hostname'))
batch_op.drop_index(batch_op.f('ix_computers_assetid'))
batch_op.drop_index('idx_computer_type')
batch_op.drop_index('idx_computer_os')
batch_op.drop_index('idx_computer_hostname')
op.drop_table('computers')
with op.batch_alter_table('assetrelationships', schema=None) as batch_op:
batch_op.drop_index('idx_asset_rel_target')
batch_op.drop_index('idx_asset_rel_source')
op.drop_table('assetrelationships')
op.drop_table('applications')
op.drop_table('userroles')
with op.batch_alter_table('usbdevices', schema=None) as batch_op:
batch_op.drop_index('idx_usb_type')
batch_op.drop_index('idx_usb_serial')
batch_op.drop_index('idx_usb_currentuser')
batch_op.drop_index('idx_usb_checkedout')
op.drop_table('usbdevices')
op.drop_table('supportteams')
with op.batch_alter_table('subnets', schema=None) as batch_op:
batch_op.drop_index('idx_subnet_vlan')
batch_op.drop_index('idx_subnet_location')
batch_op.drop_index('idx_subnet_cidr')
op.drop_table('subnets')
op.drop_table('rolepermissions')
op.drop_table('notifications')
op.drop_table('models')
with op.batch_alter_table('auditlogs', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_auditlogs_timestamp'))
batch_op.drop_index(batch_op.f('ix_auditlogs_entitytype'))
batch_op.drop_index(batch_op.f('ix_auditlogs_action'))
op.drop_table('auditlogs')
with op.batch_alter_table('assets', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_assets_serialnumber'))
batch_op.drop_index(batch_op.f('ix_assets_assetnumber'))
batch_op.drop_index('idx_asset_type_bu')
batch_op.drop_index('idx_asset_status')
batch_op.drop_index('idx_asset_location')
batch_op.drop_index('idx_asset_active')
op.drop_table('assets')
with op.batch_alter_table('vlans', schema=None) as batch_op:
batch_op.drop_index('idx_vlan_number')
op.drop_table('vlans')
op.drop_table('vendors')
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_users_username'))
op.drop_table('users')
op.drop_table('usbdevicetypes')
with op.batch_alter_table('settings', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_settings_key'))
op.drop_table('settings')
op.drop_table('roles')
op.drop_table('relationshiptypes')
op.drop_table('printertypes')
op.drop_table('permissions')
op.drop_table('pctypes')
op.drop_table('operatingsystems')
op.drop_table('notificationtypes')
op.drop_table('networkdevicetypes')
op.drop_table('machinetypes')
op.drop_table('machinestatuses')
op.drop_table('locations')
op.drop_table('equipmenttypes')
op.drop_table('computertypes')
op.drop_table('communicationtypes')
op.drop_table('businessunits')
op.drop_table('assettypes')
op.drop_table('assetstatuses')
op.drop_table('appowners')
# ### end Alembic commands ###

View File

@@ -0,0 +1,147 @@
"""ADR-001 position contract surface
Rename Asset.mapleft -> Asset.mapx, Asset.maptop -> Asset.mapy. Add
Location.mapx / Location.mapy. Add AssetRelationship.label,
AssetRelationship.inheritsposition, RelationshipType.propagatesthroughid.
Seed the three canonical relationship types (partof, controls, connectedto)
with controls.propagatesthroughid -> partof.
Revision ID: 7a01_adr001_position
Revises: 68b3947ae14f
Create Date: 2026-05-30
"""
from alembic import op
import sqlalchemy as sa
revision = '7a01_adr001_position'
down_revision = '68b3947ae14f'
branch_labels = None
depends_on = None
def upgrade():
# ---- Asset.mapleft/maptop -> mapx/mapy --------------------------------
with op.batch_alter_table('assets') as batch_op:
batch_op.alter_column('mapleft', new_column_name='mapx',
existing_type=sa.Integer(), existing_nullable=True,
comment='X coordinate on floor map (ADR-001)')
batch_op.alter_column('maptop', new_column_name='mapy',
existing_type=sa.Integer(), existing_nullable=True,
comment='Y coordinate on floor map (ADR-001)')
# ---- Location: add mapx/mapy for the fallback path --------------------
with op.batch_alter_table('locations') as batch_op:
batch_op.add_column(sa.Column('mapx', sa.Integer(), nullable=True,
comment='Default X coordinate for assets at this location'))
batch_op.add_column(sa.Column('mapy', sa.Integer(), nullable=True,
comment='Default Y coordinate for assets at this location'))
# ---- RelationshipType: add propagatesthroughid self-FK ----------------
with op.batch_alter_table('relationshiptypes') as batch_op:
batch_op.add_column(sa.Column('propagatesthroughid', sa.Integer(),
nullable=True,
comment='Sibling-propagation rail per ADR-001'))
batch_op.create_foreign_key(
'fk_relationshiptype_propagation',
'relationshiptypes',
['propagatesthroughid'],
['relationshiptypeid'],
)
# ---- AssetRelationship: add label + inheritsposition ------------------
with op.batch_alter_table('assetrelationships') as batch_op:
batch_op.add_column(sa.Column('label', sa.String(length=200), nullable=True,
comment='Free-text relationship description (ADR-001)'))
batch_op.add_column(sa.Column('inheritsposition', sa.Boolean(),
nullable=False, server_default='1',
comment='If true, resolved-position walk follows this edge (ADR-001)'))
# ---- Seed three canonical relationship types --------------------------
# Idempotent: insert only if name is not already present. Then update
# controls.propagatesthroughid to point at partof.
relationshiptypes = sa.table(
'relationshiptypes',
sa.column('relationshiptype', sa.String),
sa.column('description', sa.Text),
sa.column('propagatesthroughid', sa.Integer),
sa.column('isactive', sa.Boolean),
)
conn = op.get_bind()
dialect_name = conn.dialect.name
# MySQL default collation is case-insensitive, which makes a plain
# `WHERE relationshiptype = 'controls'` match a legacy `Controls` row
# and silently skip the insert (then accidentally wire propagation onto
# the wrong row). Force binary comparison on MySQL so the three ADR
# types are always distinct from legacy capitalized names. SQLite +
# PostgreSQL are case-sensitive by default; the plain comparison is
# safe there.
def _eq(col_expr, value):
if dialect_name == 'mysql':
return f"BINARY {col_expr} = '{value}'"
return f"{col_expr} = '{value}'"
for rt in (
('partof', 'Composition / sub-assembly (ADR-001)'),
('controls', 'Operational authority over another asset (ADR-001)'),
('connectedto', 'Network or data link without authority (ADR-001)'),
):
existing = conn.execute(sa.text(
f"SELECT relationshiptypeid FROM relationshiptypes WHERE {_eq('relationshiptype', rt[0])}"
)).first()
if not existing:
conn.execute(relationshiptypes.insert().values(
relationshiptype=rt[0],
description=rt[1],
propagatesthroughid=None,
isactive=True,
))
partof_row = conn.execute(sa.text(
f"SELECT relationshiptypeid FROM relationshiptypes WHERE {_eq('relationshiptype', 'partof')}"
)).first()
if partof_row:
conn.execute(sa.text(
f"UPDATE relationshiptypes SET propagatesthroughid = :p WHERE {_eq('relationshiptype', 'controls')}"
), {'p': partof_row[0]})
def downgrade():
# Revert in reverse order. Drops the seeded ADR types if no rows
# reference them; otherwise leaves them in place to avoid FK violations.
conn = op.get_bind()
with op.batch_alter_table('assetrelationships') as batch_op:
batch_op.drop_column('inheritsposition')
batch_op.drop_column('label')
with op.batch_alter_table('relationshiptypes') as batch_op:
batch_op.drop_constraint('fk_relationshiptype_propagation', type_='foreignkey')
batch_op.drop_column('propagatesthroughid')
with op.batch_alter_table('locations') as batch_op:
batch_op.drop_column('mapy')
batch_op.drop_column('mapx')
with op.batch_alter_table('assets') as batch_op:
batch_op.alter_column('mapx', new_column_name='mapleft',
existing_type=sa.Integer(), existing_nullable=True)
batch_op.alter_column('mapy', new_column_name='maptop',
existing_type=sa.Integer(), existing_nullable=True)
# Best-effort: drop the seeded types if nothing references them.
for name in ('connectedto', 'controls', 'partof'):
try:
conn.execute(sa.text(
"DELETE FROM relationshiptypes "
"WHERE relationshiptype = :n "
"AND relationshiptypeid NOT IN ("
" SELECT relationshiptypeid FROM assetrelationships "
" UNION SELECT relationshiptypeid FROM machinerelationships"
")"
), {'n': name})
except Exception:
pass

View File

@@ -0,0 +1,44 @@
"""Add Asset.gaugelabreference and Asset.maintenancereference
Adds dedicated external-reference columns to assets, distinct from
assetnumber. Equipment commonly carries an authoritative gauge lab reference
and a maintenance system reference; assetnumber stays the generic asset tag
and name the layperson label.
Revision ID: 7b02_gaugelabref
Revises: 7a01_adr001_position
Create Date: 2026-06-25
"""
from alembic import op
import sqlalchemy as sa
revision = '7b02_gaugelabref'
down_revision = '7a01_adr001_position'
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table('assets') as batch_op:
batch_op.add_column(sa.Column(
'gaugelabreference', sa.String(length=50), nullable=True,
comment='Gauge lab asset reference (authoritative tag the gauge '
'lab assigns to equipment); distinct from assetnumber'))
batch_op.add_column(sa.Column(
'maintenancereference', sa.String(length=50), nullable=True,
comment='Maintenance system asset reference; distinct from '
'assetnumber'))
batch_op.create_index('ix_assets_gaugelabreference',
['gaugelabreference'])
batch_op.create_index('ix_assets_maintenancereference',
['maintenancereference'])
def downgrade():
with op.batch_alter_table('assets') as batch_op:
batch_op.drop_index('ix_assets_maintenancereference')
batch_op.drop_index('ix_assets_gaugelabreference')
batch_op.drop_column('maintenancereference')
batch_op.drop_column('gaugelabreference')

View File

@@ -0,0 +1,47 @@
"""Drop the legacy Machine instance layer
Retires the Machine model (ADR-001): the asset/computer model is now the
single source of truth. Drops machines + its PC/status lookups + the legacy
relationship and installed-app tables + the legacy printer extension, and
removes the deprecated communications.machineid column. machinetypes is kept
(still referenced by models.machinetypeid).
Idempotent (IF EXISTS) so it is safe even though the live drop was applied
directly during the cutover.
Revision ID: 7c01_drop_legacy_machine
Revises: 7b02_gaugelabref
Create Date: 2026-06-26
"""
from alembic import op
import sqlalchemy as sa
revision = '7c01_drop_legacy_machine'
down_revision = '7b02_gaugelabref'
branch_labels = None
depends_on = None
_TABLES = ['printerdata', 'installedapps', 'machinerelationships',
'machines', 'pctypes', 'machinestatuses']
def upgrade():
bind = op.get_bind()
bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=0")
insp = sa.inspect(bind)
if 'machineid' in [c['name'] for c in insp.get_columns('communications')]:
for fk in insp.get_foreign_keys('communications'):
if 'machineid' in fk['constrained_columns'] and fk.get('name'):
bind.exec_driver_sql(
f"ALTER TABLE communications DROP FOREIGN KEY {fk['name']}")
op.drop_column('communications', 'machineid')
for t in _TABLES:
bind.exec_driver_sql(f"DROP TABLE IF EXISTS {t}")
bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=1")
def downgrade():
# The Machine layer is retired; recreating it is out of scope.
raise NotImplementedError("Legacy Machine layer cannot be restored")

View File

@@ -0,0 +1,29 @@
"""Add applications.isrequired
Backs the software-compliance report, which compares required applications
against what is installed per PC. There was no such flag before, so the report
errored.
Revision ID: 7c02_app_isrequired
Revises: 7c01_drop_legacy_machine
Create Date: 2026-06-26
"""
from alembic import op
import sqlalchemy as sa
revision = '7c02_app_isrequired'
down_revision = '7c01_drop_legacy_machine'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('applications', sa.Column(
'isrequired', sa.Boolean(), nullable=True, server_default=sa.false(),
comment='Required on all PCs (drives the software-compliance report)'))
def downgrade():
op.drop_column('applications', 'isrequired')

View File

@@ -0,0 +1,56 @@
"""Add locationtypes + locations tree (ADR-001)
Creates the locationtypes lookup and extends locations with locationtypeid +
parentlocationid (self-FK), so sites can classify locations and build a
location tree (sections, cells, sub-cells, operations, etc.). Seeds the
canonical location types.
Revision ID: 7c03_locationtypes
Revises: 7c02_app_isrequired
Create Date: 2026-06-26
"""
from alembic import op
import sqlalchemy as sa
revision = '7c03_locationtypes'
down_revision = '7c02_app_isrequired'
branch_labels = None
depends_on = None
_TYPES = ['section', 'cell', 'subcell', 'operation', 'meetingroom', 'lab',
'office', 'storage', 'hallway', 'networkcloset', 'building']
def upgrade():
op.create_table(
'locationtypes',
sa.Column('locationtypeid', sa.Integer(), primary_key=True),
sa.Column('locationtype', sa.String(length=50), nullable=False, unique=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=True),
sa.Column('modifieddate', sa.DateTime(), nullable=True),
sa.Column('isactive', sa.Boolean(), nullable=True),
)
with op.batch_alter_table('locations') as batch_op:
batch_op.add_column(sa.Column('locationtypeid', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('parentlocationid', sa.Integer(), nullable=True))
batch_op.create_foreign_key('fk_locations_locationtype', 'locationtypes',
['locationtypeid'], ['locationtypeid'])
batch_op.create_foreign_key('fk_locations_parent', 'locations',
['parentlocationid'], ['locationid'])
lt = sa.table('locationtypes',
sa.column('locationtype', sa.String),
sa.column('isactive', sa.Boolean))
op.bulk_insert(lt, [{'locationtype': t, 'isactive': True} for t in _TYPES])
def downgrade():
with op.batch_alter_table('locations') as batch_op:
batch_op.drop_constraint('fk_locations_parent', type_='foreignkey')
batch_op.drop_constraint('fk_locations_locationtype', type_='foreignkey')
batch_op.drop_column('parentlocationid')
batch_op.drop_column('locationtypeid')
op.drop_table('locationtypes')

View File

@@ -0,0 +1,85 @@
"""Fold remaining plugin schema into the core chain
Deploys run `flask db upgrade` (core chain) only, so the core chain must
reproduce the full bundled schema. The core baseline already creates the
plugin tables as of baseline time; this adds the post-baseline additions that
previously lived only in per-plugin migrations (which deploys don't run):
- modelsupplies (printer model -> toner/drum/waste part numbers)
- computers.vendorid + computers.modelnumberid (PC make/model)
- computerinstalledapps.installedversion (collector version string)
Idempotent: skips anything already present, so it is a no-op on the live DB
(which already has these) and creates them on a fresh deploy.
Revision ID: 7c04_fold_plugin_schema
Revises: 7c03_locationtypes
Create Date: 2026-06-26
"""
from alembic import op
import sqlalchemy as sa
revision = '7c04_fold_plugin_schema'
down_revision = '7c03_locationtypes'
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
tables = set(insp.get_table_names())
if 'modelsupplies' not in tables:
op.create_table(
'modelsupplies',
sa.Column('modelsupplyid', sa.Integer(), primary_key=True),
sa.Column('modelnumberid', sa.Integer(),
sa.ForeignKey('models.modelnumberid'), nullable=False),
sa.Column('supplytype', sa.String(length=20), nullable=False,
server_default='toner'),
sa.Column('color', sa.String(length=20), nullable=False,
server_default='none'),
sa.Column('capacitytier', sa.String(length=20), nullable=False,
server_default='standard'),
sa.Column('partnumber', sa.String(length=50), nullable=False),
sa.Column('marketingname', sa.String(length=120), nullable=True),
sa.Column('pageyield', sa.Integer(), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=True),
sa.Column('modifieddate', sa.DateTime(), nullable=True),
sa.Column('isactive', sa.Boolean(), nullable=True),
sa.UniqueConstraint('modelnumberid', 'partnumber',
name='uq_modelsupply_part'),
)
op.create_index('idx_modelsupplies_modelnumberid', 'modelsupplies',
['modelnumberid'])
comp_cols = {c['name'] for c in insp.get_columns('computers')}
with op.batch_alter_table('computers') as batch_op:
if 'vendorid' not in comp_cols:
batch_op.add_column(sa.Column('vendorid', sa.Integer(), nullable=True))
batch_op.create_foreign_key('fk_computers_vendor', 'vendors',
['vendorid'], ['vendorid'])
if 'modelnumberid' not in comp_cols:
batch_op.add_column(sa.Column('modelnumberid', sa.Integer(), nullable=True))
batch_op.create_foreign_key('fk_computers_model', 'models',
['modelnumberid'], ['modelnumberid'])
cia_cols = {c['name'] for c in insp.get_columns('computerinstalledapps')}
if 'installedversion' not in cia_cols:
op.add_column('computerinstalledapps',
sa.Column('installedversion', sa.String(length=100),
nullable=True))
def downgrade():
op.drop_table('modelsupplies')
with op.batch_alter_table('computers') as batch_op:
batch_op.drop_constraint('fk_computers_model', type_='foreignkey')
batch_op.drop_constraint('fk_computers_vendor', type_='foreignkey')
batch_op.drop_column('modelnumberid')
batch_op.drop_column('vendorid')
op.drop_column('computerinstalledapps', 'installedversion')

View File

@@ -0,0 +1,37 @@
"""Add dashboarddefaults (visitor-IP -> business-unit mapping)
A shopfloor / lobby kiosk resolves which business unit to display from the
display PC's IP. Powers the visitor-location lookup.
Revision ID: 7d01_dashboarddefaults
Revises: 7c04_fold_plugin_schema
Create Date: 2026-06-27
"""
from alembic import op
import sqlalchemy as sa
revision = '7d01_dashboarddefaults'
down_revision = '7c04_fold_plugin_schema'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'dashboarddefaults',
sa.Column('dashboarddefaultid', sa.Integer(), primary_key=True),
sa.Column('ipaddress', sa.String(length=50), nullable=False, unique=True),
sa.Column('businessunitid', sa.Integer(), nullable=False),
sa.Column('description', sa.String(length=255), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=True),
sa.Column('modifieddate', sa.DateTime(), nullable=True),
sa.Column('isactive', sa.Boolean(), nullable=True),
sa.ForeignKeyConstraint(['businessunitid'], ['businessunits.businessunitid'],
name='fk_dashboarddefaults_businessunit'),
)
def downgrade():
op.drop_table('dashboarddefaults')

View File

@@ -0,0 +1,37 @@
"""Widen notifications.employeesso / employeename to TEXT
Recognition and recertification notifications comma-join every listed
employee's SSO and name into a single column. VARCHAR(100) truncated the list
at ~11 people, dropping names off the shopfloor grid. TEXT removes the cap.
Revision ID: 7d02_widen_notification_employee_cols
Revises: 7d01_dashboarddefaults
Create Date: 2026-07-07
"""
from alembic import op
import sqlalchemy as sa
revision = '7d02_widen_notification_employee_cols'
down_revision = '7d01_dashboarddefaults'
branch_labels = None
depends_on = None
def upgrade():
op.alter_column('notifications', 'employeesso',
existing_type=sa.String(length=100), type_=sa.Text(),
existing_nullable=True)
op.alter_column('notifications', 'employeename',
existing_type=sa.String(length=100), type_=sa.Text(),
existing_nullable=True)
def downgrade():
op.alter_column('notifications', 'employeesso',
existing_type=sa.Text(), type_=sa.String(length=100),
existing_nullable=True)
op.alter_column('notifications', 'employeename',
existing_type=sa.Text(), type_=sa.String(length=100),
existing_nullable=True)

View File

@@ -0,0 +1,53 @@
"""Per-type auto-expiry rule on notificationtypes
Makes the shopfloor auto-expiry window configurable per notification type
instead of hardcoding recognition (8 AM Eastern) and recertification (14 days)
in the API. Adds:
expirymode 'none' | 'duration' | 'dailytime'
expirydays days for 'duration'
expiryhour hour (Eastern) for 'dailytime'
expiryminute minute (Eastern) for 'dailytime'
Seeds the two existing rule-bearing types so behavior is unchanged:
Recognition (typecolor 'recognition') -> dailytime 08:00 Eastern
Recertification (typecolor 'recertification') -> duration 14 days
Revision ID: 7d03_notificationtype_expiry
Revises: 7d02_widen_notification_employee_cols
Create Date: 2026-07-08
"""
from alembic import op
import sqlalchemy as sa
revision = '7d03_notificationtype_expiry'
down_revision = '7d02_widen_notification_employee_cols'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('notificationtypes', sa.Column('expirymode', sa.String(length=20), nullable=True, server_default='none'))
op.add_column('notificationtypes', sa.Column('expirydays', sa.Integer(), nullable=True))
op.add_column('notificationtypes', sa.Column('expiryhour', sa.SmallInteger(), nullable=True))
op.add_column('notificationtypes', sa.Column('expiryminute', sa.SmallInteger(), nullable=True, server_default='0'))
# preserve current behavior for the two rule-bearing types
op.execute(
"UPDATE notificationtypes SET expirymode='dailytime', expiryhour=8, expiryminute=0 "
"WHERE typecolor='recognition'"
)
op.execute(
"UPDATE notificationtypes SET expirymode='duration', expirydays=14 "
"WHERE typecolor='recertification'"
)
# everything else: explicit 'none' (indefinite unless the creator sets an end time)
op.execute("UPDATE notificationtypes SET expirymode='none' WHERE expirymode IS NULL")
def downgrade():
op.drop_column('notificationtypes', 'expiryminute')
op.drop_column('notificationtypes', 'expiryhour')
op.drop_column('notificationtypes', 'expirydays')
op.drop_column('notificationtypes', 'expirymode')

View File

@@ -0,0 +1,43 @@
"""Add data-driven shopfloor display behavior to notification types
Replaces the hardcoded recognition/training/recertification logic with per-type
columns: split one card per employee, show employee photo, and the display
style (standard rows / carousel / grid / banner). Seeds the built-in special
types by their typecolor keyword.
Revision ID: 7d04_notificationtype_display
Revises: 7d03_notificationtype_expiry
Create Date: 2026-07-08
"""
from alembic import op
import sqlalchemy as sa
revision = '7d04_notificationtype_display'
down_revision = '7d03_notificationtype_expiry'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('notificationtypes', sa.Column('splitperemployee', sa.Boolean(), nullable=True, server_default='0'))
op.add_column('notificationtypes', sa.Column('showemployeephoto', sa.Boolean(), nullable=True, server_default='0'))
op.add_column('notificationtypes', sa.Column('displaystyle', sa.String(length=20), nullable=True, server_default='standard'))
conn = op.get_bind()
conn.execute(sa.text(
"UPDATE notificationtypes SET splitperemployee=1, showemployeephoto=1, displaystyle='carousel' "
"WHERE typecolor='recognition'"))
conn.execute(sa.text(
"UPDATE notificationtypes SET splitperemployee=1, showemployeephoto=1, displaystyle='grid' "
"WHERE typecolor='recertification'"))
conn.execute(sa.text(
"UPDATE notificationtypes SET splitperemployee=1, showemployeephoto=1, displaystyle='carousel' "
"WHERE typecolor='training'"))
def downgrade():
op.drop_column('notificationtypes', 'displaystyle')
op.drop_column('notificationtypes', 'showemployeephoto')
op.drop_column('notificationtypes', 'splitperemployee')

View File

@@ -0,0 +1,76 @@
"""PC access protocols: catalog + per-PC links, retire isvnc/iswinrm
Adds an admin-managed protocol catalog (accessprotocols) and a per-PC link
table (computeraccess). Seeds VNC/WinRM/RDP, migrates each PC's isvnc/iswinrm
into computeraccess rows, then drops the two boolean columns.
Revision ID: 7d05_pc_access_protocols
Revises: 7d04_notificationtype_display
Create Date: 2026-07-08
"""
from alembic import op
import sqlalchemy as sa
revision = '7d05_pc_access_protocols'
down_revision = '7d04_notificationtype_display'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'accessprotocols',
sa.Column('protocolid', sa.Integer(), primary_key=True),
sa.Column('name', sa.String(length=50), nullable=False),
sa.Column('scheme', sa.String(length=20), nullable=False),
sa.Column('defaultport', sa.Integer(), nullable=True),
sa.Column('linktemplate', sa.String(length=255), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False, server_default='1'),
sa.UniqueConstraint('name', name='uq_accessprotocol_name'),
)
op.create_table(
'computeraccess',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('computerid', sa.Integer(), nullable=False),
sa.Column('protocolid', sa.Integer(), nullable=False),
sa.Column('portoverride', sa.Integer(), nullable=True),
sa.Column('isactive', sa.Boolean(), nullable=False, server_default='1'),
sa.ForeignKeyConstraint(['computerid'], ['computers.computerid'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['protocolid'], ['accessprotocols.protocolid']),
sa.UniqueConstraint('computerid', 'protocolid', name='uq_computer_protocol'),
)
op.create_index('idx_compaccess_computer', 'computeraccess', ['computerid'])
conn = op.get_bind()
# Seed the protocol catalog. exec_driver_sql so the ':' / '{}' in the
# templates are not parsed as bind params.
conn.exec_driver_sql(
"INSERT INTO accessprotocols (name, scheme, defaultport, linktemplate, isactive) VALUES "
"('VNC','vnc',5900,'vnc://{host}:{port}',1),"
"('WinRM','https',5986,'https://{host}:{port}/wsman',1),"
"('RDP','rdp',3389,'rdp://{host}:{port}',1)"
)
# Migrate the old booleans into per-PC access rows.
conn.exec_driver_sql(
"INSERT INTO computeraccess (computerid, protocolid, isactive) "
"SELECT c.computerid, p.protocolid, 1 FROM computers c "
"JOIN accessprotocols p ON p.name='VNC' WHERE c.isvnc = 1"
)
conn.exec_driver_sql(
"INSERT INTO computeraccess (computerid, protocolid, isactive) "
"SELECT c.computerid, p.protocolid, 1 FROM computers c "
"JOIN accessprotocols p ON p.name='WinRM' WHERE c.iswinrm = 1"
)
op.drop_column('computers', 'isvnc')
op.drop_column('computers', 'iswinrm')
def downgrade():
op.add_column('computers', sa.Column('iswinrm', sa.Boolean(), nullable=True))
op.add_column('computers', sa.Column('isvnc', sa.Boolean(), nullable=True))
op.drop_index('idx_compaccess_computer', table_name='computeraccess')
op.drop_table('computeraccess')
op.drop_table('accessprotocols')

View File

@@ -0,0 +1,46 @@
"""Give the built-in notification types real hex colors
Recognition/Recertification/Training stored keyword typecolors ('recognition'
etc.) that static color maps had to translate. Every other type already stores
a hex. This converts the three to hex so color is fully data-driven and the
static maps can go away. Their special behavior now rides on displaystyle /
splitperemployee / showemployeephoto, not the color keyword.
Revision ID: 7d06_typecolor_to_hex
Revises: 7d05_pc_access_protocols
Create Date: 2026-07-08
"""
from alembic import op
import sqlalchemy as sa
revision = '7d06_typecolor_to_hex'
down_revision = '7d05_pc_access_protocols'
branch_labels = None
depends_on = None
KEYWORD_HEX = {
'recognition': '#ffc107',
'recertification': '#0d6efd',
'training': '#17a2b8',
}
def upgrade():
conn = op.get_bind()
for keyword, hexcolor in KEYWORD_HEX.items():
conn.execute(
sa.text("UPDATE notificationtypes SET typecolor = :hex WHERE typecolor = :kw"),
{'hex': hexcolor, 'kw': keyword}
)
def downgrade():
conn = op.get_bind()
for keyword, hexcolor in KEYWORD_HEX.items():
conn.execute(
sa.text("UPDATE notificationtypes SET typecolor = :kw WHERE typecolor = :hex"),
{'kw': keyword, 'hex': hexcolor}
)

View File

@@ -0,0 +1,42 @@
"""Add color to asset types (data-driven map/type colors)
Gives AssetType a color column (like AssetStatus) so the map's top-level type
colors come from data instead of the hardcoded map in mapColors.js. Seeds the
four base types with the exact colors that were hardcoded, so nothing changes
visually until someone edits them.
Revision ID: 7d07_assettype_color
Revises: 7d06_typecolor_to_hex
Create Date: 2026-07-08
"""
from alembic import op
import sqlalchemy as sa
revision = '7d07_assettype_color'
down_revision = '7d06_typecolor_to_hex'
branch_labels = None
depends_on = None
SEED = {
'equipment': '#F44336',
'computer': '#2196F3',
'printer': '#4CAF50',
'network_device': '#FF9800',
}
def upgrade():
op.add_column('assettypes', sa.Column('color', sa.String(length=20), nullable=True))
conn = op.get_bind()
for assettype, hexcolor in SEED.items():
conn.execute(
sa.text("UPDATE assettypes SET color = :c WHERE assettype = :t AND (color IS NULL OR color = '')"),
{'c': hexcolor, 't': assettype}
)
def downgrade():
op.drop_column('assettypes', 'color')

View File

@@ -0,0 +1,36 @@
"""Slide manager: tvslides table
Backing table for the slides plugin (lobby display + shopfloor screensaver
playlists). Image files live on disk; this holds order + per-slide duration.
Revision ID: 7d08_tvslides
Revises: 7d07_assettype_color
Create Date: 2026-07-08
"""
from alembic import op
import sqlalchemy as sa
revision = '7d08_tvslides'
down_revision = '7d07_assettype_color'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'tvslides',
sa.Column('slideid', sa.Integer(), primary_key=True),
sa.Column('surface', sa.String(length=20), nullable=False),
sa.Column('filename', sa.String(length=255), nullable=False),
sa.Column('sortorder', sa.Integer(), nullable=False, server_default='0'),
sa.Column('seconds', sa.Integer(), nullable=False, server_default='0'),
sa.Column('uploadeddate', sa.DateTime(), nullable=True),
)
op.create_index('idx_tvslide_surface', 'tvslides', ['surface'])
def downgrade():
op.drop_index('idx_tvslide_surface', table_name='tvslides')
op.drop_table('tvslides')

View File

@@ -0,0 +1,31 @@
"""Add color to asset subtype tables (data-driven map colors)
The map colors equipment/computer/network/printer subtypes round-robin from a
palette. Give each subtype table a color column so a site can set stable, chosen
colors that the map reads instead of the auto-assigned palette.
Revision ID: 7d09_subtype_colors
Revises: 7d08_tvslides
Create Date: 2026-07-09
"""
from alembic import op
import sqlalchemy as sa
revision = '7d09_subtype_colors'
down_revision = '7d08_tvslides'
branch_labels = None
depends_on = None
TABLES = ['equipmenttypes', 'computertypes', 'networkdevicetypes', 'printertypes']
def upgrade():
for table in TABLES:
op.add_column(table, sa.Column('color', sa.String(length=20), nullable=True))
def downgrade():
for table in TABLES:
op.drop_column(table, 'color')

View File

@@ -0,0 +1,26 @@
"""Add color to relationship types
Lets sites color relationship types (Controls, Contains, Stored At...) so the
asset relationship graph/badges are visually distinct, like statuses.
Revision ID: 7d10_relationshiptype_color
Revises: 7d09_subtype_colors
Create Date: 2026-07-09
"""
from alembic import op
import sqlalchemy as sa
revision = '7d10_relationshiptype_color'
down_revision = '7d09_subtype_colors'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('relationshiptypes', sa.Column('color', sa.String(length=20), nullable=True))
def downgrade():
op.drop_column('relationshiptypes', 'color')

View File

@@ -0,0 +1,23 @@
"""Add color to location types
Revision ID: 7d11_locationtype_color
Revises: 7d10_relationshiptype_color
Create Date: 2026-07-09
"""
from alembic import op
import sqlalchemy as sa
revision = '7d11_locationtype_color'
down_revision = '7d10_relationshiptype_color'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('locationtypes', sa.Column('color', sa.String(length=20), nullable=True))
def downgrade():
op.drop_column('locationtypes', 'color')

View File

@@ -0,0 +1,26 @@
"""Drop computers.isshopfloor (redundant with the Shopfloor PC type)
The flag was never populated (0 rows) and duplicated the 'Shopfloor' computer
type. Shopfloor classification now lives entirely in computertype.
Revision ID: 7d12_drop_isshopfloor
Revises: 7d11_locationtype_color
Create Date: 2026-07-09
"""
from alembic import op
import sqlalchemy as sa
revision = '7d12_drop_isshopfloor'
down_revision = '7d11_locationtype_color'
branch_labels = None
depends_on = None
def upgrade():
op.drop_column('computers', 'isshopfloor')
def downgrade():
op.add_column('computers', sa.Column('isshopfloor', sa.Boolean(), nullable=True, server_default='0'))

View File

@@ -0,0 +1,32 @@
"""Printer drivers: named SMB/HTTP links to driver packages
Revision ID: 7d13_printerdrivers
Revises: 7d12_drop_isshopfloor
Create Date: 2026-07-09
"""
from alembic import op
import sqlalchemy as sa
revision = '7d13_printerdrivers'
down_revision = '7d12_drop_isshopfloor'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'printerdrivers',
sa.Column('driverid', sa.Integer(), primary_key=True),
sa.Column('name', sa.String(length=150), nullable=False),
sa.Column('location', sa.String(length=500), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('modelnumberid', sa.Integer(), nullable=True),
sa.Column('isactive', sa.Boolean(), nullable=False, server_default='1'),
sa.ForeignKeyConstraint(['modelnumberid'], ['models.modelnumberid']),
)
def downgrade():
op.drop_table('printerdrivers')

View File

@@ -0,0 +1,48 @@
"""Custom fields: site-defined extra attributes per asset type
Revision ID: 7d14_customfields
Revises: 7d13_printerdrivers
Create Date: 2026-07-09
"""
from alembic import op
import sqlalchemy as sa
revision = '7d14_customfields'
down_revision = '7d13_printerdrivers'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'customfields',
sa.Column('fieldid', sa.Integer(), primary_key=True),
sa.Column('assettypeid', sa.Integer(), nullable=False),
sa.Column('fieldkey', sa.String(length=50), nullable=False),
sa.Column('label', sa.String(length=150), nullable=False),
sa.Column('datatype', sa.String(length=20), nullable=False, server_default='text'),
sa.Column('options', sa.Text(), nullable=True),
sa.Column('showondetail', sa.Boolean(), nullable=False, server_default='1'),
sa.Column('showonform', sa.Boolean(), nullable=False, server_default='1'),
sa.Column('sortorder', sa.Integer(), nullable=False, server_default='0'),
sa.Column('isactive', sa.Boolean(), nullable=False, server_default='1'),
sa.ForeignKeyConstraint(['assettypeid'], ['assettypes.assettypeid']),
sa.UniqueConstraint('assettypeid', 'fieldkey', name='uq_customfield_type_key'),
)
op.create_table(
'customfieldvalues',
sa.Column('valueid', sa.Integer(), primary_key=True),
sa.Column('fieldid', sa.Integer(), nullable=False),
sa.Column('assetid', sa.Integer(), nullable=False),
sa.Column('value', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['fieldid'], ['customfields.fieldid'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ondelete='CASCADE'),
sa.UniqueConstraint('fieldid', 'assetid', name='uq_customfieldvalue_field_asset'),
)
def downgrade():
op.drop_table('customfieldvalues')
op.drop_table('customfields')

View File

@@ -0,0 +1,45 @@
"""Warranty plugin: warranties + warrantyassets
Revision ID: 7d15_warranties
Revises: 7d14_customfields
Create Date: 2026-07-09
"""
from alembic import op
import sqlalchemy as sa
revision = '7d15_warranties'
down_revision = '7d14_customfields'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'warranties',
sa.Column('warrantyid', sa.Integer(), primary_key=True),
sa.Column('vendor', sa.String(length=100), nullable=False),
sa.Column('servicetag', sa.String(length=100), nullable=True),
sa.Column('provider', sa.String(length=20), nullable=False, server_default='manual'),
sa.Column('servicelevel', sa.String(length=150), nullable=True),
sa.Column('startdate', sa.Date(), nullable=True),
sa.Column('enddate', sa.Date(), nullable=True),
sa.Column('lastcheckeddate', sa.DateTime(), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('isactive', sa.Boolean(), nullable=False, server_default='1'),
)
op.create_table(
'warrantyassets',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('warrantyid', sa.Integer(), nullable=False),
sa.Column('assetid', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['warrantyid'], ['warranties.warrantyid'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'], ondelete='CASCADE'),
sa.UniqueConstraint('warrantyid', 'assetid', name='uq_warrantyasset_warranty_asset'),
)
def downgrade():
op.drop_table('warrantyassets')
op.drop_table('warranties')

View File

@@ -0,0 +1,31 @@
"""Self-hosted employee directory table
Revision ID: 7d16_directoryemployees
Revises: 7d15_warranties
Create Date: 2026-07-10
"""
from alembic import op
import sqlalchemy as sa
revision = '7d16_directoryemployees'
down_revision = '7d15_warranties'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'directoryemployees',
sa.Column('sso', sa.Integer(), primary_key=True, autoincrement=False),
sa.Column('firstname', sa.String(length=100), nullable=False),
sa.Column('lastname', sa.String(length=100), nullable=False),
sa.Column('team', sa.String(length=100), nullable=True),
sa.Column('role', sa.String(length=100), nullable=True),
sa.Column('picture', sa.String(length=255), nullable=True),
)
def downgrade():
op.drop_table('directoryemployees')

View File

@@ -0,0 +1,205 @@
"""Equipment -> machines rename, core half
Part 1 of the approved equipment -> machines rename (the plugin half lives in
plugins/machines/migrations/versions/0002_rename_from_equipment.py):
1. machinetypes -> modeltypes (+ machinetypeid -> modeltypeid,
machinetype -> modeltype, models.machinetypeid -> models.modeltypeid).
The table types the vendor MODELS catalog, not machine instances, so
the new name is role-accurate and frees "machinetypes" for the plugin.
2. Data flips: assettypes 'equipment' -> 'machine' (incl pluginname and
tablename), auditlogs.entitytype 'Equipment' -> 'Machine', settings keys
identifier_*_equipment_enabled -> identifier_*_machine_enabled,
search_equipment_enabled -> search_machine_enabled, and permission
rows equipment.* -> machines.* (role links survive).
3. alembic_version_equipment -> alembic_version_machines (if present) so
the renamed plugin's migration chain resumes seamlessly.
Idempotent via inspector guards, following the 7c01 pattern. MySQL is the
only dialect the core chain runs against in practice.
Revision ID: 7d17_machines_rename
Revises: 7d16_directoryemployees
Create Date: 2026-07-11
"""
from alembic import op
import sqlalchemy as sa
revision = '7d17_machines_rename'
down_revision = '7d16_directoryemployees'
branch_labels = None
depends_on = None
def _fk_names(insp, table, referred_table):
# find FK constraint names on table pointing at referred_table
return [fk['name'] for fk in insp.get_foreign_keys(table)
if fk.get('referred_table') == referred_table and fk.get('name')]
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
tables = set(insp.get_table_names())
# -- 1. machinetypes -> modeltypes ------------------------------------
if 'machinetypes' in tables and 'modeltypes' not in tables:
bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=0")
# drop the models -> machinetypes FK and its index before renaming
for name in _fk_names(insp, 'models', 'machinetypes'):
bind.exec_driver_sql(f"ALTER TABLE models DROP FOREIGN KEY {name}")
model_indexes = {ix['name'] for ix in insp.get_indexes('models')}
if 'machinetypeid' in model_indexes:
bind.exec_driver_sql("ALTER TABLE models DROP KEY machinetypeid")
bind.exec_driver_sql("RENAME TABLE machinetypes TO modeltypes")
bind.exec_driver_sql(
"ALTER TABLE modeltypes "
"CHANGE machinetypeid modeltypeid INT NOT NULL AUTO_INCREMENT, "
"CHANGE machinetype modeltype VARCHAR(100) NOT NULL, "
"DROP KEY machinetype, "
"ADD UNIQUE KEY modeltype (modeltype)"
)
bind.exec_driver_sql(
"ALTER TABLE models "
"CHANGE machinetypeid modeltypeid INT NULL, "
"ADD KEY modeltypeid (modeltypeid), "
"ADD CONSTRAINT fk_models_modeltypeid "
"FOREIGN KEY (modeltypeid) REFERENCES modeltypes (modeltypeid)"
)
bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=1")
# -- 2. data flips ------------------------------------------------------
if 'assettypes' in tables:
bind.exec_driver_sql(
"UPDATE assettypes SET assettype='machine', pluginname='machines', "
"tablename='machines' WHERE assettype='equipment'"
)
if 'auditlogs' in tables:
bind.exec_driver_sql(
"UPDATE auditlogs SET entitytype='Machine' "
"WHERE entitytype='Equipment'"
)
if 'settings' in tables:
_rename_setting_keys(bind, old_marker='_equipment_',
new_marker='_machine_')
if 'permissions' in tables:
_rename_permissions(bind, old_prefix='equipment.',
new_prefix='machines.',
old_category='equipment', new_category='machines')
# -- 3. plugin version table --------------------------------------------
if ('alembic_version_equipment' in tables
and 'alembic_version_machines' not in tables):
bind.exec_driver_sql(
"RENAME TABLE alembic_version_equipment TO alembic_version_machines"
)
def _rename_setting_keys(bind, old_marker, new_marker):
"""Rename settings keys embedding the assettype value, collision-safe.
Covers identifier_<name>_equipment_enabled and search_equipment_enabled.
If a row with the new key already exists (fresh seed ran on new code),
the old row is dropped instead of renamed.
"""
# broad fetch; the marker replace below filters non-matches
rows = bind.exec_driver_sql(
"SELECT settingid, `key` FROM settings"
).fetchall()
for settingid, key in rows:
newkey = key.replace(old_marker, new_marker)
if newkey == key:
continue
exists = bind.exec_driver_sql(
"SELECT 1 FROM settings WHERE `key` = %s", (newkey,)
).fetchone()
if exists:
bind.exec_driver_sql(
"DELETE FROM settings WHERE settingid = %s", (settingid,))
else:
bind.exec_driver_sql(
"UPDATE settings SET `key` = %s WHERE settingid = %s",
(newkey, settingid))
def _rename_permissions(bind, old_prefix, new_prefix, old_category,
new_category):
"""Rename permission rows in place so role links survive, collision-safe."""
rows = bind.exec_driver_sql(
"SELECT permissionid, name FROM permissions"
).fetchall()
for permissionid, name in rows:
if not name.startswith(old_prefix):
continue
newname = new_prefix + name[len(old_prefix):]
exists = bind.exec_driver_sql(
"SELECT 1 FROM permissions WHERE name = %s", (newname,)
).fetchone()
if exists:
bind.exec_driver_sql(
"DELETE FROM permissions WHERE permissionid = %s",
(permissionid,))
else:
bind.exec_driver_sql(
"UPDATE permissions SET name = %s, category = %s "
"WHERE permissionid = %s",
(newname, new_category, permissionid))
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
tables = set(insp.get_table_names())
if ('alembic_version_machines' in tables
and 'alembic_version_equipment' not in tables):
bind.exec_driver_sql(
"RENAME TABLE alembic_version_machines TO alembic_version_equipment"
)
if 'settings' in tables:
_rename_setting_keys(bind, old_marker='_machine_',
new_marker='_equipment_')
if 'permissions' in tables:
_rename_permissions(bind, old_prefix='machines.',
new_prefix='equipment.',
old_category='machines', new_category='equipment')
if 'auditlogs' in tables:
bind.exec_driver_sql(
"UPDATE auditlogs SET entitytype='Equipment' "
"WHERE entitytype='Machine'"
)
if 'assettypes' in tables:
bind.exec_driver_sql(
"UPDATE assettypes SET assettype='equipment', "
"pluginname='equipment', tablename='equipment' "
"WHERE assettype='machine'"
)
if 'modeltypes' in tables and 'machinetypes' not in tables:
bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=0")
for name in _fk_names(insp, 'models', 'modeltypes'):
bind.exec_driver_sql(f"ALTER TABLE models DROP FOREIGN KEY {name}")
model_indexes = {ix['name'] for ix in insp.get_indexes('models')}
if 'modeltypeid' in model_indexes:
bind.exec_driver_sql("ALTER TABLE models DROP KEY modeltypeid")
bind.exec_driver_sql("RENAME TABLE modeltypes TO machinetypes")
bind.exec_driver_sql(
"ALTER TABLE machinetypes "
"CHANGE modeltypeid machinetypeid INT NOT NULL AUTO_INCREMENT, "
"CHANGE modeltype machinetype VARCHAR(100) NOT NULL, "
"DROP KEY modeltype, "
"ADD UNIQUE KEY machinetype (machinetype)"
)
bind.exec_driver_sql(
"ALTER TABLE models "
"CHANGE modeltypeid machinetypeid INT NULL, "
"ADD KEY machinetypeid (machinetypeid), "
"ADD CONSTRAINT models_ibfk_1 "
"FOREIGN KEY (machinetypeid) REFERENCES machinetypes (machinetypeid)"
)
bind.exec_driver_sql("SET FOREIGN_KEY_CHECKS=1")

View File

@@ -0,0 +1,150 @@
"""Support teams get contacts; drop the legacy appowners pair
Restructures the application support model: supportteams keeps teamname (now
unique) + teamurl (widened to TEXT for ServiceNow group deep links), gains a
child supportteamcontacts table, and sheds its single-owner appownerid FK and
the appowners table. Each legacy team's appowner is migrated into ONE contact.
applications.supportteamid is unchanged (it already points at supportteams).
Idempotent guards throughout so it is safe on a partially-migrated box.
Revision ID: 7d18_supportteamcontacts
Revises: 7d17_machines_rename
Create Date: 2026-07-11
"""
from alembic import op
import sqlalchemy as sa
revision = '7d18_supportteamcontacts'
down_revision = '7d17_machines_rename'
branch_labels = None
depends_on = None
def _has_table(insp, name):
return insp.has_table(name)
def _has_column(insp, table, column):
return column in [c['name'] for c in insp.get_columns(table)]
def _has_unique(insp, table, name):
return name in [u['name'] for u in insp.get_unique_constraints(table)]
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
# 1. New contacts table.
if not _has_table(insp, 'supportteamcontacts'):
op.create_table(
'supportteamcontacts',
sa.Column('contactid', sa.Integer(), primary_key=True),
sa.Column('supportteamid', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('sso', sa.String(length=50), nullable=True),
sa.Column('sortorder', sa.Integer(), nullable=False,
server_default='0'),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False,
server_default='1'),
sa.ForeignKeyConstraint(
['supportteamid'], ['supportteams.supportteamid'],
ondelete='CASCADE'),
)
# 2. Migrate each team's appowner into one contact.
if _has_table(insp, 'appowners') and \
_has_column(insp, 'supportteams', 'appownerid'):
bind.exec_driver_sql(
"INSERT INTO supportteamcontacts "
"(supportteamid, name, sso, sortorder, isactive, "
" createddate, modifieddate) "
"SELECT st.supportteamid, ao.appowner, ao.sso, 0, 1, "
" NOW(), NOW() "
"FROM supportteams st "
"JOIN appowners ao ON st.appownerid = ao.appownerid "
"WHERE st.appownerid IS NOT NULL")
# 3. teamname becomes unique.
if not _has_unique(insp, 'supportteams', 'uq_supportteams_teamname'):
op.create_unique_constraint(
'uq_supportteams_teamname', 'supportteams', ['teamname'])
# 4. Widen teamurl to TEXT.
op.alter_column('supportteams', 'teamurl',
existing_type=sa.String(length=255),
type_=sa.Text(), existing_nullable=True)
# 5. Drop the appownerid FK + column, then the appowners table.
if _has_column(insp, 'supportteams', 'appownerid'):
for fk in insp.get_foreign_keys('supportteams'):
if 'appownerid' in fk['constrained_columns'] and fk.get('name'):
bind.exec_driver_sql(
f"ALTER TABLE supportteams DROP FOREIGN KEY {fk['name']}")
op.drop_column('supportteams', 'appownerid')
bind.exec_driver_sql("DROP TABLE IF EXISTS appowners")
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
# Recreate appowners.
if not _has_table(insp, 'appowners'):
op.create_table(
'appowners',
sa.Column('appownerid', sa.Integer(), primary_key=True),
sa.Column('appowner', sa.String(length=100), nullable=False),
sa.Column('sso', sa.String(length=50), nullable=True),
sa.Column('email', sa.String(length=100), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False,
server_default='1'),
)
# Re-add the appownerid FK column.
if not _has_column(insp, 'supportteams', 'appownerid'):
op.add_column('supportteams',
sa.Column('appownerid', sa.Integer(), nullable=True))
op.create_foreign_key(
'fk_supportteams_appownerid', 'supportteams', 'appowners',
['appownerid'], ['appownerid'])
# Best-effort data reversal: each team's first active contact -> an owner.
if _has_table(insp, 'supportteamcontacts'):
rows = bind.execute(sa.text(
"SELECT supportteamid, name, sso FROM supportteamcontacts "
"WHERE isactive = 1 ORDER BY supportteamid, sortorder, contactid"))
seen = set()
for row in rows:
if row.supportteamid in seen:
continue
seen.add(row.supportteamid)
result = bind.execute(sa.text(
"INSERT INTO appowners "
"(appowner, sso, isactive, createddate, modifieddate) "
"VALUES (:name, :sso, 1, NOW(), NOW())"),
{'name': row.name, 'sso': row.sso})
bind.execute(sa.text(
"UPDATE supportteams SET appownerid = :ownerid "
"WHERE supportteamid = :teamid"),
{'ownerid': result.lastrowid, 'teamid': row.supportteamid})
# Narrow teamurl back to VARCHAR(255).
op.alter_column('supportteams', 'teamurl',
existing_type=sa.Text(),
type_=sa.String(length=255), existing_nullable=True)
# Drop the unique constraint.
if _has_unique(insp, 'supportteams', 'uq_supportteams_teamname'):
op.drop_constraint('uq_supportteams_teamname', 'supportteams',
type_='unique')
op.drop_table('supportteamcontacts')

View File

@@ -0,0 +1,70 @@
"""Relationship types get isdirectional; flip symmetric legacy types
Adds relationshiptypes.isdirectional (default true). The asset relationships
card uses it: directional types keep a source->target reading, symmetric types
(physical links, network connections) collapse both stored direction rows into
one direction-blind "connected" entry per peer.
Seeds isdirectional=false for the connection-like types present pre-1.0:
Dualpath, connectedto, Cluster Member, Serial Cable, Direct Ethernet, USB,
WiFi. Everything else (controls, Controlled By, Backup For, Master-Slave,
partof, defaultprinter) stays directional.
Idempotent guards so it is safe on a partially-migrated box.
Revision ID: 7d19_relationshiptype_directional
Revises: 7d18_supportteamcontacts
Create Date: 2026-07-11
"""
from alembic import op
import sqlalchemy as sa
revision = '7d19_relationshiptype_directional'
down_revision = '7d18_supportteamcontacts'
branch_labels = None
depends_on = None
# names seeded symmetric (isdirectional=false)
SYMMETRIC_TYPES = [
'Dualpath',
'connectedto',
'Cluster Member',
'Serial Cable',
'Direct Ethernet',
'USB',
'WiFi',
]
def _has_column(insp, table, column):
return column in [c['name'] for c in insp.get_columns(table)]
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if not _has_column(insp, 'relationshiptypes', 'isdirectional'):
op.add_column(
'relationshiptypes',
sa.Column('isdirectional', sa.Boolean(), nullable=False,
server_default='1'))
# flip the known symmetric types. re-running just re-sets the same values.
placeholders = ', '.join([f':n{i}' for i in range(len(SYMMETRIC_TYPES))])
params = {f'n{i}': name for i, name in enumerate(SYMMETRIC_TYPES)}
bind.execute(
sa.text(
"UPDATE relationshiptypes SET isdirectional = 0 "
f"WHERE relationshiptype IN ({placeholders})"),
params)
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if _has_column(insp, 'relationshiptypes', 'isdirectional'):
op.drop_column('relationshiptypes', 'isdirectional')

View File

@@ -0,0 +1,109 @@
"""Relationship-type propagation goes M:N (relationshiptypepropagations)
Replaces the single-valued relationshiptypes.propagatesthroughid with a join
table so one type can propagate through several through-types at once. On this
DB `controls` needs both `partof` (declared) and `Dualpath` (consumed: a
dual-bay pair is one physical machine with a single controller, so both bays
must carry the controls link to that PC).
Upgrade: create relationshiptypepropagations, migrate the existing
propagatesthroughid value into a row, drop the column, then seed the
controls -> Dualpath row (resolved by name, skipped if either type missing).
Downgrade: recreate the column from the first propagation row per type (best
effort) and drop the table.
Idempotent guards so it is safe on a partially-migrated box.
Revision ID: 7d20_relationshiptypepropagations
Revises: 7d19_relationshiptype_directional
Create Date: 2026-07-11
"""
from alembic import op
import sqlalchemy as sa
revision = '7d20_relationshiptypepropagations'
down_revision = '7d19_relationshiptype_directional'
branch_labels = None
depends_on = None
def _has_column(insp, table, column):
return column in [c['name'] for c in insp.get_columns(table)]
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'relationshiptypepropagations' not in insp.get_table_names():
op.create_table(
'relationshiptypepropagations',
sa.Column('propagationid', sa.Integer(), primary_key=True),
sa.Column('relationshiptypeid', sa.Integer(), nullable=False),
sa.Column('throughtypeid', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['relationshiptypeid'],
['relationshiptypes.relationshiptypeid']),
sa.ForeignKeyConstraint(['throughtypeid'],
['relationshiptypes.relationshiptypeid']),
sa.UniqueConstraint('relationshiptypeid', 'throughtypeid',
name='uq_reltype_propagation'),
)
if _has_column(insp, 'relationshiptypes', 'propagatesthroughid'):
# migrate the single-FK value into a row (dedup guard for re-runs)
bind.execute(sa.text(
"INSERT INTO relationshiptypepropagations "
"(relationshiptypeid, throughtypeid) "
"SELECT rt.relationshiptypeid, rt.propagatesthroughid "
"FROM relationshiptypes rt "
"WHERE rt.propagatesthroughid IS NOT NULL AND NOT EXISTS ("
" SELECT 1 FROM relationshiptypepropagations p "
" WHERE p.relationshiptypeid = rt.relationshiptypeid "
" AND p.throughtypeid = rt.propagatesthroughid)"))
# drop the self-FK (known name from 7a01, else reflect) then the column
fk_names = [fk['name'] for fk in insp.get_foreign_keys('relationshiptypes')
if fk.get('constrained_columns') == ['propagatesthroughid']
and fk.get('name')]
for name in fk_names:
op.drop_constraint(name, 'relationshiptypes', type_='foreignkey')
op.drop_column('relationshiptypes', 'propagatesthroughid')
# seed controls -> Dualpath by name; skip if either type is missing
rows = bind.execute(sa.text(
"SELECT relationshiptypeid, relationshiptype FROM relationshiptypes")).fetchall()
byname = {r[1]: r[0] for r in rows}
controls = byname.get('controls')
dualpath = byname.get('Dualpath')
if controls and dualpath:
exists = bind.execute(sa.text(
"SELECT 1 FROM relationshiptypepropagations "
"WHERE relationshiptypeid = :c AND throughtypeid = :d"),
{'c': controls, 'd': dualpath}).fetchone()
if not exists:
bind.execute(sa.text(
"INSERT INTO relationshiptypepropagations "
"(relationshiptypeid, throughtypeid) VALUES (:c, :d)"),
{'c': controls, 'd': dualpath})
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if not _has_column(insp, 'relationshiptypes', 'propagatesthroughid'):
op.add_column('relationshiptypes',
sa.Column('propagatesthroughid', sa.Integer(), nullable=True))
op.create_foreign_key('fk_relationshiptype_propagation',
'relationshiptypes', 'relationshiptypes',
['propagatesthroughid'], ['relationshiptypeid'])
if 'relationshiptypepropagations' in insp.get_table_names():
# best effort: restore the first propagation row per type
bind.execute(sa.text(
"UPDATE relationshiptypes rt SET propagatesthroughid = ("
" SELECT p.throughtypeid FROM relationshiptypepropagations p "
" WHERE p.relationshiptypeid = rt.relationshiptypeid "
" ORDER BY p.propagationid LIMIT 1)"))
op.drop_table('relationshiptypepropagations')

View File

@@ -0,0 +1,61 @@
"""Personal API tokens (apitokens)
Adds the apitokens table: personal access tokens that let scripts and
integrations authenticate as a user without the hourly-expiring login JWT.
Only the sha256 hash of each secret is stored.
Idempotent guard so it is safe on a partially-migrated box; real downgrade.
Revision ID: 7d21_apitokens
Revises: 7d20_relationshiptypepropagations
Create Date: 2026-07-12
"""
from alembic import op
import sqlalchemy as sa
revision = '7d21_apitokens'
down_revision = '7d20_relationshiptypepropagations'
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'apitokens' in insp.get_table_names():
return
op.create_table(
'apitokens',
sa.Column('tokenid', sa.Integer(), primary_key=True),
sa.Column('userid', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('tokenprefix', sa.String(length=16), nullable=True),
sa.Column('tokenhash', sa.String(length=64), nullable=False),
sa.Column('expiresat', sa.DateTime(), nullable=True),
sa.Column('lastusedat', sa.DateTime(), nullable=True),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False, server_default='1'),
sa.ForeignKeyConstraint(['userid'], ['users.userid']),
sa.UniqueConstraint('tokenhash', name='uq_apitoken_tokenhash'),
)
op.create_index('ix_apitokens_userid', 'apitokens', ['userid'])
op.create_index('ix_apitokens_tokenprefix', 'apitokens', ['tokenprefix'])
op.create_index('ix_apitokens_tokenhash', 'apitokens', ['tokenhash'])
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'apitokens' not in insp.get_table_names():
return
op.drop_index('ix_apitokens_tokenhash', table_name='apitokens')
op.drop_index('ix_apitokens_tokenprefix', table_name='apitokens')
op.drop_index('ix_apitokens_userid', table_name='apitokens')
op.drop_table('apitokens')

View File

@@ -0,0 +1,49 @@
"""API token permission scopes (apitokens.scopes)
Adds a nullable scopes column to apitokens: a JSON array of permission-name
strings. NULL means the token is unscoped and acts with the full authority of
its owner (the original behavior). A scoped token grants ONLY the listed
permissions, intersected with what the owner actually holds, and suspends the
admin-role bypass so a scoped token minted by an admin is genuinely limited.
Idempotent guard so it is safe on a partially-migrated box; real downgrade.
Revision ID: 7d22_apitokens_scopes
Revises: 7d21_apitokens
Create Date: 2026-07-12
"""
from alembic import op
import sqlalchemy as sa
revision = '7d22_apitokens_scopes'
down_revision = '7d21_apitokens'
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'apitokens' not in insp.get_table_names():
return
columns = {c['name'] for c in insp.get_columns('apitokens')}
if 'scopes' in columns:
return
op.add_column('apitokens', sa.Column('scopes', sa.Text(), nullable=True))
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'apitokens' not in insp.get_table_names():
return
columns = {c['name'] for c in insp.get_columns('apitokens')}
if 'scopes' not in columns:
return
op.drop_column('apitokens', 'scopes')

View File

@@ -0,0 +1,50 @@
"""User forced-password-change flag (users.mustchangepassword)
Adds a boolean users.mustchangepassword column (default false). An admin who
creates a user sets it true so the user is forced through a password change on
first login; changing the password clears it.
Idempotent guard so it is safe on a partially-migrated box; real downgrade.
Revision ID: 7d23_user_mustchangepassword
Revises: 7d22_apitokens_scopes
Create Date: 2026-07-12
"""
from alembic import op
import sqlalchemy as sa
revision = '7d23_user_mustchangepassword'
down_revision = '7d22_apitokens_scopes'
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'users' not in insp.get_table_names():
return
columns = {c['name'] for c in insp.get_columns('users')}
if 'mustchangepassword' in columns:
return
op.add_column(
'users',
sa.Column('mustchangepassword', sa.Boolean(),
nullable=False, server_default=sa.false()))
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'users' not in insp.get_table_names():
return
columns = {c['name'] for c in insp.get_columns('users')}
if 'mustchangepassword' not in columns:
return
op.drop_column('users', 'mustchangepassword')

View File

@@ -0,0 +1,50 @@
"""Custom field searchable flag (customfields.searchable)
Adds a boolean customfields.searchable column (default false). When true, the
field's stored values are matched by global search and a hit routes to the
owning asset's detail page.
Idempotent guard so it is safe on a partially-migrated box; real downgrade.
Revision ID: 7d24_customfield_searchable
Revises: 7d23_user_mustchangepassword
Create Date: 2026-07-12
"""
from alembic import op
import sqlalchemy as sa
revision = '7d24_customfield_searchable'
down_revision = '7d23_user_mustchangepassword'
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'customfields' not in insp.get_table_names():
return
columns = {c['name'] for c in insp.get_columns('customfields')}
if 'searchable' in columns:
return
op.add_column(
'customfields',
sa.Column('searchable', sa.Boolean(),
nullable=False, server_default=sa.false()))
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'customfields' not in insp.get_table_names():
return
columns = {c['name'] for c in insp.get_columns('customfields')}
if 'searchable' not in columns:
return
op.drop_column('customfields', 'searchable')

View File

@@ -0,0 +1,57 @@
"""Drop redundant secondary indexes duplicating a named idx_* / unique index.
The DB review found five non-unique secondary indexes that duplicate an existing
index on the same column: the auto-named ix_* from a column index=True alongside
a named idx_* in __table_args__ (communications.assetid, and the hostname on
computers/networkdevices/printers), plus idx_usb_serial duplicating the
serialnumber unique index on usbdevices. Each redundant pair costs insert/update
maintenance with zero read benefit. The models had the redundant index source
removed; this drops the live duplicates so the schema matches the models.
Idempotent (skips a missing table/index); real downgrade recreates them.
Revision ID: 7d25_drop_redundant_indexes
Revises: 7d24_customfield_searchable
Create Date: 2026-07-13
"""
from alembic import op
import sqlalchemy as sa
revision = '7d25_drop_redundant_indexes'
down_revision = '7d24_customfield_searchable'
branch_labels = None
depends_on = None
# (table, index name, columns, unique) - columns/unique used only to recreate on
# downgrade. All non-unique; the surviving idx_* / unique index still covers each.
_REDUNDANT = [
('communications', 'ix_communications_assetid', ['assetid'], False),
('computers', 'ix_computers_hostname', ['hostname'], False),
('networkdevices', 'ix_networkdevices_hostname', ['hostname'], False),
('printers', 'ix_printers_hostname', ['hostname'], False),
('usbdevices', 'idx_usb_serial', ['serialnumber'], False),
]
def _index_names(insp, table):
return {i['name'] for i in insp.get_indexes(table)}
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
tables = set(insp.get_table_names())
for table, name, _cols, _unique in _REDUNDANT:
if table in tables and name in _index_names(insp, table):
op.drop_index(name, table_name=table)
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
tables = set(insp.get_table_names())
for table, name, cols, unique in _REDUNDANT:
if table in tables and name not in _index_names(insp, table):
op.create_index(name, table, cols, unique=unique)