Files
shopdb-flask/plugins/computers/models/computer.py
cproudlock 3f320fcc8b
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s
Derive an asset's vendor from its catalog model, and show the model's own type
An asset that carries a model but no vendor was showing a blank the database
could already answer: the model records its vendor, and both sides reference the
same vendors table. Machines, PCs, printers and network devices now fall back to
it.

The fallback is FLAGGED, not merged silently. to_dict sets vendorfrommodel and
the detail pages render "(from model)" beside the value, because the record
itself is still empty: the edit form shows an empty vendor box, and a page
implying the vendor is stored would be lying about where it came from.

The model's type is exposed under its own name, modeltypename, and shown as a
separate "Model type" row. It is deliberately NOT used to fill in the asset's
own type. modeltypes is the catalog-wide list covering every kind of asset - it
holds "Access Point", "Camera" and "Desktop PC" alongside the machine entries -
so it is a different taxonomy from machinetypes. Only about two thirds of the
names overlap, and mapping one onto the other would mistype the remainder, with
the failure mode being a machine labelled "Desktop PC".

scripts/backfill_vendor_from_model.py writes the derived vendor down for real,
since the display fallback leaves reports that read vendorid still seeing
nothing. It is a dry run unless given --commit, fills only rows where the
asset's vendor is NULL and the model names one, and never overwrites a vendor
somebody chose. It skips a table lacking either column, so it runs against a
server whose network migration has not been applied yet.

Verified against the development database by nulling one machine's vendor inside
a transaction: it was detected as fillable, restored to exactly its original
value, and the rollback left the row untouched.

FLASK_ENV is not forced by the script. The app already reads it from .env, and
overriding it demanded a SECRET_KEY the environment had no reason to supply.
2026-08-05 09:59:39 -04:00

297 lines
9.9 KiB
Python

