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 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 (
success_response,
error_response,
@@ -225,6 +228,10 @@ def get_computer(computer_id: int):
result = comp.asset.to_dict() if comp.asset else {}
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)
@@ -339,6 +346,8 @@ def create_computer():
computertypeid=data.get('computertypeid'),
hostname=data.get('hostname'),
osid=data.get('osid'),
vendorid=data.get('vendorid'),
modelnumberid=data.get('modelnumberid'),
loggedinuser=data.get('loggedinuser'),
lastreporteddate=data.get('lastreporteddate'),
lastboottime=data.get('lastboottime'),
@@ -350,6 +359,17 @@ def create_computer():
db.session.add(comp)
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
AuditLog.log('created', 'Computer', entityid=comp.computerid,
entityname=data.get('hostname') or data['assetnumber'])
@@ -415,8 +435,9 @@ def update_computer(computer_id: int):
setattr(asset, key, data[key])
# Update computer fields
computer_fields = ['computertypeid', 'hostname', 'osid', 'loggedinuser',
'lastreporteddate', 'lastboottime', 'isvnc', 'iswinrm', 'isshopfloor']
computer_fields = ['computertypeid', 'hostname', 'osid', 'vendorid',
'modelnumberid', 'loggedinuser', 'lastreporteddate',
'lastboottime', 'isvnc', 'iswinrm', 'isshopfloor']
for key in computer_fields:
if key in data:
old_val = getattr(comp, key)
@@ -425,6 +446,23 @@ def update_computer(computer_id: int):
changes[key] = {'old': old_val, 'new': new_val}
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
if changes:
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
)
# 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
loggedinuser = db.Column(db.String(100), nullable=True)
lastreporteddate = db.Column(db.DateTime, nullable=True)
@@ -93,6 +105,8 @@ class Computer(BaseModel):
)
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(
@@ -120,6 +134,10 @@ class Computer(BaseModel):
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
return result
@@ -149,6 +167,8 @@ class ComputerInstalledApp(db.Model):
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=db.func.now())

View File

@@ -53,6 +53,9 @@ class Config:
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_URL = os.environ.get('ZABBIX_URL', '')
ZABBIX_TOKEN = os.environ.get('ZABBIX_TOKEN', '')

View File

