Migrate PC form + collector off legacy Machine onto the asset/computer model

- PCForm saves/loads via computersApi (asset core + computer extension +
  primary IP in one call); PC Type now uses the dedicated computertypes table
  instead of MachineType, fixing the cross-table id mismatch.
- Collector (/api/collector/*) writes the Computer model: lookup by hostname
  or asset number, update loggedinuser/lastreporteddate/lastboottime + asset
  serial, installed apps via ComputerInstalledApp.
- Add computers.vendorid + modelnumberid (PCs carry make/model) and
  computerinstalledapps.installedversion; computer GET now includes
  communications. Wire COLLECTOR_API_KEY into config.

Retires the last write paths to the Machine model for PCs (ADR-001).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-06-26 09:16:09 -04:00
parent b516b9b771
commit e1948a4774
5 changed files with 374 additions and 377 deletions

View File

@@ -4,7 +4,10 @@ from flask import Blueprint, request
from flask_jwt_extended import jwt_required from flask_jwt_extended import jwt_required
from shopdb.extensions import db from shopdb.extensions import db
from shopdb.core.models import Asset, AssetType, OperatingSystem, Application, AppVersion, AuditLog from shopdb.core.models import (
Asset, AssetType, OperatingSystem, Application, AppVersion, AuditLog,
Communication, CommunicationType,
)
from shopdb.utils.responses import ( from shopdb.utils.responses import (
success_response, success_response,
error_response, error_response,
@@ -225,6 +228,10 @@ def get_computer(computer_id: int):
result = comp.asset.to_dict() if comp.asset else {} result = comp.asset.to_dict() if comp.asset else {}
result['computer'] = comp.to_dict() result['computer'] = comp.to_dict()
result['communications'] = [
c.to_dict() for c in
Communication.query.filter_by(assetid=comp.assetid).all()
]
return success_response(result) return success_response(result)
@@ -339,6 +346,8 @@ def create_computer():
computertypeid=data.get('computertypeid'), computertypeid=data.get('computertypeid'),
hostname=data.get('hostname'), hostname=data.get('hostname'),
osid=data.get('osid'), osid=data.get('osid'),
vendorid=data.get('vendorid'),
modelnumberid=data.get('modelnumberid'),
loggedinuser=data.get('loggedinuser'), loggedinuser=data.get('loggedinuser'),
lastreporteddate=data.get('lastreporteddate'), lastreporteddate=data.get('lastreporteddate'),
lastboottime=data.get('lastboottime'), lastboottime=data.get('lastboottime'),
@@ -350,6 +359,17 @@ def create_computer():
db.session.add(comp) db.session.add(comp)
db.session.flush() db.session.flush()
# Optional primary IP communication
if data.get('ipaddress'):
ip_comtype = CommunicationType.query.filter_by(comtype='IP').first()
if ip_comtype:
db.session.add(Communication(
assetid=asset.assetid,
comtypeid=ip_comtype.comtypeid,
ipaddress=data['ipaddress'],
isprimary=True,
))
# Audit log # Audit log
AuditLog.log('created', 'Computer', entityid=comp.computerid, AuditLog.log('created', 'Computer', entityid=comp.computerid,
entityname=data.get('hostname') or data['assetnumber']) entityname=data.get('hostname') or data['assetnumber'])
@@ -415,8 +435,9 @@ def update_computer(computer_id: int):
setattr(asset, key, data[key]) setattr(asset, key, data[key])
# Update computer fields # Update computer fields
computer_fields = ['computertypeid', 'hostname', 'osid', 'loggedinuser', computer_fields = ['computertypeid', 'hostname', 'osid', 'vendorid',
'lastreporteddate', 'lastboottime', 'isvnc', 'iswinrm', 'isshopfloor'] 'modelnumberid', 'loggedinuser', 'lastreporteddate',
'lastboottime', 'isvnc', 'iswinrm', 'isshopfloor']
for key in computer_fields: for key in computer_fields:
if key in data: if key in data:
old_val = getattr(comp, key) old_val = getattr(comp, key)
@@ -425,6 +446,23 @@ def update_computer(computer_id: int):
changes[key] = {'old': old_val, 'new': new_val} changes[key] = {'old': old_val, 'new': new_val}
setattr(comp, key, data[key]) setattr(comp, key, data[key])
# Upsert the primary IP communication so a single PUT covers it
if 'ipaddress' in data:
ip = (data.get('ipaddress') or '').strip()
primary = Communication.query.filter_by(
assetid=asset.assetid, isprimary=True).first()
if ip:
if primary:
primary.ipaddress = ip
else:
ip_comtype = CommunicationType.query.filter_by(comtype='IP').first()
if ip_comtype:
db.session.add(Communication(
assetid=asset.assetid, comtypeid=ip_comtype.comtypeid,
ipaddress=ip, isprimary=True))
elif primary:
primary.ipaddress = None
# Audit log if there were changes # Audit log if there were changes
if changes: if changes:
AuditLog.log('updated', 'Computer', entityid=comp.computerid, AuditLog.log('updated', 'Computer', entityid=comp.computerid,

View File

@@ -0,0 +1,44 @@
"""Add PC hardware make/model + installed-app version string
Adds computers.vendorid + computers.modelnumberid (PCs carry vendor/model like
equipment) and computerinstalledapps.installedversion (raw version string from
automated collection, when there is no curated AppVersion). Supports moving the
PC form and the collector off the legacy Machine model onto the asset/computer
model (ADR-001).
Revision ID: 0002_pc_hardware
Revises: 0001_baseline_computers
Create Date: 2026-06-26
"""
from alembic import op
import sqlalchemy as sa
revision = '0002_pc_hardware'
down_revision = '0001_baseline_computers'
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table('computers') as batch_op:
batch_op.add_column(sa.Column('vendorid', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('modelnumberid', sa.Integer(), nullable=True))
batch_op.create_foreign_key('fk_computers_vendor', 'vendors',
['vendorid'], ['vendorid'])
batch_op.create_foreign_key('fk_computers_model', 'models',
['modelnumberid'], ['modelnumberid'])
with op.batch_alter_table('computerinstalledapps') as batch_op:
batch_op.add_column(sa.Column('installedversion', sa.String(length=100),
nullable=True))
def downgrade():
with op.batch_alter_table('computerinstalledapps') as batch_op:
batch_op.drop_column('installedversion')
with op.batch_alter_table('computers') as batch_op:
batch_op.drop_constraint('fk_computers_model', type_='foreignkey')
batch_op.drop_constraint('fk_computers_vendor', type_='foreignkey')
batch_op.drop_column('modelnumberid')
batch_op.drop_column('vendorid')

View File

@@ -62,6 +62,18 @@ class Computer(BaseModel):
nullable=True nullable=True
) )
# Hardware make/model (PCs carry vendor + model like equipment)
vendorid = db.Column(
db.Integer,
db.ForeignKey('vendors.vendorid'),
nullable=True
)
modelnumberid = db.Column(
db.Integer,
db.ForeignKey('models.modelnumberid'),
nullable=True
)
# Status tracking # Status tracking
loggedinuser = db.Column(db.String(100), nullable=True) loggedinuser = db.Column(db.String(100), nullable=True)
lastreporteddate = db.Column(db.DateTime, nullable=True) lastreporteddate = db.Column(db.DateTime, nullable=True)
@@ -93,6 +105,8 @@ class Computer(BaseModel):
) )
computertype = db.relationship('ComputerType', backref='computers') computertype = db.relationship('ComputerType', backref='computers')
operatingsystem = db.relationship('OperatingSystem', backref='computers') operatingsystem = db.relationship('OperatingSystem', backref='computers')
vendor = db.relationship('Vendor')
model = db.relationship('Model')
# Installed applications (one-to-many) # Installed applications (one-to-many)
installedapps = db.relationship( installedapps = db.relationship(
@@ -120,6 +134,10 @@ class Computer(BaseModel):
result['computertypename'] = self.computertype.computertype result['computertypename'] = self.computertype.computertype
if self.operatingsystem: if self.operatingsystem:
result['osname'] = self.operatingsystem.osname result['osname'] = self.operatingsystem.osname
if self.vendor:
result['vendorname'] = self.vendor.vendor
if self.model:
result['modelname'] = self.model.modelnumber
return result return result
@@ -149,6 +167,8 @@ class ComputerInstalledApp(db.Model):
db.ForeignKey('appversions.appversionid'), db.ForeignKey('appversions.appversionid'),
nullable=True 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) isactive = db.Column(db.Boolean, default=True, nullable=False)
installeddate = db.Column(db.DateTime, default=db.func.now()) installeddate = db.Column(db.DateTime, default=db.func.now())

View File

@@ -53,6 +53,9 @@ class Config:
LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO') LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO')
# API key for the unattended PowerShell collector scripts
COLLECTOR_API_KEY = os.environ.get('COLLECTOR_API_KEY', '')
ZABBIX_ENABLED = os.environ.get('ZABBIX_ENABLED', 'false').lower() == 'true' ZABBIX_ENABLED = os.environ.get('ZABBIX_ENABLED', 'false').lower() == 'true'
ZABBIX_URL = os.environ.get('ZABBIX_URL', '') ZABBIX_URL = os.environ.get('ZABBIX_URL', '')
ZABBIX_TOKEN = os.environ.get('ZABBIX_TOKEN', '') ZABBIX_TOKEN = os.environ.get('ZABBIX_TOKEN', '')

View File

@@ -1,374 +1,266 @@
""" """
PowerShell Data Collection API endpoints. PowerShell data-collection API endpoints.
Compatibility layer for existing PowerShell scripts that update PC data. Compatibility layer for the PowerShell scripts that report PC state. Uses an
Uses API key authentication instead of JWT for automated scripts. API key (not JWT) for unattended scripts. Writes the asset/computer model
""" (ADR-001), not the retired Machine model.
"""
from datetime import datetime
from functools import wraps from datetime import datetime
from flask import Blueprint, request, current_app from functools import wraps
from flask import Blueprint, request, current_app
from shopdb.extensions import db
from shopdb.core.models import Machine, Application, InstalledApp from shopdb.extensions import db
from shopdb.utils.responses import success_response, error_response, ErrorCodes from shopdb.core.models import Asset, Application
from plugins.computers.models import Computer, ComputerInstalledApp
collector_bp = Blueprint('collector', __name__)
from shopdb.utils.responses import success_response, error_response, ErrorCodes
def require_api_key(f): collector_bp = Blueprint('collector', __name__)
"""Decorator to require API key authentication."""
@wraps(f)
def decorated(*args, **kwargs): def require_api_key(f):
api_key = request.headers.get('X-API-Key') """Require API key authentication."""
if not api_key: @wraps(f)
api_key = request.args.get('api_key') def decorated(*args, **kwargs):
api_key = request.headers.get('X-API-Key') or request.args.get('api_key')
expected_key = current_app.config.get('COLLECTOR_API_KEY') expected_key = current_app.config.get('COLLECTOR_API_KEY')
if not expected_key: if not expected_key:
return error_response( return error_response(
ErrorCodes.INTERNAL_ERROR, ErrorCodes.INTERNAL_ERROR,
'Collector API key not configured', 'Collector API key not configured',
http_code=500 http_code=500
) )
if api_key != expected_key:
if api_key != expected_key: return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key',
return error_response( http_code=401)
ErrorCodes.UNAUTHORIZED, return f(*args, **kwargs)
'Invalid API key', return decorated
http_code=401
)
def _find_pc(hostname):
return f(*args, **kwargs) """Find a computer by hostname, falling back to its asset number."""
return decorated comp = Computer.query.filter(Computer.hostname.ilike(hostname)).first()
if comp:
return comp
@collector_bp.route('/pc', methods=['POST']) return (
@require_api_key Computer.query.join(Asset, Asset.assetid == Computer.assetid)
def update_pc_info(): .filter(Asset.assetnumber.ilike(hostname))
""" .first()
Update PC information from PowerShell collection script. )
Expected JSON payload:
{ def _parse_boot(value):
"hostname": "PC-1234", try:
"osname": "Windows 10 Enterprise", return datetime.fromisoformat(value.replace('Z', '+00:00'))
"osversion": "10.0.19045", except (ValueError, AttributeError):
"lastboottime": "2024-01-15T08:30:00", return None
"currentuser": "jsmith",
"ipaddress": "10.1.2.100",
"macaddress": "00:11:22:33:44:55", @collector_bp.route('/pc', methods=['POST'])
"serialnumber": "ABC123", @require_api_key
"manufacturer": "Dell", def update_pc_info():
"model": "OptiPlex 7090" """Update one PC from the collection script (matched by hostname)."""
} data = request.get_json()
""" if not data:
data = request.get_json() return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
if not data: hostname = data.get('hostname')
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') if not hostname:
return error_response(ErrorCodes.VALIDATION_ERROR, 'hostname is required')
hostname = data.get('hostname')
if not hostname: comp = _find_pc(hostname)
return error_response(ErrorCodes.VALIDATION_ERROR, 'hostname is required') if not comp:
return error_response(ErrorCodes.NOT_FOUND,
# Find the PC by hostname f'PC with hostname {hostname} not found',
pc = Machine.query.filter( http_code=404)
Machine.hostname.ilike(hostname),
Machine.pctypeid.isnot(None) comp.lastreporteddate = datetime.utcnow()
).first()
if data.get('lastboottime'):
if not pc: boot = _parse_boot(data['lastboottime'])
# Try to find by machine number if hostname not found if boot:
pc = Machine.query.filter( comp.lastboottime = boot
Machine.machinenumber.ilike(hostname), if data.get('currentuser'):
Machine.pctypeid.isnot(None) comp.loggedinuser = data['currentuser']
).first() if data.get('serialnumber') and comp.asset:
comp.asset.serialnumber = data['serialnumber']
if not pc:
return error_response( db.session.commit()
ErrorCodes.NOT_FOUND,
f'PC with hostname {hostname} not found', return success_response({
http_code=404 'computerid': comp.computerid,
) 'hostname': comp.hostname,
'updated': True
# Update PC fields }, message='PC info updated')
update_fields = {
'lastzabbixsync': datetime.utcnow(), # Track last collection time
} @collector_bp.route('/apps', methods=['POST'])
@require_api_key
if data.get('lastboottime'): def update_installed_apps():
try: """Update installed applications for a PC (matched by hostname)."""
update_fields['lastboottime'] = datetime.fromisoformat( data = request.get_json()
data['lastboottime'].replace('Z', '+00:00') if not data:
) return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
except ValueError:
pass hostname = data.get('hostname')
if not hostname:
if data.get('currentuser'): return error_response(ErrorCodes.VALIDATION_ERROR, 'hostname is required')
# Store previous user before updating
if pc.currentuserid != data['currentuser']: apps = data.get('apps', [])
update_fields['lastuserid'] = pc.currentuserid if not apps:
update_fields['currentuserid'] = data['currentuser'] return error_response(ErrorCodes.VALIDATION_ERROR, 'apps list is required')
if data.get('serialnumber'): comp = _find_pc(hostname)
update_fields['serialnumber'] = data['serialnumber'] if not comp:
return error_response(ErrorCodes.NOT_FOUND,
# Update the record f'PC with hostname {hostname} not found',
for key, value in update_fields.items(): http_code=404)
if hasattr(pc, key):
setattr(pc, key, value) updated_count = created_count = skipped_count = 0
db.session.commit() for app_data in apps:
app_name = app_data.get('appname')
return success_response({ if not app_name:
'machineid': pc.machineid, skipped_count += 1
'hostname': pc.hostname, continue
'updated': True
}, message='PC info updated') app = Application.query.filter(Application.appname.ilike(app_name)).first()
if not app:
# Only track applications we manage
@collector_bp.route('/apps', methods=['POST']) skipped_count += 1
@require_api_key continue
def update_installed_apps():
""" version = app_data.get('version')
Update installed applications for a PC. installed = ComputerInstalledApp.query.filter_by(
computerid=comp.computerid, appid=app.appid).first()
Expected JSON payload:
{ if installed:
"hostname": "PC-1234", changed = False
"apps": [ if version and installed.installedversion != version:
{ installed.installedversion = version
"appname": "Microsoft Office", changed = True
"version": "16.0.14326.20454", if not installed.isactive:
"installdate": "2024-01-10" installed.isactive = True
}, changed = True
... if changed:
] updated_count += 1
} else:
""" db.session.add(ComputerInstalledApp(
data = request.get_json() computerid=comp.computerid,
appid=app.appid,
if not data: installedversion=version,
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') ))
created_count += 1
hostname = data.get('hostname')
if not hostname: db.session.commit()
return error_response(ErrorCodes.VALIDATION_ERROR, 'hostname is required')
return success_response({
apps = data.get('apps', []) 'hostname': hostname,
if not apps: 'computerid': comp.computerid,
return error_response(ErrorCodes.VALIDATION_ERROR, 'apps list is required') 'created': created_count,
'updated': updated_count,
# Find the PC 'skipped': skipped_count
pc = Machine.query.filter( }, message='Installed apps updated')
Machine.hostname.ilike(hostname),
Machine.pctypeid.isnot(None)
).first() @collector_bp.route('/heartbeat', methods=['POST'])
@require_api_key
if not pc: def pc_heartbeat():
return error_response( """Record PC online status / heartbeat (single or batch)."""
ErrorCodes.NOT_FOUND, data = request.get_json()
f'PC with hostname {hostname} not found', if not data:
http_code=404 return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
)
hostnames = data.get('hostnames', [])
updated_count = 0 if not hostnames and data.get('hostname'):
created_count = 0 hostnames = [data['hostname']]
skipped_count = 0 if not hostnames:
return error_response(ErrorCodes.VALIDATION_ERROR,
for app_data in apps: 'hostname or hostnames required')
app_name = app_data.get('appname')
if not app_name: updated = 0
skipped_count += 1 not_found = []
continue now = datetime.utcnow()
for hostname in hostnames:
# Find the application in the database comp = _find_pc(hostname)
app = Application.query.filter( if comp:
Application.appname.ilike(app_name) comp.lastreporteddate = now
).first() updated += 1
else:
if not app: not_found.append(hostname)
# Skip apps not in our tracked list
skipped_count += 1 db.session.commit()
continue
return success_response({
# Check if already installed 'updated': updated,
installed = InstalledApp.query.filter_by( 'notfound': not_found,
machineid=pc.machineid, 'timestamp': now.isoformat()
appid=app.appid }, message=f'{updated} PC(s) heartbeat recorded')
).first()
if installed: @collector_bp.route('/bulk', methods=['POST'])
# Update version if changed @require_api_key
new_version = app_data.get('version') def bulk_update():
if new_version and installed.installedversion != new_version: """Bulk update multiple PCs at once."""
installed.installedversion = new_version data = request.get_json()
installed.modifieddate = datetime.utcnow() if not data:
updated_count += 1 return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
else:
# Create new installed app record pcs = data.get('pcs', [])
installed = InstalledApp( if not pcs:
machineid=pc.machineid, return error_response(ErrorCodes.VALIDATION_ERROR, 'pcs list is required')
appid=app.appid,
installedversion=app_data.get('version'), updated = 0
installdate=datetime.utcnow() not_found = []
) errors = []
db.session.add(installed) now = datetime.utcnow()
created_count += 1
for pc_data in pcs:
db.session.commit() hostname = pc_data.get('hostname')
if not hostname:
return success_response({ continue
'hostname': hostname,
'machineid': pc.machineid, comp = _find_pc(hostname)
'created': created_count, if not comp:
'updated': updated_count, not_found.append(hostname)
'skipped': skipped_count continue
}, message='Installed apps updated')
try:
comp.lastreporteddate = now
@collector_bp.route('/heartbeat', methods=['POST']) if pc_data.get('currentuser'):
@require_api_key comp.loggedinuser = pc_data['currentuser']
def pc_heartbeat(): if pc_data.get('lastboottime'):
""" boot = _parse_boot(pc_data['lastboottime'])
Record PC online status / heartbeat. if boot:
comp.lastboottime = boot
Expected JSON payload: updated += 1
{ except Exception as exc:
"hostname": "PC-1234" errors.append({'hostname': hostname, 'error': str(exc)})
}
db.session.commit()
Or batch update:
{ return success_response({
"hostnames": ["PC-1234", "PC-1235", "PC-1236"] 'updated': updated,
} 'notfound': not_found,
""" 'errors': errors,
data = request.get_json() 'timestamp': now.isoformat()
}, message=f'{updated} PC(s) updated')
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
@collector_bp.route('/status', methods=['GET'])
hostnames = data.get('hostnames', []) @require_api_key
if not hostnames and data.get('hostname'): def collector_status():
hostnames = [data['hostname']] """Check collector API status."""
return success_response({
if not hostnames: 'status': 'ok',
return error_response(ErrorCodes.VALIDATION_ERROR, 'hostname or hostnames required') 'timestamp': datetime.utcnow().isoformat(),
'endpoints': [
updated = 0 'POST /api/collector/pc',
not_found = [] 'POST /api/collector/apps',
'POST /api/collector/heartbeat',
for hostname in hostnames: 'POST /api/collector/bulk',
pc = Machine.query.filter( 'GET /api/collector/status'
Machine.hostname.ilike(hostname), ]
Machine.pctypeid.isnot(None) })
).first()
if pc:
pc.lastzabbixsync = datetime.utcnow()
updated += 1
else:
not_found.append(hostname)
db.session.commit()
return success_response({
'updated': updated,
'notfound': not_found,
'timestamp': datetime.utcnow().isoformat()
}, message=f'{updated} PC(s) heartbeat recorded')
@collector_bp.route('/bulk', methods=['POST'])
@require_api_key
def bulk_update():
"""
Bulk update multiple PCs at once.
Expected JSON payload:
{
"pcs": [
{
"hostname": "PC-1234",
"currentuser": "jsmith",
"lastboottime": "2024-01-15T08:30:00"
},
...
]
}
"""
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
pcs = data.get('pcs', [])
if not pcs:
return error_response(ErrorCodes.VALIDATION_ERROR, 'pcs list is required')
updated = 0
not_found = []
errors = []
for pc_data in pcs:
hostname = pc_data.get('hostname')
if not hostname:
continue
pc = Machine.query.filter(
Machine.hostname.ilike(hostname),
Machine.pctypeid.isnot(None)
).first()
if not pc:
not_found.append(hostname)
continue
try:
pc.lastzabbixsync = datetime.utcnow()
if pc_data.get('currentuser'):
if pc.currentuserid != pc_data['currentuser']:
pc.lastuserid = pc.currentuserid
pc.currentuserid = pc_data['currentuser']
if pc_data.get('lastboottime'):
try:
pc.lastboottime = datetime.fromisoformat(
pc_data['lastboottime'].replace('Z', '+00:00')
)
except ValueError:
pass
updated += 1
except Exception as e:
errors.append({'hostname': hostname, 'error': str(e)})
db.session.commit()
return success_response({
'updated': updated,
'notfound': not_found,
'errors': errors,
'timestamp': datetime.utcnow().isoformat()
}, message=f'{updated} PC(s) updated')
@collector_bp.route('/status', methods=['GET'])
@require_api_key
def collector_status():
"""Check collector API status and configuration."""
return success_response({
'status': 'ok',
'timestamp': datetime.utcnow().isoformat(),
'endpoints': [
'POST /api/collector/pc',
'POST /api/collector/apps',
'POST /api/collector/heartbeat',
'POST /api/collector/bulk',
'GET /api/collector/status'
]
})