Add custom fields + warranty plugin, rework settings into two-pane shell

Feature work from the 2026-07 session:

Settings IA
- Replace the flat 27-card settings hub with a persistent two-pane shell
  (SettingsLayout.vue): grouped, searchable left rail + content pane.
- Nest all settings/* routes under the shell via router post-processing;
  shared nav catalog in settingsNav.js. Group by asset class (PCs, Printers,
  Equipment, Network) so per-type settings stop scattering.

Custom fields (core)
- customfields + customfieldvalues tables (migration 7d14), CRUD API at
  /api/customfields, per-asset value get/save.
- Settings management page + reusable CustomFieldsSection (detail) and
  CustomFieldsInputs (form) wired into all four asset types.

Warranty (new plugin)
- plugins/warranty: warranties + warrantyassets (migration 7d15), derived
  coverage status, provider abstraction (manual now; Dell/Lenovo/HP stubs).
- API CRUD + per-asset panel + report buckets; WarrantyPanel on all four
  detail pages; Warranties management page; Warranty report + Reports card.
- Seed warranty.* permissions.

Printer drivers
- printerdrivers table (migration 7d13) linked to printer models; drivers now
  surface on the matching printer's detail page.

Other
- PCDetail rebalanced (Network + Status + Warranty + custom fields on the right).
- Rename PCs list "Features" column to "Remote Access"; fix badge hover underline.
- Drop equipment islocationonly field.
- Centralize asset-type label/route maps into utils/assetTypes.js.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-09 15:37:21 -04:00
parent 419f26107d
commit 78a0ee8d83
154 changed files with 9479 additions and 1098 deletions

View File

@@ -4,6 +4,27 @@ 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.

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')