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,13 @@
# Legacy data migration (superseded)
The direct-database migration this guide used to describe is retired. Legacy
data now enters through the HTTP import surface, which runs authorization,
validation, auditing, and plugin hooks on every row:
- Contract: the IMPORT-API wiki page (admin API token + `X-Import-Mode: true`,
which preserves original created/modified timestamps).
- Adoption playbook for a new site: the IMPORT-ADOPTION wiki page.
- Worked reference loader (West Jefferson classic-ASP schema, 15 stages,
idempotent and resumable): `scripts/site_imports/wjf/`.
- Full pilot sequence (stand up + import + verify + cutover):
the PILOT-DEPLOY wiki page.

View File

@@ -0,0 +1,89 @@
# Fix LocationOnly Equipment Types Migration
## Issue
During the original migration from the VBScript ShopDB site, 110 machines were imported with `LocationOnly` type even though they have models assigned that specify the correct equipment type.
## Root Cause
The migration copied the `machinetypeid` directly from the `machines` table, but many machines had `machinetypeid=1` (LocationOnly) even when they had a model with a proper type.
## Affected Records
- **110 machines** in the `machines` table
- **110 equipment** records in the `equipment` table
## Example
| Machine | Current Type | Should Be | Model |
|---------|-------------|-----------|-------|
| 7502 | LocationOnly | Horizontal Machining Center | a81nx |
| 3108 | LocationOnly | Vertical Lathe | VTM-100 |
| 4001 | LocationOnly | 5-axis Mill | VP9000 |
## Fix SQL
### Step 1: Preview affected records
```sql
-- Preview what will be changed
SELECT
m.machineid,
m.machinenumber,
mt_current.machinetype as current_type,
mt_model.machinetype as correct_type,
mo.modelnumber
FROM machines m
JOIN models mo ON mo.modelnumberid = m.modelnumberid
JOIN machinetypes mt_current ON mt_current.machinetypeid = m.machinetypeid
JOIN machinetypes mt_model ON mt_model.machinetypeid = mo.machinetypeid
WHERE m.machinetypeid = 1 -- Currently LocationOnly
AND mo.machinetypeid != 1; -- Model has a real type
```
### Step 2: Fix the machines table
```sql
-- Update machines table to use the model's machine type
UPDATE machines m
JOIN models mo ON mo.modelnumberid = m.modelnumberid
SET m.machinetypeid = mo.machinetypeid
WHERE m.machinetypeid = 1 -- Currently LocationOnly
AND mo.machinetypeid != 1; -- Model has a real type
```
### Step 3: Fix the equipment table
```sql
-- Update equipment table to match
-- Note: equipmenttypeid values match machinetypeid values
UPDATE equipment e
JOIN assets a ON a.assetid = e.assetid
JOIN machines m ON m.machinenumber = SUBSTRING_INDEX(a.assetnumber, '-', 1)
SET e.equipmenttypeid = m.machinetypeid
WHERE e.equipmenttypeid = 1 -- Currently LocationOnly
AND m.machinetypeid != 1; -- Machine now has correct type
```
### Alternative Step 3 (if asset numbers don't match)
```sql
-- Update equipment based on their own model reference
UPDATE equipment e
JOIN models mo ON mo.modelnumberid = e.modelnumberid
SET e.equipmenttypeid = mo.machinetypeid
WHERE e.equipmenttypeid = 1 -- Currently LocationOnly
AND mo.machinetypeid != 1 -- Model has a real type
AND e.modelnumberid IS NOT NULL;
```
## Verification
```sql
-- Count remaining LocationOnly records that should have been fixed
SELECT COUNT(*) as remaining_fixable
FROM machines m
JOIN models mo ON mo.modelnumberid = m.modelnumberid
WHERE m.machinetypeid = 1
AND mo.machinetypeid != 1;
-- Should return 0 after fix
```
## Notes for Future Migrations
1. When importing equipment, always check if the model has a type and use that as the default
2. Only use "LocationOnly" for genuine location markers (rooms, offices) without models
3. Validate after import: any equipment with a model should NOT be LocationOnly
## Date Created
2026-01-27

View File

