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,8 +1,9 @@
""" """
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 datetime import datetime
@@ -10,20 +11,19 @@ from functools import wraps
from flask import Blueprint, request, current_app from flask import Blueprint, request, current_app
from shopdb.extensions import db from shopdb.extensions import db
from shopdb.core.models import Machine, Application, InstalledApp from shopdb.core.models import Asset, Application
from plugins.computers.models import Computer, ComputerInstalledApp
from shopdb.utils.responses import success_response, error_response, ErrorCodes from shopdb.utils.responses import success_response, error_response, ErrorCodes
collector_bp = Blueprint('collector', __name__) collector_bp = Blueprint('collector', __name__)
def require_api_key(f): def require_api_key(f):
"""Decorator to require API key authentication.""" """Require API key authentication."""
@wraps(f) @wraps(f)
def decorated(*args, **kwargs): def decorated(*args, **kwargs):
api_key = request.headers.get('X-API-Key') api_key = request.headers.get('X-API-Key') or request.args.get('api_key')
if not api_key:
api_key = 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:
@@ -32,40 +32,37 @@ def require_api_key(f):
'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( return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key',
ErrorCodes.UNAUTHORIZED, http_code=401)
'Invalid API key',
http_code=401
)
return f(*args, **kwargs) return f(*args, **kwargs)
return decorated return decorated
def _find_pc(hostname):
"""Find a computer by hostname, falling back to its asset number."""
comp = Computer.query.filter(Computer.hostname.ilike(hostname)).first()
if comp:
return comp
return (
Computer.query.join(Asset, Asset.assetid == Computer.assetid)
.filter(Asset.assetnumber.ilike(hostname))
.first()
)
def _parse_boot(value):
try:
return datetime.fromisoformat(value.replace('Z', '+00:00'))
except (ValueError, AttributeError):
return None
@collector_bp.route('/pc', methods=['POST']) @collector_bp.route('/pc', methods=['POST'])
@require_api_key @require_api_key
def update_pc_info(): def update_pc_info():
""" """Update one PC from the collection script (matched by hostname)."""
Update PC information from PowerShell collection script.
Expected JSON payload:
{
"hostname": "PC-1234",
"osname": "Windows 10 Enterprise",
"osversion": "10.0.19045",
"lastboottime": "2024-01-15T08:30:00",
"currentuser": "jsmith",
"ipaddress": "10.1.2.100",
"macaddress": "00:11:22:33:44:55",
"serialnumber": "ABC123",
"manufacturer": "Dell",
"model": "OptiPlex 7090"
}
"""
data = request.get_json() data = request.get_json()
if not data: if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
@@ -73,58 +70,28 @@ def update_pc_info():
if not hostname: if not hostname:
return error_response(ErrorCodes.VALIDATION_ERROR, 'hostname is required') return error_response(ErrorCodes.VALIDATION_ERROR, 'hostname is required')
# Find the PC by hostname comp = _find_pc(hostname)
pc = Machine.query.filter( if not comp:
Machine.hostname.ilike(hostname), return error_response(ErrorCodes.NOT_FOUND,
Machine.pctypeid.isnot(None) f'PC with hostname {hostname} not found',
).first() http_code=404)
if not pc: comp.lastreporteddate = datetime.utcnow()
# Try to find by machine number if hostname not found
pc = Machine.query.filter(
Machine.machinenumber.ilike(hostname),
Machine.pctypeid.isnot(None)
).first()
if not pc:
return error_response(
ErrorCodes.NOT_FOUND,
f'PC with hostname {hostname} not found',
http_code=404
)
# Update PC fields
update_fields = {
'lastzabbixsync': datetime.utcnow(), # Track last collection time
}
if data.get('lastboottime'): if data.get('lastboottime'):
try: boot = _parse_boot(data['lastboottime'])
update_fields['lastboottime'] = datetime.fromisoformat( if boot:
data['lastboottime'].replace('Z', '+00:00') comp.lastboottime = boot
)
except ValueError:
pass
if data.get('currentuser'): if data.get('currentuser'):
# Store previous user before updating comp.loggedinuser = data['currentuser']
if pc.currentuserid != data['currentuser']: if data.get('serialnumber') and comp.asset:
update_fields['lastuserid'] = pc.currentuserid comp.asset.serialnumber = data['serialnumber']
update_fields['currentuserid'] = data['currentuser']
if data.get('serialnumber'):
update_fields['serialnumber'] = data['serialnumber']
# Update the record
for key, value in update_fields.items():
if hasattr(pc, key):
setattr(pc, key, value)
db.session.commit() db.session.commit()
return success_response({ return success_response({
'machineid': pc.machineid, 'computerid': comp.computerid,
'hostname': pc.hostname, 'hostname': comp.hostname,
'updated': True 'updated': True
}, message='PC info updated') }, message='PC info updated')
@@ -132,24 +99,8 @@ def update_pc_info():
@collector_bp.route('/apps', methods=['POST']) @collector_bp.route('/apps', methods=['POST'])
@require_api_key @require_api_key
def update_installed_apps(): def update_installed_apps():
""" """Update installed applications for a PC (matched by hostname)."""
Update installed applications for a PC.
Expected JSON payload:
{
"hostname": "PC-1234",
"apps": [
{
"appname": "Microsoft Office",
"version": "16.0.14326.20454",
"installdate": "2024-01-10"
},
...
]
}
"""
data = request.get_json() data = request.get_json()
if not data: if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
@@ -161,22 +112,13 @@ def update_installed_apps():
if not apps: if not apps:
return error_response(ErrorCodes.VALIDATION_ERROR, 'apps list is required') return error_response(ErrorCodes.VALIDATION_ERROR, 'apps list is required')
# Find the PC comp = _find_pc(hostname)
pc = Machine.query.filter( if not comp:
Machine.hostname.ilike(hostname), return error_response(ErrorCodes.NOT_FOUND,
Machine.pctypeid.isnot(None) f'PC with hostname {hostname} not found',
).first() http_code=404)
if not pc: updated_count = created_count = skipped_count = 0
return error_response(
ErrorCodes.NOT_FOUND,
f'PC with hostname {hostname} not found',
http_code=404
)
updated_count = 0
created_count = 0
skipped_count = 0
for app_data in apps: for app_data in apps:
app_name = app_data.get('appname') app_name = app_data.get('appname')
@@ -184,45 +126,39 @@ def update_installed_apps():
skipped_count += 1 skipped_count += 1
continue continue
# Find the application in the database app = Application.query.filter(Application.appname.ilike(app_name)).first()
app = Application.query.filter(
Application.appname.ilike(app_name)
).first()
if not app: if not app:
# Skip apps not in our tracked list # Only track applications we manage
skipped_count += 1 skipped_count += 1
continue continue
# Check if already installed version = app_data.get('version')
installed = InstalledApp.query.filter_by( installed = ComputerInstalledApp.query.filter_by(
machineid=pc.machineid, computerid=comp.computerid, appid=app.appid).first()
appid=app.appid
).first()
if installed: if installed:
# Update version if changed changed = False
new_version = app_data.get('version') if version and installed.installedversion != version:
if new_version and installed.installedversion != new_version: installed.installedversion = version
installed.installedversion = new_version changed = True
installed.modifieddate = datetime.utcnow() if not installed.isactive:
installed.isactive = True
changed = True
if changed:
updated_count += 1 updated_count += 1
else: else:
# Create new installed app record db.session.add(ComputerInstalledApp(
installed = InstalledApp( computerid=comp.computerid,
machineid=pc.machineid,
appid=app.appid, appid=app.appid,
installedversion=app_data.get('version'), installedversion=version,
installdate=datetime.utcnow() ))
)
db.session.add(installed)
created_count += 1 created_count += 1
db.session.commit() db.session.commit()
return success_response({ return success_response({
'hostname': hostname, 'hostname': hostname,
'machineid': pc.machineid, 'computerid': comp.computerid,
'created': created_count, 'created': created_count,
'updated': updated_count, 'updated': updated_count,
'skipped': skipped_count 'skipped': skipped_count
@@ -232,42 +168,25 @@ def update_installed_apps():
@collector_bp.route('/heartbeat', methods=['POST']) @collector_bp.route('/heartbeat', methods=['POST'])
@require_api_key @require_api_key
def pc_heartbeat(): def pc_heartbeat():
""" """Record PC online status / heartbeat (single or batch)."""
Record PC online status / heartbeat.
Expected JSON payload:
{
"hostname": "PC-1234"
}
Or batch update:
{
"hostnames": ["PC-1234", "PC-1235", "PC-1236"]
}
"""
data = request.get_json() data = request.get_json()
if not data: if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
hostnames = data.get('hostnames', []) hostnames = data.get('hostnames', [])
if not hostnames and data.get('hostname'): if not hostnames and data.get('hostname'):
hostnames = [data['hostname']] hostnames = [data['hostname']]
if not hostnames: if not hostnames:
return error_response(ErrorCodes.VALIDATION_ERROR, 'hostname or hostnames required') return error_response(ErrorCodes.VALIDATION_ERROR,
'hostname or hostnames required')
updated = 0 updated = 0
not_found = [] not_found = []
now = datetime.utcnow()
for hostname in hostnames: for hostname in hostnames:
pc = Machine.query.filter( comp = _find_pc(hostname)
Machine.hostname.ilike(hostname), if comp:
Machine.pctypeid.isnot(None) comp.lastreporteddate = now
).first()
if pc:
pc.lastzabbixsync = datetime.utcnow()
updated += 1 updated += 1
else: else:
not_found.append(hostname) not_found.append(hostname)
@@ -277,30 +196,15 @@ def pc_heartbeat():
return success_response({ return success_response({
'updated': updated, 'updated': updated,
'notfound': not_found, 'notfound': not_found,
'timestamp': datetime.utcnow().isoformat() 'timestamp': now.isoformat()
}, message=f'{updated} PC(s) heartbeat recorded') }, message=f'{updated} PC(s) heartbeat recorded')
@collector_bp.route('/bulk', methods=['POST']) @collector_bp.route('/bulk', methods=['POST'])
@require_api_key @require_api_key
def bulk_update(): def bulk_update():
""" """Bulk update multiple PCs at once."""
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() data = request.get_json()
if not data: if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided') return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
@@ -311,41 +215,29 @@ def bulk_update():
updated = 0 updated = 0
not_found = [] not_found = []
errors = [] errors = []
now = datetime.utcnow()
for pc_data in pcs: for pc_data in pcs:
hostname = pc_data.get('hostname') hostname = pc_data.get('hostname')
if not hostname: if not hostname:
continue continue
pc = Machine.query.filter( comp = _find_pc(hostname)
Machine.hostname.ilike(hostname), if not comp:
Machine.pctypeid.isnot(None)
).first()
if not pc:
not_found.append(hostname) not_found.append(hostname)
continue continue
try: try:
pc.lastzabbixsync = datetime.utcnow() comp.lastreporteddate = now
if pc_data.get('currentuser'): if pc_data.get('currentuser'):
if pc.currentuserid != pc_data['currentuser']: comp.loggedinuser = pc_data['currentuser']
pc.lastuserid = pc.currentuserid
pc.currentuserid = pc_data['currentuser']
if pc_data.get('lastboottime'): if pc_data.get('lastboottime'):
try: boot = _parse_boot(pc_data['lastboottime'])
pc.lastboottime = datetime.fromisoformat( if boot:
pc_data['lastboottime'].replace('Z', '+00:00') comp.lastboottime = boot
)
except ValueError:
pass
updated += 1 updated += 1
except Exception as exc:
except Exception as e: errors.append({'hostname': hostname, 'error': str(exc)})
errors.append({'hostname': hostname, 'error': str(e)})
db.session.commit() db.session.commit()
@@ -353,14 +245,14 @@ def bulk_update():
'updated': updated, 'updated': updated,
'notfound': not_found, 'notfound': not_found,
'errors': errors, 'errors': errors,
'timestamp': datetime.utcnow().isoformat() 'timestamp': now.isoformat()
}, message=f'{updated} PC(s) updated') }, message=f'{updated} PC(s) updated')
@collector_bp.route('/status', methods=['GET']) @collector_bp.route('/status', methods=['GET'])
@require_api_key @require_api_key
def collector_status(): def collector_status():
"""Check collector API status and configuration.""" """Check collector API status."""
return success_response({ return success_response({
'status': 'ok', 'status': 'ok',
'timestamp': datetime.utcnow().isoformat(), 'timestamp': datetime.utcnow().isoformat(),