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,5 @@
"""Network plugin for ShopDB."""
from .plugin import NetworkPlugin
__all__ = ['NetworkPlugin']

View File

@@ -0,0 +1,5 @@
"""Network plugin API."""
from .routes import network_bp
__all__ = ['network_bp']

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
{
"name": "network",
"version": "1.0.0",
"description": "Network device management plugin for switches, APs, cameras, and IDFs",
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=0.1.0,<1.0.0",
"api_prefix": "/api/network",
"provides": {
"asset_type": "network_device",
"features": [
"network_device_tracking",
"port_management",
"firmware_tracking",
"poe_monitoring"
]
},
"settings": {
"enable_snmp_polling": false,
"snmp_community": "public"
}
}

View File

@@ -0,0 +1,14 @@
"""Alembic environment for the network plugin migration chain.
Delegates to the shared runner in shopdb.plugins.alembic_template, which
filters the metadata to this plugin's tables and drives Alembic against the
per-plugin version table alembic_version_network. See ADR-008 for the
ownership cutover between the core chain and per-plugin chains.
"""
import os
os.environ['PLUGIN_NAME'] = 'network'
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
run_migrations()

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,28 @@
"""network plugin anchor (ownership cutover).
Stamp-only no-op. The core Alembic chain (baseline .. 7d16_directoryemployees)
already created every table this plugin owns at the cutover point, so there is
nothing to build here. This revision gives the plugin's own chain a base that
`flask plugin upgrade-all` can stamp into alembic_version_network. From this
anchor forward, new network schema changes land as 000N revisions in this
directory, never in the core chain. See ADR-008.
"""
from alembic import op # noqa: F401
import sqlalchemy as sa # noqa: F401
# revision identifiers, used by Alembic.
revision = 'network0001anchor'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# No-op: the core chain owns every table that exists at the cutover.
pass
def downgrade():
# No-op: this anchor never unwinds core-owned tables.
pass

View File

@@ -0,0 +1,11 @@
"""Network plugin models."""
from .network_device import NetworkDevice, NetworkDeviceType
from .subnet import Subnet, VLAN
__all__ = [
'NetworkDevice',
'NetworkDeviceType',
'Subnet',
'VLAN',
]

View File

@@ -0,0 +1,120 @@
"""Network device plugin models."""
from shopdb.api import db, BaseModel
class NetworkDeviceType(BaseModel):
"""
Network device type classification.
Examples: Switch, Router, Access Point, Camera, IDF, Firewall, etc.
"""
__tablename__ = 'networkdevicetypes'
networkdevicetypeid = db.Column(db.Integer, primary_key=True)
networkdevicetype = db.Column(db.String(100), unique=True, nullable=False)
description = db.Column(db.Text)
icon = db.Column(db.String(50), comment='Icon name for UI')
color = db.Column(db.String(20), comment='CSS color for UI/map markers')
def __repr__(self):
return f"<NetworkDeviceType {self.networkdevicetype}>"
class NetworkDevice(BaseModel):
"""
Network device-specific extension data.
Links to core Asset table via assetid.
Stores network device-specific fields like hostname, firmware, ports, etc.
"""
__tablename__ = 'networkdevices'
networkdeviceid = db.Column(db.Integer, primary_key=True)
# Link to core asset
assetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
unique=True,
nullable=False,
index=True
)
# Network device classification
networkdevicetypeid = db.Column(
db.Integer,
db.ForeignKey('networkdevicetypes.networkdevicetypeid'),
nullable=True
)
# Vendor
vendorid = db.Column(
db.Integer,
db.ForeignKey('vendors.vendorid'),
nullable=True
)
# Network identity
hostname = db.Column(
db.String(100),
comment='Network hostname'
)
# Firmware/software version
firmwareversion = db.Column(db.String(100), nullable=True)
# Physical characteristics
portcount = db.Column(
db.Integer,
nullable=True,
comment='Number of ports (for switches)'
)
# Features
ispoe = db.Column(
db.Boolean,
default=False,
comment='Power over Ethernet capable'
)
ismanaged = db.Column(
db.Boolean,
default=False,
comment='Managed device (SNMP, web interface, etc.)'
)
# For IDF/closet locations
rackunit = db.Column(
db.String(20),
nullable=True,
comment='Rack unit position (e.g., U1, U5)'
)
# Relationships
asset = db.relationship(
'Asset',
backref=db.backref('network_device', uselist=False, lazy='joined')
)
networkdevicetype = db.relationship('NetworkDeviceType', backref='networkdevices')
vendor = db.relationship('Vendor', backref='network_devices')
__table_args__ = (
db.Index('idx_netdev_type', 'networkdevicetypeid'),
db.Index('idx_netdev_hostname', 'hostname'),
db.Index('idx_netdev_vendor', 'vendorid'),
)
def __repr__(self):
return f"<NetworkDevice {self.hostname or self.assetid}>"
def to_dict(self):
"""Convert to dictionary with related names."""
result = super().to_dict()
# Add related object names
if self.networkdevicetype:
result['networkdevicetypename'] = self.networkdevicetype.networkdevicetype
if self.vendor:
result['vendorname'] = self.vendor.vendor
return result