"""Computer plugin models."""
from datetime import datetime, timezone
from shopdb.api import db, BaseModel
def _utcnow():
# naive UTC to match the other DB DateTime columns (stored without tzinfo)
return datetime.now(timezone.utc).replace(tzinfo=None)
class ComputerType(BaseModel):
"""
Computer type classification.
Examples: Shopfloor PC, Engineer Workstation, CMM PC, Server, etc.
"""
__tablename__ = 'computertypes'
computertypeid = db.Column(db.Integer, primary_key=True)
computertype = 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')
color = db.Column(db.String(20), comment='CSS color for UI/map markers')
def __repr__(self):
return f"<ComputerType {self.computertype}>"
class Computer(BaseModel):
"""
Computer-specific extension data.
Links to core Asset table via assetid.
Stores computer-specific fields like hostname, OS, logged in user, etc.
"""
__tablename__ = 'computers'
computerid = 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
)
# Computer classification
computertypeid = db.Column(
db.Integer,
db.ForeignKey('computertypes.computertypeid'),
nullable=True
)
# Network identity
hostname = db.Column(
db.String(100),
comment='Network hostname'
)
# Operating system
osid = db.Column(
db.Integer,
db.ForeignKey('operatingsystems.osid'),
nullable=True
)
# Hardware make/model (PCs carry vendor + model like machines)
vendorid = db.Column(
db.Integer,
db.ForeignKey('vendors.vendorid'),
nullable=True
)
modelnumberid = db.Column(
db.Integer,
db.ForeignKey('models.modelnumberid'),
nullable=True
)
# Status tracking
loggedinuser = db.Column(db.String(100), nullable=True)
lastreporteddate = db.Column(db.DateTime, nullable=True)
lastboottime = db.Column(db.DateTime, nullable=True)
# Remote access is now modeled per-protocol via the accessmethods
# relationship (AccessProtocol / ComputerAccess), replacing isvnc/iswinrm.
# Relationships
asset = db.relationship(
'Asset',
backref=db.backref('computer', uselist=False, lazy='joined')
)
computertype = db.relationship('ComputerType', backref='computers')
operatingsystem = db.relationship('OperatingSystem', backref='computers')
vendor = db.relationship('Vendor')
model = db.relationship('Model')
# Installed applications (one-to-many)
installedapps = db.relationship(
'ComputerInstalledApp',
back_populates='computer',
cascade='all, delete-orphan',
lazy='dynamic'
)
# Remote-access protocols enabled on this PC (replaces isvnc/iswinrm)
accessmethods = db.relationship(
'ComputerAccess',
back_populates='computer',
cascade='all, delete-orphan',
lazy='selectin'
)
__table_args__ = (
db.Index('idx_computer_type', 'computertypeid'),
db.Index('idx_computer_hostname', 'hostname'),
db.Index('idx_computer_os', 'osid'),
)
def __repr__(self):
return f"<Computer {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.computertype:
result['computertypename'] = self.computertype.computertype
if self.operatingsystem:
result['osname'] = self.operatingsystem.osname
if self.vendor:
result['vendorname'] = self.vendor.vendor
if self.model:
result['modelname'] = self.model.modelnumber
if self.model.imageurl:
result['imageurl'] = self.model.imageurl
# The catalog model already knows its maker, so an asset that has a
# model but no vendor of its own is showing a blank the database can
# fill. Flagged rather than merged silently: the edit form still has
# an empty vendor box, and a page implying otherwise would be lying.
if not self.vendor and self.model.vendor:
result['vendorname'] = self.model.vendor.vendor
result['vendorfrommodel'] = True
# Exposed under its OWN name. modeltypes is the catalog-wide list
# covering every kind of asset, so it is not interchangeable with
# this asset's own type and must never be substituted for it.
if self.model.modeltype:
result['modeltypename'] = self.model.modeltype.modeltype
# Names of enabled remote-access protocols (for list badges)
result['accessprotocolnames'] = [
am.protocol.name for am in self.accessmethods
if am.isactive and am.protocol and am.protocol.isactive
]
return result
class ComputerInstalledApp(db.Model):
"""
Junction table for applications installed on computers.
Tracks which applications are installed on which computers,
including version information.
"""
__tablename__ = 'computerinstalledapps'
id = db.Column(db.Integer, primary_key=True)
computerid = db.Column(
db.Integer,
db.ForeignKey('computers.computerid', ondelete='CASCADE'),
nullable=False
)
appid = db.Column(
db.Integer,
db.ForeignKey('applications.appid'),
nullable=False
)
appversionid = db.Column(
db.Integer,
db.ForeignKey('appversions.appversionid'),
nullable=True
)
# Raw version string from automated collection (when no curated AppVersion)
installedversion = db.Column(db.String(100), nullable=True)
isactive = db.Column(db.Boolean, default=True, nullable=False)
installeddate = db.Column(db.DateTime, default=_utcnow)
# Relationships
computer = db.relationship('Computer', back_populates='installedapps')
application = db.relationship('Application')
appversion = db.relationship('AppVersion')
__table_args__ = (
db.UniqueConstraint('computerid', 'appid', name='uq_computer_app'),
db.Index('idx_compapp_computer', 'computerid'),
db.Index('idx_compapp_app', 'appid'),
)
def to_dict(self):
# Curated AppVersion wins; else the raw collected version string.
version = None
if self.appversion is not None:
version = self.appversion.version
elif self.installedversion:
version = self.installedversion
return {
'id': self.id,
'computerid': self.computerid,
'appid': self.appid,
'appname': self.application.appname if self.application else None,
'appdescription': self.application.appdescription if self.application else None,
'appversionid': self.appversionid,
'installedversion': version,
'installeddate': self.installeddate.isoformat() + 'Z' if self.installeddate else None,
'isactive': self.isactive,
}
class AccessProtocol(db.Model):
"""
Catalog of remote-access protocols a PC can expose (VNC, WinRM, RDP, SSH...).
linktemplate builds a connection URL from placeholders {host}, {port},
{scheme}. {host} is the PC hostname joined to the pc_access_domain setting.
Admin-managed; replaces the old fixed isvnc/iswinrm booleans.
"""
__tablename__ = 'accessprotocols'
protocolid = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(50), unique=True, nullable=False)
scheme = db.Column(db.String(20), nullable=False)
defaultport = db.Column(db.Integer, nullable=True)
linktemplate = db.Column(db.String(255), nullable=False)
isactive = db.Column(db.Boolean, default=True, nullable=False)
def to_dict(self):
return {
'protocolid': self.protocolid,
'name': self.name,
'scheme': self.scheme,
'defaultport': self.defaultport,
'linktemplate': self.linktemplate,
'isactive': bool(self.isactive),
}
class ComputerAccess(db.Model):
"""A protocol enabled on a specific PC, with an optional port override."""
__tablename__ = 'computeraccess'
id = db.Column(db.Integer, primary_key=True)
computerid = db.Column(
db.Integer,
db.ForeignKey('computers.computerid', ondelete='CASCADE'),
nullable=False
)
protocolid = db.Column(
db.Integer,
db.ForeignKey('accessprotocols.protocolid'),
nullable=False
)
portoverride = db.Column(db.Integer, nullable=True)
isactive = db.Column(db.Boolean, default=True, nullable=False)
protocol = db.relationship('AccessProtocol')
computer = db.relationship('Computer', back_populates='accessmethods')
__table_args__ = (
db.UniqueConstraint('computerid', 'protocolid', name='uq_computer_protocol'),
db.Index('idx_compaccess_computer', 'computerid'),
)
def to_dict(self):
"""Convert to dictionary."""
return {
'id': self.id,
'computerid': self.computerid,
'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"<ComputerInstalledApp computer={self.computerid} app={self.appid}>"