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

@@ -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'
]
})