View File

@@ -0,0 +1,145 @@
"""Subnet and VLAN models for network plugin."""
from shopdb.api import db, BaseModel
class VLAN(BaseModel):
"""
VLAN definition.
Represents a virtual LAN for network segmentation.
"""
__tablename__ = 'vlans'
vlanid = db.Column(db.Integer, primary_key=True)
vlannumber = db.Column(db.Integer, unique=True, nullable=False, comment='VLAN ID number')
name = db.Column(db.String(100), nullable=False, comment='VLAN name')
description = db.Column(db.Text, nullable=True)
# Optional classification
vlantype = db.Column(
db.String(50),
nullable=True,
comment='Type: data, voice, management, guest, etc.'
)
# Relationships
subnets = db.relationship('Subnet', backref='vlan', lazy='dynamic')
__table_args__ = (
db.Index('idx_vlan_number', 'vlannumber'),
)
def __repr__(self):
return f"<VLAN {self.vlannumber} - {self.name}>"
def to_dict(self):
"""Convert to dictionary."""
result = super().to_dict()
result['subnetcount'] = self.subnets.count() if self.subnets else 0
return result
class Subnet(BaseModel):
"""
Subnet/IP network definition.
Represents an IP subnet with optional VLAN association.
"""
__tablename__ = 'subnets'
subnetid = db.Column(db.Integer, primary_key=True)
# Network definition
cidr = db.Column(
db.String(18),
unique=True,
nullable=False,
comment='CIDR notation (e.g., 10.1.1.0/24)'
)
name = db.Column(db.String(100), nullable=False, comment='Subnet name')
description = db.Column(db.Text, nullable=True)
# Network details
gatewayip = db.Column(
db.String(15),
nullable=True,
comment='Default gateway IP address'
)
subnetmask = db.Column(
db.String(15),
nullable=True,
comment='Subnet mask (e.g., 255.255.255.0)'
)
networkaddress = db.Column(
db.String(15),
nullable=True,
comment='Network address (e.g., 10.1.1.0)'
)
broadcastaddress = db.Column(
db.String(15),
nullable=True,
comment='Broadcast address (e.g., 10.1.1.255)'
)
# VLAN association
vlanid = db.Column(
db.Integer,
db.ForeignKey('vlans.vlanid'),
nullable=True
)
# Classification
subnettype = db.Column(
db.String(50),
nullable=True,
comment='Type: production, development, management, dmz, etc.'
)
# Location association
locationid = db.Column(
db.Integer,
db.ForeignKey('locations.locationid'),
nullable=True
)
# DHCP settings
dhcpenabled = db.Column(db.Boolean, default=True, comment='DHCP enabled for this subnet')
dhcprangestart = db.Column(db.String(15), nullable=True, comment='DHCP range start IP')
dhcprangeend = db.Column(db.String(15), nullable=True, comment='DHCP range end IP')
# DNS settings
dns1 = db.Column(db.String(15), nullable=True, comment='Primary DNS server')
dns2 = db.Column(db.String(15), nullable=True, comment='Secondary DNS server')
# Relationships
location = db.relationship('Location', backref='subnets')
__table_args__ = (
db.Index('idx_subnet_cidr', 'cidr'),
db.Index('idx_subnet_vlan', 'vlanid'),
db.Index('idx_subnet_location', 'locationid'),
)
def __repr__(self):
return f"<Subnet {self.cidr} - {self.name}>"
@property
def vlan_number(self):
"""Get the VLAN number."""
return self.vlan.vlannumber if self.vlan else None
def to_dict(self):
"""Convert to dictionary with related data."""
result = super().to_dict()
# Add VLAN info
if self.vlan:
result['vlannumber'] = self.vlan.vlannumber
result['vlanname'] = self.vlan.name
# Add location info
if self.location:
result['locationname'] = self.location.locationname
return result