@@ -0,0 +1,92 @@
# Migrate USB Devices from Equipment Table
## Issue
6 USB devices were incorrectly stored in the `equipment` table instead of the proper `usbdevices` table.
## Status
- [x] `pin` field added to `usbdevices` table for encrypted devices
- [x] `pin` field added to USBDevice model
- [x] USB Device equipment type deactivated (equipmenttypeid=44)
- [ ] Migrate 6 USB devices to usbdevices table
## Affected Records
| Asset Number | Name | Serial Number |
|--------------|------|---------------|
| 82841957-5891 | Green Kingston 64GB | 82841957 |
| 48854302-5892 | Blue Kingston 64GB | 48854302 |
| 75953637-5893 | Blue Kingston 64GB USB 3.0 | 75953637 |
| 41299370-5904 | Lenovo Portable DVD | 41299370 |
| 15492331-5905 | TEAC Portable Floppy Drive | 15492331 |
| 25777358-5906 | Netgear WiFi Adapter | 25777358 |
## Migration SQL
### Step 1: Insert into usbdevices
```sql
INSERT INTO usbdevices (
serialnumber,
label,
assetnumber,
usbdevicetypeid,
storagelocation,
notes,
createddate,
modifieddate,
isactive
)
SELECT
a.serialnumber,
a.name,
a.assetnumber,
NULL, -- Assign proper type later
NULL, -- Storage location TBD
a.notes,
a.createddate,
a.modifieddate,
a.isactive
FROM assets a
JOIN equipment e ON e.assetid = a.assetid
WHERE e.equipmenttypeid = 44; -- USB Device type
```
### Step 2: Delete from equipment table
```sql
DELETE e FROM equipment e
JOIN assets a ON a.assetid = e.assetid
WHERE e.equipmenttypeid = 44;
```
### Step 3: Delete from assets table
```sql
DELETE a FROM assets a
JOIN equipment e ON e.assetid = a.assetid
WHERE e.equipmenttypeid = 44;
-- Note: Run step 2 first since it references assets
```
### Alternative: Manual Migration
Since there are only 6 devices, you may prefer to:
1. Manually create them in the USB Devices section
2. Delete the equipment/asset records
## USB Device Types to Create
```sql
-- Check if types exist, create if needed
INSERT IGNORE INTO usbdevicetypes (typename, description, icon) VALUES
('Flash Drive', 'USB flash drive / thumb drive', 'usb'),
('External HDD', 'External hard disk drive', 'harddisk'),
('External SSD', 'External solid state drive', 'harddisk'),
('Card Reader', 'SD/CF card reader', 'sdcard'),
('Optical Drive', 'External CD/DVD/Blu-ray drive', 'disc'),
('WiFi Adapter', 'USB wireless network adapter', 'wifi'),
('Other', 'Other USB device', 'usb');
```
## Notes
- USB Device equipment type has been deactivated (isactive=0)
- New USB devices should be created in the USB Devices section
- USB devices do NOT need map positions (no mapleft/maptop)
## Date Created
2026-01-27

View File

