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