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

5
plugins/usb/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""USB device checkout plugin."""
from .plugin import USBPlugin
__all__ = ['USBPlugin']

View File

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

275
plugins/usb/api/routes.py Normal file
View File

@@ -0,0 +1,275 @@
"""USB plugin API endpoints."""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from datetime import datetime
from shopdb.extensions import db
from shopdb.core.models import Machine, MachineType, Vendor, Model
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 USBCheckout
usb_bp = Blueprint('usb', __name__)
def get_usb_machinetype_id():
"""Get the USB Device machine type ID dynamically."""
usb_type = MachineType.query.filter(
MachineType.machinetype.ilike('%usb%')
).first()
return usb_type.machinetypeid if usb_type else None
@usb_bp.route('', methods=['GET'])
@jwt_required()
def list_usb_devices():
"""
List all USB devices with checkout status.
Query parameters:
- page, per_page: Pagination
- search: Search by serial number or alias
- available: Filter to only available (not checked out) devices
"""
page, per_page = get_pagination_params(request)
usb_type_id = get_usb_machinetype_id()
if not usb_type_id:
return success_response([]) # No USB type found
# Get USB devices from machines table
query = db.session.query(Machine).filter(
Machine.machinetypeid == usb_type_id,
Machine.isactive == True
)
# Search filter
if search := request.args.get('search'):
query = query.filter(
db.or_(
Machine.serialnumber.ilike(f'%{search}%'),
Machine.alias.ilike(f'%{search}%'),
Machine.machinenumber.ilike(f'%{search}%')
)
)
query = query.order_by(Machine.alias)
items, total = paginate_query(query, page, per_page)
# Build response with checkout status
data = []
for device in items:
# Check if currently checked out
active_checkout = USBCheckout.query.filter_by(
machineid=device.machineid,
checkin_time=None
).first()
item = {
'machineid': device.machineid,
'machinenumber': device.machinenumber,
'alias': device.alias,
'serialnumber': device.serialnumber,
'notes': device.notes,
'vendor_name': device.vendor.vendorname if device.vendor else None,
'model_name': device.model.modelnumber if device.model else None,
'is_checked_out': active_checkout is not None,
'current_checkout': active_checkout.to_dict() if active_checkout else None
}
data.append(item)
# Filter by availability if requested
if request.args.get('available', '').lower() == 'true':
data = [d for d in data if not d['is_checked_out']]
total = len(data)
return paginated_response(data, page, per_page, total)
@usb_bp.route('/<int:device_id>', methods=['GET'])
@jwt_required()
def get_usb_device(device_id: int):
"""Get a single USB device with checkout history."""
device = Machine.query.filter_by(
machineid=device_id,
machinetypeid=get_usb_machinetype_id()
).first()
if not device:
return error_response(
ErrorCodes.NOT_FOUND,
f'USB device with ID {device_id} not found',
http_code=404
)
# Get checkout history
checkouts = USBCheckout.query.filter_by(
machineid=device_id
).order_by(USBCheckout.checkout_time.desc()).limit(50).all()
# Check current checkout
active_checkout = next((c for c in checkouts if c.checkin_time is None), None)
result = {
'machineid': device.machineid,
'machinenumber': device.machinenumber,
'alias': device.alias,
'serialnumber': device.serialnumber,
'notes': device.notes,
'vendor_name': device.vendor.vendorname if device.vendor else None,
'model_name': device.model.modelnumber if device.model else None,
'is_checked_out': active_checkout is not None,
'current_checkout': active_checkout.to_dict() if active_checkout else None,
'checkout_history': [c.to_dict() for c in checkouts]
}
return success_response(result)
@usb_bp.route('/<int:device_id>/checkout', methods=['POST'])
@jwt_required()
def checkout_device(device_id: int):
"""Check out a USB device."""
device = Machine.query.filter_by(
machineid=device_id,
machinetypeid=get_usb_machinetype_id()
).first()
if not device:
return error_response(
ErrorCodes.NOT_FOUND,
f'USB device with ID {device_id} not found',
http_code=404
)
# Check if already checked out
active_checkout = USBCheckout.query.filter_by(
machineid=device_id,
checkin_time=None
).first()
if active_checkout:
return error_response(
ErrorCodes.CONFLICT,
f'Device is already checked out by {active_checkout.sso}',
http_code=409
)
data = request.get_json() or {}
if not data.get('sso'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'sso is required')
checkout = USBCheckout(
machineid=device_id,
sso=data['sso'],
checkout_name=data.get('name'),
checkout_reason=data.get('reason'),
checkout_time=datetime.utcnow()
)
db.session.add(checkout)
db.session.commit()
return success_response(checkout.to_dict(), message='Device checked out', http_code=201)
@usb_bp.route('/<int:device_id>/checkin', methods=['POST'])
@jwt_required()
def checkin_device(device_id: int):
"""Check in a USB device."""
device = Machine.query.filter_by(
machineid=device_id,
machinetypeid=get_usb_machinetype_id()
).first()
if not device:
return error_response(
ErrorCodes.NOT_FOUND,
f'USB device with ID {device_id} not found',
http_code=404
)
# Find active checkout
active_checkout = USBCheckout.query.filter_by(
machineid=device_id,
checkin_time=None
).first()
if not active_checkout:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'Device is not currently checked out',
http_code=400
)
data = request.get_json() or {}
active_checkout.checkin_time = datetime.utcnow()
active_checkout.was_wiped = data.get('was_wiped', False)
active_checkout.checkin_notes = data.get('notes')
db.session.commit()
return success_response(active_checkout.to_dict(), message='Device checked in')
@usb_bp.route('/<int:device_id>/history', methods=['GET'])
@jwt_required()
def get_checkout_history(device_id: int):
"""Get checkout history for a USB device."""
page, per_page = get_pagination_params(request)
query = USBCheckout.query.filter_by(
machineid=device_id
).order_by(USBCheckout.checkout_time.desc())
items, total = paginate_query(query, page, per_page)
data = [c.to_dict() for c in items]
return paginated_response(data, page, per_page, total)
@usb_bp.route('/checkouts', methods=['GET'])
@jwt_required()
def list_all_checkouts():
"""List all checkouts (active and historical)."""
page, per_page = get_pagination_params(request)
query = db.session.query(USBCheckout).join(
Machine, USBCheckout.machineid == Machine.machineid
)
# Filter by active only
if request.args.get('active', '').lower() == 'true':
query = query.filter(USBCheckout.checkin_time == None)
# Filter by user
if sso := request.args.get('sso'):
query = query.filter(USBCheckout.sso == sso)
query = query.order_by(USBCheckout.checkout_time.desc())
items, total = paginate_query(query, page, per_page)
# Include device info
data = []
for checkout in items:
device = Machine.query.get(checkout.machineid)
item = checkout.to_dict()
item['device'] = {
'machineid': device.machineid,
'alias': device.alias,
'serialnumber': device.serialnumber
} if device else None
data.append(item)
return paginated_response(data, page, per_page, total)

View File

@@ -0,0 +1,9 @@
{
"name": "usb",
"version": "1.0.0",
"description": "USB device checkout management",
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=1.0.0",
"api_prefix": "/api/usb"
}

View File

@@ -0,0 +1,5 @@
"""USB plugin models."""
from .usb_checkout import USBCheckout
__all__ = ['USBCheckout']

View File

@@ -0,0 +1,38 @@
"""USB Checkout model."""
from shopdb.extensions import db
from datetime import datetime
class USBCheckout(db.Model):
"""
USB device checkout tracking.
References machines table (USB devices have machinetypeid=44).
"""
__tablename__ = 'usbcheckouts'
checkoutid = db.Column(db.Integer, primary_key=True)
machineid = db.Column(db.Integer, db.ForeignKey('machines.machineid'), nullable=False, index=True)
sso = db.Column(db.String(20), nullable=False, index=True)
checkout_name = db.Column(db.String(100))
checkout_reason = db.Column(db.Text)
checkout_time = db.Column(db.DateTime, default=datetime.utcnow, index=True)
checkin_time = db.Column(db.DateTime, index=True)
was_wiped = db.Column(db.Boolean, default=False)
checkin_notes = db.Column(db.Text)
def to_dict(self):
"""Convert to dictionary."""
return {
'checkoutid': self.checkoutid,
'machineid': self.machineid,
'sso': self.sso,
'checkout_name': self.checkout_name,
'checkout_reason': self.checkout_reason,
'checkout_time': self.checkout_time.isoformat() if self.checkout_time else None,
'checkin_time': self.checkin_time.isoformat() if self.checkin_time else None,
'was_wiped': self.was_wiped,
'checkin_notes': self.checkin_notes,
'is_checked_out': self.checkin_time is None
}

View File

@@ -0,0 +1,169 @@
"""USB device plugin models."""
from datetime import datetime
from shopdb.extensions import db
from shopdb.core.models.base import BaseModel, AuditMixin
class USBDeviceType(BaseModel):
"""
USB device type classification.
Examples: Flash Drive, External HDD, External SSD, Card Reader
"""
__tablename__ = 'usbdevicetypes'
usbdevicetypeid = db.Column(db.Integer, primary_key=True)
typename = db.Column(db.String(50), unique=True, nullable=False)
description = db.Column(db.Text)
icon = db.Column(db.String(50), default='usb', comment='Icon name for UI')
def __repr__(self):
return f"<USBDeviceType {self.typename}>"
class USBDevice(BaseModel, AuditMixin):
"""
USB device model.
Tracks USB storage devices that can be checked out by users.
"""
__tablename__ = 'usbdevices'
usbdeviceid = db.Column(db.Integer, primary_key=True)
# Identification
serialnumber = db.Column(db.String(100), unique=True, nullable=False)
label = db.Column(db.String(100), nullable=True, comment='Human-readable label')
assetnumber = db.Column(db.String(50), nullable=True, comment='Optional asset tag')
# Classification
usbdevicetypeid = db.Column(
db.Integer,
db.ForeignKey('usbdevicetypes.usbdevicetypeid'),
nullable=True
)
# Specifications
capacitygb = db.Column(db.Integer, nullable=True, comment='Capacity in GB')
vendorid = db.Column(db.String(10), nullable=True, comment='USB Vendor ID (hex)')
productid = db.Column(db.String(10), nullable=True, comment='USB Product ID (hex)')
manufacturer = db.Column(db.String(100), nullable=True)
productname = db.Column(db.String(100), nullable=True)
# Current status
ischeckedout = db.Column(db.Boolean, default=False)
currentuserid = db.Column(db.String(50), nullable=True, comment='SSO of current user')
currentusername = db.Column(db.String(100), nullable=True, comment='Name of current user')
currentcheckoutdate = db.Column(db.DateTime, nullable=True)
# Location
storagelocation = db.Column(db.String(200), nullable=True, comment='Where device is stored when not checked out')
# Notes
notes = db.Column(db.Text, nullable=True)
# Relationships
devicetype = db.relationship('USBDeviceType', backref='devices')
# Indexes
__table_args__ = (
db.Index('idx_usb_serial', 'serialnumber'),
db.Index('idx_usb_checkedout', 'ischeckedout'),
db.Index('idx_usb_type', 'usbdevicetypeid'),
db.Index('idx_usb_currentuser', 'currentuserid'),
)
def __repr__(self):
return f"<USBDevice {self.label or self.serialnumber}>"
@property
def display_name(self):
"""Get display name (label if set, otherwise serial number)."""
return self.label or self.serialnumber
def to_dict(self):
"""Convert to dictionary with related data."""
result = super().to_dict()
# Add type info
if self.devicetype:
result['typename'] = self.devicetype.typename
result['typeicon'] = self.devicetype.icon
# Add computed property
result['displayname'] = self.display_name
return result
class USBCheckout(BaseModel):
"""
USB device checkout history.
Tracks when devices are checked out and returned.
"""
__tablename__ = 'usbcheckouts'
usbcheckoutid = db.Column(db.Integer, primary_key=True)
# Device reference
usbdeviceid = db.Column(
db.Integer,
db.ForeignKey('usbdevices.usbdeviceid', ondelete='CASCADE'),
nullable=False
)
# User info
userid = db.Column(db.String(50), nullable=False, comment='SSO of user')
username = db.Column(db.String(100), nullable=True, comment='Name of user')
# Checkout details
checkoutdate = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
checkindate = db.Column(db.DateTime, nullable=True)
expectedreturndate = db.Column(db.DateTime, nullable=True)
# Metadata
purpose = db.Column(db.String(500), nullable=True, comment='Reason for checkout')
notes = db.Column(db.Text, nullable=True)
checkedoutby = db.Column(db.String(50), nullable=True, comment='Admin who processed checkout')
checkedinby = db.Column(db.String(50), nullable=True, comment='Admin who processed checkin')
# Relationships
device = db.relationship('USBDevice', backref=db.backref('checkouts', lazy='dynamic'))
# Indexes
__table_args__ = (
db.Index('idx_usbcheckout_device', 'usbdeviceid'),
db.Index('idx_usbcheckout_user', 'userid'),
db.Index('idx_usbcheckout_dates', 'checkoutdate', 'checkindate'),
)
def __repr__(self):
return f"<USBCheckout device={self.usbdeviceid} user={self.userid}>"
@property
def is_active(self):
"""Check if this checkout is currently active (not returned)."""
return self.checkindate is None
@property
def duration_days(self):
"""Get duration of checkout in days."""
end = self.checkindate or datetime.utcnow()
delta = end - self.checkoutdate
return delta.days
def to_dict(self):
"""Convert to dictionary with computed fields."""
result = super().to_dict()
result['isactivecheckout'] = self.is_active
result['durationdays'] = self.duration_days
# Add device info if loaded
if self.device:
result['devicelabel'] = self.device.label
result['deviceserialnumber'] = self.device.serialnumber
return result

80
plugins/usb/plugin.py Normal file
View File

@@ -0,0 +1,80 @@
"""USB plugin main class."""
import json
import logging
from pathlib import Path
from typing import List, Dict, Optional, Type
from flask import Flask, Blueprint
from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.extensions import db
from .models import USBCheckout
from .api import usb_bp
logger = logging.getLogger(__name__)
class USBPlugin(BasePlugin):
"""
USB plugin - manages USB device checkouts.
USB devices are stored in the machines table (machinetypeid=44).
This plugin provides checkout/checkin tracking.
"""
def __init__(self):
self._manifest = self._load_manifest()
def _load_manifest(self) -> Dict:
"""Load plugin manifest from JSON file."""
manifest_path = Path(__file__).parent / 'manifest.json'
if manifest_path.exists():
with open(manifest_path, 'r') as f:
return json.load(f)
return {}
@property
def meta(self) -> PluginMeta:
"""Return plugin metadata."""
return PluginMeta(
name=self._manifest.get('name', 'usb'),
version=self._manifest.get('version', '1.0.0'),
description=self._manifest.get('description', 'USB device checkout management'),
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/usb'),
)
def get_blueprint(self) -> Optional[Blueprint]:
"""Return Flask Blueprint with API routes."""
return usb_bp
def get_models(self) -> List[Type]:
"""Return list of SQLAlchemy model classes."""
return [USBCheckout]
def init_app(self, app: Flask, db_instance) -> None:
"""Initialize plugin with Flask app."""
logger.info(f"USB plugin initialized (v{self.meta.version})")
def on_install(self, app: Flask) -> None:
"""Called when plugin is installed."""
logger.info("USB plugin installed")
def on_uninstall(self, app: Flask) -> None:
"""Called when plugin is uninstalled."""
logger.info("USB plugin uninstalled")
def get_navigation_items(self) -> List[Dict]:
"""Return navigation menu items."""
return [
{
'name': 'USB Devices',
'icon': 'usb',
'route': '/usb',
'position': 45,
},
]