@@ -0,0 +1,165 @@
# Production Migration Guide
## Overview
This guide documents the process for migrating data from the legacy VBScript ShopDB site to the new Flask-based ShopDB.
## Database Architecture
### Legacy System (VBScript)
- Single `machines` table containing all equipment, PCs, printers
- `machinetypes` table for classification
- `models` table with `machinetypeid` reference
### New System (Flask)
- Core `assets` table (unified asset registry)
- Plugin-specific extension tables:
- `equipment` (equipmenttypeid → equipmenttypes)
- `computers` (computertypeid → computertypes)
- `printers` (printertypeid → printertypes)
- `network_device` (networkdevicetypeid → networkdevicetypes)
## Key Mappings
### Asset Type Mapping
| Legacy Category | New Asset Type | Extension Table |
|-----------------|---------------|-----------------|
| Equipment machines | Equipment | equipment |
| PC/Computer | Computer | computers |
| Printer | Printer | printers |
| Network (IDF, Switch, AP) | Network Device | network_device |
### Type ID Alignment
The `equipmenttypes` table IDs match `machinetypes` IDs for easy migration:
- equipmenttypeid = machinetypeid (where applicable)
## Migration Steps
### Step 1: Export from Legacy Database
```sql
-- Export machines with all related data
SELECT
m.*,
mt.machinetype,
mo.modelnumber,
mo.machinetypeid as model_typeid,
v.vendor,
bu.businessunit,
s.status
FROM machines m
LEFT JOIN machinetypes mt ON mt.machinetypeid = m.machinetypeid
LEFT JOIN models mo ON mo.modelnumberid = m.modelnumberid
LEFT JOIN vendors v ON v.vendorid = m.vendorid
LEFT JOIN businessunits bu ON bu.businessunitid = m.businessunitid
LEFT JOIN statuses s ON s.statusid = m.statusid;
```
### Step 2: Create Assets
For each machine, create an asset record:
```sql
INSERT INTO assets (assetnumber, name, assettypeid, statusid, locationid, businessunitid, mapleft, maptop)
SELECT
CONCAT(machinenumber, '-', machineid), -- Unique asset number
alias,
CASE
WHEN category = 'PC' THEN 2 -- Computer
WHEN category = 'Printer' THEN 4 -- Printer
ELSE 1 -- Equipment
END,
statusid,
locationid,
businessunitid,
mapleft,
maptop
FROM machines;
```
### Step 3: Create Extension Records
```sql
-- For Equipment
INSERT INTO equipment (assetid, equipmenttypeid, vendorid, modelnumberid)
SELECT
a.assetid,
COALESCE(mo.machinetypeid, m.machinetypeid), -- Use model's type if available!
m.vendorid,
m.modelnumberid
FROM machines m
JOIN assets a ON a.assetnumber = CONCAT(m.machinenumber, '-', m.machineid)
LEFT JOIN models mo ON mo.modelnumberid = m.modelnumberid
WHERE m.category = 'Equipment' OR m.category IS NULL;
```
### Step 4: Post-Migration Fixes
#### Fix LocationOnly Equipment Types
Equipment imported with LocationOnly type should inherit type from their model:
```sql
-- Fix equipment that has a model with a proper type
UPDATE equipment e
JOIN assets a ON a.assetid = e.assetid
JOIN machines m ON m.machinenumber = SUBSTRING_INDEX(a.assetnumber, '-', 1)
JOIN models mo ON mo.modelnumberid = m.modelnumberid
SET e.equipmenttypeid = mo.machinetypeid
WHERE e.equipmenttypeid = 1 -- LocationOnly
AND mo.machinetypeid != 1; -- Model has real type
```
## Validation Queries
### Check for Orphaned Assets
```sql
SELECT a.* FROM assets a
LEFT JOIN equipment e ON e.assetid = a.assetid
LEFT JOIN computers c ON c.assetid = a.assetid
LEFT JOIN printers p ON p.assetid = a.assetid
WHERE a.assettypeid = 1 -- Equipment type
AND e.assetid IS NULL;
```
### Check LocationOnly with Models
```sql
-- Should return 0 after migration fix
SELECT COUNT(*)
FROM equipment e
JOIN assets a ON a.assetid = e.assetid
JOIN machines m ON m.machinenumber = SUBSTRING_INDEX(a.assetnumber, '-', 1)
JOIN models mo ON mo.modelnumberid = m.modelnumberid
WHERE e.equipmenttypeid = 1
AND mo.machinetypeid != 1;
```
### Verify Type Distribution
```sql
SELECT et.equipmenttype, COUNT(*) as count
FROM equipment e
JOIN equipmenttypes et ON et.equipmenttypeid = e.equipmenttypeid
GROUP BY et.equipmenttype
ORDER BY count DESC;
```
## Common Issues
### Issue: Equipment marked as LocationOnly but has model
**Cause**: Migration copied machinetypeid from machines table instead of using model's type
**Fix**: See FIX_LOCATIONONLY_EQUIPMENT_TYPES.md
### Issue: Missing model relationship
**Cause**: Equipment table modelnumberid not populated during migration
**Fix**: Link through machines table using asset number pattern
### Issue: Duplicate asset numbers
**Cause**: Asset number generation didn't account for existing duplicates
**Fix**: Use unique suffix or check before insert
## Scripts Location
- `/migrations/FIX_LOCATIONONLY_EQUIPMENT_TYPES.md` - Fix for LocationOnly type issue
- `/scripts/import_from_mysql.py` - Original import script (may need updates)
- `/scripts/migration/` - Migration utilities
## Notes
- Always backup before running migration fixes
- Test on staging/dev before production
- Verify counts before and after each fix
- Keep legacy `machines` table for reference during transition
## Date Created
2026-01-27

1
migrations/README Normal file
View File

@@ -0,0 +1 @@
Single-database configuration for Flask.

50
migrations/alembic.ini Normal file
View File

@@ -0,0 +1,50 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

169
migrations/env.py Normal file
View File

