Add USB, Notifications, Network plugins and reusable EmployeeSearch component

New Plugins:
- USB plugin: Device checkout/checkin with employee lookup, checkout history
- Notifications plugin: Announcements with types, scheduling, shopfloor display
- Network plugin: Network device management with subnets and VLANs
- Equipment and Computers plugins: Asset type separation

Frontend:
- EmployeeSearch component: Reusable employee lookup with autocomplete
- USB views: List, detail, checkout/checkin modals
- Notifications views: List, form with recognition mode
- Network views: Device list, detail, form
- Calendar view with FullCalendar integration
- Shopfloor and TV dashboard views
- Reports index page
- Map editor for asset positioning
- Light/dark mode fixes for map tooltips

Backend:
- Employee search API with external lookup service
- Collector API for PowerShell data collection
- Reports API endpoints
- Slides API for TV dashboard
- Fixed AppVersion model (removed BaseModel inheritance)
- Added checkout_name column to usbcheckouts table

Styling:
- Unified detail page styles
- Improved pagination (page numbers instead of prev/next)
- Dark/light mode theme improvements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-01-21 16:37:49 -05:00
parent 02d83335ee
commit 9c220a4194
110 changed files with 17693 additions and 600 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']

View File

@@ -0,0 +1,817 @@
"""Network plugin API endpoints."""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.extensions import db
from shopdb.core.models import Asset, AssetType, Vendor
from shopdb.utils.responses import (
success_response,
error_response,
paginated_response,
ErrorCodes
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
from ..models import NetworkDevice, NetworkDeviceType, Subnet, VLAN
network_bp = Blueprint('network', __name__)
# =============================================================================
# Network Device Types
# =============================================================================
@network_bp.route('/types', methods=['GET'])
@jwt_required()
def list_network_device_types():
"""List all network device types."""
page, per_page = get_pagination_params(request)
query = NetworkDeviceType.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(NetworkDeviceType.isactive == True)
if search := request.args.get('search'):
query = query.filter(NetworkDeviceType.networkdevicetype.ilike(f'%{search}%'))
query = query.order_by(NetworkDeviceType.networkdevicetype)
items, total = paginate_query(query, page, per_page)
data = [t.to_dict() for t in items]
return paginated_response(data, page, per_page, total)
@network_bp.route('/types/<int:type_id>', methods=['GET'])
@jwt_required()
def get_network_device_type(type_id: int):
"""Get a single network device type."""
t = NetworkDeviceType.query.get(type_id)
if not t:
return error_response(
ErrorCodes.NOT_FOUND,
f'Network device type with ID {type_id} not found',
http_code=404
)
return success_response(t.to_dict())
@network_bp.route('/types', methods=['POST'])
@jwt_required()
def create_network_device_type():
"""Create a new network device type."""
data = request.get_json()
if not data or not data.get('networkdevicetype'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'networkdevicetype is required')
if NetworkDeviceType.query.filter_by(networkdevicetype=data['networkdevicetype']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Network device type '{data['networkdevicetype']}' already exists",
http_code=409
)
t = NetworkDeviceType(
networkdevicetype=data['networkdevicetype'],
description=data.get('description'),
icon=data.get('icon')
)
db.session.add(t)
db.session.commit()
return success_response(t.to_dict(), message='Network device type created', http_code=201)
@network_bp.route('/types/<int:type_id>', methods=['PUT'])
@jwt_required()
def update_network_device_type(type_id: int):
"""Update a network device type."""
t = NetworkDeviceType.query.get(type_id)
if not t:
return error_response(
ErrorCodes.NOT_FOUND,
f'Network device type with ID {type_id} not found',
http_code=404
)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
if 'networkdevicetype' in data and data['networkdevicetype'] != t.networkdevicetype:
if NetworkDeviceType.query.filter_by(networkdevicetype=data['networkdevicetype']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Network device type '{data['networkdevicetype']}' already exists",
http_code=409
)
for key in ['networkdevicetype', 'description', 'icon', 'isactive']:
if key in data:
setattr(t, key, data[key])
db.session.commit()
return success_response(t.to_dict(), message='Network device type updated')
# =============================================================================
# Network Devices CRUD
# =============================================================================
@network_bp.route('', methods=['GET'])
@jwt_required()
def list_network_devices():
"""
List all network devices with filtering and pagination.
Query parameters:
- page, per_page: Pagination
- active: Filter by active status
- search: Search by asset number, name, or hostname
- type_id: Filter by network device type ID
- vendor_id: Filter by vendor ID
- location_id: Filter by location ID
- businessunit_id: Filter by business unit ID
- poe: Filter by PoE capability (true/false)
- managed: Filter by managed status (true/false)
"""
page, per_page = get_pagination_params(request)
# Join NetworkDevice with Asset
query = db.session.query(NetworkDevice).join(Asset)
# Active filter
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(Asset.isactive == True)
# Search filter
if search := request.args.get('search'):
query = query.filter(
db.or_(
Asset.assetnumber.ilike(f'%{search}%'),
Asset.name.ilike(f'%{search}%'),
Asset.serialnumber.ilike(f'%{search}%'),
NetworkDevice.hostname.ilike(f'%{search}%')
)
)
# Type filter
if type_id := request.args.get('type_id'):
query = query.filter(NetworkDevice.networkdevicetypeid == int(type_id))
# Vendor filter
if vendor_id := request.args.get('vendor_id'):
query = query.filter(NetworkDevice.vendorid == int(vendor_id))
# Location filter
if location_id := request.args.get('location_id'):
query = query.filter(Asset.locationid == int(location_id))
# Business unit filter
if bu_id := request.args.get('businessunit_id'):
query = query.filter(Asset.businessunitid == int(bu_id))
# PoE filter
if poe := request.args.get('poe'):
query = query.filter(NetworkDevice.ispoe == (poe.lower() == 'true'))
# Managed filter
if managed := request.args.get('managed'):
query = query.filter(NetworkDevice.ismanaged == (managed.lower() == 'true'))
# Sorting
sort_by = request.args.get('sort', 'hostname')
sort_dir = request.args.get('dir', 'asc')
if sort_by == 'hostname':
col = NetworkDevice.hostname
elif sort_by == 'assetnumber':
col = Asset.assetnumber
elif sort_by == 'name':
col = Asset.name
else:
col = NetworkDevice.hostname
query = query.order_by(col.desc() if sort_dir == 'desc' else col)
items, total = paginate_query(query, page, per_page)
# Build response with both asset and network device data
data = []
for netdev in items:
item = netdev.asset.to_dict() if netdev.asset else {}
item['network_device'] = netdev.to_dict()
data.append(item)
return paginated_response(data, page, per_page, total)
@network_bp.route('/<int:device_id>', methods=['GET'])
@jwt_required()
def get_network_device(device_id: int):
"""Get a single network device with full details."""
netdev = NetworkDevice.query.get(device_id)
if not netdev:
return error_response(
ErrorCodes.NOT_FOUND,
f'Network device with ID {device_id} not found',
http_code=404
)
result = netdev.asset.to_dict() if netdev.asset else {}
result['network_device'] = netdev.to_dict()
return success_response(result)
@network_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
@jwt_required()
def get_network_device_by_asset(asset_id: int):
"""Get network device data by asset ID."""
netdev = NetworkDevice.query.filter_by(assetid=asset_id).first()
if not netdev:
return error_response(
ErrorCodes.NOT_FOUND,
f'Network device for asset {asset_id} not found',
http_code=404
)
result = netdev.asset.to_dict() if netdev.asset else {}
result['network_device'] = netdev.to_dict()
return success_response(result)
@network_bp.route('/by-hostname/<hostname>', methods=['GET'])
@jwt_required()
def get_network_device_by_hostname(hostname: str):
"""Get network device by hostname."""
netdev = NetworkDevice.query.filter_by(hostname=hostname).first()
if not netdev:
return error_response(
ErrorCodes.NOT_FOUND,
f'Network device with hostname {hostname} not found',
http_code=404
)
result = netdev.asset.to_dict() if netdev.asset else {}
result['network_device'] = netdev.to_dict()
return success_response(result)
@network_bp.route('', methods=['POST'])
@jwt_required()
def create_network_device():
"""
Create new network device (creates both Asset and NetworkDevice records).
Required fields:
- assetnumber: Business identifier
Optional fields:
- name, serialnumber, statusid, locationid, businessunitid
- networkdevicetypeid, vendorid, hostname
- firmwareversion, portcount, ispoe, ismanaged, rackunit
- mapleft, maptop, notes
"""
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
if not data.get('assetnumber'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'assetnumber is required')
# Check for duplicate assetnumber
if Asset.query.filter_by(assetnumber=data['assetnumber']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Asset with number '{data['assetnumber']}' already exists",
http_code=409
)
# Check for duplicate hostname
if data.get('hostname'):
if NetworkDevice.query.filter_by(hostname=data['hostname']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Network device with hostname '{data['hostname']}' already exists",
http_code=409
)
# Get network device asset type
network_type = AssetType.query.filter_by(assettype='network_device').first()
if not network_type:
return error_response(
ErrorCodes.INTERNAL_ERROR,
'Network device asset type not found. Plugin may not be properly installed.',
http_code=500
)
# Create the core asset
asset = Asset(
assetnumber=data['assetnumber'],
name=data.get('name'),
serialnumber=data.get('serialnumber'),
assettypeid=network_type.assettypeid,
statusid=data.get('statusid', 1),
locationid=data.get('locationid'),
businessunitid=data.get('businessunitid'),
mapleft=data.get('mapleft'),
maptop=data.get('maptop'),
notes=data.get('notes')
)
db.session.add(asset)
db.session.flush() # Get the assetid
# Create the network device extension
netdev = NetworkDevice(
assetid=asset.assetid,
networkdevicetypeid=data.get('networkdevicetypeid'),
vendorid=data.get('vendorid'),
hostname=data.get('hostname'),
firmwareversion=data.get('firmwareversion'),
portcount=data.get('portcount'),
ispoe=data.get('ispoe', False),
ismanaged=data.get('ismanaged', False),
rackunit=data.get('rackunit')
)
db.session.add(netdev)
db.session.commit()
result = asset.to_dict()
result['network_device'] = netdev.to_dict()
return success_response(result, message='Network device created', http_code=201)
@network_bp.route('/<int:device_id>', methods=['PUT'])
@jwt_required()
def update_network_device(device_id: int):
"""Update network device (both Asset and NetworkDevice records)."""
netdev = NetworkDevice.query.get(device_id)
if not netdev:
return error_response(
ErrorCodes.NOT_FOUND,
f'Network device with ID {device_id} not found',
http_code=404
)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
asset = netdev.asset
# Check for conflicting assetnumber
if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber:
if Asset.query.filter_by(assetnumber=data['assetnumber']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Asset with number '{data['assetnumber']}' already exists",
http_code=409
)
# Check for conflicting hostname
if 'hostname' in data and data['hostname'] != netdev.hostname:
existing = NetworkDevice.query.filter_by(hostname=data['hostname']).first()
if existing and existing.networkdeviceid != device_id:
return error_response(
ErrorCodes.CONFLICT,
f"Network device with hostname '{data['hostname']}' already exists",
http_code=409
)
# Update asset fields
asset_fields = ['assetnumber', 'name', 'serialnumber', 'statusid',
'locationid', 'businessunitid', 'mapleft', 'maptop', 'notes', 'isactive']
for key in asset_fields:
if key in data:
setattr(asset, key, data[key])
# Update network device fields
netdev_fields = ['networkdevicetypeid', 'vendorid', 'hostname',
'firmwareversion', 'portcount', 'ispoe', 'ismanaged', 'rackunit']
for key in netdev_fields:
if key in data:
setattr(netdev, key, data[key])
db.session.commit()
result = asset.to_dict()
result['network_device'] = netdev.to_dict()
return success_response(result, message='Network device updated')
@network_bp.route('/<int:device_id>', methods=['DELETE'])
@jwt_required()
def delete_network_device(device_id: int):
"""Delete (soft delete) network device."""
netdev = NetworkDevice.query.get(device_id)
if not netdev:
return error_response(
ErrorCodes.NOT_FOUND,
f'Network device with ID {device_id} not found',
http_code=404
)
# Soft delete the asset
netdev.asset.isactive = False
db.session.commit()
return success_response(message='Network device deleted')
# =============================================================================
# Dashboard
# =============================================================================
@network_bp.route('/dashboard/summary', methods=['GET'])
@jwt_required()
def dashboard_summary():
"""Get network device dashboard summary data."""
# Total active network devices
total = db.session.query(NetworkDevice).join(Asset).filter(
Asset.isactive == True
).count()
# Count by device 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()
# Count by vendor
by_vendor = db.session.query(
Vendor.vendor,
db.func.count(NetworkDevice.networkdeviceid)
).join(NetworkDevice, NetworkDevice.vendorid == Vendor.vendorid
).join(Asset, Asset.assetid == NetworkDevice.assetid
).filter(Asset.isactive == True
).group_by(Vendor.vendor
).all()
# Count PoE vs non-PoE
poe_count = db.session.query(NetworkDevice).join(Asset).filter(
Asset.isactive == True,
NetworkDevice.ispoe == True
).count()
return success_response({
'total': total,
'by_type': [{'type': t, 'count': c} for t, c in by_type],
'by_vendor': [{'vendor': v, 'count': c} for v, c in by_vendor],
'poe': poe_count,
'non_poe': total - poe_count
})
# =============================================================================
# VLANs
# =============================================================================
@network_bp.route('/vlans', methods=['GET'])
@jwt_required()
def list_vlans():
"""List all VLANs with filtering and pagination."""
page, per_page = get_pagination_params(request)
query = VLAN.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(VLAN.isactive == True)
# Search filter
if search := request.args.get('search'):
query = query.filter(
db.or_(
VLAN.name.ilike(f'%{search}%'),
VLAN.description.ilike(f'%{search}%'),
db.cast(VLAN.vlannumber, db.String).ilike(f'%{search}%')
)
)
# Type filter
if vlan_type := request.args.get('type'):
query = query.filter(VLAN.vlantype == vlan_type)
query = query.order_by(VLAN.vlannumber)
items, total = paginate_query(query, page, per_page)
data = [v.to_dict() for v in items]
return paginated_response(data, page, per_page, total)
@network_bp.route('/vlans/<int:vlan_id>', methods=['GET'])
@jwt_required()
def get_vlan(vlan_id: int):
"""Get a single VLAN with its subnets."""
vlan = VLAN.query.get(vlan_id)
if not vlan:
return error_response(
ErrorCodes.NOT_FOUND,
f'VLAN with ID {vlan_id} not found',
http_code=404
)
result = vlan.to_dict()
# Include associated subnets
result['subnets'] = [s.to_dict() for s in vlan.subnets.filter_by(isactive=True).all()]
return success_response(result)
@network_bp.route('/vlans', methods=['POST'])
@jwt_required()
def create_vlan():
"""Create a new VLAN."""
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
if not data.get('vlannumber'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'vlannumber is required')
if not data.get('name'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required')
# Check for duplicate VLAN number
if VLAN.query.filter_by(vlannumber=data['vlannumber']).first():
return error_response(
ErrorCodes.CONFLICT,
f"VLAN {data['vlannumber']} already exists",
http_code=409
)
vlan = VLAN(
vlannumber=data['vlannumber'],
name=data['name'],
description=data.get('description'),
vlantype=data.get('vlantype')
)
db.session.add(vlan)
db.session.commit()
return success_response(vlan.to_dict(), message='VLAN created', http_code=201)
@network_bp.route('/vlans/<int:vlan_id>', methods=['PUT'])
@jwt_required()
def update_vlan(vlan_id: int):
"""Update a VLAN."""
vlan = VLAN.query.get(vlan_id)
if not vlan:
return error_response(
ErrorCodes.NOT_FOUND,
f'VLAN with ID {vlan_id} not found',
http_code=404
)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
# Check for conflicting VLAN number
if 'vlannumber' in data and data['vlannumber'] != vlan.vlannumber:
if VLAN.query.filter_by(vlannumber=data['vlannumber']).first():
return error_response(
ErrorCodes.CONFLICT,
f"VLAN {data['vlannumber']} already exists",
http_code=409
)
for key in ['vlannumber', 'name', 'description', 'vlantype', 'isactive']:
if key in data:
setattr(vlan, key, data[key])
db.session.commit()
return success_response(vlan.to_dict(), message='VLAN updated')
@network_bp.route('/vlans/<int:vlan_id>', methods=['DELETE'])
@jwt_required()
def delete_vlan(vlan_id: int):
"""Delete (soft delete) a VLAN."""
vlan = VLAN.query.get(vlan_id)
if not vlan:
return error_response(
ErrorCodes.NOT_FOUND,
f'VLAN with ID {vlan_id} not found',
http_code=404
)
# Check if VLAN has associated subnets
if vlan.subnets.filter_by(isactive=True).count() > 0:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Cannot delete VLAN with associated subnets',
http_code=400
)
vlan.isactive = False
db.session.commit()
return success_response(message='VLAN deleted')
# =============================================================================
# Subnets
# =============================================================================
@network_bp.route('/subnets', methods=['GET'])
@jwt_required()
def list_subnets():
"""List all subnets with filtering and pagination."""
page, per_page = get_pagination_params(request)
query = Subnet.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(Subnet.isactive == True)
# Search filter
if search := request.args.get('search'):
query = query.filter(
db.or_(
Subnet.cidr.ilike(f'%{search}%'),
Subnet.name.ilike(f'%{search}%'),
Subnet.description.ilike(f'%{search}%')
)
)
# VLAN filter
if vlan_id := request.args.get('vlanid'):
query = query.filter(Subnet.vlanid == int(vlan_id))
# Location filter
if location_id := request.args.get('locationid'):
query = query.filter(Subnet.locationid == int(location_id))
# Type filter
if subnet_type := request.args.get('type'):
query = query.filter(Subnet.subnettype == subnet_type)
query = query.order_by(Subnet.cidr)
items, total = paginate_query(query, page, per_page)
data = [s.to_dict() for s in items]
return paginated_response(data, page, per_page, total)
@network_bp.route('/subnets/<int:subnet_id>', methods=['GET'])
@jwt_required()
def get_subnet(subnet_id: int):
"""Get a single subnet."""
subnet = Subnet.query.get(subnet_id)
if not subnet:
return error_response(
ErrorCodes.NOT_FOUND,
f'Subnet with ID {subnet_id} not found',
http_code=404
)
return success_response(subnet.to_dict())
@network_bp.route('/subnets', methods=['POST'])
@jwt_required()
def create_subnet():
"""Create a new subnet."""
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
if not data.get('cidr'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'cidr is required')
if not data.get('name'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required')
# Validate CIDR format (basic check)
cidr = data['cidr']
if '/' not in cidr:
return error_response(ErrorCodes.VALIDATION_ERROR, 'cidr must be in CIDR notation (e.g., 10.1.1.0/24)')
# Check for duplicate CIDR
if Subnet.query.filter_by(cidr=cidr).first():
return error_response(
ErrorCodes.CONFLICT,
f"Subnet {cidr} already exists",
http_code=409
)
# Validate VLAN if provided
if data.get('vlanid'):
if not VLAN.query.get(data['vlanid']):
return error_response(
ErrorCodes.VALIDATION_ERROR,
f"VLAN with ID {data['vlanid']} not found"
)
subnet = Subnet(
cidr=cidr,
name=data['name'],
description=data.get('description'),
gatewayip=data.get('gatewayip'),
subnetmask=data.get('subnetmask'),
networkaddress=data.get('networkaddress'),
broadcastaddress=data.get('broadcastaddress'),
vlanid=data.get('vlanid'),
subnettype=data.get('subnettype'),
locationid=data.get('locationid'),
dhcpenabled=data.get('dhcpenabled', True),
dhcprangestart=data.get('dhcprangestart'),
dhcprangeend=data.get('dhcprangeend'),
dns1=data.get('dns1'),
dns2=data.get('dns2')
)
db.session.add(subnet)
db.session.commit()
return success_response(subnet.to_dict(), message='Subnet created', http_code=201)
@network_bp.route('/subnets/<int:subnet_id>', methods=['PUT'])
@jwt_required()
def update_subnet(subnet_id: int):
"""Update a subnet."""
subnet = Subnet.query.get(subnet_id)
if not subnet:
return error_response(
ErrorCodes.NOT_FOUND,
f'Subnet with ID {subnet_id} not found',
http_code=404
)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
# Check for conflicting CIDR
if 'cidr' in data and data['cidr'] != subnet.cidr:
if Subnet.query.filter_by(cidr=data['cidr']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Subnet {data['cidr']} already exists",
http_code=409
)
allowed_fields = ['cidr', 'name', 'description', 'gatewayip', 'subnetmask',
'networkaddress', 'broadcastaddress', 'vlanid', 'subnettype',
'locationid', 'dhcpenabled', 'dhcprangestart', 'dhcprangeend',
'dns1', 'dns2', 'isactive']
for key in allowed_fields:
if key in data:
setattr(subnet, key, data[key])
db.session.commit()
return success_response(subnet.to_dict(), message='Subnet updated')
@network_bp.route('/subnets/<int:subnet_id>', methods=['DELETE'])
@jwt_required()
def delete_subnet(subnet_id: int):
"""Delete (soft delete) a subnet."""
subnet = Subnet.query.get(subnet_id)
if not subnet:
return error_response(
ErrorCodes.NOT_FOUND,
f'Subnet with ID {subnet_id} not found',
http_code=404
)
subnet.isactive = False
db.session.commit()
return success_response(message='Subnet deleted')

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": ">=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,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,121 @@
"""Network device plugin models."""
from shopdb.extensions import db
from shopdb.core.models.base import 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')
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),
index=True,
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['networkdevicetype_name'] = self.networkdevicetype.networkdevicetype
if self.vendor:
result['vendor_name'] = self.vendor.vendor
return result

View File

@@ -0,0 +1,146 @@
"""Subnet and VLAN models for network plugin."""
from shopdb.extensions import db
from shopdb.core.models.base import 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

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

@@ -0,0 +1,217 @@
"""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.extensions import db
from shopdb.core.models import 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',
plugin_name='network',
table_name='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.core.models 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,
},
]