Files
shopdb-flask/plugins/computers/api/routes.py
cproudlock 035419fa51 ADR-015: stop shipping one site's values, and make the rule a gate
The scanner has been reporting the same count for weeks, which is what a rule
that only prints becomes. It now FAILS the build, and it looks where the leaks
actually were: PowerShell, the installer, the seeds, generated JSON, the
frontend - case-insensitively, across plugins, shopdb, scripts, deploy, tools.
A line that is deliberate declares itself with an ADR-015-OK marker and a
reason, so the claim is visible in review instead of tolerated in silence.

What it found, fixed here:

- The shadow client wrote one site's ShopDB URL into HKLM whenever the registry
  disagreed. At the site it was written for that reads as healing drift;
  anywhere else it overwrites the site's own address on every enforce cycle,
  and the site cannot win because the cycle repeats. The bay's value now wins,
  an explicit -BaseUrl seeds it, and with neither there is nothing honest to
  write, so it says so and skips.
- The kiosk dispatcher fell back to one plant's host when HKLM was unset, so a
  kiosk elsewhere quietly opened a server it has no business reaching. The
  fallback is now this site's site_base_url, baked in at seed time, and the
  dispatcher refuses rather than guessing when neither is set. Its legacy
  shortcut matcher derives the host from that URL instead of naming one.
- The OpenAPI generator hardcoded a production hostname into every spec it
  generated, which then published to a public wiki. The relative mount is the
  only server it can honestly name; a site passes its own by environment.
- Placeholders and examples in the UI and the client help offered real internal
  subnets and a real production URL. They now use documentation ranges.

Both publication gates - the export scrub and the docs publishability test -
carry the site patterns, which neither did. One plant's hostname, FQDN and
internal networks are out of the documentation and the generated specs.

Comments naming the reference site are reworded rather than deleted: the
reasoning is worth keeping, the plant name is not what makes it true.
2026-08-14 13:47:39 -04:00

1061 lines
37 KiB
Python