225
plugins/network/plugin.py Normal file
View File

@@ -0,0 +1,225 @@
"""Network plugin main class."""
import json
import logging
from pathlib import Path
from typing import List, Dict, Optional, Type
from flask import Flask, Blueprint
import click
from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.api import db, AssetType
from .models import NetworkDevice, NetworkDeviceType, Subnet, VLAN
from .api import network_bp
logger = logging.getLogger(__name__)
class NetworkPlugin(BasePlugin):
"""
Network plugin - manages network device assets.
Network devices include switches, routers, access points, cameras, IDFs, etc.
Uses the new Asset architecture with NetworkDevice extension table.
"""
def __init__(self):
self._manifest = self._load_manifest()
def _load_manifest(self) -> Dict:
"""Load plugin manifest from JSON file."""
manifestpath = Path(__file__).parent / 'manifest.json'
if manifestpath.exists():
with open(manifestpath, 'r') as f:
return json.load(f)
return {}
@property
def meta(self) -> PluginMeta:
"""Return plugin metadata."""
return PluginMeta(
name=self._manifest.get('name', 'network'),
version=self._manifest.get('version', '1.0.0'),
description=self._manifest.get(
'description',
'Network device management for switches, APs, and cameras'
),
author=self._manifest.get('author', 'ShopDB Team'),
dependencies=self._manifest.get('dependencies', []),
core_version=self._manifest.get('core_version', '>=1.0.0'),
api_prefix=self._manifest.get('api_prefix', '/api/network'),
)
def get_blueprint(self) -> Optional[Blueprint]:
"""Return Flask Blueprint with API routes."""
return network_bp
def get_models(self) -> List[Type]:
"""Return list of SQLAlchemy model classes."""
return [NetworkDevice, NetworkDeviceType, Subnet, VLAN]
def init_app(self, app: Flask, db_instance) -> None:
"""Initialize plugin with Flask app."""
logger.info(f"Network plugin initialized (v{self.meta.version})")
def on_install(self, app: Flask) -> None:
"""Called when plugin is installed."""
with app.app_context():
self._ensure_asset_type()
self._ensure_network_device_types()
logger.info("Network plugin installed")
def _ensure_asset_type(self) -> None:
"""Ensure network_device asset type exists."""
existing = AssetType.query.filter_by(assettype='network_device').first()
if not existing:
at = AssetType(
assettype='network_device',
pluginname='network',
tablename='networkdevices',
description='Network infrastructure devices (switches, APs, cameras, etc.)',
icon='network-wired'
)
db.session.add(at)
logger.debug("Created asset type: network_device")
db.session.commit()
def _ensure_network_device_types(self) -> None:
"""Ensure basic network device types exist."""
device_types = [
('Switch', 'Network switch', 'network-wired'),
('Router', 'Network router', 'router'),
('Access Point', 'Wireless access point', 'wifi'),
('Firewall', 'Network firewall', 'shield'),
('Camera', 'IP camera', 'video'),
('IDF', 'Intermediate Distribution Frame/closet', 'box'),
('MDF', 'Main Distribution Frame', 'building'),
('Patch Panel', 'Patch panel', 'th'),
('UPS', 'Uninterruptible power supply', 'battery'),
('Other', 'Other network device', 'network-wired'),
]
for name, description, icon in device_types:
existing = NetworkDeviceType.query.filter_by(networkdevicetype=name).first()
if not existing:
ndt = NetworkDeviceType(
networkdevicetype=name,
description=description,
icon=icon
)
db.session.add(ndt)
logger.debug(f"Created network device type: {name}")
db.session.commit()
def on_uninstall(self, app: Flask) -> None:
"""Called when plugin is uninstalled."""
logger.info("Network plugin uninstalled")
def get_cli_commands(self) -> List:
"""Return CLI commands for this plugin."""
@click.group('network')
def networkcli():
"""Network plugin commands."""
pass
@networkcli.command('list-types')
def list_types():
"""List all network device types."""
from flask import current_app
with current_app.app_context():
types = NetworkDeviceType.query.filter_by(isactive=True).all()
if not types:
click.echo('No network device types found.')
return
click.echo('Network Device Types:')
for t in types:
click.echo(f" [{t.networkdevicetypeid}] {t.networkdevicetype}")
@networkcli.command('stats')
def stats():
"""Show network device statistics."""
from flask import current_app
from shopdb.api import Asset
with current_app.app_context():
total = db.session.query(NetworkDevice).join(Asset).filter(
Asset.isactive == True
).count()
click.echo(f"Total active network devices: {total}")
# By type
by_type = db.session.query(
NetworkDeviceType.networkdevicetype,
db.func.count(NetworkDevice.networkdeviceid)
).join(NetworkDevice, NetworkDevice.networkdevicetypeid == NetworkDeviceType.networkdevicetypeid
).join(Asset, Asset.assetid == NetworkDevice.assetid
).filter(Asset.isactive == True
).group_by(NetworkDeviceType.networkdevicetype
).all()
if by_type:
click.echo("\nBy Type:")
for t, c in by_type:
click.echo(f" {t}: {c}")
@networkcli.command('find')
@click.argument('hostname')
def find_by_hostname(hostname):
"""Find a network device by hostname."""
from flask import current_app
with current_app.app_context():
netdev = NetworkDevice.query.filter(
NetworkDevice.hostname.ilike(f'%{hostname}%')
).first()
if not netdev:
click.echo(f'No network device found matching hostname: {hostname}')
return
click.echo(f'Found: {netdev.hostname}')
click.echo(f' Asset: {netdev.asset.assetnumber}')
click.echo(f' Type: {netdev.networkdevicetype.networkdevicetype if netdev.networkdevicetype else "N/A"}')
click.echo(f' Firmware: {netdev.firmwareversion or "N/A"}')
click.echo(f' PoE: {"Yes" if netdev.ispoe else "No"}')
return [networkcli]
def get_dashboard_widgets(self) -> List[Dict]:
"""Return dashboard widget definitions."""
return [
{
'name': 'Network Status',
'component': 'NetworkStatusWidget',
'endpoint': '/api/network/dashboard/summary',
'size': 'medium',
'position': 7,
},
]
def get_navigation_items(self) -> List[Dict]:
"""Return navigation menu items."""
return [
{
'name': 'Network',
'icon': 'network-wired',
'route': '/network',
'position': 18,
},
]
def get_permissions(self) -> List:
"""Return the RBAC permissions this plugin owns."""
return [
('network.view', 'View network devices', 'network'),
('network.create', 'Create network devices', 'network'),
('network.edit', 'Edit network devices', 'network'),
('network.delete', 'Delete network devices', 'network'),
]