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:
@@ -1,13 +1,14 @@
|
||||
"""Core SQLAlchemy models."""
|
||||
|
||||
from .base import BaseModel, SoftDeleteMixin, AuditMixin
|
||||
from .asset import Asset, AssetType, AssetStatus
|
||||
from .machine import Machine, MachineType, MachineStatus, PCType
|
||||
from .vendor import Vendor
|
||||
from .model import Model
|
||||
from .businessunit import BusinessUnit
|
||||
from .location import Location
|
||||
from .operatingsystem import OperatingSystem
|
||||
from .relationship import MachineRelationship, RelationshipType
|
||||
from .relationship import MachineRelationship, AssetRelationship, RelationshipType
|
||||
from .communication import Communication, CommunicationType
|
||||
from .user import User, Role
|
||||
from .application import Application, AppVersion, AppOwner, SupportTeam, InstalledApp
|
||||
@@ -18,7 +19,11 @@ __all__ = [
|
||||
'BaseModel',
|
||||
'SoftDeleteMixin',
|
||||
'AuditMixin',
|
||||
# Machine
|
||||
# Asset (new architecture)
|
||||
'Asset',
|
||||
'AssetType',
|
||||
'AssetStatus',
|
||||
# Machine (legacy)
|
||||
'Machine',
|
||||
'MachineType',
|
||||
'MachineStatus',
|
||||
@@ -31,6 +36,7 @@ __all__ = [
|
||||
'OperatingSystem',
|
||||
# Relationships
|
||||
'MachineRelationship',
|
||||
'AssetRelationship',
|
||||
'RelationshipType',
|
||||
# Communication
|
||||
'Communication',
|
||||
|
||||
@@ -64,7 +64,7 @@ class Application(BaseModel):
|
||||
return f"<Application {self.appname}>"
|
||||
|
||||
|
||||
class AppVersion(BaseModel):
|
||||
class AppVersion(db.Model):
|
||||
"""Application version tracking."""
|
||||
__tablename__ = 'appversions'
|
||||
|
||||
@@ -74,6 +74,7 @@ class AppVersion(BaseModel):
|
||||
releasedate = db.Column(db.Date)
|
||||
notes = db.Column(db.String(255))
|
||||
dateadded = db.Column(db.DateTime, default=db.func.now())
|
||||
isactive = db.Column(db.Boolean, default=True)
|
||||
|
||||
# Relationships
|
||||
application = db.relationship('Application', back_populates='versions')
|
||||
@@ -84,6 +85,18 @@ class AppVersion(BaseModel):
|
||||
db.UniqueConstraint('appid', 'version', name='uq_app_version'),
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
'appversionid': self.appversionid,
|
||||
'appid': self.appid,
|
||||
'version': self.version,
|
||||
'releasedate': self.releasedate.isoformat() if self.releasedate else None,
|
||||
'notes': self.notes,
|
||||
'dateadded': self.dateadded.isoformat() + 'Z' if self.dateadded else None,
|
||||
'isactive': self.isactive
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AppVersion {self.application.appname if self.application else self.appid} v{self.version}>"
|
||||
|
||||
|
||||
200
shopdb/core/models/asset.py
Normal file
200
shopdb/core/models/asset.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""Polymorphic Asset models - core of the new asset architecture."""
|
||||
|
||||
from shopdb.extensions import db
|
||||
from .base import BaseModel, SoftDeleteMixin, AuditMixin
|
||||
|
||||
|
||||
class AssetType(BaseModel):
|
||||
"""
|
||||
Registry of asset categories.
|
||||
|
||||
Each type maps to a plugin-owned extension table.
|
||||
Examples: equipment, computer, network_device, printer
|
||||
"""
|
||||
__tablename__ = 'assettypes'
|
||||
|
||||
assettypeid = db.Column(db.Integer, primary_key=True)
|
||||
assettype = db.Column(
|
||||
db.String(50),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
comment='Category name: equipment, computer, network_device, printer'
|
||||
)
|
||||
plugin_name = db.Column(
|
||||
db.String(100),
|
||||
nullable=True,
|
||||
comment='Plugin that owns this type'
|
||||
)
|
||||
table_name = db.Column(
|
||||
db.String(100),
|
||||
nullable=True,
|
||||
comment='Extension table name for this type'
|
||||
)
|
||||
description = db.Column(db.Text)
|
||||
icon = db.Column(db.String(50), comment='Icon name for UI')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AssetType {self.assettype}>"
|
||||
|
||||
|
||||
class AssetStatus(BaseModel):
|
||||
"""Asset status options."""
|
||||
__tablename__ = 'assetstatuses'
|
||||
|
||||
statusid = db.Column(db.Integer, primary_key=True)
|
||||
status = db.Column(db.String(50), unique=True, nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
color = db.Column(db.String(20), comment='CSS color for UI')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AssetStatus {self.status}>"
|
||||
|
||||
|
||||
class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
|
||||
"""
|
||||
Core asset model - minimal shared fields.
|
||||
|
||||
Category-specific data lives in plugin extension tables
|
||||
(equipment, computers, network_devices, printers).
|
||||
The assetid matches original machineid for migration compatibility.
|
||||
"""
|
||||
__tablename__ = 'assets'
|
||||
|
||||
assetid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Identification
|
||||
assetnumber = db.Column(
|
||||
db.String(50),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment='Business identifier (e.g., CMM01, G5QX1GT3ESF)'
|
||||
)
|
||||
name = db.Column(
|
||||
db.String(100),
|
||||
comment='Display name/alias'
|
||||
)
|
||||
serialnumber = db.Column(
|
||||
db.String(100),
|
||||
index=True,
|
||||
comment='Hardware serial number'
|
||||
)
|
||||
|
||||
# Classification
|
||||
assettypeid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assettypes.assettypeid'),
|
||||
nullable=False
|
||||
)
|
||||
statusid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assetstatuses.statusid'),
|
||||
default=1,
|
||||
comment='In Use, Spare, Retired, etc.'
|
||||
)
|
||||
|
||||
# Location and organization
|
||||
locationid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('locations.locationid'),
|
||||
nullable=True
|
||||
)
|
||||
businessunitid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('businessunits.businessunitid'),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Floor map position
|
||||
mapleft = db.Column(db.Integer, comment='X coordinate on floor map')
|
||||
maptop = db.Column(db.Integer, comment='Y coordinate on floor map')
|
||||
|
||||
# Notes
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Relationships
|
||||
assettype = db.relationship('AssetType', backref='assets')
|
||||
status = db.relationship('AssetStatus', backref='assets')
|
||||
location = db.relationship('Location', backref='assets')
|
||||
businessunit = db.relationship('BusinessUnit', backref='assets')
|
||||
|
||||
# Communications (one-to-many) - will be migrated to use assetid
|
||||
communications = db.relationship(
|
||||
'Communication',
|
||||
foreign_keys='Communication.assetid',
|
||||
backref='asset',
|
||||
cascade='all, delete-orphan',
|
||||
lazy='dynamic'
|
||||
)
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
db.Index('idx_asset_type_bu', 'assettypeid', 'businessunitid'),
|
||||
db.Index('idx_asset_location', 'locationid'),
|
||||
db.Index('idx_asset_active', 'isactive'),
|
||||
db.Index('idx_asset_status', 'statusid'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Asset {self.assetnumber}>"
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
"""Get display name (name if set, otherwise assetnumber)."""
|
||||
return self.name or self.assetnumber
|
||||
|
||||
@property
|
||||
def primary_ip(self):
|
||||
"""Get primary IP address from communications."""
|
||||
comm = self.communications.filter_by(
|
||||
isprimary=True,
|
||||
comtypeid=1 # IP type
|
||||
).first()
|
||||
if comm:
|
||||
return comm.ipaddress
|
||||
# Fall back to any IP
|
||||
comm = self.communications.filter_by(comtypeid=1).first()
|
||||
return comm.ipaddress if comm else None
|
||||
|
||||
def to_dict(self, include_type_data=False):
|
||||
"""
|
||||
Convert model to dictionary.
|
||||
|
||||
Args:
|
||||
include_type_data: If True, include category-specific data from extension table
|
||||
"""
|
||||
result = super().to_dict()
|
||||
|
||||
# Add related object names for convenience
|
||||
if self.assettype:
|
||||
result['assettype_name'] = self.assettype.assettype
|
||||
if self.status:
|
||||
result['status_name'] = self.status.status
|
||||
if self.location:
|
||||
result['location_name'] = self.location.locationname
|
||||
if self.businessunit:
|
||||
result['businessunit_name'] = self.businessunit.businessunit
|
||||
|
||||
# Include extension data if requested
|
||||
if include_type_data:
|
||||
ext_data = self._get_extension_data()
|
||||
if ext_data:
|
||||
result['type_data'] = ext_data
|
||||
|
||||
return result
|
||||
|
||||
def _get_extension_data(self):
|
||||
"""Get category-specific data from extension table."""
|
||||
# Check for equipment extension
|
||||
if hasattr(self, 'equipment') and self.equipment:
|
||||
return self.equipment.to_dict()
|
||||
# Check for computer extension
|
||||
if hasattr(self, 'computer') and self.computer:
|
||||
return self.computer.to_dict()
|
||||
# Check for network_device extension
|
||||
if hasattr(self, 'network_device') and self.network_device:
|
||||
return self.network_device.to_dict()
|
||||
# Check for printer extension
|
||||
if hasattr(self, 'printer') and self.printer:
|
||||
return self.printer.to_dict()
|
||||
return None
|
||||
@@ -20,18 +20,30 @@ class CommunicationType(BaseModel):
|
||||
|
||||
class Communication(BaseModel):
|
||||
"""
|
||||
Communication interface for a machine.
|
||||
Communication interface for an asset (or legacy machine).
|
||||
Stores network config, serial settings, etc.
|
||||
"""
|
||||
__tablename__ = 'communications'
|
||||
|
||||
communicationid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# New asset-based FK (preferred)
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid'),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment='FK to assets table (new architecture)'
|
||||
)
|
||||
|
||||
# Legacy machine FK (for backward compatibility during migration)
|
||||
machineid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('machines.machineid'),
|
||||
nullable=False
|
||||
nullable=True,
|
||||
comment='DEPRECATED: FK to machines table - use assetid instead'
|
||||
)
|
||||
|
||||
comtypeid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('communicationtypes.comtypeid'),
|
||||
@@ -82,6 +94,7 @@ class Communication(BaseModel):
|
||||
comtype = db.relationship('CommunicationType', backref='communications')
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_comm_asset', 'assetid'),
|
||||
db.Index('idx_comm_machine', 'machineid'),
|
||||
db.Index('idx_comm_ip', 'ipaddress'),
|
||||
)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Machine relationship models."""
|
||||
"""Machine and Asset relationship models."""
|
||||
|
||||
from shopdb.extensions import db
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
class RelationshipType(BaseModel):
|
||||
"""Types of relationships between machines."""
|
||||
"""Types of relationships between machines/assets."""
|
||||
__tablename__ = 'relationshiptypes'
|
||||
|
||||
relationshiptypeid = db.Column(db.Integer, primary_key=True)
|
||||
@@ -21,6 +21,65 @@ class RelationshipType(BaseModel):
|
||||
return f"<RelationshipType {self.relationshiptype}>"
|
||||
|
||||
|
||||
class AssetRelationship(BaseModel):
|
||||
"""
|
||||
Relationships between assets.
|
||||
|
||||
Examples:
|
||||
- Computer controls Equipment
|
||||
- Two machines are dualpath partners
|
||||
- Network device connects to equipment
|
||||
"""
|
||||
__tablename__ = 'assetrelationships'
|
||||
|
||||
relationshipid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
source_assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid'),
|
||||
nullable=False
|
||||
)
|
||||
target_assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid'),
|
||||
nullable=False
|
||||
)
|
||||
relationshiptypeid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('relationshiptypes.relationshiptypeid'),
|
||||
nullable=False
|
||||
)
|
||||
|
||||
notes = db.Column(db.Text)
|
||||
|
||||
# Relationships
|
||||
source_asset = db.relationship(
|
||||
'Asset',
|
||||
foreign_keys=[source_assetid],
|
||||
backref='outgoing_relationships'
|
||||
)
|
||||
target_asset = db.relationship(
|
||||
'Asset',
|
||||
foreign_keys=[target_assetid],
|
||||
backref='incoming_relationships'
|
||||
)
|
||||
relationship_type = db.relationship('RelationshipType', backref='asset_relationships')
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint(
|
||||
'source_assetid',
|
||||
'target_assetid',
|
||||
'relationshiptypeid',
|
||||
name='uq_asset_relationship'
|
||||
),
|
||||
db.Index('idx_asset_rel_source', 'source_assetid'),
|
||||
db.Index('idx_asset_rel_target', 'target_assetid'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AssetRelationship {self.source_assetid} -> {self.target_assetid}>"
|
||||
|
||||
|
||||
class MachineRelationship(BaseModel):
|
||||
"""
|
||||
Relationships between machines.
|
||||
|
||||
Reference in New Issue
Block a user