"""Computers plugin API endpoints."""
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
from shopdb.api import db, Asset, AssetType, OperatingSystem, Application, AuditLog, Communication, CommunicationType, Setting, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
from ..models import Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess
from shopdb.api import require_permission, apply_import_timestamps
computers_bp = Blueprint('computers', __name__)
# =============================================================================
# Computer Types
# =============================================================================
@computers_bp.route('/types', methods=['GET'])
@jwt_required(optional=True)
def list_computer_types():
"""List all computer types."""
page, per_page = get_pagination_params(request)
query = ComputerType.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(ComputerType.isactive == True)
if search := request.args.get('search'):
query = query.filter(ComputerType.computertype.ilike(f'%{search}%'))
query = query.order_by(ComputerType.computertype)
items, total = paginate_query(query, page, per_page)
data = [t.to_dict() for t in items]
return paginated_response(data, page, per_page, total)
@computers_bp.route('/types/<int:type_id>', methods=['GET'])
@jwt_required(optional=True)
def get_computer_type(type_id: int):
"""Get a single computer type."""
t = db.session.get(ComputerType, type_id)
if not t:
return error_response(
ErrorCodes.NOT_FOUND,
f'Computer type with ID {type_id} not found',
http_code=404
)
return success_response(t.to_dict())
@computers_bp.route('/types', methods=['POST'])
@jwt_required()
@require_permission('computers.create')
def create_computer_type():
"""Create a new computer type."""
data = request.get_json()
if not data or not data.get('computertype'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'computertype is required')
existing = ComputerType.query.filter_by(computertype=data['computertype']).first()
if existing:
if not existing.isactive:
# Adding a name that matches a deactivated type revives it.
existing.isactive = True
for key in ('description', 'icon', 'color'):
if data.get(key) is not None:
setattr(existing, key, data[key])
db.session.commit()
return success_response(existing.to_dict(), message='Reactivated existing computer type')
return error_response(
ErrorCodes.CONFLICT,
f"Computer type '{data['computertype']}' already exists",
http_code=409
)
t = ComputerType(
computertype=data['computertype'],
description=data.get('description'),
icon=data.get('icon'), color=data.get('color')
)
db.session.add(t)
db.session.commit()
return success_response(t.to_dict(), message='Computer type created', http_code=201)
@computers_bp.route('/types/<int:type_id>', methods=['PUT'])
@jwt_required()
@require_permission('computers.edit')
def update_computer_type(type_id: int):
"""Update a computer type."""
t = db.session.get(ComputerType, type_id)
if not t:
return error_response(
ErrorCodes.NOT_FOUND,
f'Computer type with ID {type_id} not found',
http_code=404
)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
if 'computertype' in data and data['computertype'] != t.computertype:
if ComputerType.query.filter_by(computertype=data['computertype']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Computer type '{data['computertype']}' already exists",
http_code=409
)
for key in ['computertype', 'description', 'icon', 'color', 'isactive']:
if key in data:
setattr(t, key, data[key])
db.session.commit()
return success_response(t.to_dict(), message='Computer type updated')
@computers_bp.route('/types/<int:type_id>', methods=['DELETE'])
@jwt_required()
@require_permission('computers.delete')
def delete_computer_type(type_id: int):
"""Delete a computer type. Refused if any PC still uses it."""
t = db.session.get(ComputerType, type_id)
if not t:
return error_response(ErrorCodes.NOT_FOUND, 'Computer type not found', http_code=404)
inuse = Computer.query.filter_by(computertypeid=type_id).count()
if inuse:
return error_response(ErrorCodes.CONFLICT,
f"Cannot delete: {inuse} PC(s) still use this type", http_code=409)
db.session.delete(t)
db.session.commit()
return success_response(message='Computer type deleted')
# =============================================================================
# Access protocol catalog (VNC / WinRM / RDP / ...) - admin-managed
# =============================================================================
@computers_bp.route('/protocols', methods=['GET'])
@jwt_required(optional=True)
def list_protocols():
"""List access protocols. ?active=false includes disabled ones."""
query = AccessProtocol.query
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(AccessProtocol.isactive == True)
protocols = query.order_by(AccessProtocol.name).all()
return success_response([p.to_dict() for p in protocols])
@computers_bp.route('/protocols', methods=['POST'])
@jwt_required()
@require_permission('computers.edit')
def create_protocol():
data = request.get_json() or {}
if not (data.get('name') and data.get('scheme') and data.get('linktemplate')):
return error_response(ErrorCodes.VALIDATION_ERROR, 'name, scheme and linktemplate are required')
if AccessProtocol.query.filter_by(name=data['name']).first():
return error_response(ErrorCodes.CONFLICT, f"Protocol '{data['name']}' already exists", http_code=409)
p = AccessProtocol(
name=data['name'],
scheme=data['scheme'],
defaultport=data.get('defaultport') or None,
linktemplate=data['linktemplate'],
isactive=data.get('isactive', True),
)
db.session.add(p)
db.session.commit()
return success_response(p.to_dict(), message='Protocol created', http_code=201)
@computers_bp.route('/protocols/<int:protocol_id>', methods=['PUT', 'PATCH'])
@jwt_required()
@require_permission('computers.edit')
def update_protocol(protocol_id):
p = db.session.get(AccessProtocol, protocol_id)
if not p:
return error_response(ErrorCodes.NOT_FOUND, 'Protocol not found', http_code=404)
data = request.get_json() or {}
for field in ('name', 'scheme', 'linktemplate'):
if data.get(field):
setattr(p, field, data[field])
if 'defaultport' in data:
p.defaultport = data['defaultport'] or None
if 'isactive' in data:
p.isactive = bool(data['isactive'])
db.session.commit()
return success_response(p.to_dict(), message='Protocol updated')
@computers_bp.route('/protocols/<int:protocol_id>', methods=['DELETE'])
@jwt_required()
@require_permission('computers.edit')
def delete_protocol(protocol_id):
p = db.session.get(AccessProtocol, protocol_id)
if not p:
return error_response(ErrorCodes.NOT_FOUND, 'Protocol not found', http_code=404)
# If any PC still references it, deactivate rather than hard-delete.
if ComputerAccess.query.filter_by(protocolid=protocol_id).first():
p.isactive = False
db.session.commit()
return success_response(message='Protocol is in use; deactivated instead of deleted')
db.session.delete(p)
db.session.commit()
return success_response(message='Protocol deleted')
def _pc_access_domain():
# Contract-pure read of the pc_access_domain setting (no core.api import).
row = Setting.query.filter_by(key='pc_access_domain').first()
return ((row.value if row else '') or '').strip()
def _computer_access_links(comp, domain=None):
"""Resolved remote-access links for a computer: each enabled protocol's
template filled with the PC hostname joined to the pc_access_domain setting.
A hostname that is already an FQDN (has a dot) is used as-is. Pass domain
when calling in a loop to avoid one settings lookup per computer."""
if domain is None:
domain = _pc_access_domain()
hostname = (comp.hostname or '').strip()
if not hostname:
host = ''
elif '.' in hostname or not domain:
host = hostname
else:
host = f"{hostname}.{domain}"
links = []
for am in comp.accessmethods:
protocol = am.protocol
if not (am.isactive and protocol and protocol.isactive):
continue
port = am.portoverride or protocol.defaultport
link = None
if host:
try:
link = protocol.linktemplate.format(
host=host,
port=(port if port is not None else ''),
scheme=protocol.scheme,
)
except (KeyError, IndexError, ValueError):
link = None
links.append({
'id': am.id,
'protocolid': protocol.protocolid,
'name': protocol.name,
'scheme': protocol.scheme,
'port': port,
'portoverride': am.portoverride,
'link': link,
})
return links
def _sync_access_methods(comp, data):
"""Replace a computer's enabled protocols from data['accessmethods'] (a list
of {protocolid, portoverride?}). No-op if the key is absent, so callers that
don't touch access aren't affected."""
if 'accessmethods' not in data:
return
desired = data.get('accessmethods') or []
ComputerAccess.query.filter_by(computerid=comp.computerid).delete()
seen = set()
for m in desired:
try:
pid = int(m.get('protocolid'))
except (TypeError, ValueError):
continue
if pid in seen:
continue
seen.add(pid)
port = m.get('portoverride')
try:
port = int(port) if port not in (None, '') else None
except (TypeError, ValueError):
port = None
db.session.add(ComputerAccess(
computerid=comp.computerid,
protocolid=pid,
portoverride=port,
isactive=True,
))
# =============================================================================
# Computers CRUD
# =============================================================================
@computers_bp.route('/display-kiosks', methods=['GET'])
@jwt_required(optional=True)
def list_display_kiosks():
"""Reporting display kiosks, for the Dashboard Defaults picker.
Returns the computers whose type is the one gea-shopfloor-display maps to
(default 'Kiosk'), each with its DERIVED FQDN (F<serial>.<domain>, domain
from the display_fqdn_domain setting) so an admin picks a kiosk from a
dropdown instead of typing an IP/FQDN. Keeps the same F<serial>.<domain>
format as core derive_display_fqdn (duplicated to avoid a contract bump).
"""
from ..pctypemap import pctype_mapping
from shopdb.api import Setting
display_type_name = pctype_mapping().get('gea-shopfloor-display', 'Kiosk')
ctype = ComputerType.query.filter_by(computertype=display_type_name).first()
if not ctype:
return success_response([])
domain = (Setting.get('display_fqdn_domain', 'device.geaerospace.net') # ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override.
or 'device.geaerospace.net').strip().strip('.') # ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override.
rows = (db.session.query(Computer).join(Asset)
.filter(Computer.computertypeid == ctype.computertypeid,
Asset.isactive == True)
.order_by(Computer.hostname).all())
out = []
for comp in rows:
serial = (comp.asset.serialnumber or '').strip() if comp.asset else ''
out.append({
'computerid': comp.computerid,
'hostname': comp.hostname,
'serialnumber': serial or None,
'fqdn': f'F{serial}.{domain}'.lower() if serial else None,
})
return success_response(out)
@computers_bp.route('', methods=['GET'])
@jwt_required(optional=True)
def list_computers():
"""
List all computers with filtering and pagination.
Query parameters:
- page, per_page: Pagination
- active: Filter by active status
- search: Search by asset number, name, or hostname
- type_id: Filter by computer type ID
- os_id: Filter by operating system ID
- location_id: Filter by location ID
- businessunit_id: Filter by business unit ID
- shopfloor: Filter by shopfloor flag (true/false)
"""
page, per_page = get_pagination_params(request)
# Join Computer with Asset
query = db.session.query(Computer).join(Asset)
# Active filter
if request.args.get('active', 'true').lower() != 'false':
query = query.filter(Asset.isactive == True)
# Exact-match natural-key lookup for idempotent import (asset number).
if exactassetnumber := request.args.get('assetnumber'):
query = query.filter(Asset.assetnumber == exactassetnumber)
# Search filter. Covers the type name too - it is a column in the list, so
# searching 'Standard' must find the PCs of that type. Outer join so a PC
# with no type still matches on its own fields.
if search := request.args.get('search'):
pattern = f'%{search}%'
query = query.outerjoin(
ComputerType, Computer.computertypeid == ComputerType.computertypeid
).filter(
db.or_(
Asset.assetnumber.ilike(pattern),
Asset.name.ilike(pattern),
Asset.serialnumber.ilike(pattern),
Computer.hostname.ilike(pattern),
ComputerType.computertype.ilike(pattern)
)
)
# Computer type filter
if type_id := request.args.get('typeid', request.args.get('type_id')):
query = query.filter(Computer.computertypeid == int(type_id))
# OS filter
if os_id := request.args.get('osid', request.args.get('os_id')):
query = query.filter(Computer.osid == int(os_id))
# Location filter
if location_id := request.args.get('locationid', request.args.get('location_id')):
query = query.filter(Asset.locationid == int(location_id))
# Business unit filter
if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')):
query = query.filter(Asset.businessunitid == int(bu_id))
# Shopfloor filter (by the Shopfloor computer type)
if shopfloor := request.args.get('shopfloor'):
sf = ComputerType.query.filter_by(computertype='Shopfloor').first()
sf_id = sf.computertypeid if sf else -1
if shopfloor.lower() == 'true':
query = query.filter(Computer.computertypeid == sf_id)
else:
query = query.filter(db.or_(Computer.computertypeid != sf_id,
Computer.computertypeid.is_(None)))
# Sorting
sort_by = request.args.get('sort', 'hostname')
sort_dir = request.args.get('dir', 'asc')
if sort_by == 'hostname':
col = Computer.hostname
elif sort_by == 'assetnumber':
col = Asset.assetnumber
elif sort_by == 'name':
col = Asset.name
elif sort_by == 'lastreporteddate':
col = Computer.lastreporteddate
else:
col = Computer.hostname
query = query.order_by(col.desc() if sort_dir == 'desc' else col)
items, total = paginate_query(query, page, per_page)
# Build response with both asset and computer data
data = []
accessdomain = _pc_access_domain()
for comp in items:
item = comp.asset.to_dict() if comp.asset else {}
item['computer'] = comp.to_dict()
item['accessmethods'] = _computer_access_links(comp, domain=accessdomain)
data.append(item)
return paginated_response(data, page, per_page, total)
@computers_bp.route('/<int:computer_id>', methods=['GET'])
@jwt_required(optional=True)
def get_computer(computer_id: int):
"""Get a single computer with full details."""
comp = db.session.get(Computer, computer_id)
if not comp:
return error_response(
ErrorCodes.NOT_FOUND,
f'Computer with ID {computer_id} not found',
http_code=404
)
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()
]
result['accessmethods'] = _computer_access_links(comp)
return success_response(result)
@computers_bp.route('/by-asset/<int:asset_id>', methods=['GET'])
@jwt_required(optional=True)
def get_computer_by_asset(asset_id: int):
"""Get computer data by asset ID."""
comp = Computer.query.filter_by(assetid=asset_id).first()
if not comp:
return error_response(
ErrorCodes.NOT_FOUND,
f'Computer for asset {asset_id} not found',
http_code=404
)
result = comp.asset.to_dict() if comp.asset else {}
result['computer'] = comp.to_dict()
return success_response(result)
@computers_bp.route('/by-hostname/<hostname>', methods=['GET'])
@jwt_required(optional=True)
def get_computer_by_hostname(hostname: str):
"""Get computer by hostname."""
comp = Computer.query.filter_by(hostname=hostname).first()
if not comp:
return error_response(
ErrorCodes.NOT_FOUND,
f'Computer with hostname {hostname} not found',
http_code=404
)
result = comp.asset.to_dict() if comp.asset else {}
result['computer'] = comp.to_dict()
return success_response(result)
@computers_bp.route('', methods=['POST'])
@jwt_required()
@require_permission('computers.create')
def create_computer():
"""
Create new computer (creates both Asset and Computer records).
Required fields:
- assetnumber: Business identifier
Optional fields:
- name, serialnumber, statusid, locationid, businessunitid
- computertypeid, hostname, osid
- mapx, mapy, notes
"""
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
if not data.get('assetnumber'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'assetnumber is required')
# Check for duplicate assetnumber
if Asset.query.filter_by(assetnumber=data['assetnumber']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Asset with number '{data['assetnumber']}' already exists",
http_code=409
)
# Check for duplicate hostname
if data.get('hostname'):
if Computer.query.filter_by(hostname=data['hostname']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Computer with hostname '{data['hostname']}' already exists",
http_code=409
)
# Get computer asset type
computer_type = AssetType.query.filter_by(assettype='computer').first()
if not computer_type:
return error_response(
ErrorCodes.INTERNAL_ERROR,
'Computer asset type not found. Plugin may not be properly installed.',
http_code=500
)
# Create the core asset
asset = Asset(
assetnumber=data['assetnumber'],
name=data.get('name'),
serialnumber=data.get('serialnumber'),
gaugelabreference=data.get('gaugelabreference'),
maintenancereference=data.get('maintenancereference'),
assettypeid=computer_type.assettypeid,
statusid=data.get('statusid', 1),
locationid=data.get('locationid'),
businessunitid=data.get('businessunitid'),
mapx=data.get('mapx'),
mapy=data.get('mapy'),
notes=data.get('notes')
)
db.session.add(asset)
db.session.flush() # Get the assetid
# Create the computer extension
comp = Computer(
assetid=asset.assetid,
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')
)
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,
))
# Remote-access protocols
_sync_access_methods(comp, data)
# Preserve legacy timestamps in import mode (no-op otherwise)
apply_import_timestamps(asset, data)
# Audit log
AuditLog.log('created', 'Computer', entityid=comp.computerid,
entityname=data.get('hostname') or data['assetnumber'])
db.session.commit()
result = asset.to_dict()
result['computer'] = comp.to_dict()
result['accessmethods'] = _computer_access_links(comp)
return success_response(result, message='Computer created', http_code=201)
@computers_bp.route('/<int:computer_id>', methods=['PUT'])
@jwt_required()
@require_permission('computers.edit')
def update_computer(computer_id: int):
"""Update computer (both Asset and Computer records)."""
comp = db.session.get(Computer, computer_id)
if not comp:
return error_response(
ErrorCodes.NOT_FOUND,
f'Computer with ID {computer_id} not found',
http_code=404
)
data = request.get_json()
if not data:
return error_response(ErrorCodes.VALIDATION_ERROR, 'No data provided')
asset = comp.asset
# Check for conflicting assetnumber
if 'assetnumber' in data and data['assetnumber'] != asset.assetnumber:
if Asset.query.filter_by(assetnumber=data['assetnumber']).first():
return error_response(
ErrorCodes.CONFLICT,
f"Asset with number '{data['assetnumber']}' already exists",
http_code=409
)
# Check for conflicting hostname
if 'hostname' in data and data['hostname'] != comp.hostname:
existing = Computer.query.filter_by(hostname=data['hostname']).first()
if existing and existing.computerid != computer_id:
return error_response(
ErrorCodes.CONFLICT,
f"Computer with hostname '{data['hostname']}' already exists",
http_code=409
)
# Track changes for audit log
changes = {}
# Update asset fields
asset_fields = ['assetnumber', 'name', 'serialnumber', 'gaugelabreference',
'maintenancereference', 'statusid',
'locationid', 'businessunitid', 'mapx', 'mapy', 'notes', 'isactive']
for key in asset_fields:
if key in data:
old_val = getattr(asset, key)
new_val = data[key]
if old_val != new_val:
changes[key] = {'old': old_val, 'new': new_val}
setattr(asset, key, data[key])
# Update computer fields
computer_fields = ['computertypeid', 'hostname', 'osid', 'vendorid',
'modelnumberid', 'loggedinuser', 'lastreporteddate',
'lastboottime']
for key in computer_fields:
if key in data:
old_val = getattr(comp, key)
new_val = data[key]
if old_val != new_val:
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
# Remote-access protocols
_sync_access_methods(comp, data)
# Audit log if there were changes
if changes:
AuditLog.log('updated', 'Computer', entityid=comp.computerid,
entityname=comp.hostname or asset.assetnumber, changes=changes)
apply_import_timestamps(asset, data)
db.session.commit()
result = asset.to_dict()
result['computer'] = comp.to_dict()
result['accessmethods'] = _computer_access_links(comp)
return success_response(result, message='Computer updated')
@computers_bp.route('/<int:computer_id>', methods=['DELETE'])
@jwt_required()
@require_permission('computers.delete')
def delete_computer(computer_id: int):
"""Delete (soft delete) computer."""
comp = db.session.get(Computer, computer_id)
if not comp:
return error_response(
ErrorCodes.NOT_FOUND,
f'Computer with ID {computer_id} not found',
http_code=404
)
# Soft delete the asset
comp.asset.isactive = False
# Audit log
AuditLog.log('deleted', 'Computer', entityid=comp.computerid,
entityname=comp.hostname or comp.asset.assetnumber)
db.session.commit()
return success_response(message='Computer deleted')
# =============================================================================
# Installed Applications
# =============================================================================
@computers_bp.route('/<int:computer_id>/apps', methods=['GET'])
@jwt_required(optional=True)
def get_installed_apps(computer_id: int):
"""Get all installed applications for a computer."""
comp = db.session.get(Computer, computer_id)
if not comp:
return error_response(
ErrorCodes.NOT_FOUND,
f'Computer with ID {computer_id} not found',
http_code=404
)
apps = ComputerInstalledApp.query.filter_by(
computerid=computer_id,
isactive=True
).all()
data = [app.to_dict() for app in apps]
return success_response(data)
@computers_bp.route('/<int:computer_id>/apps', methods=['POST'])
@jwt_required()
@require_permission('computers.create')
def add_installed_app(computer_id: int):
"""Add an installed application to a computer."""
comp = db.session.get(Computer, computer_id)
if not comp:
return error_response(
ErrorCodes.NOT_FOUND,
f'Computer with ID {computer_id} not found',
http_code=404
)
data = request.get_json()
if not data or not data.get('appid'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'appid is required')
appid = data['appid']
# Validate app exists
if not db.session.get(Application, appid):
return error_response(ErrorCodes.NOT_FOUND, f'Application {appid} not found', http_code=404)
# Check for duplicate
existing = ComputerInstalledApp.query.filter_by(
computerid=computer_id,
appid=appid
).first()
if existing:
if existing.isactive:
return error_response(
ErrorCodes.CONFLICT,
'This application is already installed on this computer',
http_code=409
)
else:
# Reactivate
existing.isactive = True
existing.appversionid = data.get('appversionid')
db.session.commit()
return success_response(existing.to_dict(), message='Application reinstalled')
# Create new installation record
installed = ComputerInstalledApp(
computerid=computer_id,
appid=appid,
appversionid=data.get('appversionid')
)
db.session.add(installed)
db.session.commit()
return success_response(installed.to_dict(), message='Application installed', http_code=201)
@computers_bp.route('/<int:computer_id>/apps/<int:app_id>', methods=['DELETE'])
@jwt_required()
@require_permission('computers.delete')
def remove_installed_app(computer_id: int, app_id: int):
"""Remove an installed application from a computer."""
installed = ComputerInstalledApp.query.filter_by(
computerid=computer_id,
appid=app_id,
isactive=True
).first()
if not installed:
return error_response(
ErrorCodes.NOT_FOUND,
'Installation record not found',
http_code=404
)
installed.isactive = False
db.session.commit()
return success_response(message='Application uninstalled')
# =============================================================================
# Status Reporting
# =============================================================================
@computers_bp.route('/<int:computer_id>/report', methods=['POST'])
@jwt_required()
@require_permission('computers.create')
def report_status(computer_id: int):
"""
Report computer status (for agent-based reporting).
This endpoint can be called periodically by a client agent
to update status information.
"""
comp = db.session.get(Computer, computer_id)
if not comp:
return error_response(
ErrorCodes.NOT_FOUND,
f'Computer with ID {computer_id} not found',
http_code=404
)
data = request.get_json() or {}
# Update status fields
from datetime import datetime, timezone
comp.lastreporteddate = datetime.now(timezone.utc).replace(tzinfo=None)
if 'loggedinuser' in data:
comp.loggedinuser = data['loggedinuser']
if 'lastboottime' in data:
comp.lastboottime = data['lastboottime']
db.session.commit()
return success_response(message='Status reported')
# =============================================================================
# Dashboard
# =============================================================================
@computers_bp.route('/dashboard/summary', methods=['GET'])
@jwt_required(optional=True)
def dashboard_summary():
"""Get computer dashboard summary data."""
# Total active computers
total = db.session.query(Computer).join(Asset).filter(
Asset.isactive == True
).count()
# Count by computer type
by_type = db.session.query(
ComputerType.computertype,
db.func.count(Computer.computerid)
).join(Computer, Computer.computertypeid == ComputerType.computertypeid
).join(Asset, Asset.assetid == Computer.assetid
).filter(Asset.isactive == True
).group_by(ComputerType.computertype
).all()
# Count by OS
by_os = db.session.query(
OperatingSystem.osname,
db.func.count(Computer.computerid)
).join(Computer, Computer.osid == OperatingSystem.osid
).join(Asset, Asset.assetid == Computer.assetid
).filter(Asset.isactive == True
).group_by(OperatingSystem.osname
).all()
# Count shopfloor vs non-shopfloor
sf = ComputerType.query.filter_by(computertype='Shopfloor').first()
shopfloor_count = db.session.query(Computer).join(Asset).filter(
Asset.isactive == True,
Computer.computertypeid == (sf.computertypeid if sf else -1)
).count()
return success_response({
'total': total,
'bytype': [{'type': t, 'count': c} for t, c in by_type],
'byos': [{'os': o, 'count': c} for o, c in by_os],
'shopfloor': shopfloor_count,
'nonshopfloor': total - shopfloor_count
})
@computers_bp.route('/dashboard/quiet', methods=['GET'])
@jwt_required()
@require_permission('computers.view')
def dashboard_quiet():
"""PCs that WERE reporting, have stopped, and are still meant to be in use.
Three filters, and each removes a population that would otherwise be noise:
Never reported at all is EXCLUDED. Those are usually records created by
hand or imported, not bays that broke - listing them buries the PCs that
actually changed state behind rows nobody is going to act on.
Not In Use is EXCLUDED. A PC in Repair, Inventory or Retired is silent on
purpose; that is the status doing its job, not a fault.
Soft-deleted is excluded for the same reason.
What is left is the real signal: a machine that was working, is not now, and
nobody has marked as anything else. Silence is the only evidence available -
there is no heartbeat separate from the report.
"""
from datetime import datetime, timedelta, timezone
from shopdb.api import AssetStatus
hours = 24
setting = Setting.query.filter_by(key='computers_quietreporthours').first()
if setting and (setting.value or '').strip():
try:
hours = int(setting.value)
except (TypeError, ValueError):
pass
cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(hours=hours)
now = datetime.now(timezone.utc).replace(tzinfo=None)
rows = (db.session.query(Computer, Asset)
.join(Asset, Asset.assetid == Computer.assetid)
.join(AssetStatus, AssetStatus.statusid == Asset.statusid)
.filter(Asset.isactive.is_(True),
AssetStatus.status == 'In Use',
Computer.lastreporteddate.isnot(None),
Computer.lastreporteddate < cutoff)
.all())
out = [{
'computerid': comp.computerid,
'hostname': comp.hostname,
'lastreported': comp.lastreporteddate.isoformat() + 'Z',
'quietdays': (now - comp.lastreporteddate).days,
} for comp, _asset in rows]
out.sort(key=lambda r: -r['quietdays'])
return success_response(out[:50])
@computers_bp.route('/dashboard/sharedmachines', methods=['GET'])
@jwt_required()
@require_permission('computers.view')
def dashboard_sharedmachines():
"""Machine numbers claimed by more than one PC, with nothing filed under
them.
Several devices genuinely sharing a number is legitimate - part markers do
it - and those are modelled: each device is its own asset filed `partof` the
operation, so the operation has CHILD ASSETS. Two PCs carrying the same
machine number by mistake looks identical from a count and has none. That
distinction is the whole card; without it this would list correct data
alongside faults and get ignored.
Promoted from `flask relationships check-shared-machines`, which answers the
same question and which nobody will remember to run. This one found seven
mis-numbered bays that had been that way for weeks.
"""
from shopdb.api import AssetRelationship, RelationshipType
from sqlalchemy.orm import aliased
controls = RelationshipType.query.filter_by(
relationshiptype='controls').first()
partof = RelationshipType.query.filter_by(relationshiptype='partof').first()
if not controls:
return success_response([])
pcasset = aliased(Asset)
machineasset = aliased(Asset)
rows = (db.session.query(machineasset.assetid, machineasset.assetnumber,
pcasset.assetnumber)
.select_from(AssetRelationship)
.join(pcasset, AssetRelationship.sourceassetid == pcasset.assetid)
.join(machineasset,
AssetRelationship.targetassetid == machineasset.assetid)
.filter(AssetRelationship.relationshiptypeid ==
controls.relationshiptypeid,
AssetRelationship.label == 'collector:machine',
AssetRelationship.isactive.is_(True))
.all())
bymachine = {}
for assetid, machinenumber, pcnumber in rows:
bymachine.setdefault((assetid, machinenumber), []).append(pcnumber)
out = []
for (assetid, machinenumber), pcs in bymachine.items():
if len(pcs) < 2:
continue
children = 0
if partof:
children = (AssetRelationship.query
.filter_by(targetassetid=assetid,
relationshiptypeid=partof.relationshiptypeid,
isactive=True)
.count())
if children:
continue # modelled: the devices are assets in their own right
out.append({
'assetid': assetid,
'machinenumber': machinenumber,
'pccount': len(pcs),
'pcs': ', '.join(sorted(p for p in pcs if p)),
})
out.sort(key=lambda r: -r['pccount'])
return success_response(out)