Retire the legacy Machine model (ADR-001 cutover)

The asset/computer model is now the single source of truth. Remove the Machine
instance layer end to end:

- Delete models Machine, MachineStatus, PCType, MachineRelationship,
  InstalledApp, PrinterData; keep MachineType (models.machinetypeid still
  references it).
- Delete the /api/machines, /api/statuses, /api/pctypes blueprints and the
  legacy /api/printers/legacy (PrinterData) blueprint.
- Drop the deprecated communications.machineid column and its FK.
- Migration 7c01 drops tables machines, machinestatuses, pctypes,
  machinerelationships, installedapps, printerdata (idempotent).
- Fix remaining readers (applications install counts) to ComputerInstalledApp.
- Frontend: remove dead machinesApi/statusesApi/pctypesApi wrappers; repoint
  the PC Types settings page at computer types.

143 tests pass; all asset/computer/dashboard/report/collector endpoints 200.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-06-26 09:59:17 -04:00
parent cb47b9087e
commit b0a0670fcd
23 changed files with 344 additions and 2047 deletions

View File

@@ -2,16 +2,16 @@
from .base import BaseModel, SoftDeleteMixin, AuditMixin
from .asset import Asset, AssetType, AssetStatus
from .machine import Machine, MachineType, MachineStatus, PCType
from .machine import MachineType
from .vendor import Vendor
from .model import Model
from .businessunit import BusinessUnit
from .location import Location
from .operatingsystem import OperatingSystem
from .relationship import MachineRelationship, AssetRelationship, RelationshipType
from .relationship import AssetRelationship, RelationshipType
from .communication import Communication, CommunicationType
from .user import User, Role, Permission
from .application import Application, AppVersion, AppOwner, SupportTeam, InstalledApp
from .application import Application, AppVersion, AppOwner, SupportTeam
from .knowledgebase import KnowledgeBase
from .setting import Setting
from .auditlog import AuditLog
@@ -25,11 +25,8 @@ __all__ = [
'Asset',
'AssetType',
'AssetStatus',
# Machine (legacy)
'Machine',
# Legacy machine type lookup (still referenced by models.machinetypeid)
'MachineType',
'MachineStatus',
'PCType',
# Reference
'Vendor',
'Model',
@@ -37,7 +34,6 @@ __all__ = [
'Location',
'OperatingSystem',
# Relationships
'MachineRelationship',
'AssetRelationship',
'RelationshipType',
# Communication
@@ -52,7 +48,6 @@ __all__ = [
'AppVersion',
'AppOwner',
'SupportTeam',
'InstalledApp',
# Knowledge Base
'KnowledgeBase',
# Settings

View File

@@ -1,143 +1,99 @@
"""Application tracking models."""
from shopdb.extensions import db
from .base import BaseModel
class AppOwner(BaseModel):
"""Application owner/contact."""
__tablename__ = 'appowners'
appownerid = db.Column(db.Integer, primary_key=True)
appowner = db.Column(db.String(100), nullable=False)
sso = db.Column(db.String(50))
email = db.Column(db.String(100))
# Relationships
supportteams = db.relationship('SupportTeam', back_populates='owner', lazy='dynamic')
def __repr__(self):
return f"<AppOwner {self.appowner}>"
class SupportTeam(BaseModel):
"""Application support team."""
__tablename__ = 'supportteams'
supportteamid = db.Column(db.Integer, primary_key=True)
teamname = db.Column(db.String(100), nullable=False)
teamurl = db.Column(db.String(255))
appownerid = db.Column(db.Integer, db.ForeignKey('appowners.appownerid'))
# Relationships
owner = db.relationship('AppOwner', back_populates='supportteams')
applications = db.relationship('Application', back_populates='supportteam', lazy='dynamic')
def __repr__(self):
return f"<SupportTeam {self.teamname}>"
class Application(BaseModel):
"""Application catalog."""
__tablename__ = 'applications'
appid = db.Column(db.Integer, primary_key=True)
appname = db.Column(db.String(100), unique=True, nullable=False)
appdescription = db.Column(db.String(255))
supportteamid = db.Column(db.Integer, db.ForeignKey('supportteams.supportteamid'))
isinstallable = db.Column(db.Boolean, default=False)
applicationnotes = db.Column(db.Text)
installpath = db.Column(db.String(255))
applicationlink = db.Column(db.String(512))
documentationpath = db.Column(db.String(512))
ishidden = db.Column(db.Boolean, default=False)
isprinter = db.Column(db.Boolean, default=False)
islicenced = db.Column(db.Boolean, default=False)
image = db.Column(db.String(255))
# Relationships
supportteam = db.relationship('SupportTeam', back_populates='applications')
versions = db.relationship('AppVersion', back_populates='application', lazy='dynamic')
installed_on = db.relationship('InstalledApp', back_populates='application', lazy='dynamic')
def __repr__(self):
return f"<Application {self.appname}>"
class AppVersion(db.Model):
"""Application version tracking."""
__tablename__ = 'appversions'
appversionid = db.Column(db.Integer, primary_key=True)
appid = db.Column(db.Integer, db.ForeignKey('applications.appid'), nullable=False)
version = db.Column(db.String(50), nullable=False)
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')
installations = db.relationship('InstalledApp', back_populates='appversion', lazy='dynamic')
# Unique constraint on app + version
__table_args__ = (
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}>"
class InstalledApp(db.Model):
"""Junction table for applications installed on machines (PCs)."""
__tablename__ = 'installedapps'
id = db.Column(db.Integer, primary_key=True)
machineid = db.Column(db.Integer, db.ForeignKey('machines.machineid'), nullable=False)
appid = db.Column(db.Integer, db.ForeignKey('applications.appid'), nullable=False)
appversionid = db.Column(db.Integer, db.ForeignKey('appversions.appversionid'))
isactive = db.Column(db.Boolean, default=True, nullable=False)
installeddate = db.Column(db.DateTime, default=db.func.now())
# Relationships
machine = db.relationship('Machine', back_populates='installedapps')
application = db.relationship('Application', back_populates='installed_on')
appversion = db.relationship('AppVersion', back_populates='installations')
# Unique constraint - one app per machine (can have different versions over time)
__table_args__ = (
db.UniqueConstraint('machineid', 'appid', name='uq_machine_app'),
)
def to_dict(self):
"""Convert to dictionary."""
return {
'id': self.id,
'machineid': self.machineid,
'appid': self.appid,
'appversionid': self.appversionid,
'isactive': self.isactive,
'installeddate': self.installeddate.isoformat() + 'Z' if self.installeddate else None,
'application': {
'appid': self.application.appid,
'appname': self.application.appname,
'appdescription': self.application.appdescription,
} if self.application else None,
'version': self.appversion.version if self.appversion else None
}
def __repr__(self):
return f"<InstalledApp machine={self.machineid} app={self.appid}>"
"""Application tracking models."""
from shopdb.extensions import db
from .base import BaseModel
class AppOwner(BaseModel):
"""Application owner/contact."""
__tablename__ = 'appowners'
appownerid = db.Column(db.Integer, primary_key=True)
appowner = db.Column(db.String(100), nullable=False)
sso = db.Column(db.String(50))
email = db.Column(db.String(100))
# Relationships
supportteams = db.relationship('SupportTeam', back_populates='owner', lazy='dynamic')
def __repr__(self):
return f"<AppOwner {self.appowner}>"
class SupportTeam(BaseModel):
"""Application support team."""
__tablename__ = 'supportteams'
supportteamid = db.Column(db.Integer, primary_key=True)
teamname = db.Column(db.String(100), nullable=False)
teamurl = db.Column(db.String(255))
appownerid = db.Column(db.Integer, db.ForeignKey('appowners.appownerid'))
# Relationships
owner = db.relationship('AppOwner', back_populates='supportteams')
applications = db.relationship('Application', back_populates='supportteam', lazy='dynamic')
def __repr__(self):
return f"<SupportTeam {self.teamname}>"
class Application(BaseModel):
"""Application catalog."""
__tablename__ = 'applications'
appid = db.Column(db.Integer, primary_key=True)
appname = db.Column(db.String(100), unique=True, nullable=False)
appdescription = db.Column(db.String(255))
supportteamid = db.Column(db.Integer, db.ForeignKey('supportteams.supportteamid'))
isinstallable = db.Column(db.Boolean, default=False)
applicationnotes = db.Column(db.Text)
installpath = db.Column(db.String(255))
applicationlink = db.Column(db.String(512))
documentationpath = db.Column(db.String(512))
ishidden = db.Column(db.Boolean, default=False)
isprinter = db.Column(db.Boolean, default=False)
islicenced = db.Column(db.Boolean, default=False)
image = db.Column(db.String(255))
# Relationships
supportteam = db.relationship('SupportTeam', back_populates='applications')
versions = db.relationship('AppVersion', back_populates='application', lazy='dynamic')
def __repr__(self):
return f"<Application {self.appname}>"
class AppVersion(db.Model):
"""Application version tracking."""
__tablename__ = 'appversions'
appversionid = db.Column(db.Integer, primary_key=True)
appid = db.Column(db.Integer, db.ForeignKey('applications.appid'), nullable=False)
version = db.Column(db.String(50), nullable=False)
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')
# Unique constraint on app + version
__table_args__ = (
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}>"

View File

@@ -36,14 +36,6 @@ class Communication(BaseModel):
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=True,
comment='DEPRECATED: FK to machines table - use assetid instead'
)
comtypeid = db.Column(
db.Integer,
db.ForeignKey('communicationtypes.comtypeid'),
@@ -95,9 +87,8 @@ class Communication(BaseModel):
__table_args__ = (
db.Index('idx_comm_asset', 'assetid'),
db.Index('idx_comm_machine', 'machineid'),
db.Index('idx_comm_ip', 'ipaddress'),
)
def __repr__(self):
return f"<Communication {self.machineid}:{self.comtype.comtype if self.comtype else 'Unknown'}>"
return f"<Communication {self.assetid}:{self.comtype.comtype if self.comtype else 'Unknown'}>"

View File

@@ -1,7 +1,12 @@
"""Unified Machine model - combines equipment and PCs."""
"""Legacy machine type lookup.
The Machine instance model and its PC/status lookups were retired (ADR-001);
assets are the platform contract. MachineType is kept only because the shared
`models` table still references it via models.machinetypeid.
"""
from shopdb.extensions import db
from .base import BaseModel, SoftDeleteMixin, AuditMixin
from .base import BaseModel
class MachineType(BaseModel):
@@ -24,229 +29,3 @@ class MachineType(BaseModel):
def __repr__(self):
return f"<MachineType {self.machinetype}>"
class MachineStatus(BaseModel):
"""Machine status options."""
__tablename__ = 'machinestatuses'
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"<MachineStatus {self.status}>"
class PCType(BaseModel):
"""
PC type classification for more specific PC categorization.
Examples: Shopfloor PC, Engineer Workstation, CMM PC, etc.
"""
__tablename__ = 'pctypes'
pctypeid = db.Column(db.Integer, primary_key=True)
pctype = db.Column(db.String(100), unique=True, nullable=False)
description = db.Column(db.Text)
def __repr__(self):
return f"<PCType {self.pctype}>"
class Machine(BaseModel, SoftDeleteMixin, AuditMixin):
"""
Unified machine model for all asset types.
Machine types can be:
- CNC machines, CMMs, EDMs, etc. (manufacturing equipment)
- PCs (shopfloor PCs, engineer workstations, etc.)
- Network devices (servers, switches, etc.) - if network_devices plugin not used
The machinetype.category field distinguishes between types.
"""
__tablename__ = 'machines'
machineid = db.Column(db.Integer, primary_key=True)
# Identification
machinenumber = db.Column(
db.String(50),
unique=True,
nullable=False,
index=True,
comment='Business identifier (e.g., CMM01, G5QX1GT3ESF)'
)
alias = db.Column(
db.String(100),
comment='Friendly name'
)
hostname = db.Column(
db.String(100),
index=True,
comment='Network hostname (for PCs)'
)
serialnumber = db.Column(
db.String(100),
index=True,
comment='Hardware serial number'
)
# Classification
machinetypeid = db.Column(
db.Integer,
db.ForeignKey('machinetypes.machinetypeid'),
nullable=False
)
pctypeid = db.Column(
db.Integer,
db.ForeignKey('pctypes.pctypeid'),
nullable=True,
comment='Set for PCs, NULL for equipment'
)
businessunitid = db.Column(
db.Integer,
db.ForeignKey('businessunits.businessunitid'),
nullable=True
)
modelnumberid = db.Column(
db.Integer,
db.ForeignKey('models.modelnumberid'),
nullable=True
)
vendorid = db.Column(
db.Integer,
db.ForeignKey('vendors.vendorid'),
nullable=True
)
# Status
statusid = db.Column(
db.Integer,
db.ForeignKey('machinestatuses.statusid'),
default=1,
comment='In Use, Spare, Retired, etc.'
)
# Location and mapping
locationid = db.Column(
db.Integer,
db.ForeignKey('locations.locationid'),
nullable=True
)
mapleft = db.Column(db.Integer, comment='X coordinate on floor map')
maptop = db.Column(db.Integer, comment='Y coordinate on floor map')
islocationonly = db.Column(
db.Boolean,
default=False,
comment='Virtual location marker (not actual machine)'
)
# PC-specific fields (nullable for non-PC machines)
osid = db.Column(
db.Integer,
db.ForeignKey('operatingsystems.osid'),
nullable=True
)
loggedinuser = db.Column(db.String(100), nullable=True)
lastreporteddate = db.Column(db.DateTime, nullable=True)
lastboottime = db.Column(db.DateTime, nullable=True)
# Features/flags
isvnc = db.Column(db.Boolean, default=False, comment='VNC remote access enabled')
iswinrm = db.Column(db.Boolean, default=False, comment='WinRM enabled')
isshopfloor = db.Column(db.Boolean, default=False, comment='Shopfloor PC')
requiresmanualconfig = db.Column(
db.Boolean,
default=False,
comment='Multi-PC machine needs manual configuration'
)
# Notes
notes = db.Column(db.Text, nullable=True)
# Relationships
machinetype = db.relationship('MachineType', backref='machines')
pctype = db.relationship('PCType', backref='machines')
businessunit = db.relationship('BusinessUnit', backref='machines')
model = db.relationship('Model', backref='machines')
vendor = db.relationship('Vendor', backref='machines')
status = db.relationship('MachineStatus', backref='machines')
location = db.relationship('Location', backref='machines')
operatingsystem = db.relationship('OperatingSystem', backref='machines')
# Communications (one-to-many)
communications = db.relationship(
'Communication',
backref='machine',
cascade='all, delete-orphan',
lazy='dynamic'
)
# Installed applications (for PCs)
installedapps = db.relationship(
'InstalledApp',
back_populates='machine',
cascade='all, delete-orphan',
lazy='dynamic'
)
# Indexes
__table_args__ = (
db.Index('idx_machine_type_bu', 'machinetypeid', 'businessunitid'),
db.Index('idx_machine_location', 'locationid'),
db.Index('idx_machine_active', 'isactive'),
db.Index('idx_machine_hostname', 'hostname'),
)
def __repr__(self):
return f"<Machine {self.machinenumber}>"
@property
def display_name(self):
"""Get display name (alias if set, otherwise machinenumber)."""
return self.alias or self.machinenumber
@property
def derived_machinetype(self):
"""Get machinetype from model (single source of truth)."""
if self.model and self.model.machinetype:
return self.model.machinetype
return None
@property
def is_pc(self):
"""Check if this machine is a PC type."""
mt = self.derived_machinetype
return mt.category == 'PC' if mt else False
@property
def is_equipment(self):
"""Check if this machine is equipment."""
mt = self.derived_machinetype
return mt.category == 'Equipment' if mt else False
@property
def is_network_device(self):
"""Check if this machine is a network device."""
mt = self.derived_machinetype
return mt.category == 'Network' if mt else False
@property
def is_printer(self):
"""Check if this machine is a printer."""
mt = self.derived_machinetype
return mt.category == 'Printer' if mt else False
@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

View File

@@ -115,59 +115,3 @@ class AssetRelationship(BaseModel):
def __repr__(self):
return f"<AssetRelationship {self.sourceassetid} -> {self.targetassetid}>"
class MachineRelationship(BaseModel):
"""
Relationships between machines.
Examples:
- PC controls CNC machine
- Two CNCs are dualpath partners
"""
__tablename__ = 'machinerelationships'
relationshipid = db.Column(db.Integer, primary_key=True)
parentmachineid = db.Column(
db.Integer,
db.ForeignKey('machines.machineid'),
nullable=False
)
childmachineid = db.Column(
db.Integer,
db.ForeignKey('machines.machineid'),
nullable=False
)
relationshiptypeid = db.Column(
db.Integer,
db.ForeignKey('relationshiptypes.relationshiptypeid'),
nullable=False
)
notes = db.Column(db.Text)
# Relationships
parent_machine = db.relationship(
'Machine',
foreign_keys=[parentmachineid],
backref='child_relationships'
)
child_machine = db.relationship(
'Machine',
foreign_keys=[childmachineid],
backref='parent_relationships'
)
relationship_type = db.relationship('RelationshipType', backref='relationships')
__table_args__ = (
db.UniqueConstraint(
'parentmachineid',
'childmachineid',
'relationshiptypeid',
name='uq_machine_relationship'
),
)
def __repr__(self):
return f"<MachineRelationship {self.parentmachineid} -> {self.childmachineid}>"