@@ -0,0 +1,169 @@
import logging
from logging.config import fileConfig
from flask import current_app
from alembic import context
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.schema import CreateTable
# Force every table the migrations create on MySQL to utf8mb4 + DYNAMIC row
# format. Without this a fresh `flask db upgrade` inherits the server default
# charset, so a box whose default is latin1 (common on older MySQL) silently
# builds a latin1 schema that drifts from the utf8mb4 production target. The
# DYNAMIC row format also keeps utf8mb4 indexes under the 767-byte prefix limit
# on pre-5.7 InnoDB. Scoped to the mysql dialect so the SQLite test DB is
# untouched.
@compiles(CreateTable, "mysql")
def _mysql_create_table_utf8mb4(element, compiler, **kw):
sql = compiler.visit_create_table(element, **kw)
if "CHARSET" not in sql.upper():
sql = sql.rstrip().rstrip(";")
sql += (
" ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
" COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC"
)
return sql
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')
def get_engine():
try:
# this works with Flask-SQLAlchemy<3 and Alchemical
return current_app.extensions['migrate'].db.get_engine()
except (TypeError, AttributeError):
# this works with Flask-SQLAlchemy>=3
return current_app.extensions['migrate'].db.engine
def get_engine_url():
try:
return get_engine().url.render_as_string(hide_password=False).replace(
'%', '%%')
except AttributeError:
return str(get_engine().url).replace('%', '%%')
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
config.set_main_option('sqlalchemy.url', get_engine_url())
target_db = current_app.extensions['migrate'].db
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def get_metadata():
if hasattr(target_db, 'metadatas'):
return target_db.metadatas[None]
return target_db.metadata
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url, target_metadata=get_metadata(), literal_binds=True
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')
conf_args = current_app.extensions['migrate'].configure_args
if conf_args.get("process_revision_directives") is None:
conf_args["process_revision_directives"] = process_revision_directives
connectable = get_engine()
# Alembic creates alembic_version.version_num as VARCHAR(32), but some
# revision ids in this chain exceed 32 chars. On MySQL that truncates the
# stored id, so the next migration's version bump matches 0 rows and the
# upgrade dies. Force the column wide in its OWN committed connection first
# (keeping it out of alembic's migration transaction), so long ids never
# truncate - fresh or existing.
try:
with connectable.connect() as prep:
prep.exec_driver_sql(
'CREATE TABLE IF NOT EXISTS alembic_version '
'(version_num VARCHAR(128) NOT NULL, '
'CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num))'
)
prep.exec_driver_sql(
'ALTER TABLE alembic_version MODIFY version_num VARCHAR(128) NOT NULL'
)
prep.commit()
except Exception:
# Non-MySQL backends (e.g. sqlite in tests) - alembic's default is fine.
pass
with connectable.connect() as connection:
# Relax the session sql_mode for the migration run. Some historical
# migrations seed reference rows with raw INSERTs that omit NOT-NULL
# timestamp columns (the ORM supplies those via Python defaults at
# runtime, but a raw migration INSERT does not). MySQL 5.x's lax default
# accepted that; strict MySQL 8 rejects it with 1364 "Field 'createddate'
# doesn't have a default value". Dropping STRICT_TRANS_TABLES for the
# migration session only (the app's own connections keep their mode)
# makes the chain portable across MySQL versions.
try:
connection.exec_driver_sql(
"SET SESSION sql_mode = 'NO_ENGINE_SUBSTITUTION'")
except Exception:
pass # non-MySQL backends (e.g. sqlite in tests)
context.configure(
connection=connection,
target_metadata=get_metadata(),
**conf_args
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,23 @@
-- Rename database columns to remove underscores per naming convention
-- Date: 2026-02-03
-- Affects: assettypes, assetrelationships, equipment, usbcheckouts
-- 1. assettypes table
ALTER TABLE assettypes CHANGE COLUMN plugin_name pluginname VARCHAR(100) NULL COMMENT 'Plugin that owns this type';
ALTER TABLE assettypes CHANGE COLUMN table_name tablename VARCHAR(100) NULL COMMENT 'Extension table name for this type';
-- 2. assetrelationships table
ALTER TABLE assetrelationships CHANGE COLUMN source_assetid sourceassetid INT NOT NULL;
ALTER TABLE assetrelationships CHANGE COLUMN target_assetid targetassetid INT NOT NULL;
-- 3. equipment table
ALTER TABLE equipment CHANGE COLUMN controller_vendorid controllervendorid INT NULL COMMENT 'Controller vendor (e.g., FANUC)';
ALTER TABLE equipment CHANGE COLUMN controller_modelid controllermodelid INT NULL COMMENT 'Controller model (e.g., 31B)';
-- 4. usbcheckouts table
ALTER TABLE usbcheckouts CHANGE COLUMN checkout_name checkoutname VARCHAR(100) NULL COMMENT 'Name of user';
ALTER TABLE usbcheckouts CHANGE COLUMN checkout_time checkouttime DATETIME NOT NULL;
ALTER TABLE usbcheckouts CHANGE COLUMN checkin_time checkintime DATETIME NULL;
ALTER TABLE usbcheckouts CHANGE COLUMN checkout_reason checkoutreason TEXT NULL COMMENT 'Reason for checkout';
ALTER TABLE usbcheckouts CHANGE COLUMN checkin_notes checkinnotes TEXT NULL;
ALTER TABLE usbcheckouts CHANGE COLUMN was_wiped waswiped TINYINT(1) NULL COMMENT 'Was device wiped after return';

24
migrations/script.py.mako Normal file
View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

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)