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:
@@ -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', '')
|
||||
|
||||
@@ -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'
|
||||
]
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user