diff --git a/plugins/computers/api/routes.py b/plugins/computers/api/routes.py index fcdcc11..e4294a4 100644 --- a/plugins/computers/api/routes.py +++ b/plugins/computers/api/routes.py @@ -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, diff --git a/plugins/computers/migrations/versions/0002_pc_hardware_fields.py b/plugins/computers/migrations/versions/0002_pc_hardware_fields.py new file mode 100644 index 0000000..a82b791 --- /dev/null +++ b/plugins/computers/migrations/versions/0002_pc_hardware_fields.py @@ -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') diff --git a/plugins/computers/models/computer.py b/plugins/computers/models/computer.py index 5e149fd..27325b1 100644 --- a/plugins/computers/models/computer.py +++ b/plugins/computers/models/computer.py @@ -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()) diff --git a/shopdb/config.py b/shopdb/config.py index 655b7ca..deecffa 100644 --- a/shopdb/config.py +++ b/shopdb/config.py @@ -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', '') diff --git a/shopdb/core/api/collector.py b/shopdb/core/api/collector.py index 2f15dfc..60df619 100644 --- a/shopdb/core/api/collector.py +++ b/shopdb/core/api/collector.py @@ -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' + ] + })