@@ -1,374 +1,266 @@
"""
PowerShell Data Collection API endpoints.
Compatibility layer for existing PowerShell scripts that update PC data.
Uses API key authentication instead of JWT for automated scripts.
"""
from datetime import datetime
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.utils.responses import success_response, error_response, ErrorCodes
collector_bp = Blueprint('collector', __name__)
def require_api_key(f):
"""Decorator to require API key authentication."""
@wraps(f)
def decorated(*args, **kwargs):
api_key = request.headers.get('X-API-Key')
if not api_key:
api_key = request.args.get('api_key')
expected_key = current_app.config.get('COLLECTOR_API_KEY')
if not expected_key:
return error_response(
ErrorCodes.INTERNAL_ERROR,
'Collector API key not configured',
http_code=500
)
if api_key != expected_key:
return error_response(
ErrorCodes.UNAUTHORIZED,
'Invalid API key',
http_code=401
)
return f(*args, **kwargs)
return decorated
@collector_bp.route('/pc', methods=['POST'])
@require_api_key
def update_pc_info():
"""
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()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
hostname = data.get('hostname')
if not hostname:
return error_response(ErrorCodes.VALIDATION_ERROR, 'hostname is required')
# Find the PC by hostname
pc = Machine.query.filter(
Machine.hostname.ilike(hostname),
Machine.pctypeid.isnot(None)
).first()
if not pc:
# 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'):
try:
update_fields['lastboottime'] = datetime.fromisoformat(
data['lastboottime'].replace('Z', '+00:00')
)
except ValueError:
pass
if data.get('currentuser'):
# Store previous user before updating
if pc.currentuserid != data['currentuser']:
update_fields['lastuserid'] = pc.currentuserid
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()
return success_response({
'machineid': pc.machineid,
'hostname': pc.hostname,
'updated': True
}, message='PC info updated')
@collector_bp.route('/apps', methods=['POST'])
@require_api_key
def update_installed_apps():
"""
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()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
hostname = data.get('hostname')
if not hostname:
return error_response(ErrorCodes.VALIDATION_ERROR, 'hostname is required')
apps = data.get('apps', [])
if not apps:
return error_response(ErrorCodes.VALIDATION_ERROR, 'apps list is required')
# Find the PC
pc = Machine.query.filter(
Machine.hostname.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
)
updated_count = 0
created_count = 0
skipped_count = 0
for app_data in apps:
app_name = app_data.get('appname')
if not app_name:
skipped_count += 1
continue
# Find the application in the database
app = Application.query.filter(
Application.appname.ilike(app_name)
).first()
if not app:
# Skip apps not in our tracked list
skipped_count += 1
continue
# Check if already installed
installed = InstalledApp.query.filter_by(
machineid=pc.machineid,
appid=app.appid
).first()
if installed:
# Update version if changed
new_version = app_data.get('version')
if new_version and installed.installedversion != new_version:
installed.installedversion = new_version
installed.modifieddate = datetime.utcnow()
updated_count += 1
else:
# Create new installed app record
installed = InstalledApp(
machineid=pc.machineid,
appid=app.appid,
installedversion=app_data.get('version'),
installdate=datetime.utcnow()
)
db.session.add(installed)
created_count += 1
db.session.commit()
return success_response({
'hostname': hostname,
'machineid': pc.machineid,
'created': created_count,
'updated': updated_count,
'skipped': skipped_count
}, message='Installed apps updated')
@collector_bp.route('/heartbeat', methods=['POST'])
@require_api_key
def pc_heartbeat():
"""
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()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
hostnames = data.get('hostnames', [])
if not hostnames and data.get('hostname'):
hostnames = [data['hostname']]
if not hostnames:
return error_response(ErrorCodes.VALIDATION_ERROR, 'hostname or hostnames required')
updated = 0
not_found = []
for hostname in hostnames:
pc = Machine.query.filter(
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'
]
})
"""
PowerShell data-collection API endpoints.
Compatibility layer for the PowerShell scripts that report PC state. Uses an
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 flask import Blueprint, request, current_app
from shopdb.extensions import db
from shopdb.core.models import Asset, Application
from plugins.computers.models import Computer, ComputerInstalledApp
from shopdb.utils.responses import success_response, error_response, ErrorCodes
collector_bp = Blueprint('collector', __name__)
def require_api_key(f):
"""Require API key authentication."""
@wraps(f)
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')
if not expected_key:
return error_response(
ErrorCodes.INTERNAL_ERROR,
'Collector API key not configured',
http_code=500
)
if api_key != expected_key:
return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key',
http_code=401)
return f(*args, **kwargs)
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'])
@require_api_key
def update_pc_info():
"""Update one PC from the collection script (matched by hostname)."""
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
hostname = data.get('hostname')
if not hostname:
return error_response(ErrorCodes.VALIDATION_ERROR, 'hostname is required')
comp = _find_pc(hostname)
if not comp:
return error_response(ErrorCodes.NOT_FOUND,
f'PC with hostname {hostname} not found',
http_code=404)
comp.lastreporteddate = datetime.utcnow()
if data.get('lastboottime'):
boot = _parse_boot(data['lastboottime'])
if boot:
comp.lastboottime = boot
if data.get('currentuser'):
comp.loggedinuser = data['currentuser']
if data.get('serialnumber') and comp.asset:
comp.asset.serialnumber = data['serialnumber']
db.session.commit()
return success_response({
'computerid': comp.computerid,
'hostname': comp.hostname,
'updated': True
}, message='PC info updated')
@collector_bp.route('/apps', methods=['POST'])
@require_api_key
def update_installed_apps():
"""Update installed applications for a PC (matched by hostname)."""
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
hostname = data.get('hostname')
if not hostname:
return error_response(ErrorCodes.VALIDATION_ERROR, 'hostname is required')
apps = data.get('apps', [])
if not apps:
return error_response(ErrorCodes.VALIDATION_ERROR, 'apps list is required')
comp = _find_pc(hostname)
if not comp:
return error_response(ErrorCodes.NOT_FOUND,
f'PC with hostname {hostname} not found',
http_code=404)
updated_count = created_count = skipped_count = 0
for app_data in apps:
app_name = app_data.get('appname')
if not app_name:
skipped_count += 1
continue
app = Application.query.filter(Application.appname.ilike(app_name)).first()
if not app:
# Only track applications we manage
skipped_count += 1
continue
version = app_data.get('version')
installed = ComputerInstalledApp.query.filter_by(
computerid=comp.computerid, appid=app.appid).first()
if installed:
changed = False
if version and installed.installedversion != version:
installed.installedversion = version
changed = True
if not installed.isactive:
installed.isactive = True
changed = True
if changed:
updated_count += 1
else:
db.session.add(ComputerInstalledApp(
computerid=comp.computerid,
appid=app.appid,
installedversion=version,
))
created_count += 1
db.session.commit()
return success_response({
'hostname': hostname,
'computerid': comp.computerid,
'created': created_count,
'updated': updated_count,
'skipped': skipped_count
}, message='Installed apps updated')
@collector_bp.route('/heartbeat', methods=['POST'])
@require_api_key
def pc_heartbeat():
"""Record PC online status / heartbeat (single or batch)."""
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
hostnames = data.get('hostnames', [])
if not hostnames and data.get('hostname'):
hostnames = [data['hostname']]
if not hostnames:
return error_response(ErrorCodes.VALIDATION_ERROR,
'hostname or hostnames required')
updated = 0
not_found = []
now = datetime.utcnow()
for hostname in hostnames:
comp = _find_pc(hostname)
if comp:
comp.lastreporteddate = now
updated += 1
else:
not_found.append(hostname)
db.session.commit()
return success_response({
'updated': updated,
'notfound': not_found,
'timestamp': now.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."""
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 = []
now = datetime.utcnow()
for pc_data in pcs:
hostname = pc_data.get('hostname')
if not hostname:
continue
comp = _find_pc(hostname)
if not comp:
not_found.append(hostname)
continue
try:
comp.lastreporteddate = now
if pc_data.get('currentuser'):
comp.loggedinuser = pc_data['currentuser']
if pc_data.get('lastboottime'):
boot = _parse_boot(pc_data['lastboottime'])
if boot:
comp.lastboottime = boot
updated += 1
except Exception as exc:
errors.append({'hostname': hostname, 'error': str(exc)})
db.session.commit()
return success_response({
'updated': updated,
'notfound': not_found,
'errors': errors,
'timestamp': now.isoformat()
}, message=f'{updated} PC(s) updated')
@collector_bp.route('/status', methods=['GET'])
@require_api_key
def collector_status():
"""Check collector API status."""
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'
]
})