main was 16 days and 259 commits behind the line the product is actually built on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It was neither current nor curated - just stale. This brings it forward and, at the same time, makes it mean something: main is what a receiving site installs, and nothing else. WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides, knowledgebase, machines, network, notifications, printers, slides, tools, warranty - plus applications, which is manifest-less core and always ships. The only manifest dependency inside that set is endpoints on computers, and both are in it, so the closure is self-contained. WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb stay on the site line. So do the Windows installer tree, the VM rehearsal helpers, the agent tooling config, one site's data loader, the internal audit and findings records, and the planner spreadsheet. 1,135 tracked files against 1,438. A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped: deploy/site-profile-universal.json still named all seventeen plugins. It is the file docs/DEPLOY.md tells a site to provision from, so a fresh install would have failed at `flask plugin apply-profile` with PluginError - the first thing a receiving site does, broken by the branch meant to serve them. It now names the twelve that exist, and its comment says the two must agree. agent/ was never refreshed. Whoever populated this branch copied shopdb/, plugins/, frontend/ and tests/ from the site line and left the agent tree at its September state: 22 C# and PowerShell sources missing, including AgentCredentials, EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the site line had deleted still present. The old tree is internally consistent, so it compiles, and nothing in CI builds it - every gate was green over a server that speaks enrollment and per-PC credentials to a bundled agent source that cannot. Resynced whole. DECISIONS THIS ENCODES, rather than defers: - The plugin-authoring lab builds printedparts, whose manifest depends on employees, so it could not stay. The lab remains on the site line and main ships no build-along tutorial. That is a real loss for an adopting developer and is recorded here rather than discovered later. - Recognition, Training and Recertification notifications are no longer seeded. Each splits one notification into a card per employee and renders a photo, which needs a plugin this branch does not bundle. Seeding them would offer an author a type that cannot resolve anybody. A site that installs employees gets them from that plugin's own seed. - Windows/IIS is documented as the legacy path throughout, and the README leads with Docker, because that is what a receiving site gets. The API description is regenerated for THIS branch - 347 paths and 483 operations, down from 415 and 573 - so the published contract describes what is here rather than what the site line serves. docs/CONFIG.md lost the two measuring-tool label keys the code no longer declares, which two lanes had decided opposite ways. Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run rather than skip, and every index line count is derived from the file it names.
323 lines
12 KiB
Python
323 lines
12 KiB
Python
"""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: machine, 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: machine, computer, network_device, printer'
|
|
)
|
|
pluginname = db.Column(
|
|
db.String(100),
|
|
nullable=True,
|
|
comment='Plugin that owns this type'
|
|
)
|
|
tablename = 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')
|
|
color = db.Column(db.String(20), comment='CSS color for UI/map markers')
|
|
|
|
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
|
|
(machines, 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'
|
|
)
|
|
gaugelabreference = db.Column(
|
|
db.String(50),
|
|
index=True,
|
|
comment='Gauge lab asset reference (authoritative tag the gauge lab '
|
|
'assigns to machines); distinct from assetnumber'
|
|
)
|
|
maintenancereference = db.Column(
|
|
db.String(50),
|
|
index=True,
|
|
comment='Maintenance system asset reference; distinct from assetnumber'
|
|
)
|
|
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 (ADR-001: asset-specific override; nullable).
|
|
#
|
|
# Absolute pixels in the NATIVE COORDINATE SPACE OF ITS LEVEL, not of the
|
|
# site (ADR-017). levelid says which drawing they are pixels of, and without
|
|
# it a position cannot be rendered - a marker drawn on the wrong level's
|
|
# blueprint looks perfectly correct and points at the wrong place, so the UI
|
|
# shows "level unknown" rather than assuming the default.
|
|
mapx = db.Column(db.Integer, comment='X coordinate on this level (ADR-017)')
|
|
mapy = db.Column(db.Integer, comment='Y coordinate on this level (ADR-017)')
|
|
levelid = db.Column(
|
|
db.Integer, db.ForeignKey('maplevels.levelid'), nullable=True,
|
|
index=True,
|
|
comment='Which drawing mapx/mapy are pixels of (ADR-017)')
|
|
# When the position was last CONFIRMED against the current drawing. A bulk
|
|
# transform clears it, because a transform is a starting guess: the levels
|
|
# were redrawn and machines moved, and nothing in the coordinates says which
|
|
# markers are now stale. Null means "not yet reviewed on this drawing".
|
|
mapverifiedat = db.Column(db.DateTime, nullable=True)
|
|
|
|
# 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 get_inherited_location(self):
|
|
"""
|
|
Get location data from a related asset if this asset has none.
|
|
|
|
Returns dict with locationid, location_name, mapx, mapy, and
|
|
inherited_from (assetnumber of source asset) if location was inherited.
|
|
Returns None if no location data available.
|
|
"""
|
|
if self.locationid is not None or (self.mapx is not None and self.mapy is not None):
|
|
return None
|
|
|
|
related_assets = []
|
|
|
|
if hasattr(self, 'incoming_relationships'):
|
|
for rel in self.incoming_relationships:
|
|
if rel.sourceasset and rel.isactive:
|
|
related_assets.append(rel.sourceasset)
|
|
|
|
if hasattr(self, 'outgoing_relationships'):
|
|
for rel in self.outgoing_relationships:
|
|
if rel.targetasset and rel.isactive:
|
|
related_assets.append(rel.targetasset)
|
|
|
|
for related in related_assets:
|
|
if related.locationid is not None or (related.mapx is not None and related.mapy is not None):
|
|
return {
|
|
'locationid': related.locationid,
|
|
'locationname': related.location.locationname if related.location else None,
|
|
'mapx': related.mapx,
|
|
'mapy': related.mapy,
|
|
# The level belongs to whichever asset supplied the
|
|
# coordinates (ADR-017). Inheriting a position without its
|
|
# level draws it on the borrower's drawing instead.
|
|
'levelid': related.levelid,
|
|
'inheritedfrom': related.assetnumber
|
|
}
|
|
|
|
return None
|
|
|
|
def to_dict(self, include_type_data=False, include_inherited_location=True):
|
|
"""
|
|
Convert model to dictionary.
|
|
|
|
Args:
|
|
include_type_data: If True, include category-specific data from extension table
|
|
include_inherited_location: If True, include location from related assets when missing
|
|
"""
|
|
result = super().to_dict()
|
|
|
|
# Add related object names for convenience
|
|
if self.assettype:
|
|
result['assettypename'] = self.assettype.assettype
|
|
result['assettypecolor'] = getattr(self.assettype, 'color', None)
|
|
if self.status:
|
|
result['statusname'] = self.status.status
|
|
result['statuscolor'] = self.status.color
|
|
if self.location:
|
|
result['locationname'] = self.location.locationname
|
|
if self.businessunit:
|
|
result['businessunitname'] = self.businessunit.businessunit
|
|
|
|
# Add plugin-specific ID for navigation purposes
|
|
if hasattr(self, 'machine') and self.machine:
|
|
result['pluginid'] = self.machine.machineid
|
|
elif hasattr(self, 'computer') and self.computer:
|
|
result['pluginid'] = self.computer.computerid
|
|
elif hasattr(self, 'network_device') and self.network_device:
|
|
result['pluginid'] = self.network_device.networkdeviceid
|
|
elif hasattr(self, 'printer') and self.printer:
|
|
result['pluginid'] = self.printer.printerid
|
|
|
|
# What this asset OWNS, before any inheritance is merged in below.
|
|
#
|
|
# The merge is right for a reader: a detail page, a list and the map all
|
|
# want the resolved location, whoever it came from. It is wrong for a
|
|
# FORM, which reads the same payload and cannot tell an owned value
|
|
# from a derived one - so the PC form pre-selected an inherited
|
|
# location, the operator saved something else entirely, and the
|
|
# inherited value was written back as the record's own. The asset then
|
|
# stopped following its parent, silently, on an edit that never touched
|
|
# location.
|
|
#
|
|
# Always emitted, even when nothing is inherited, so a client can bind
|
|
# to these without checking whether the merge happened.
|
|
result['ownlocationid'] = self.locationid
|
|
result['ownmapx'] = self.mapx
|
|
result['ownmapy'] = self.mapy
|
|
result['ownlevelid'] = self.levelid
|
|
|
|
# Include inherited location if this asset has no location data
|
|
if include_inherited_location:
|
|
inherited = self.get_inherited_location()
|
|
if inherited:
|
|
result['inheritedlocation'] = inherited
|
|
# Also set the location fields if they're missing
|
|
if result.get('locationid') is None:
|
|
result['locationid'] = inherited['locationid']
|
|
result['locationname'] = inherited['locationname']
|
|
if result.get('mapx') is None:
|
|
result['mapx'] = inherited['mapx']
|
|
if result.get('mapy') is None:
|
|
result['mapy'] = inherited['mapy']
|
|
# Coordinates and their level move together, always. Copying the
|
|
# position while leaving levelid as this asset's own is how an
|
|
# inherited marker lands on the wrong drawing.
|
|
if result.get('levelid') is None:
|
|
result['levelid'] = inherited.get('levelid')
|
|
|
|
# Operation/short code of the resolved location (own or inherited).
|
|
# Derived from the location name's leading token; labels can encode a
|
|
# tool's inspection operation instead of the tool. None when unplaced.
|
|
from .location import derive_locationcode
|
|
result['locationcode'] = derive_locationcode(result.get('locationname'))
|
|
|
|
# Include extension data if requested
|
|
if include_type_data:
|
|
ext_data = self._get_extension_data()
|
|
if ext_data:
|
|
result['typedata'] = ext_data
|
|
|
|
return result
|
|
|
|
def _get_extension_data(self):
|
|
"""Get category-specific data from extension table."""
|
|
# Check for machine extension
|
|
if hasattr(self, 'machine') and self.machine:
|
|
return self.machine.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
|