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 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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
# 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())
|
||||
|
||||
|
||||
@@ -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